diff --git a/.github/workflows/smoke-test-daily.yml b/.github/workflows/smoke-test-daily.yml index e859ad41ed3..ea5ba068ba2 100644 --- a/.github/workflows/smoke-test-daily.yml +++ b/.github/workflows/smoke-test-daily.yml @@ -39,4 +39,5 @@ jobs: E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} run: | + npx wc-e2e test:e2e ./tests/e2e/specs/smoke-tests/update-woocommerce.test.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..3f97cff946f 100644 --- a/tests/e2e/env/CHANGELOG.md +++ b/tests/e2e/env/CHANGELOG.md @@ -1,6 +1,8 @@ # 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. # 0.2.2 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..c4c7d052e7b --- /dev/null +++ b/tests/e2e/env/utils/get-plugin-zip.js @@ -0,0 +1,83 @@ +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 checkZip( 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 checkZip = 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, +}; 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.test.js b/tests/e2e/specs/smoke-tests/update-woocommerce.test.js new file mode 100644 index 00000000000..8f9723ca53e --- /dev/null +++ b/tests/e2e/specs/smoke-tests/update-woocommerce.test.js @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ + const { merchant } = require( '@woocommerce/e2e-utils' ); + + const { getRemotePluginZip } = require( '@woocommerce/e2e-environment' ); + + /** + * External dependencies + */ + const { + it, + describe, + beforeAll, + } = require( '@jest/globals' ); + + + const nightlyZip = 'https://github.com/woocommerce/woocommerce/releases/download/nightly/woocommerce-trunk-nightly.zip'; + const pluginName = 'WooCommerce'; + + let pluginPath; + + describe( '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;