diff --git a/.github/workflows/smoke-test-daily.yml b/.github/workflows/smoke-test-daily.yml index e859ad41ed3..9cbd156b68a 100644 --- a/.github/workflows/smoke-test-daily.yml +++ b/.github/workflows/smoke-test-daily.yml @@ -38,5 +38,7 @@ jobs: E2E_RETEST: 1 E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} + UPDATE_WC: 1 run: | + npx wc-e2e test:e2e ./tests/e2e/specs/smoke-tests/update-woocommerce.js npx wc-e2e test:e2e diff --git a/.gitignore b/.gitignore index 96cf28f638d..841fe64ce6e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ tests/cli/vendor /tests/e2e/env/build/ /tests/e2e/env/build-module/ /tests/e2e/screenshots +/tests/e2e/plugins /tests/e2e/utils/build/ /tests/e2e/utils/build-module/ diff --git a/package-lock.json b/package-lock.json index 5527c9dd2dd..0c874e3ff01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9232,7 +9232,9 @@ "app-root-path": "^3.0.0", "jest": "^25.1.0", "jest-each": "25.5.0", - "jest-puppeteer": "^4.4.0" + "jest-puppeteer": "^4.4.0", + "node-stream-zip": "^1.13.6", + "request": "^2.88.2" }, "dependencies": { "@automattic/puppeteer-utils": { @@ -33239,6 +33241,12 @@ } } }, + "node-stream-zip": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.13.6.tgz", + "integrity": "sha512-c7tRSVkLNOHvasWgmZ2d86cDgTWEygnkuuHNOY9c0mR3yLZtQTTrGvMaJ/fPs6+LOJn3240y30l5sjLaXFtcvw==", + "dev": true + }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", diff --git a/tests/e2e/env/CHANGELOG.md b/tests/e2e/env/CHANGELOG.md index dee76d2481b..e57468d8e24 100644 --- a/tests/e2e/env/CHANGELOG.md +++ b/tests/e2e/env/CHANGELOG.md @@ -1,6 +1,11 @@ # Unreleased - `updateReadyPageStatus` utility to update the status of the ready page +- Added plugin upload functionality util that provides a method to pull a plugin zip from a remote location + - `getRemotePluginZip( fileUrl )` to get the remote zip. Returns the filepath of the zip location. +- Added plugin zip utility functions: + - `checkNestedZip( zipFilePath, savePath )` checks a plugin zip file for any nested zip files. If one is found, it is extracted. Returns the path where the zip file is located. + - `downloadZip( fileUrl, downloadPath )` downloads a plugin zip file from a remote location to the provided path. # 0.2.2 diff --git a/tests/e2e/env/README.md b/tests/e2e/env/README.md index c34a79e80c1..c3b38dd3bd2 100644 --- a/tests/e2e/env/README.md +++ b/tests/e2e/env/README.md @@ -180,6 +180,22 @@ To implement the Slackbot in your CI: To test your setup, create a pull request that triggers an error in the E2E tests. +## Plugin functions + +Depending on the testing scenario, you may wish to upload a plugin that can be used in the tests from a remote location. + +To download a zip file, you can use `getRemotePluginZip( fileUrl )` to get the remote zip. This returns the filepath of the location where the zip file was downloaded to. For example, you could use this method to download the latest nightly version of WooCommerce: + +```javascript +const pluginZipUrl = 'https://github.com/woocommerce/woocommerce/releases/download/nightly/woocommerce-trunk-nightly.zip'; +await getRemotePluginZip( pluginZipUrl ); +``` + +The above method also makes use of the following utility methods which can also be used: + +- `checkNestedZip( zipFilePath, savePath )` used to check a plugin zip file for any nested zip files. If one is found, it is extracted. Returns the path where the zip file is located. +- `downloadZip( fileUrl, downloadPath )` can be used to directly download a plugin zip file from a remote location to the provided path. + ## Additional information Refer to [`tests/e2e/core-tests`](https://github.com/woocommerce/woocommerce/tree/trunk/tests/e2e/core-tests) for some test examples, and [`tests/e2e`](https://github.com/woocommerce/woocommerce/tree/trunk/tests/e2e) for general information on e2e tests. diff --git a/tests/e2e/env/package.json b/tests/e2e/env/package.json index edc2aaab107..0f18c42a147 100644 --- a/tests/e2e/env/package.json +++ b/tests/e2e/env/package.json @@ -30,7 +30,9 @@ "app-root-path": "^3.0.0", "jest": "^25.1.0", "jest-each": "25.5.0", - "jest-puppeteer": "^4.4.0" + "jest-puppeteer": "^4.4.0", + "request": "^2.88.2", + "node-stream-zip": "^1.13.6" }, "devDependencies": { "@babel/cli": "7.12.0", diff --git a/tests/e2e/env/utils/get-plugin-zip.js b/tests/e2e/env/utils/get-plugin-zip.js new file mode 100644 index 00000000000..edd9f404be9 --- /dev/null +++ b/tests/e2e/env/utils/get-plugin-zip.js @@ -0,0 +1,85 @@ +const path = require( 'path' ); +const getAppRoot = require( './app-root' ); +const fs = require('fs'); +const mkdirp = require( 'mkdirp' ); +const request = require('request'); +const StreamZip = require('node-stream-zip'); + +/** + * Upload a plugin zip from a remote location, such as a GitHub URL or other hosted location. + * + * @param {string} fileUrl The URL where the zip file is located. + * @returns {string} The path where the zip file is located. + */ +const getRemotePluginZip = async ( fileUrl ) => { + const appPath = getAppRoot(); + const savePath = path.resolve( appPath, 'tests/e2e/plugins' ); + mkdirp.sync( savePath ); + + // Pull the filename from the end of the URL + let fileName = fileUrl.split('/').pop(); + let filePath = path.join( savePath, fileName ); + + // First, download the zip file + await downloadZip( fileUrl, filePath ); + + // Check for a nested zip and update the filepath + filePath = await checkNestedZip( filePath, savePath ); + + return filePath; +}; + +/** + * Check the zip file for any nested zips. If one is found, extract it. + * + * @param {string} zipFilePath The location of the zip file. + * @param {string} savePath The location where to save a nested zip if found. + * @returns {string} The path where the zip file is located. + */ +const checkNestedZip = async ( zipFilePath, savePath ) => { + const zip = new StreamZip.async( { file: zipFilePath } ); + const entries = await zip.entries(); + + for (const entry of Object.values( entries )) { + if ( entry.name.match( /.zip/ )) { + await zip.extract( null, savePath ); + await zip.close(); + return path.join( savePath, entry.name ); + } + } + + return zipFilePath; +} + +/** + * Download the zip file from a remote location. + * + * @param {string} fileUrl The URL where the zip file is located. + * @param {string} downloadPath The location where to download the zip to. + * @returns {Promise} + */ +const downloadZip = async ( fileUrl, downloadPath ) => { + const options = { + url: fileUrl, + method: 'GET', + encoding: null, + }; + + // Wrap in a promise to make the request async + return new Promise( function( resolve, reject ) { + request.get(options, function( err, resp, body ) { + if ( err ) { + reject( err ); + } else { + resolve( body ); + } + }) + .pipe( fs.createWriteStream( downloadPath ) ); + }); +}; + +module.exports = { + getRemotePluginZip, + checkNestedZip, + downloadZip, +}; diff --git a/tests/e2e/env/utils/index.js b/tests/e2e/env/utils/index.js index 207d00f1049..6df8984caf7 100644 --- a/tests/e2e/env/utils/index.js +++ b/tests/e2e/env/utils/index.js @@ -1,6 +1,7 @@ const getAppRoot = require( './app-root' ); const { getAppName, getAppBase } = require( './app-name' ); const { getTestConfig, getAdminConfig } = require( './test-config' ); +const { getRemotePluginZip } = require('./get-plugin-zip'); const takeScreenshotFor = require( './take-screenshot' ); const updateReadyPageStatus = require('./update-ready-page'); const consoleUtils = require( './filter-console' ); @@ -11,6 +12,7 @@ module.exports = { getAppName, getTestConfig, getAdminConfig, + getRemotePluginZip, takeScreenshotFor, updateReadyPageStatus, ...consoleUtils, diff --git a/tests/e2e/specs/smoke-tests/update-woocommerce.js b/tests/e2e/specs/smoke-tests/update-woocommerce.js new file mode 100644 index 00000000000..8b1d2380a15 --- /dev/null +++ b/tests/e2e/specs/smoke-tests/update-woocommerce.js @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +const { merchant, utils } = require( '@woocommerce/e2e-utils' ); + +const { getRemotePluginZip } = require( '@woocommerce/e2e-environment' ); + +/** + * External dependencies + */ +const { + it, + beforeAll, +} = require( '@jest/globals' ); + +const { UPDATE_WC } = process.env; + +const nightlyZip = 'https://github.com/woocommerce/woocommerce/releases/download/nightly/woocommerce-trunk-nightly.zip'; +const pluginName = 'WooCommerce'; + +let pluginPath; + +utils.describeIf( UPDATE_WC )( 'WooCommerce plugin can be uploaded and activated', () => { + beforeAll( async () => { + pluginPath = await getRemotePluginZip( nightlyZip ); + await merchant.login(); + }); + + afterAll( async () => { + await merchant.logout(); + }); + + it( 'can upload and activate the WooCommerce plugin', async () => { + await merchant.uploadAndActivatePlugin( pluginPath, pluginName ); + }); + +}); diff --git a/tests/e2e/utils/CHANGELOG.md b/tests/e2e/utils/CHANGELOG.md index 35a7d9abe01..925774a3acc 100644 --- a/tests/e2e/utils/CHANGELOG.md +++ b/tests/e2e/utils/CHANGELOG.md @@ -8,6 +8,10 @@ - Added new merchant flows: - `openWordPressUpdatesPage()` - `installAllUpdates()` +- Added `getSlug()` helper to return the slug string for a provided string +- Added `describeIf()` to conditionally run a test suite +- Added `itIf()` to conditionally run a test case. +- Added merchant workflows around plugins: `uploadAndActivatePlugin()`, `activatePlugin()`, `deactivatePlugin()`, `deletePlugin()` # 0.1.5 diff --git a/tests/e2e/utils/README.md b/tests/e2e/utils/README.md index 00ebb58889e..e817711a13e 100644 --- a/tests/e2e/utils/README.md +++ b/tests/e2e/utils/README.md @@ -64,6 +64,7 @@ This package provides support for enabling retries in tests: - `WP_ADMIN_WC_SETTINGS` - WooCommerce settings page root - `WP_ADMIN_NEW_SHIPPING_ZONE` - WooCommerce new shipping zone - `WP_ADMIN_WC_EXTENSIONS` - WooCommerce extensions page +- `WP_ADMIN_PLUGIN_INSTALL` - WordPress plugin install page #### Front end diff --git a/tests/e2e/utils/src/flows/constants.js b/tests/e2e/utils/src/flows/constants.js index 141554b0160..99de43aa0a7 100644 --- a/tests/e2e/utils/src/flows/constants.js +++ b/tests/e2e/utils/src/flows/constants.js @@ -12,8 +12,10 @@ export const WP_ADMIN_LOGIN = baseUrl + 'wp-login.php'; export const WP_ADMIN_DASHBOARD = baseUrl + 'wp-admin/'; export const WP_ADMIN_WP_UPDATES = WP_ADMIN_DASHBOARD + 'update-core.php'; export const WP_ADMIN_PLUGINS = WP_ADMIN_DASHBOARD + 'plugins.php'; +export const WP_ADMIN_PLUGIN_INSTALL = WP_ADMIN_DASHBOARD + 'plugin-install.php'; export const WP_ADMIN_PERMALINK_SETTINGS = WP_ADMIN_DASHBOARD + 'options-permalink.php'; export const WP_ADMIN_ALL_USERS_VIEW = WP_ADMIN_DASHBOARD + 'users.php'; + /** * WooCommerce core post type pages. * @type {string} @@ -27,6 +29,7 @@ export const WP_ADMIN_NEW_ORDER = WP_ADMIN_NEW_POST_TYPE + 'shop_order'; export const WP_ADMIN_ALL_PRODUCTS_VIEW = WP_ADMIN_POST_TYPE + 'product'; export const WP_ADMIN_NEW_PRODUCT = WP_ADMIN_NEW_POST_TYPE + 'product'; export const WP_ADMIN_IMPORT_PRODUCTS = WP_ADMIN_ALL_PRODUCTS_VIEW + '&page=product_importer'; + /** * WooCommerce settings pages. * @type {string} @@ -38,6 +41,7 @@ export const WP_ADMIN_ANALYTICS_PAGES = WP_ADMIN_WC_HOME + '&path=%2Fanalytics%2 export const WP_ADMIN_WC_SETTINGS = WP_ADMIN_PLUGIN_PAGE + 'wc-settings&tab='; export const WP_ADMIN_NEW_SHIPPING_ZONE = WP_ADMIN_WC_SETTINGS + 'shipping&zone_id=new'; export const WP_ADMIN_WC_EXTENSIONS = WP_ADMIN_PLUGIN_PAGE + 'wc-addons'; + /** * Shop pages. * @type {string} @@ -47,6 +51,7 @@ export const SHOP_PRODUCT_PAGE = baseUrl + '?p='; export const SHOP_CART_PAGE = baseUrl + 'cart'; export const SHOP_CHECKOUT_PAGE = baseUrl + 'checkout/'; export const SHOP_MY_ACCOUNT_PAGE = baseUrl + 'my-account/'; + /** * Customer account pages. * @type {string} diff --git a/tests/e2e/utils/src/flows/index.js b/tests/e2e/utils/src/flows/index.js index 93e71658f41..eb701fa048b 100644 --- a/tests/e2e/utils/src/flows/index.js +++ b/tests/e2e/utils/src/flows/index.js @@ -6,6 +6,7 @@ const flowExpressions = require( './expressions' ); const merchant = require( './merchant' ); const shopper = require( './shopper' ); const { withRestApi } = require( './with-rest-api' ); +const utils = require( './utils' ); module.exports = { ...flowConstants, @@ -13,4 +14,5 @@ module.exports = { merchant, shopper, withRestApi, + utils, }; diff --git a/tests/e2e/utils/src/flows/merchant.js b/tests/e2e/utils/src/flows/merchant.js index 55a2a21060d..c025d18ca29 100644 --- a/tests/e2e/utils/src/flows/merchant.js +++ b/tests/e2e/utils/src/flows/merchant.js @@ -25,10 +25,13 @@ const { WP_ADMIN_ANALYTICS_PAGES, WP_ADMIN_ALL_USERS_VIEW, WP_ADMIN_IMPORT_PRODUCTS, + WP_ADMIN_PLUGIN_INSTALL, WP_ADMIN_WP_UPDATES, IS_RETEST_MODE, } = require( './constants' ); +const { getSlug } = require('./utils'); + const baseUrl = config.get( 'url' ); const WP_ADMIN_SINGLE_CPT_VIEW = ( postId ) => baseUrl + `wp-admin/post.php?post=${ postId }&action=edit`; @@ -276,7 +279,101 @@ const merchant = { page.waitForNavigation( { waitUntil: 'networkidle0' } ), ]); } - } + }, + + /* Uploads and activates a plugin located at the provided file path. This will also deactivate and delete the plugin if it exists. + * + * @param {string} pluginFilePath The location of the plugin zip file to upload. + * @param {string} pluginName The name of the plugin. For example, `WooCommerce`. + */ + uploadAndActivatePlugin: async ( pluginFilePath, pluginName ) => { + await merchant.openPlugins(); + + // Deactivate and delete the plugin if it exists + let pluginSlug = getSlug( pluginName ); + if ( await page.$( `a#deactivate-${pluginSlug}` ) !== null ) { + await merchant.deactivatePlugin( pluginName, true ); + } + + // Open the plugin install page + await page.goto( WP_ADMIN_PLUGIN_INSTALL, { + waitUntil: 'networkidle0', + } ); + + // Upload the plugin zip + await page.click( 'a.upload-view-toggle' ); + + await expect( page ).toMatchElement( + 'p.install-help', + { + text: 'If you have a plugin in a .zip format, you may install or update it by uploading it here.' + } + ); + + const uploader = await page.$( 'input[type=file]' ); + + await uploader.uploadFile( pluginFilePath ); + + // Manually update the button to `enabled` so we can submit the file + await page.evaluate(() => { + document.getElementById( 'install-plugin-submit' ).disabled = false; + }); + + // Click to upload the file + await page.click( '#install-plugin-submit' ); + + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + + // Click to activate the plugin + await page.click( '.button-primary' ); + + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + }, + + /** + * Activate a given plugin by the plugin's name. + * + * @param {string} pluginName The name of the plugin to activate. For example, `WooCommerce`. + */ + activatePlugin: async ( pluginName ) => { + let pluginSlug = getSlug( pluginName ); + + await expect( page ).toClick( `a#activate-${pluginSlug}` ); + + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + }, + + /** + * Deactivate a plugin by the plugin's name with the option to delete the plugin as well. + * + * @param {string} pluginName The name of the plugin to deactivate. For example, `WooCommerce`. + * @param {Boolean} deletePlugin Pass in `true` to delete the plugin. Defaults to `false`. + */ + deactivatePlugin: async ( pluginName, deletePlugin = false ) => { + let pluginSlug = getSlug( pluginName ); + + await expect( page ).toClick( `a#deactivate-${pluginSlug}` ); + + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + + if ( deletePlugin ) { + await merchant.deletePlugin( pluginName ); + } + }, + + /** + * Delete a plugin by the plugin's name. + * + * @param {string} pluginName The name of the plugin to delete. For example, `WooCommerce`. + */ + deletePlugin: async ( pluginName ) => { + let pluginSlug = getSlug( pluginName ); + + await expect( page ).toClick( `a#delete-${pluginSlug}` ); + + // Wait for Ajax calls to finish + await page.waitForResponse( response => response.status() === 200 ); + }, }; module.exports = merchant; diff --git a/tests/e2e/utils/src/flows/utils.js b/tests/e2e/utils/src/flows/utils.js new file mode 100644 index 00000000000..7e7436feb57 --- /dev/null +++ b/tests/e2e/utils/src/flows/utils.js @@ -0,0 +1,33 @@ +/** + * Take a string name and generate the slug for it. + * Example: 'My plugin' => 'my-plugin' + * + * Sourced from: https://gist.github.com/spyesx/561b1d65d4afb595f295 + **/ + export const getSlug = ( text ) => { + text = text.trim().toLowerCase(); + + // remove accents, swap ñ for n, etc + const from = 'åàáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; + const to = 'aaaaaaeeeeiiiioooouuuunc------'; + + for (let i = 0, l = from.length; i < l; i++) { + text = text.replace(new RegExp(from.charAt(i), "g"), to.charAt(i)); + } + + return text + .replace(/[^a-z0-9 -]/g, '') // remove invalid chars + .replace(/\s+/g, '-') // collapse whitespace and replace by - + .replace(/-+/g, '-') // collapse dashes + .replace(/^-+/, '') // trim - from start of text + .replace(/-+$/, '') // trim - from end of text + .replace(/-/g, '-'); +}; + +// Conditionally determine whether or not to skip a test suite +export const describeIf = ( condition ) => + condition ? describe : describe.skip; + +// Conditionally determine whether or not to skip a test case +export const itIf = ( condition ) => + condition ? it : it.skip;