diff --git a/packages/js/e2e-core-tests/CHANGELOG.md b/packages/js/e2e-core-tests/CHANGELOG.md index d0a15fbe6e0..03eebaf6d20 100644 --- a/packages/js/e2e-core-tests/CHANGELOG.md +++ b/packages/js/e2e-core-tests/CHANGELOG.md @@ -4,6 +4,7 @@ - A `specs/data` folder to store page element data. - Tests to verify that different top-level menu and their associated sub-menus load successfully. +- Test scaffolding via `npx wc-e2e install @woocommerce/e2e-core-tests` ## Changed diff --git a/packages/js/e2e-core-tests/README.md b/packages/js/e2e-core-tests/README.md index a23b038345c..1252c3b9274 100644 --- a/packages/js/e2e-core-tests/README.md +++ b/packages/js/e2e-core-tests/README.md @@ -20,6 +20,18 @@ Follow [E2E setup instructions](https://github.com/woocommerce/woocommerce/blob/ ### Setting up core tests +#### Version 0.2.0 or newer + +Version 0.2.0 added a test installer that will populate the `tests/e2e/specs` folder with test scripts for all the current core test suite. It also creates sample configuration files including all the configuration data needed to run the core tests. + +- Install the e2e-environment `npm install @woocommerce/e2e-environment --save-dev` +- Run the installer `npx wc-e2e install @woocommerce/e2e-core-tests` +- Merge the sample configuration files: + - `tests/e2e/docker/woocommerce.e2e-core-tests.sh` => `initialize.sh` + - `tests/e2e/config/default-woocommerce.e2e-core-tests.json` => `default.json` + +#### Version 0.1.X or other test runner + - Create the folder `tests/e2e/specs` in your repository if it does not exist. - To add a core test to your test suite, create a new `.test.js` file within `tests/e2e/specs` . Example code to run all the shopper tests: ```js diff --git a/packages/js/e2e-core-tests/installFiles/default-test-config.json b/packages/js/e2e-core-tests/installFiles/default-test-config.json new file mode 100644 index 00000000000..c67465ed4ba --- /dev/null +++ b/packages/js/e2e-core-tests/installFiles/default-test-config.json @@ -0,0 +1,195 @@ +{ + "url": "http://localhost:8084/", + "users": { + "admin": { + "username": "admin", + "password": "password" + }, + "customer": { + "username": "customer", + "password": "password" + } + }, + "products": { + "simple": { + "name": "Simple product" + }, + "variable": { + "name": "Variable Product with Three Attributes", + "defaultAttributes": [ + { + "id": 0, + "name": "Size", + "option": "Medium" + }, + { + "id": 0, + "name": "Colour", + "option": "Blue" + } + ], + "attributes": [ + { + "id": 0, + "name": "Colour", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Red", + "Green", + "Blue" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Size", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Small", + "Medium", + "Large" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Logo", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Woo", + "WordPress" + ], + "sortOrder": 0 + } + ] + }, + "variations": [ + { + "regularPrice": "19.99", + "attributes": [ + { + "name": "Size", + "option": "Large" + }, + { + "name": "Colour", + "option": "Red" + } + ] + }, + { + "regularPrice": "18.99", + "attributes": [ + { + "name": "Size", + "option": "Medium" + }, + { + "name": "Colour", + "option": "Green" + } + ] + }, + { + "regularPrice": "17.99", + "attributes": [ + { + "name": "Size", + "option": "Small" + }, + { + "name": "Colour", + "option": "Blue" + } + ] + } + ], + "grouped": { + "name": "Grouped Product with Three Children", + "groupedProducts": [ + { + "name": "Base Unit", + "regularPrice": "29.99" + }, + { + "name": "Add-on A", + "regularPrice": "11.95" + }, + { + "name": "Add-on B", + "regularPrice": "18.97" + } + ] + }, + "external": { + "name": "External product", + "regularPrice": "24.99", + "buttonText": "Buy now", + "externalUrl": "https://wordpress.org/plugins/woocommerce" + } + }, + "coupons": { + "percentage": { + "code": "20percent", + "discountType": "percent", + "amount": "20.00" + } + }, + "addresses": { + "admin": { + "store": { + "email": "admin@woocommercecoree2etestsuite.com", + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "countryandstate": "United States (US) — California", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + }, + "customer": { + "billing": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "postcode": "94107", + "phone": "123456789", + "email": "john.doe@example.com" + }, + "shipping": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + } + }, + "orders": { + "basicPaidOrder": { + "paymentMethod": "cod", + "status": "processing", + "billing": { + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com" + } + } + } +} diff --git a/packages/js/e2e-core-tests/installFiles/index.js b/packages/js/e2e-core-tests/installFiles/index.js new file mode 100644 index 00000000000..6601661582c --- /dev/null +++ b/packages/js/e2e-core-tests/installFiles/index.js @@ -0,0 +1,5 @@ +module.exports = { + testSpecs: 'installFiles/scaffold-tests.json', + defaultJson: 'installFiles/default-test-config.json', + initializeSh: 'installFiles/initialize.sh.default', +}; diff --git a/packages/js/e2e-core-tests/installFiles/initialize.sh.default b/packages/js/e2e-core-tests/installFiles/initialize.sh.default new file mode 100755 index 00000000000..7c0ab8f9991 --- /dev/null +++ b/packages/js/e2e-core-tests/installFiles/initialize.sh.default @@ -0,0 +1,25 @@ +#!/bin/bash + +echo "Initializing WooCommerce E2E" + +# This is a workaround to accommodate different directory names. +wp plugin activate --all +wp plugin deactivate akismet +wp plugin deactivate hello + +wp theme install twentynineteen --activate +wp user create customer customer@woocommercecoree2etestsuite.com \ + --user_pass=password \ + --role=subscriber \ + --first_name='Jane' \ + --last_name='Smith' \ + --path=/var/www/html + +# we cannot create API keys for the API, so we using basic auth, this plugin allows that. +wp plugin install https://github.com/WP-API/Basic-Auth/archive/master.zip --activate + +# install the WP Mail Logging plugin to test emails +wp plugin install wp-mail-logging --activate + +# initialize pretty permalinks +wp rewrite structure /%postname%/ diff --git a/packages/js/e2e-core-tests/installFiles/scaffold-tests.json b/packages/js/e2e-core-tests/installFiles/scaffold-tests.json new file mode 100644 index 00000000000..7d95b03587d --- /dev/null +++ b/packages/js/e2e-core-tests/installFiles/scaffold-tests.json @@ -0,0 +1,138 @@ +{ + "active": [ + { + "name": "front-end", + "description": "Shopper tests", + "testFiles": [ + { + "name": "cart-begin", + "functions": [ "runCartPageTest" ] + }, { + "name": "cart-calculate-shipping", + "functions": [ "runCartCalculateShippingTest" ] + }, { + "name": "cart-coupons", + "functions": [ "runCartApplyCouponsTest" ] + }, { + "name": "checkout-begin", + "functions": [ "runCheckoutPageTest" ] + }, { + "name": "checkout-coupons", + "functions": [ "runCheckoutApplyCouponsTest" ] + }, { + "name": "checkout-create-account", + "functions": [ "runCheckoutCreateAccountTest" ] + }, { + "name": "checkout-login-account", + "functions": [ "runCheckoutLoginAccountTest" ] + }, { + "name": "my-account-create-account", + "functions": [ "runMyAccountCreateAccountTest" ] + }, { + "name": "my-account-pay-order", + "functions": [ "runMyAccountPayOrderTest" ] + }, { + "name": "my-account", + "functions": [ "runMyAccountPageTest" ] + }, { + "name": "order-email-receiving", + "functions": [ "runOrderEmailReceivingTest" ] + }, { + "name": "product-browse-search-sort", + "functions": [ "runProductBrowseSearchSortTest" ] + }, { + "name": "single-product-page", + "functions": [ "runSingleProductPageTest" ] + }, { + "name": "variable-product-updates", + "functions": [ "runVariableProductUpdateTest" ] + } + ] + }, { + "name": "rest-api", + "description": "REST API tests", + "testFiles": [ + { + "name": "api", + "functions": [ "runApiTests" ] + } + ] + }, { + "name": "wp-admin", + "description": "Merchant tests", + "testFiles": [ + { + "name": "create-coupon", + "functions": [ "runCreateCouponTest" ] + }, { + "name": "create-order", + "functions": [ "runCreateOrderTest" ] + }, { + "name": "create-shipping-classes", + "functions": [ "runAddShippingClassesTest" ] + }, { + "name": "create-shipping-zones", + "functions": [ "runAddNewShippingZoneTest" ] + }, { + "name": "create-simple-product", + "functions": [ "runAddSimpleProductTest" ] + }, { + "name": "create-variable-product", + "functions": [ "runAddVariableProductTest" ] + }, { + "name": "order-coupon", + "functions": [ "runOrderApplyCouponTest" ] + }, { + "name": "order-customer-payment-page", + "functions": [ "runMerchantOrdersCustomerPaymentPage" ] + }, { + "name": "order-edit", + "functions": [ "runEditOrderTest" ] + }, { + "name": "order-emails", + "functions": [ "runMerchantOrderEmailsTest" ] + }, { + "name": "order-refund", + "functions": [ "runOrderRefundTest" ] + }, { + "name": "order-searching", + "functions": [ "runOrderSearchingTest" ] + }, { + "name": "order-status-filters", + "functions": [ "runOrderStatusFiltersTest" ] + }, { + "name": "product-edit", + "functions": [ "runProductEditDetailsTest" ] + }, { + "name": "product-import-csv", + "functions": [ "runImportProductsTest" ] + }, { + "name": "product-search", + "functions": [ "runProductSearchTest" ] + }, { + "name": "update-general-settings", + "functions": [ "runUpdateGeneralSettingsTest" ] + }, { + "name": "update-product-settings", + "functions": [ "runProductSettingsTest" ] + }, { + "name": "update-tax-settings", + "functions": [ "runTaxSettingsTest" ] + }, { + "name": "wccom-connect", + "functions": [ "runInitiateWccomConnectionTest" ] + } + ] + } + ], + "deprecated": [ + { + "name": "example-folder", + "testFiles": [ + { "name": "any-filename-to-deprecate" } + ] + } + ] +} + + diff --git a/packages/js/e2e-environment/CHANGELOG.md b/packages/js/e2e-environment/CHANGELOG.md index 781c66330cb..44e84d7266a 100644 --- a/packages/js/e2e-environment/CHANGELOG.md +++ b/packages/js/e2e-environment/CHANGELOG.md @@ -3,10 +3,12 @@ ## Added - Added `await` for every call to `shopper.logout` +- Test setup, scaffolding, and removal via `wc-e2e install` and `wc-e2e uninstall` ## Fixed - Updated the browserViewport in `jest.setup.js` to match the `defaultViewport` dimensions defined in `jest-puppeteer.config.js` + ## Added - Added quotes around `WORDPRESS_TITLE` value in .env file to address issue with docker compose 2 "key cannot contain a space" error. diff --git a/packages/js/e2e-environment/README.md b/packages/js/e2e-environment/README.md index e3e43f256a3..3b95cf7f174 100644 --- a/packages/js/e2e-environment/README.md +++ b/packages/js/e2e-environment/README.md @@ -9,6 +9,19 @@ npm install @woocommerce/e2e-environment --save npm install jest --global ``` +### Version 0.3.0 and newer + +Version 0.3.0 added a test installer that will populate the `tests/e2e/*` folder with test scripts and configuration files. The installer will create test scripts for E2E test packages that include support for the installer. + +- [Adding test scaffolding to E2E test packages](https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/e2e-environment/test-packages.md) + +#### Using the installer + +- Install a default test environment: `npx wc-e2e install` +- Install test specs from an E2E tests package: `npx wc-e2e install @woocommerce-e2e-tests [--format cjs] [--ext spec.js]` +- The default test spec format and extension are `ES6` and `test.js` +- Remove test specs for an E2E tests package: `npx wc-e2e uninstall @woocommerce-e2e-tests` + ## Configuration The `@woocommerce/e2e-environment` package exports configuration objects that can be consumed in JavaScript config files in your project. Additionally, it includes a basic hosting container for running tests and includes instructions for creating your Travis CI setup. @@ -60,10 +73,10 @@ The E2E environment uses Jest as a test runner. Extending the base config is nec ```js const path = require( 'path' ); -const { useE2EJestConfig } = require( '@woocommerce/e2e-environment' ); +const { useE2EJestConfig, resolveLocalE2ePath } = require( '@woocommerce/e2e-environment' ); const jestConfig = useE2EJestConfig( { - roots: [ path.resolve( __dirname, '../specs' ) ], + roots: [ resolveLocalE2ePath( 'specs' ) ], } ); module.exports = jestConfig; diff --git a/packages/js/e2e-environment/bin/docker-compose.js b/packages/js/e2e-environment/bin/docker-compose.js index b6046773bae..9bfd509ba76 100755 --- a/packages/js/e2e-environment/bin/docker-compose.js +++ b/packages/js/e2e-environment/bin/docker-compose.js @@ -11,6 +11,7 @@ const { getAppName, getTestConfig, resolveLocalE2ePath, + resolvePackagePath, } = require( '../utils' ); const dockerArgs = []; @@ -63,7 +64,7 @@ if ( appPath ) { if ( fs.existsSync( appInitFile ) ) { fs.copyFileSync( appInitFile, - path.resolve( __dirname, '../docker/wp-cli/initialize.sh' ) + resolvePackagePath( 'docker/wp-cli/initialize.sh' ) ); console.log( 'Initializing ' + appInitFile ); } @@ -90,7 +91,7 @@ if ( ! process.env.WORDPRESS_URL ) { } // Ensure that the first Docker compose file loaded is from our local env. -dockerArgs.unshift( '-f', path.resolve( __dirname, '../docker-compose.yaml' ) ); +dockerArgs.unshift( '-f', resolvePackagePath( 'docker-compose.yaml' ) ); const dockerProcess = spawnSync( 'docker-compose', dockerArgs, { stdio: 'inherit', diff --git a/packages/js/e2e-environment/bin/e2e-test-integration.js b/packages/js/e2e-environment/bin/e2e-test-integration.js index 09f0c95dcf5..be837564888 100755 --- a/packages/js/e2e-environment/bin/e2e-test-integration.js +++ b/packages/js/e2e-environment/bin/e2e-test-integration.js @@ -4,7 +4,11 @@ const { spawnSync } = require( 'child_process' ); const program = require( 'commander' ); const path = require( 'path' ); const fs = require( 'fs' ); -const { getAppRoot, resolveLocalE2ePath } = require( '../utils' ); +const { + getAppRoot, + resolveLocalE2ePath, + resolvePackagePath, +} = require( '../utils' ); const { WC_E2E_SCREENSHOTS, JEST_PUPPETEER_CONFIG, @@ -30,7 +34,7 @@ if ( WC_E2E_SCREENSHOTS ) { } } -const nodeConfigDirs = [ path.resolve( __dirname, '../config' ) ]; +const nodeConfigDirs = [ resolvePackagePath( 'config' ) ]; if ( appPath ) { nodeConfigDirs.unshift( resolveLocalE2ePath( 'config' ) ); @@ -51,10 +55,7 @@ if ( ! JEST_PUPPETEER_CONFIG ) { // Use local Puppeteer config if there is one. // Load test configuration file into an object. const localJestConfigFile = resolveLocalE2ePath( 'config/jest-puppeteer.config.js' ); - const jestConfigFile = path.resolve( - __dirname, - '../config/jest-puppeteer.config.js' - ); + const jestConfigFile = resolvePackagePath( 'config/jest-puppeteer.config.js' ); testEnvVars.JEST_PUPPETEER_CONFIG = fs.existsSync( localJestConfigFile ) ? localJestConfigFile @@ -88,7 +89,7 @@ if ( program.debug ) { const envVars = Object.assign( {}, process.env, testEnvVars ); -let configPath = path.resolve( __dirname, '../config/jest.config.js' ); +let configPath = resolvePackagePath( 'config/jest.config.js' ); // Look for a Jest config in the dependent app's path. if ( appPath ) { diff --git a/packages/js/e2e-environment/bin/scaffold.js b/packages/js/e2e-environment/bin/scaffold.js new file mode 100755 index 00000000000..ba7a9257d8b --- /dev/null +++ b/packages/js/e2e-environment/bin/scaffold.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +/** + * External dependencies. + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const sprintf = require( 'sprintf-js' ).sprintf; + +/** + * Internal dependencies. + */ +const { + resolvePackage, + resolvePackagePath, +} = require( '../utils' ); +const { + createLocalE2ePath, + confirm, + confirmLocalCopy, + confirmLocalDelete, + getPackageData, + installDefaults +} = require( '../utils/scaffold' ); + +const args = process.argv.slice( 2 ); +const [ command, packageName ] = args; + +// Allow multiple spec file extensions and formats. +let testExtension = 'test.js'; +let testFormat = ''; +for ( let a = 2; a < args.length; a++ ) { + const nextArg = a + 1; + if ( nextArg >= args.length ) { + break; + } + switch ( args[ a ] ) { + case '--format': + testFormat = args[ nextArg ]; + break; + case '--ext': + testExtension = args[ nextArg ]; + break; + } +} + +/** + * Install the test scripts and sample default.json configuration + */ +if ( command == 'install' ) { + // Install some environment defaults if no package is requested. + if ( ! packageName ) { + installDefaults(); + return; + } + + // `package` is a reserved word + const pkg = resolvePackage( packageName ).name; + if ( ! pkg.length ) { + //@todo add error message + return; + } + const { packageSlug, testSpecs, defaultJson, initializeSh } = getPackageData( pkg ); + + // Write sample default.json + if ( defaultJson ) { + const defaultJsonName = `config${path.sep}default-${packageSlug}.json`; + createLocalE2ePath( 'config' ); + if ( confirmLocalCopy( defaultJsonName, defaultJson, pkg ) ) { + console.log( `Created sample test configuration to 'tests/e2e/${defaultJsonName}'.` ); + } + } + + // Write sample initialize.sh + if ( initializeSh ) { + const defaultInitName = `docker${path.sep}${packageSlug}.sh`; + createLocalE2ePath( 'docker' ); + if ( confirmLocalCopy( defaultInitName, initializeSh, pkg ) ) { + console.log( `Created sample test container initialization script to 'tests/e2e/${defaultInitName}'.` ); + } + } + + if ( ! testSpecs ) { + return; + } + + // Write test files + const testsSpecFile = resolvePackagePath( testSpecs, pkg ); + const specs = fs.readFileSync( testsSpecFile ); + const tests = JSON.parse( specs ); + const { active, deprecated } = tests; + + if ( active && active.length ) { + const blankLine = ''; + const eol = "\n"; + const autoGenerate = sprintf( '/* This file was auto-generated by the command `npx wc-e2e install %s`. */', packageName ); + let importLineFormat; + let overwriteFiles; + let confirmPrompt; + if ( testFormat.toLowerCase() == 'cjs' ) { + importLineFormat = sprintf( "const {%%s} = require( '%s' );", pkg ); + } else { + importLineFormat = sprintf( "import {%%s} from '%s';", pkg ); + } + + // Create the specs folder if not present + let specFolderPath = createLocalE2ePath( 'specs' ); + + // Loop through folders and files to write test scripts. + for ( let f = 0; f < active.length; f++ ) { + if ( overwriteFiles == 'q' ) { + overwriteFiles = ''; + break; + } + + const testFolder = active[ f ]; + const { testFiles } = testFolder; + + if ( ! testFiles || ! testFiles.length ) { + continue; + } + + let specFolder; + if ( testFolder.name.length ) { + specFolder = createLocalE2ePath( `specs${path.sep}${testFolder.name}` ); + } else { + specFolder = specFolderPath; + } + + // Create the test files. + for ( let t = 0; t < testFiles.length; t++ ) { + const testFile = testFiles[ t ]; + if ( ! testFile.functions.length ) { + continue; + } + + const testFileName = `${testFolder.name}${path.sep}${testFile.name}.${testExtension}`; + const testFilePath = `${specFolder}${path.sep}${testFile.name}.${testExtension}`; + + // Check to see if file exists. + if ( fs.existsSync( testFilePath ) ) { + if ( overwriteFiles != 'a' ) { + confirmPrompt = `${testFileName} already exists. Overwrite? [y]es/[n]o/[a]ll/[q]uit: `; + overwriteFiles = confirm( confirmPrompt, 'anqy' ); + overwriteFiles = overwriteFiles.toLowerCase(); + } + + if ( overwriteFiles == 'q' ) { + break; + } + if ( overwriteFiles != 'a' && overwriteFiles != 'y' ) { + continue; + } + } + + console.log( 'Writing tests/e2e/specs/' + testFileName ); + let buffer = [ autoGenerate ]; + let testSeparator, testTerminator, importPrefix; + + // Add the import line. + if ( testFile.functions.length > 3 ) { + testSeparator = ',' + eol; + testTerminator = eol; + importPrefix = eol; + } else { + testSeparator = ', '; + testTerminator = ' '; + importPrefix = ' '; + } + const testImport = testFile.functions.join( testSeparator ) + testTerminator; + buffer.push( sprintf( importLineFormat, importPrefix + testImport ), blankLine ); + + // Add test function calls and write the file + let functionCalls = testFile.functions.map( functionName => functionName + '();' ); + buffer.push( ...functionCalls, blankLine ); + fs.writeFileSync( testFilePath, buffer.join( eol ) ); + } + } + } + // @todo: deprecated files. +} else if ( command == 'uninstall' ) { + if ( ! packageName ) { + // @todo: write error message + return; + } + + const pkg = resolvePackage( packageName ).name; + const { packageSlug, testSpecs, defaultJson, initializeSh } = getPackageData( pkg ); + + // Delete sample default.json + if ( defaultJson ) { + const defaultJsonName = `config${path.sep}default-${packageSlug}.json`; + confirmLocalDelete( defaultJsonName ); + } + + // Delete sample initialize.sh + if ( initializeSh ) { + const defaultInitName = `docker${path.sep}${packageSlug}.sh`; + confirmLocalDelete( defaultInitName ); + } + + if ( ! testSpecs ) { + return; + } + + const testsSpecFile = resolvePackagePath( testSpecs, pkg ); + const specs = fs.readFileSync( testsSpecFile ); + const tests = JSON.parse( specs ); + const { active } = tests; + + if ( ! active || ! active.length ) { + return; + } + + // Loop through folders and files to delete test scripts. + for ( let f = 0; f < active.length; f++ ) { + const testFolder = active[ f ]; + const { testFiles } = testFolder; + + if ( ! testFiles || ! testFiles.length ) { + continue; + } + + const specFolder = testFolder.name.length ? `specs${path.sep}${testFolder.name}` : 'specs'; + for ( let t = 0; t < testFiles.length; t++ ) { + const testFile = testFiles[ t ]; + const testFilePath = `${specFolder}${path.sep}${testFile.name}.${testExtension}`; + + confirmLocalDelete( testFilePath ); + } + } + +} diff --git a/packages/js/e2e-environment/bin/wc-e2e.sh b/packages/js/e2e-environment/bin/wc-e2e.sh index 11be2f572bb..9f7fa6d72d1 100755 --- a/packages/js/e2e-environment/bin/wc-e2e.sh +++ b/packages/js/e2e-environment/bin/wc-e2e.sh @@ -71,6 +71,10 @@ case $1 in ./bin/wait-for-build.sh && ./bin/e2e-test-integration.js --dev --debug $2 TESTRESULT=$? ;; + 'install' | \ + 'uninstall') + ./bin/scaffold.js $@ + ;; *) usage ;; diff --git a/packages/js/e2e-environment/installFiles/initialize.sh b/packages/js/e2e-environment/installFiles/initialize.sh new file mode 100755 index 00000000000..7c0ab8f9991 --- /dev/null +++ b/packages/js/e2e-environment/installFiles/initialize.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +echo "Initializing WooCommerce E2E" + +# This is a workaround to accommodate different directory names. +wp plugin activate --all +wp plugin deactivate akismet +wp plugin deactivate hello + +wp theme install twentynineteen --activate +wp user create customer customer@woocommercecoree2etestsuite.com \ + --user_pass=password \ + --role=subscriber \ + --first_name='Jane' \ + --last_name='Smith' \ + --path=/var/www/html + +# we cannot create API keys for the API, so we using basic auth, this plugin allows that. +wp plugin install https://github.com/WP-API/Basic-Auth/archive/master.zip --activate + +# install the WP Mail Logging plugin to test emails +wp plugin install wp-mail-logging --activate + +# initialize pretty permalinks +wp rewrite structure /%postname%/ diff --git a/packages/js/e2e-environment/installFiles/jest.config.js b/packages/js/e2e-environment/installFiles/jest.config.js new file mode 100644 index 00000000000..4e3b87bcce7 --- /dev/null +++ b/packages/js/e2e-environment/installFiles/jest.config.js @@ -0,0 +1,8 @@ +const path = require( 'path' ); +const { useE2EJestConfig, getAppRoot } = require( '@woocommerce/e2e-environment' ); + +const jestConfig = useE2EJestConfig( { + roots: [ path.resolve( __dirname, '../specs' ) ], +} ); + +module.exports = jestConfig; diff --git a/packages/js/e2e-environment/installFiles/jest.setup.js b/packages/js/e2e-environment/installFiles/jest.setup.js new file mode 100644 index 00000000000..5fd474f804f --- /dev/null +++ b/packages/js/e2e-environment/installFiles/jest.setup.js @@ -0,0 +1,80 @@ +import { + clearLocalStorage, + setBrowserViewport, + withRestApi, + WP_ADMIN_LOGIN +} from '@woocommerce/e2e-utils'; + +const config = require( 'config' ); +const { HTTPClientFactory } = require( '@woocommerce/api' ); +const { addConsoleSuppression, updateReadyPageStatus } = require( '@woocommerce/e2e-environment' ); +const { DEFAULT_TIMEOUT_OVERRIDE } = process.env; + +// @todo: remove this once https://github.com/woocommerce/woocommerce-admin/issues/6992 has been addressed +addConsoleSuppression( 'woocommerce_shared_settings', false ); + +/** + * Uses the WordPress API to delete all existing posts + */ +async function trashExistingPosts() { + const apiUrl = config.get('url'); + const wpPostsEndpoint = '/wp/v2/posts'; + const adminUsername = config.get('users.admin.username'); + const adminPassword = config.get('users.admin.password'); + const client = HTTPClientFactory.build(apiUrl) + .withBasicAuth(adminUsername, adminPassword) + .create(); + + // List all existing posts + const response = await client.get(wpPostsEndpoint); + const posts = response.data; + + // Delete each post + for (const post of posts) { + await client.delete(`${wpPostsEndpoint}/${post.id}`); + } +} + +// Before every test suite run, delete all content created by the test. This ensures +// other posts/comments/etc. aren't dirtying tests and tests don't depend on +// each other's side-effects. +beforeAll(async () => { + + if ( DEFAULT_TIMEOUT_OVERRIDE ) { + page.setDefaultNavigationTimeout( DEFAULT_TIMEOUT_OVERRIDE ); + page.setDefaultTimeout( DEFAULT_TIMEOUT_OVERRIDE ); + } + + try { + // Update the ready page to prevent concurrent test runs + await updateReadyPageStatus('draft'); + await trashExistingPosts(); + await withRestApi.deleteAllProducts(); + await withRestApi.deleteAllCoupons(); + await withRestApi.deleteAllOrders(); + } catch ( error ) { + // Prevent an error here causing tests to fail. + } + + await page.goto(WP_ADMIN_LOGIN); + await clearLocalStorage(); + await setBrowserViewport( { + width: 1280, + height: 800, + }); +}); + +// Clear browser cookies and cache using DevTools. +// This is to ensure that each test ends with no user logged in. +afterAll(async () => { + // Reset the ready page to published to allow future test runs + try { + await updateReadyPageStatus('publish'); + } catch ( error ) { + // Prevent an error here causing tests to fail. + } + + const client = await page.target().createCDPSession(); + await client.send('Network.clearBrowserCookies'); + await client.send('Network.clearBrowserCache'); +}); diff --git a/packages/js/e2e-environment/package.json b/packages/js/e2e-environment/package.json index 95e9ca63c16..9d1e1cfe409 100644 --- a/packages/js/e2e-environment/package.json +++ b/packages/js/e2e-environment/package.json @@ -33,7 +33,9 @@ "jest-each": "25.5.0", "jest-puppeteer": "^4.4.0", "node-stream-zip": "^1.13.6", - "request": "^2.88.2" + "readline-sync": "^1.4.10", + "request": "^2.88.2", + "sprintf-js": "^1.1.2" }, "devDependencies": { "@babel/cli": "7.12.8", diff --git a/packages/js/e2e-environment/test-packages.md b/packages/js/e2e-environment/test-packages.md new file mode 100644 index 00000000000..12370393d44 --- /dev/null +++ b/packages/js/e2e-environment/test-packages.md @@ -0,0 +1,104 @@ +# WooCommerce End-to-End Test Packages + +There are two limitations which significantly impact the architecture of E2E test packages: + +- Referencing the `jest` functions `describe`, `it`, `beforeAll`, etc. throws a fatal error outside the `jest` environment. +- `jest` will not scan for tests in any path containing `node_mdules`. + +## Creating a tests package + +The way to create a tests package with the above limitations is + +- **In the tests package**, wrap each test in a function + +```js +/** + * Require the necessary jest functions to prevent the package build from referencing them + * `import` references imported functions during package build + */ + +const { describe, it, beforeAll } = require( '@jest/globals' ); + +const testMyCriticalFlow = () => { + describe( 'My Critical Flow', () => { + beforeAll( async () => { + // Test setup + } ); + it( 'can complete first step', async () => { + // Do stuff + expect( someValue ).toBeTruthy(); + } ); + } ); +}; + +modules.exports = testMyFlow; +``` + +- **In the `tests/e2e/specs` folder**, create a test spec that calls the test function + +```js +import { testMyCriticalFlow } from 'MyTestsPackage'; + +testMyCriticalFlow(); +``` + +## Adding the scaffolds for the test installer + +To work with the limitations outlined above, the test installer needs to access the test scaffolding information without accessing the package index. As a result, the `installFiles` is a required path in the steps below + +- Create an `installFiles` folder in the root of the package +- Add an `index.js` to the folder which exports an object with some or all of three properties +```js +module.exports = { + defaultJson: 'installFiles/default-test-config.json', + initializeSh: 'installFiles/initialize.sh.default', + testSpecs: 'installFiles/scaffold-tests.json', +}; +``` +- The value of each of the properties should be a relative path from the package `index.js`. The test installer will remove `dist`, `build`, and `build-modules` from the end of the package index path. +- `defaultJson`: Path to a JSON file containing all `default.json` entries needed for the tests in the package. +- `initializeSh`: Path to a bash script containing the WP CLI commands needed to initialize the `e2e-environment` test container. +- `testSpecs`: Path to a JSON file containing a nested object +```json +{ + "active": [ + { + "name": "first-folder-name", + "description": "First tests", + "testFiles": [ + { + "name": "test-name-a", + "functions": [ + "testMyCriticalFlow" + ] + }, + { + "name": "test-name-b", + "functions": [ + "testSecondCriticalFlow", + "testThirdCriticalFlow" + ] + } + ] + }, + { + "name": "second-folder-name", + "description": "Second tests", + "testFiles": [ + .... + ] + } + ] +} +``` + +The test installer uses the `testSpecs` nested object to create test specs. Using the example above, create `tests/e2e/specs/first-folder-name/test-name-b.test.js`: + +```js +/* This file was auto-generated by the command `npx wc-e2e install your-package-name`. */ +import { testSecondCriticalFlow, testThirdCriticalFlow } from 'your-package-name'; + +testSecondCriticalFlow(); +testThirdCriticalFlow(); +``` + diff --git a/packages/js/e2e-environment/utils/get-plugin-zip.js b/packages/js/e2e-environment/utils/get-plugin-zip.js index a537813e408..91be84837c1 100644 --- a/packages/js/e2e-environment/utils/get-plugin-zip.js +++ b/packages/js/e2e-environment/utils/get-plugin-zip.js @@ -13,10 +13,7 @@ const StreamZip = require( 'node-stream-zip' ); */ const getRemotePluginZip = async ( fileUrl ) => { const appPath = getAppRoot(); - const savePath = path.resolve( - appPath, - 'plugins/woocommerce/tests/e2e/plugins' - ); + const savePath = resolveLocalE2ePath( 'plugins' ); mkdirp.sync( savePath ); // Pull the filename from the end of the URL diff --git a/packages/js/e2e-environment/utils/index.js b/packages/js/e2e-environment/utils/index.js index baf54dc1b82..54e5939a5c7 100644 --- a/packages/js/e2e-environment/utils/index.js +++ b/packages/js/e2e-environment/utils/index.js @@ -1,6 +1,6 @@ const getAppRoot = require( './app-root' ); const { getAppName, getAppBase } = require( './app-name' ); -const { getTestConfig, getAdminConfig, resolveLocalE2ePath } = require( './test-config' ); +const testConfig = require( './test-config' ); const { getRemotePluginZip, getLatestReleaseZipUrl } = require('./get-plugin-zip'); const takeScreenshotFor = require( './take-screenshot' ); const updateReadyPageStatus = require('./update-ready-page'); @@ -10,12 +10,10 @@ module.exports = { getAppBase, getAppRoot, getAppName, - getTestConfig, - getAdminConfig, - resolveLocalE2ePath, getRemotePluginZip, getLatestReleaseZipUrl, takeScreenshotFor, updateReadyPageStatus, + ...testConfig, ...consoleUtils, }; diff --git a/packages/js/e2e-environment/utils/scaffold.js b/packages/js/e2e-environment/utils/scaffold.js new file mode 100644 index 00000000000..7662a35ad50 --- /dev/null +++ b/packages/js/e2e-environment/utils/scaffold.js @@ -0,0 +1,124 @@ +/** + * External dependencies. + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const readlineSync = require( 'readline-sync' ); + +/** + * Internal dependencies. + */ +const { resolveLocalE2ePath, resolvePackagePath } = require( './test-config' ); + +/** + * Create a path relative to the local `tests/e2e` folder. + * @param relativePath + * @return {string} + */ +const createLocalE2ePath = ( relativePath ) => { + let specFolderPath = ''; + const folders = [ `..${path.sep}..${path.sep}tests`, `..${path.sep}e2e`, relativePath ]; + folders.forEach( ( folder ) => { + specFolderPath = resolveLocalE2ePath( folder ); + if ( ! fs.existsSync( specFolderPath ) ) { + console.log( `Creating folder ${specFolderPath}` ); + fs.mkdirSync( specFolderPath ); + } + } ); + + return specFolderPath; +}; + +/** + * Prompt the console for confirmation. + * + * @param {string} prompt Prompt for the user. + * @param {string} choices valid responses. + * @return {string} + */ +const confirm = ( prompt, choices ) => { + const answer = readlineSync.keyIn( prompt, choices ); + return answer; +}; + +/** + * + * @param {string} localE2ePath Destination path + * @param {string} packageE2ePath Source path + * @param {string} packageName Source package. Default @woocommerce/e2e-environment package. + * @return {boolean} + */ +const confirmLocalCopy = ( localE2ePath, packageE2ePath, packageName = '' ) => { + const localPath = resolveLocalE2ePath( localE2ePath ); + const packagePath = resolvePackagePath( packageE2ePath, packageName ); + const confirmPrompt = `${localE2ePath} already exists. Overwrite? [Y]es/[n]o: `; + + let overwriteFiles; + if ( fs.existsSync( localPath ) ) { + overwriteFiles = confirm( confirmPrompt, 'ny' ); + overwriteFiles = overwriteFiles.toLowerCase(); + } else { + overwriteFiles = 'y'; + } + if ( overwriteFiles == 'y' ) { + fs.copyFileSync( packagePath, localPath ); + return true; + } + + return false; +}; + +/** + * Prompt for confirmation before deleting a local E2E file. + * + * @param {string} localE2ePath Relative path to local E2E file. + */ +const confirmLocalDelete = ( localE2ePath ) => { + const localPath = resolveLocalE2ePath( localE2ePath ); + if ( ! fs.existsSync( localPath ) ) { + return; + } + + const confirmPrompt = `${localE2ePath} exists. Delete? [y]es/[n]o: `; + const deleteFile = confirm( confirmPrompt, 'ny' ); + if ( deleteFile == 'y' ) { + fs.unlinkSync( localPath ); + } +}; + +/** + * Get the install data for a tests package. + * + * @param {string} packageName npm package name + * @return {string} + */ +const getPackageData = ( packageName ) => { + const packageSlug = packageName.replace( '@', '' ).replace( /\//g, '.' ); + const installFiles = require( `${packageName}${path.sep}installFiles` ); + + return { packageSlug, ...installFiles }; +}; + +/** + * Install test runner and test container defaults + */ +const installDefaults = () => { + createLocalE2ePath( 'docker' ); + console.log( 'Writing tests/e2e/docker/initialize.sh' ); + confirmLocalCopy( `docker${path.sep}initialize.sh`, `installFiles${path.sep}initialize.sh` ); + + createLocalE2ePath( 'config' ); + console.log( 'Writing tests/e2e/config/jest.config.js' ); + confirmLocalCopy( `config${path.sep}jest.config.js`, `installFiles${path.sep}jest.config.js` ); + console.log( 'Writing tests/e2e/config/jest.setup.js' ); + confirmLocalCopy( `config${path.sep}jest.setup.js`, `installFiles${path.sep}jest.setup.js` ); +}; + +module.exports = { + createLocalE2ePath, + confirm, + confirmLocalCopy, + confirmLocalDelete, + getPackageData, + installDefaults, +}; diff --git a/packages/js/e2e-environment/utils/test-config.js b/packages/js/e2e-environment/utils/test-config.js index 2f4f187e5c3..35d369cf182 100644 --- a/packages/js/e2e-environment/utils/test-config.js +++ b/packages/js/e2e-environment/utils/test-config.js @@ -19,15 +19,84 @@ const resolveLocalE2ePath = ( filename = '' ) => { ); return resolvedPath; -} +}; + +/** + * Resolve a package name installable by npm install. + * + * @param {string} packageName Name of the installed package. + * @param {boolean} allowRecurse Allow a recursive call. Default true. + * @return {object} + */ +const resolvePackage = ( packageName, allowRecurse = true ) => { + const resolvedPackage = {}; + + try { + const resolvedPath = path.dirname( require.resolve( packageName ) ); + const buildPaths = [ 'dist', 'build', 'build-modules' ]; + + // Remove build paths from the resolved path. + let resolvedParts = resolvedPath.split( path.sep ); + for ( let rp = resolvedParts.length - 1; rp >= 0; rp-- ) { + if ( buildPaths.includes( resolvedParts[ rp ] ) ) { + resolvedParts = resolvedParts.slice( 0, -1 ); + } else { + break; + } + } + resolvedPackage.path = resolvedParts.join( path.sep ); + resolvedPackage.name = packageName; + } catch ( e ) { + // Package name installed is not the package name. + resolvedPackage.path = ''; + resolvedPackage.name = ''; + } + + // Attempt to find the package through the project package lock file. + if ( ! resolvedPackage.path.length && allowRecurse ) { + const packageLockPath = path.resolve( appPath, 'package-lock.json' ); + const packageLockContent = fs.readFileSync( packageLockPath ); + const { dependencies } = JSON.parse( packageLockContent ); + + for ( const [ key, value ] of Object.entries( dependencies ) ) { + if ( value.version.indexOf( packageName ) == 0 ) { + resolvedPackage = resolvePackage( key, false ); + break; + } + } + } + + return resolvedPackage; +}; + +/** + * Resolve a file in a package. + * + * @param {string} filename Filename to append to the path. + * @param {string} packageName Name of the installed package. Default @woocommerce/e2e-environment. + * @return {string} + */ +const resolvePackagePath = ( filename, packageName = '' ) => { + let packagePath; + if ( ! packageName.length ) { + packagePath = path.resolve( __dirname, '../' ); + } else { + const pkg = resolvePackage( packageName ); + packagePath = pkg.path; + } + + const resolvedPath = path.resolve( + packagePath, + filename.indexOf( '/' ) == 0 ? filename.slice( 1 ) : filename + ); + + return resolvedPath; +}; // Copy local test configuration file if it exists. const localTestConfigFile = resolveLocalE2ePath( 'config/default.json' ); -const defaultConfigFile = path.resolve( - __dirname, - '../config/default/default.json' -); -const testConfigFile = path.resolve( __dirname, '../config/default.json' ); +const defaultConfigFile = resolvePackagePath( 'config/default/default.json' ); +const testConfigFile = resolvePackagePath( 'config/default.json' ); if ( fs.existsSync( localTestConfigFile ) ) { fs.copyFileSync( localTestConfigFile, testConfigFile ); @@ -94,4 +163,6 @@ module.exports = { getTestConfig, getAdminConfig, resolveLocalE2ePath, + resolvePackage, + resolvePackagePath, }; diff --git a/packages/js/e2e-utils/src/flows/with-rest-api.js b/packages/js/e2e-utils/src/flows/with-rest-api.js index ce15c98ce29..6e52d58d07f 100644 --- a/packages/js/e2e-utils/src/flows/with-rest-api.js +++ b/packages/js/e2e-utils/src/flows/with-rest-api.js @@ -78,7 +78,7 @@ export const withRestApi = { }; const response = await client.put( onboardingProfileEndpoint, onboardingReset ); - expect( response.status ).toEqual( 200 ); + expect( response.statusCode ).toEqual( 200 ); }, /** * Use api package to delete coupons. diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json index 80b4dacdb37..7684d5273fc 100644 --- a/plugins/woocommerce/composer.json +++ b/plugins/woocommerce/composer.json @@ -21,7 +21,7 @@ "pelago/emogrifier": "3.1.0", "psr/container": "1.0.0", "woocommerce/action-scheduler": "3.4.0", - "woocommerce/woocommerce-admin": "2.9.3", + "woocommerce/woocommerce-admin": "3.0.0-rc.1", "woocommerce/woocommerce-blocks": "6.3.3" }, "require-dev": { diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock index 7bc30dc2a45..faf3db2a3a1 100644 --- a/plugins/woocommerce/composer.lock +++ b/plugins/woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8c4f8b290830d85dce9e69de46ca72ce", + "content-hash": "c4f41955bfde1a0a4e2d6d7428b8ee58", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -543,16 +543,16 @@ }, { "name": "woocommerce/woocommerce-admin", - "version": "2.9.3", + "version": "3.0.0-rc.1", "source": { "type": "git", "url": "https://github.com/woocommerce/woocommerce-admin.git", - "reference": "0c4f1e637ad03178999ff53ae930b8258ca95962" + "reference": "7c0cdd01ae98be058d684dd19023b0f40094cb63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/woocommerce/woocommerce-admin/zipball/0c4f1e637ad03178999ff53ae930b8258ca95962", - "reference": "0c4f1e637ad03178999ff53ae930b8258ca95962", + "url": "https://api.github.com/repos/woocommerce/woocommerce-admin/zipball/7c0cdd01ae98be058d684dd19023b0f40094cb63", + "reference": "7c0cdd01ae98be058d684dd19023b0f40094cb63", "shasum": "" }, "require": { @@ -608,9 +608,9 @@ "homepage": "https://github.com/woocommerce/woocommerce-admin", "support": { "issues": "https://github.com/woocommerce/woocommerce-admin/issues", - "source": "https://github.com/woocommerce/woocommerce-admin/tree/v2.9.3" + "source": "https://github.com/woocommerce/woocommerce-admin/tree/v3.0.0-rc.1" }, - "time": "2021-12-15T03:02:58+00:00" + "time": "2021-12-14T23:55:42+00:00" }, { "name": "woocommerce/woocommerce-blocks", @@ -2926,5 +2926,5 @@ "platform-overrides": { "php": "7.0.33" }, - "plugin-api-version": "2.1.0" + "plugin-api-version": "2.0.0" } diff --git a/plugins/woocommerce/includes/class-wc-geolocation.php b/plugins/woocommerce/includes/class-wc-geolocation.php index 09c76e9b435..d47ac659386 100644 --- a/plugins/woocommerce/includes/class-wc-geolocation.php +++ b/plugins/woocommerce/includes/class-wc-geolocation.php @@ -149,11 +149,10 @@ class WC_Geolocation { } if ( empty( $ip_address ) ) { - $ip_address = self::get_ip_address(); + $ip_address = self::get_ip_address(); + $country_code = self::get_country_code_from_headers(); } - $country_code = self::get_country_code_from_headers(); - /** * Get geolocation filter. *