merge master

This commit is contained in:
Ron Rennick 2020-11-09 14:30:01 -04:00
commit 7282e0bca6
32 changed files with 2790 additions and 5548 deletions

View File

@ -563,7 +563,7 @@ jQuery( function( $ ) {
wc_checkout_form.$checkout_form.removeClass( 'processing' ).unblock(); wc_checkout_form.$checkout_form.removeClass( 'processing' ).unblock();
wc_checkout_form.$checkout_form.find( '.input-text, select, input:checkbox' ).trigger( 'validate' ).blur(); wc_checkout_form.$checkout_form.find( '.input-text, select, input:checkbox' ).trigger( 'validate' ).blur();
wc_checkout_form.scroll_to_notices(); wc_checkout_form.scroll_to_notices();
$( document.body ).trigger( 'checkout_error' ); $( document.body ).trigger( 'checkout_error' , [ error_message ] );
}, },
scroll_to_notices: function() { scroll_to_notices: function() {
var scrollElement = $( '.woocommerce-NoticeGroup-updateOrderReview, .woocommerce-NoticeGroup-checkout' ); var scrollElement = $( '.woocommerce-NoticeGroup-updateOrderReview, .woocommerce-NoticeGroup-checkout' );

View File

@ -1,5 +1,64 @@
== Changelog == == Changelog ==
= 4.7.0 - 2020-11-10 =
**WooCommerce**
* Tweak - Update `product_cat/tag` taxonomy template file names to `product-cat/tag`. #27736
* Tweak - Exclude draft pages from the "Shop page" setting. #27890
* Tweak - Styling to properly display product reviews within the dashboard activity widget. #27968
* Fix - Fixes Photoswipe action buttons being obscured by admin bar. #27010
* Fix - Allow variation image to be removed via REST API. #27299
* Fix - Fixed WP CLI command to delete tax classes. #27310
* Fix - Prevent regenerate image filter loop. #27483
* Fix - Fixed some race conditions in `WC_Install`. #27696
* Fix - Improved PHP 8 support for `Automattic\WooCommerce\RestApi\Utilities\SingletonTrait`. #27707
* Fix - Adjust stock even if `reduce_stock` meta is not set in `wc_maybe_reduce_stock_levels`. #27763
* Fix - Removed duplicated CSS code from jQuery UI. #27767
* Fix - HTML syntax error in scheduled product message. #27842
* Fix - Update logic to determine if an order requires payment to check the order instead of the cart. #27893
* Fix - Use `Set password` title for lost password reset form when applicable. #27898
* Fix - REST API - Fixed deprecated notices while querying orders and refunds through REST API v1 endpoints. #27934
* Fix - Email address starting with `www` being displayed as a URL link in the admin order details page. #27983
* Fix - Unexpected HTTP 401 "Sorry, you cannot list resources" REST API responses that occur when a plugin or custom code determines the current WordPress user before WooCommerce is fully initialized. #27587
* Dev - Add `woocommerce_should_send_low_stock_notification` filter. #27819
* Dev - Introduce (again) a dependency injection framework for the code in the src directory. #27733
* Dev - Remove leftover code and data from the reverted improvement for variations filtering by attribute. #27748
* Dev - Escaped labels in `woocommerce_form_field()`. #27800
* Dev - Add a `NumberUtil::round` method to workaround a breaking change in the buil-in round function in PHP8. #27830
* Dev - Remove default value from optional parameters that are followed by required parameters in functions/methods, since those are de-facto required and trigger a deprectation notice in PHP 8. #27840
* Dev - REST API - Add user-friendly attribute names and values to order line items metadata.
* Dev - REST API - Adds `parent_name` to `line_items` of the GET /orders endpoint.
* Localization - Added Serbia districts. #27778
* Localization - Make city, and postcode non-required fields. #27779
* Localization - Add i18n locale information for Uganda, Kenya and Tanzania. #27164
* Localization - Renamed "Postcode / ZIP" to "Pin code", and renamed "State / County" to "State" for India. #27516
* Localization - Added postcode validation for addresses in India. #27546
**WooCommerce Blocks - 3.5.0 & 3.6.0**
* Make 'retry' property on errors from checkoutAfterProcessingWithSuccess/Error observers default to true if it's undefined. ([3261](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3261))
* Ensure new payment methods are only displayed when no saved payment method is selected. ([3247](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3247))
* Load WC Blocks CSS after editor CSS. ([3219](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3219))
* Restore saved payment method data after closing an express payment method. ([3210](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3210))
* Use light default background colour for country/state dropdowns. ([3189](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3189))
* Fix broken Express Payment Method use in the Checkout block for logged out or incognito users. ([3165](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3165))
* Fix State label for Spain. ([3147](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3147))
* Don't throw an error when registering a payment method fails. ([3134](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3134))
* Don't load contents of payment method hidden tabs. ([3227](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3227))
* Use noticeContexts from useEmitResponse instead of hardcoded values. ([3161](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3161))
**WooCommerce Admin - 1.6.3**
* Tweak: Add BR and IN to list of stripe countries [#5377](https://github.com/woocommerce/woocommerce-admin/pull/5377)
* Fix: Redirect instead of stalling on WCPay Inbox note action [#5413](https://github.com/woocommerce/woocommerce-admin/pull/5413)
= 4.6.2 - 2020-11-05 =
**WooCommerce**
* Prevent checkout from creating accounts when related setting is disabled.
= 4.6.1 - 2020-10-21 = = 4.6.1 - 2020-10-21 =
**WooCommerce** **WooCommerce**

38
composer.lock generated
View File

@ -259,6 +259,12 @@
"provider", "provider",
"service" "service"
], ],
"funding": [
{
"url": "https://github.com/philipobenito",
"type": "github"
}
],
"time": "2020-09-28T13:38:44+00:00" "time": "2020-09-28T13:38:44+00:00"
}, },
{ {
@ -446,27 +452,22 @@
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
"version": "v3.4.45", "version": "v3.4.46",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/css-selector.git", "url": "https://github.com/symfony/css-selector.git",
"reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518" "reference": "da3d9da2ce0026771f5fe64cb332158f1bd2bc33"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/9ccf6e78077a3fc1596e6c7b5958008965a11518", "url": "https://api.github.com/repos/symfony/css-selector/zipball/da3d9da2ce0026771f5fe64cb332158f1bd2bc33",
"reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518", "reference": "da3d9da2ce0026771f5fe64cb332158f1bd2bc33",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^5.5.9|>=7.0.8" "php": "^5.5.9|>=7.0.8"
}, },
"type": "library", "type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.4-dev"
}
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Symfony\\Component\\CssSelector\\": "" "Symfony\\Component\\CssSelector\\": ""
@ -495,7 +496,21 @@
], ],
"description": "Symfony CssSelector Component", "description": "Symfony CssSelector Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2020-03-16T08:31:04+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-10-24T10:57:07+00:00"
}, },
{ {
"name": "woocommerce/action-scheduler", "name": "woocommerce/action-scheduler",
@ -686,5 +701,6 @@
"platform-dev": [], "platform-dev": [],
"platform-overrides": { "platform-overrides": {
"php": "7.1" "php": "7.1"
} },
"plugin-api-version": "1.1.0"
} }

View File

@ -667,15 +667,18 @@ class WC_Checkout {
* @return array of data. * @return array of data.
*/ */
public function get_posted_data() { public function get_posted_data() {
$skipped = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing
$data = array( $data = array(
'terms' => (int) isset( $_POST['terms'] ), // WPCS: input var ok, CSRF ok. 'terms' => (int) isset( $_POST['terms'] ),
'createaccount' => (int) ! empty( $_POST['createaccount'] ), // WPCS: input var ok, CSRF ok. 'createaccount' => (int) ( $this->is_registration_enabled() ? ! empty( $_POST['createaccount'] ) : false ),
'payment_method' => isset( $_POST['payment_method'] ) ? wc_clean( wp_unslash( $_POST['payment_method'] ) ) : '', // WPCS: input var ok, CSRF ok. 'payment_method' => isset( $_POST['payment_method'] ) ? wc_clean( wp_unslash( $_POST['payment_method'] ) ) : '',
'shipping_method' => isset( $_POST['shipping_method'] ) ? wc_clean( wp_unslash( $_POST['shipping_method'] ) ) : '', // WPCS: input var ok, CSRF ok. 'shipping_method' => isset( $_POST['shipping_method'] ) ? wc_clean( wp_unslash( $_POST['shipping_method'] ) ) : '',
'ship_to_different_address' => ! empty( $_POST['ship_to_different_address'] ) && ! wc_ship_to_billing_address_only(), // WPCS: input var ok, CSRF ok. 'ship_to_different_address' => ! empty( $_POST['ship_to_different_address'] ) && ! wc_ship_to_billing_address_only(),
'woocommerce_checkout_update_totals' => isset( $_POST['woocommerce_checkout_update_totals'] ), // WPCS: input var ok, CSRF ok. 'woocommerce_checkout_update_totals' => isset( $_POST['woocommerce_checkout_update_totals'] ),
); );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$skipped = array();
foreach ( $this->get_checkout_fields() as $fieldset_key => $fieldset ) { foreach ( $this->get_checkout_fields() as $fieldset_key => $fieldset ) {
if ( $this->maybe_skip_fieldset( $fieldset_key, $data ) ) { if ( $this->maybe_skip_fieldset( $fieldset_key, $data ) ) {
$skipped[] = $fieldset_key; $skipped[] = $fieldset_key;

View File

@ -565,7 +565,7 @@ class WC_Shortcodes {
$single_product = new WP_Query( $args ); $single_product = new WP_Query( $args );
?> ?>
<script type="text/javascript"> <script type="text/javascript">
jQuery( document ).ready( function( $ ) { jQuery( function( $ ) {
var $variations_form = $( '[data-product-page-preselected-id="<?php echo esc_attr( $preselected_id ); ?>"]' ).find( 'form.variations_form' ); var $variations_form = $( '[data-product-page-preselected-id="<?php echo esc_attr( $preselected_id ); ?>"]' ).find( 'form.variations_form' );
<?php foreach ( $attributes as $attr => $value ) { ?> <?php foreach ( $attributes as $attr => $value ) { ?>

View File

@ -56,6 +56,9 @@ class WC_Validation {
case 'BA': case 'BA':
$valid = (bool) preg_match( '/^([7-8]{1})([0-9]{4})$/', $postcode ); $valid = (bool) preg_match( '/^([7-8]{1})([0-9]{4})$/', $postcode );
break; break;
case 'BE':
$valid = (bool) preg_match( '/^([0-9]{4})$/i', $postcode );
break;
case 'BR': case 'BR':
$valid = (bool) preg_match( '/^([0-9]{5})([-])?([0-9]{3})$/', $postcode ); $valid = (bool) preg_match( '/^([0-9]{5})([-])?([0-9]{3})$/', $postcode );
break; break;

View File

@ -102,7 +102,7 @@ class WC_Shop_Customizer {
$max_notice = __( 'The maximum allowed setting is %d', 'woocommerce' ); $max_notice = __( 'The maximum allowed setting is %d', 'woocommerce' );
?> ?>
<script type="text/javascript"> <script type="text/javascript">
jQuery( document ).ready( function( $ ) { jQuery( function( $ ) {
$( document.body ).on( 'change', '.woocommerce-cropping-control input[type="radio"]', function() { $( document.body ).on( 'change', '.woocommerce-cropping-control input[type="radio"]', function() {
var $wrapper = $( this ).closest( '.woocommerce-cropping-control' ), var $wrapper = $( this ).closest( '.woocommerce-cropping-control' ),
value = $wrapper.find( 'input:checked' ).val(); value = $wrapper.find( 'input:checked' ).val();

View File

@ -108,8 +108,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
$order->set_props( $order->set_props(
array( array(
'parent_id' => $post_object->post_parent, 'parent_id' => $post_object->post_parent,
'date_created' => 0 < $post_object->post_date_gmt ? wc_string_to_timestamp( $post_object->post_date_gmt ) : null, 'date_created' => $this->string_to_timestamp( $post_object->post_date_gmt ),
'date_modified' => 0 < $post_object->post_modified_gmt ? wc_string_to_timestamp( $post_object->post_modified_gmt ) : null, 'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
'status' => $post_object->post_status, 'status' => $post_object->post_status,
) )
); );

View File

@ -123,8 +123,8 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
array( array(
'code' => $post_object->post_title, 'code' => $post_object->post_title,
'description' => $post_object->post_excerpt, 'description' => $post_object->post_excerpt,
'date_created' => 0 < $post_object->post_date_gmt ? wc_string_to_timestamp( $post_object->post_date_gmt ) : null, 'date_created' => $this->string_to_timestamp( $post_object->post_date_gmt ),
'date_modified' => 0 < $post_object->post_modified_gmt ? wc_string_to_timestamp( $post_object->post_modified_gmt ) : null, 'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
'date_expires' => metadata_exists( 'post', $coupon_id, 'date_expires' ) ? get_post_meta( $coupon_id, 'date_expires', true ) : get_post_meta( $coupon_id, 'expiry_date', true ), // @todo: Migrate expiry_date meta to date_expires in upgrade routine. 'date_expires' => metadata_exists( 'post', $coupon_id, 'date_expires' ) ? get_post_meta( $coupon_id, 'date_expires', true ) : get_post_meta( $coupon_id, 'expiry_date', true ), // @todo: Migrate expiry_date meta to date_expires in upgrade routine.
'discount_type' => get_post_meta( $coupon_id, 'discount_type', true ), 'discount_type' => get_post_meta( $coupon_id, 'discount_type', true ),
'amount' => get_post_meta( $coupon_id, 'coupon_amount', true ), 'amount' => get_post_meta( $coupon_id, 'coupon_amount', true ),

View File

@ -630,4 +630,16 @@ class WC_Data_Store_WP {
); );
wp_cache_delete( 'lookup_table', 'object_' . $id ); wp_cache_delete( 'lookup_table', 'object_' . $id );
} }
/**
* Converts a WP post date string into a timestamp.
*
* @since 4.8.0
*
* @param string $time_string The WP post date string.
* @return int|null The date string converted to a timestamp or null.
*/
protected function string_to_timestamp( $time_string ) {
return '0000-00-00 00:00:00' !== $time_string ? wc_string_to_timestamp( $time_string ) : null;
}
} }

View File

@ -170,8 +170,8 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
array( array(
'name' => $post_object->post_title, 'name' => $post_object->post_title,
'slug' => $post_object->post_name, 'slug' => $post_object->post_name,
'date_created' => 0 < $post_object->post_date_gmt ? wc_string_to_timestamp( $post_object->post_date_gmt ) : null, 'date_created' => $this->string_to_timestamp( $post_object->post_date_gmt ),
'date_modified' => 0 < $post_object->post_modified_gmt ? wc_string_to_timestamp( $post_object->post_modified_gmt ) : null, 'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
'status' => $post_object->post_status, 'status' => $post_object->post_status,
'description' => $post_object->post_content, 'description' => $post_object->post_content,
'short_description' => $post_object->post_excerpt, 'short_description' => $post_object->post_excerpt,

View File

@ -62,8 +62,8 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
array( array(
'name' => $post_object->post_title, 'name' => $post_object->post_title,
'slug' => $post_object->post_name, 'slug' => $post_object->post_name,
'date_created' => 0 < $post_object->post_date_gmt ? wc_string_to_timestamp( $post_object->post_date_gmt ) : null, 'date_created' => $this->string_to_timestamp( $post_object->post_date_gmt ),
'date_modified' => 0 < $post_object->post_modified_gmt ? wc_string_to_timestamp( $post_object->post_modified_gmt ) : null, 'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
'status' => $post_object->post_status, 'status' => $post_object->post_status,
'menu_order' => $post_object->menu_order, 'menu_order' => $post_object->menu_order,
'reviews_allowed' => 'open' === $post_object->comment_status, 'reviews_allowed' => 'open' === $post_object->comment_status,

View File

@ -316,15 +316,16 @@ function wc_cart_totals_order_total_html() {
if ( ! empty( $tax_string_array ) ) { if ( ! empty( $tax_string_array ) ) {
$taxable_address = WC()->customer->get_taxable_address(); $taxable_address = WC()->customer->get_taxable_address();
/* translators: %s: country name */ if ( WC()->customer->is_customer_outside_base() && ! WC()->customer->has_calculated_shipping() ) {
$estimated_text = WC()->customer->is_customer_outside_base() && ! WC()->customer->has_calculated_shipping() ? sprintf( ' ' . __( 'estimated for %s', 'woocommerce' ), WC()->countries->estimated_for_prefix( $taxable_address[0] ) . WC()->countries->countries[ $taxable_address[0] ] ) : ''; $country = WC()->countries->estimated_for_prefix( $taxable_address[0] ) . WC()->countries->countries[ $taxable_address[0] ];
$value .= '<small class="includes_tax">(' /* translators: 1: tax amount 2: country name */
/* translators: includes tax information */ $tax_text = wp_kses_post( sprintf( __( '(includes %1$s estimated for %2$s)', 'woocommerce' ), implode( ', ', $tax_string_array ), $country ) );
. esc_html__( 'includes', 'woocommerce' ) } else {
. ' ' /* translators: %s: tax amount */
. wp_kses_post( implode( ', ', $tax_string_array ) ) $tax_text = wp_kses_post( sprintf( __( '(includes %s)', 'woocommerce' ), implode( ', ', $tax_string_array ) ) );
. esc_html( $estimated_text ) }
. ')</small>';
$value .= '<small class="includes_tax">' . $tax_text . '</small>';
} }
} }

6387
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -83,7 +83,6 @@
"mocha": "7.2.0", "mocha": "7.2.0",
"node-sass": "4.13.1", "node-sass": "4.13.1",
"prettier": "npm:wp-prettier@2.0.5", "prettier": "npm:wp-prettier@2.0.5",
"puppeteer": "^2.1.1",
"stylelint": "12.0.1", "stylelint": "12.0.1",
"stylelint-config-wordpress": "16.0.0", "stylelint-config-wordpress": "16.0.0",
"typescript": "3.9.7", "typescript": "3.9.7",

View File

@ -4,7 +4,7 @@ Tags: e-commerce, store, sales, sell, woo, shop, cart, checkout, downloadable, d
Requires at least: 5.3 Requires at least: 5.3
Tested up to: 5.5 Tested up to: 5.5
Requires PHP: 7.0 Requires PHP: 7.0
Stable tag: 4.6.1 Stable tag: 4.6.2
License: GPLv3 License: GPLv3
License URI: https://www.gnu.org/licenses/gpl-3.0.html License URI: https://www.gnu.org/licenses/gpl-3.0.html

View File

@ -24,24 +24,14 @@ The simplest way to use the client is directly:
import { HTTPClientFactory } from '@woocommerce/api'; import { HTTPClientFactory } from '@woocommerce/api';
// You can create an API client using the client factory with pre-configured middleware for convenience. // You can create an API client using the client factory with pre-configured middleware for convenience.
let httpClient = HTTPClientFactory.withBasicAuth( let client = HTTPClientFactory.build( 'https://example.com' )
// The base URL of your REST API. .withBasicAuth( 'username', 'password' )
'https://example.com/wp-json/', .create();
// The username for your WordPress user.
'username',
// The password for your WordPress user.
'password',
);
// You can also create an API client configured for requests using OAuth. // You can also create an API client configured for requests using OAuth.
httpClient = HTTPClientFactory.withOAuth( client = HTTPClientFactory.build( 'https://example.com' )
// The base URL of your REST API. .withOAuth( 'consumer_secret', 'consumer_password' )
'https://example.com/wp-json/', .create();
// The OAuth API Key's consumer secret.
'consumer_secret',
// The OAuth API Key's consumer password.
'consumer_pasword',
);
// You can then use the client to make API requests. // You can then use the client to make API requests.
httpClient.get( '/wc/v3/products' ).then( ( response ) => { httpClient.get( '/wc/v3/products' ).then( ( response ) => {
@ -54,6 +44,7 @@ httpClient.get( '/wc/v3/products' ).then( ( response ) => {
}, ( error ) => { }, ( error ) => {
// Handle errors that may have come up. // Handle errors that may have come up.
} ); } );
``` ```
### Repositories ### Repositories
@ -66,7 +57,9 @@ import { SimpleProduct } from '@woocommerce/api';
// Prepare the HTTP client that will be consumed by the repository. // Prepare the HTTP client that will be consumed by the repository.
// This is necessary so that it can make requests to the REST API. // This is necessary so that it can make requests to the REST API.
const httpClient = HTTPClientFactory.withBasicAuth( 'https://example.com/wp-json/','username','password' ); const httpClient = HTTPClientFactory.build( 'https://example.com' )
.withBasicAuth( 'username', 'password' )
.create();
const repository = SimpleProduct.restRepository( httpClient ); const repository = SimpleProduct.restRepository( httpClient );

View File

@ -0,0 +1,34 @@
import axios, { AxiosInstance } from 'axios';
import * as moxios from 'moxios';
import { AxiosURLToQueryInterceptor } from '../axios-url-to-query-interceptor';
describe( 'AxiosURLToQueryInterceptor', () => {
let urlToQueryInterceptor: AxiosURLToQueryInterceptor;
let axiosInstance: AxiosInstance;
beforeEach( () => {
axiosInstance = axios.create();
moxios.install( axiosInstance );
urlToQueryInterceptor = new AxiosURLToQueryInterceptor( 'test' );
urlToQueryInterceptor.start( axiosInstance );
} );
afterEach( () => {
urlToQueryInterceptor.stop( axiosInstance );
moxios.uninstall();
} );
it( 'should put path in query string', async () => {
moxios.stubRequest( 'http://test.test/?test=%2Ftest%2Froute', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await axiosInstance.get( 'http://test.test/test/route' );
expect( response.status ).toEqual( 200 );
} );
} );

View File

@ -1,4 +1,4 @@
import { buildURL } from '../utils'; import { buildURL, buildURLWithParams } from '../utils';
describe( 'buildURL', () => { describe( 'buildURL', () => {
it( 'should use base when given no url', () => { it( 'should use base when given no url', () => {
@ -15,9 +15,16 @@ describe( 'buildURL', () => {
const url = buildURL( { baseURL: 'http://test.test', url: 'yes/test' } ); const url = buildURL( { baseURL: 'http://test.test', url: 'yes/test' } );
expect( url ).toBe( 'http://test.test/yes/test' ); expect( url ).toBe( 'http://test.test/yes/test' );
} ); } );
} );
it( 'should combine base and url with trailing/leading slashes', () => { describe( 'buildURLWithParams', () => {
const url = buildURL( { baseURL: 'http://test.test/////', url: '////yes/test' } ); it( 'should do nothing without query string', () => {
expect( url ).toBe( 'http://test.test/yes/test' ); const url = buildURLWithParams( { baseURL: 'http://test.test' } );
expect( url ).toBe( 'http://test.test' );
} );
it( 'should append query string', () => {
const url = buildURLWithParams( { baseURL: 'http://test.test', params: { test: 'yes' } } );
expect( url ).toBe( 'http://test.test?test=yes' );
} ); } );
} ); } );

View File

@ -2,10 +2,10 @@ import type { AxiosRequestConfig } from 'axios';
import * as createHmac from 'create-hmac'; import * as createHmac from 'create-hmac';
import * as OAuth from 'oauth-1.0a'; import * as OAuth from 'oauth-1.0a';
import { AxiosInterceptor } from './axios-interceptor'; import { AxiosInterceptor } from './axios-interceptor';
import { buildURL } from './utils'; import { buildURLWithParams } from './utils';
/** /**
* A utility class for managing the lifecycle of an authentication interceptor. * An interceptor for adding OAuth 1.0a signatures to HTTP requests.
*/ */
export class AxiosOAuthInterceptor extends AxiosInterceptor { export class AxiosOAuthInterceptor extends AxiosInterceptor {
/** /**
@ -14,7 +14,7 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor {
* @type {Object} * @type {Object}
* @private * @private
*/ */
private oauth: OAuth; private readonly oauth: OAuth;
/** /**
* Creates a new interceptor. * Creates a new interceptor.
@ -44,7 +44,7 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor {
* @return {AxiosRequestConfig} The request with the additional authorization headers. * @return {AxiosRequestConfig} The request with the additional authorization headers.
*/ */
protected handleRequest( request: AxiosRequestConfig ): AxiosRequestConfig { protected handleRequest( request: AxiosRequestConfig ): AxiosRequestConfig {
const url = buildURL( request ); const url = buildURLWithParams( request );
if ( url.startsWith( 'https' ) ) { if ( url.startsWith( 'https' ) ) {
request.auth = { request.auth = {
username: this.oauth.consumer.key, username: this.oauth.consumer.key,

View File

@ -2,6 +2,9 @@ import { AxiosResponse } from 'axios';
import { AxiosInterceptor } from './axios-interceptor'; import { AxiosInterceptor } from './axios-interceptor';
import { HTTPResponse } from '../http-client'; import { HTTPResponse } from '../http-client';
/**
* An interceptor for transforming the responses from axios into a consistent format for package consumers.
*/
export class AxiosResponseInterceptor extends AxiosInterceptor { export class AxiosResponseInterceptor extends AxiosInterceptor {
/** /**
* Transforms the Axios response into our HTTP response. * Transforms the Axios response into our HTTP response.

View File

@ -0,0 +1,53 @@
import { AxiosInterceptor } from './axios-interceptor';
import { AxiosRequestConfig } from 'axios';
import { buildURL } from './utils';
/**
* An interceptor for transforming the request's path into a query parameter.
*/
export class AxiosURLToQueryInterceptor extends AxiosInterceptor {
/**
* The query parameter we want to assign the path to.
*
* @type {string}
* @private
*/
private readonly queryParam: string;
/**
* Constructs a new interceptor.
*
* @param {string} queryParam The query parameter we want to assign the path to.
*/
public constructor( queryParam: string ) {
super();
this.queryParam = queryParam;
}
/**
* Converts the outgoing path into a query parameter.
*
* @param {AxiosRequestConfig} config The axios config.
* @return {AxiosRequestConfig} The axios config.
*/
protected handleRequest( config: AxiosRequestConfig ): AxiosRequestConfig {
const url = new URL( buildURL( config ) );
// Store the path in the query string.
if ( config.params instanceof URLSearchParams ) {
config.params.set( this.queryParam, url.pathname );
} else if ( config.params ) {
config.params[ this.queryParam ] = url.pathname;
} else {
config.params = { [ this.queryParam ]: url.pathname };
}
// Store the URL without the path now that it's in the query string.
url.pathname = '';
config.url = url.toString();
delete config.baseURL;
return config;
}
}

View File

@ -1,5 +1,10 @@
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
// @ts-ignore
import buildFullPath = require( 'axios/lib/core/buildFullPath' );
// @ts-ignore
import appendParams = require( 'axios/lib/helpers/buildURL' );
/** /**
* Given an Axios request config this function generates the URL that Axios will * Given an Axios request config this function generates the URL that Axios will
* use to make the request. * use to make the request.
@ -8,17 +13,16 @@ import { AxiosRequestConfig } from 'axios';
* @return {string} The merged URL. * @return {string} The merged URL.
*/ */
export function buildURL( request: AxiosRequestConfig ): string { export function buildURL( request: AxiosRequestConfig ): string {
const base = request.baseURL || ''; return buildFullPath( request.baseURL, request.url );
if ( ! request.url ) {
return base;
} }
// Axios ignores the base when the URL is absolute. /**
const url = request.url; * Given an Axios request config this function generates the URL that Axios will
if ( ! base || url.match( /^([a-z][a-z\d+\-.]*:)?\/\/[^\/]/i ) ) { * use to make the request with the query parameters included.
return url; *
} * @param {AxiosRequestConfig} request The Axios request we're building the URL for.
* @return {string} The merged URL.
// Remove trailing slashes from the base and leading slashes from the URL so we can combine them consistently. */
return base.replace( /\/+$/, '' ) + '/' + url.replace( /^\/+/, '' ); export function buildURLWithParams( request: AxiosRequestConfig ): string {
return appendParams( buildURL( request ), request.params, request.paramsSerializer );
} }

View File

@ -1,39 +1,141 @@
import { HTTPClient } from './http-client'; import { HTTPClient } from './http-client';
import { AxiosClient, AxiosOAuthInterceptor } from './axios'; import { AxiosClient, AxiosOAuthInterceptor } from './axios';
import { AxiosRequestConfig } from 'axios';
import { AxiosInterceptor } from './axios/axios-interceptor';
import { AxiosURLToQueryInterceptor } from './axios/axios-url-to-query-interceptor';
/** /**
* A class for generating HTTPClient instances with desired configurations. * These types describe the shape of the different auth methods our factory supports.
*/
type OAuthMethod = {
type: 'oauth',
key: string,
secret: string,
};
type BasicAuthMethod = {
type: 'basic',
username: string,
password: string,
}
/**
* An interface for describing the shape of a client to create using the factory.
*/
interface BuildParams {
wpURL: string,
useIndexPermalinks?: boolean,
auth?: OAuthMethod | BasicAuthMethod,
}
/**
* A factory for generating an HTTPClient with a desired configuration.
*/ */
export class HTTPClientFactory { export class HTTPClientFactory {
/** /**
* Creates a new client instance prepared for basic auth. * The configuration object describing the client we're trying to create.
* *
* @param {string} apiURL * @private
* @param {string} username
* @param {string} password
* @return {HTTPClient} An HTTP client configured for OAuth requests.
*/ */
public static withBasicAuth( apiURL: string, username: string, password: string ): HTTPClient { private clientConfig: BuildParams;
return new AxiosClient(
{ private constructor( wpURL: string ) {
baseURL: apiURL, this.clientConfig = { wpURL };
auth: { username, password },
},
);
} }
/** /**
* Creates a new client instance prepared for oauth. * Creates a new factory that can be used to build clients.
* *
* @param {string} apiURL * @param {string} wpURL The root URL of the WordPress installation we're querying.
* @param {string} consumerKey * @return {HTTPClientFactory} The new factory instance.
* @param {string} consumerSecret
* @return {HTTPClient} An HTTP client configured for OAuth requests.
*/ */
public static withOAuth( apiURL: string, consumerKey: string, consumerSecret: string ): HTTPClient { public static build( wpURL: string ): HTTPClientFactory {
return new AxiosClient( return new HTTPClientFactory( wpURL );
{ baseURL: apiURL }, }
[ new AxiosOAuthInterceptor( consumerKey, consumerSecret ) ],
/**
* Configures the client to utilize OAuth.
*
* @param {string} key The OAuth consumer key to use.
* @param {string} secret The OAuth consumer secret to use.
* @return {HTTPClientFactory} This factory.
*/
public withOAuth( key: string, secret: string ): this {
this.clientConfig.auth = { type: 'oauth', key, secret };
return this;
}
/**
* Configures the client to utilize basic auth.
*
* @param {string} username The WordPress username to use.
* @param {string} password The password for the WordPress user.
* @return {HTTPClientFactory} This factory.
*/
public withBasicAuth( username: string, password: string ): this {
this.clientConfig.auth = { type: 'basic', username, password };
return this;
}
/**
* Configures the client to use index permalinks.
*
* @return {HTTPClientFactory} This factory.
*/
public withIndexPermalinks(): this {
this.clientConfig.useIndexPermalinks = true;
return this;
}
/**
* Configures the client to use query permalinks.
*
* @return {HTTPClientFactory} This factory.
*/
public withoutIndexPermalinks(): this {
this.clientConfig.useIndexPermalinks = false;
return this;
}
/**
* Creates a client instance using the configuration stored within.
*
* @return {HTTPClient} The created client.
*/
public create(): HTTPClient {
const axiosConfig: AxiosRequestConfig = {};
const interceptors: AxiosInterceptor[] = [];
axiosConfig.baseURL = this.clientConfig.wpURL;
if ( ! axiosConfig.baseURL.endsWith( '/' ) ) {
axiosConfig.baseURL += '/';
}
if ( this.clientConfig.useIndexPermalinks ) {
axiosConfig.baseURL += 'wp-json/';
} else {
interceptors.push( new AxiosURLToQueryInterceptor( 'rest_route' ) );
}
if ( this.clientConfig.auth ) {
switch ( this.clientConfig.auth.type ) {
case 'basic':
axiosConfig.auth = {
username: this.clientConfig.auth.username,
password: this.clientConfig.auth.password,
};
break;
case 'oauth':
interceptors.push(
new AxiosOAuthInterceptor(
this.clientConfig.auth.key,
this.clientConfig.auth.secret,
),
); );
break;
}
}
return new AxiosClient( axiosConfig, interceptors );
} }
} }

View File

@ -0,0 +1,100 @@
# WooCommerce Core End to End Test Suite
This package contains the automated end-to-end tests for WooCommerce.
## Table of contents
- [Pre-requisites](#pre-requisites)
- [Setting up core tests](#setting-up-core-tests)
- [Test functions](#test-functions)
- [Activation and setup](#activation-and-setup)
- [Merchant](#merchant)
- [Shopper](#shopper)
- [Contributing a new test](#contributing-a-new-test)
## Pre-requisites
### Setting up the test environment
Follow [E2E setup instructions](https://github.com/woocommerce/woocommerce/blob/master/tests/e2e/README.md).
### Setting up core tests
- 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
const { runShopperTests } = require( '@woocommerce/e2e-core-tests' );
runShopperTests();
```
## Test functions
The functions to access the core tests are:
### Activation and setup
- `runSetupOnboardingTests` - Run all setup and onboarding tests
- `runActivationTest` - Merchant can activate WooCommerce
- `runOnboardingFlowTest` - Merchant can complete onboarding flow
- `runTaskListTest` - Merchant can complete onboarding task list
- `runInitialStoreSettingsTest` - Merchant can complete initial settings
### Merchant
- `runMerchantTests` - Run all merchant tests
- `runCreateCouponTest` - Merchant can create coupon
- `runCreateOrderTest` - Merchant can create order
- `runAddSimpleProductTest` - Merchant can create a simple product
- `runAddVariableProductTest` - Merchant can create a variable product
- `runUpdateGeneralSettingsTest` - Merchant can update general settings
- `runProductSettingsTest` - Merchant can update product settings
- `runTaxSettingsTest` - Merchant can update tax settings
### Shopper
- `runShopperTests` - Run all shopper tests
- `runCartPageTest` - Shopper can view and update cart
- `runCheckoutPageTest` - Shopper can complete checkout
- `runMyAccountPageTest` - Shopper can access my account page
- `runSingleProductPageTest` - Shopper can view single product page
## Contributing a new test
- In your branch create a new `example-test-name.test.js` under the `tests/e2e/core-tests/specs` folder.
- Jest does not allow its global functions to be accessed outside the jest environment. To allow the test code to be published in a package import any jest global functions used in your test
```js
const {
it,
describe,
beforeAll,
} = require( '@jest/globals' );
```
- Wrap your test in a function and export it
```js
const runExampleTestName = () => {
describe('Example test', () => {
beforeAll(async () => {
// ...
});
it('do some example action', async () => {
// ...
});
// ...
});
});
module.exports = runExampleTestName;
```
- Add your test to `tests/e2e/core-tests/specs/index.js`
```js
const runExampleTestName = require( './grouping/example-test-name.test' );
// ...
module.exports = {
// ...
runExampleTestName,
}
```

View File

@ -1,44 +1,6 @@
/* /*
* Internal dependencies * Internal dependencies
*/ */
const { const allSpecs = require( './specs' );
runActivationTest,
runOnboardingFlowTest,
runTaskListTest,
runInitialStoreSettingsTest,
runSetupOnboardingTests,
runCartPageTest,
runCheckoutPageTest,
runMyAccountPageTest,
runSingleProductPageTest,
runShopperTests,
runCreateCouponTest,
runCreateOrderTest,
runAddSimpleProductTest,
runAddVariableProductTest,
runUpdateGeneralSettingsTest,
runProductSettingsTest,
runTaxSettingsTest,
runMerchantTests,
} = require( './specs' );
module.exports = { module.exports = allSpecs;
runActivationTest,
runOnboardingFlowTest,
runTaskListTest,
runInitialStoreSettingsTest,
runSetupOnboardingTests,
runCartPageTest,
runCheckoutPageTest,
runMyAccountPageTest,
runSingleProductPageTest,
runShopperTests,
runCreateCouponTest,
runCreateOrderTest,
runAddSimpleProductTest,
runAddVariableProductTest,
runUpdateGeneralSettingsTest,
runProductSettingsTest,
runTaxSettingsTest,
runMerchantTests,
};

View File

@ -42,7 +42,7 @@ const runCreateCouponTest = () => {
// Publish coupon, verify that it was published. Trash coupon, verify that it was trashed. // Publish coupon, verify that it was published. Trash coupon, verify that it was trashed.
await verifyPublishAndTrash( await verifyPublishAndTrash(
'#publish', '#publish',
'#message', '.notice',
'Coupon updated.', 'Coupon updated.',
'1 coupon moved to the Trash.' '1 coupon moved to the Trash.'
); );

View File

@ -196,9 +196,8 @@ const runAddVariableProductTest = () => {
// Publish product, verify that it was published. Trash product, verify that it was trashed. // Publish product, verify that it was published. Trash product, verify that it was trashed.
await verifyPublishAndTrash( await verifyPublishAndTrash(
'#publish', '#publish',
'.updated.notice', '.notice',
'Product published.', 'Product published.',
'Move to Trash',
'1 product moved to the Trash.' '1 product moved to the Trash.'
); );
}); });

1286
tests/e2e/env/package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

View File

@ -23,12 +23,11 @@
"dependencies": { "dependencies": {
"@automattic/puppeteer-utils": "github:Automattic/puppeteer-utils#0f3ec50", "@automattic/puppeteer-utils": "github:Automattic/puppeteer-utils#0f3ec50",
"@jest/test-sequencer": "^25.5.4", "@jest/test-sequencer": "^25.5.4",
"@wordpress/e2e-test-utils": "^4.6.0", "@wordpress/e2e-test-utils": "^4.15.0",
"@wordpress/jest-preset-default": "^6.2.0", "@wordpress/jest-preset-default": "^6.4.0",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
"jest": "^25.1.0", "jest": "^25.1.0",
"jest-puppeteer": "^4.4.0", "jest-puppeteer": "^4.4.0"
"puppeteer": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.12.0", "@babel/cli": "7.12.0",
@ -36,6 +35,11 @@
"@babel/polyfill": "7.11.5", "@babel/polyfill": "7.11.5",
"@babel/preset-env": "7.12.0", "@babel/preset-env": "7.12.0",
"@wordpress/eslint-plugin": "7.1.0", "@wordpress/eslint-plugin": "7.1.0",
"@babel/cli": "^7.12.1",
"@babel/core": "^7.12.3",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.12.1",
"@wordpress/eslint-plugin": "^4.0.0",
"ndb": "^1.1.5", "ndb": "^1.1.5",
"semver": "^7.3.2" "semver": "^7.3.2"
}, },

View File

@ -1,11 +1,9 @@
import { HTTPClientFactory } from '@woocommerce/api'; import { HTTPClientFactory } from '@woocommerce/api';
const config = require( 'config' ); const config = require( 'config' );
const httpClient = HTTPClientFactory.withBasicAuth( const httpClient = HTTPClientFactory.build( config.get( 'url' ) )
config.get( 'url' ) + '/wp-json', .withBasicAuth( config.get( 'users.admin.username' ), config.get( 'users.admin.password' ) )
config.get( 'users.admin.username' ), .create();
config.get( 'users.admin.password' ),
);
import { simpleProductFactory } from './factories/simple-product'; import { simpleProductFactory } from './factories/simple-product';

View File

@ -110,7 +110,8 @@ const verifyPublishAndTrash = async ( button, publishNotice, publishVerification
} }
// Trash // Trash
await expect( page ).toClick( 'a', { text: "Move to Trash" } ); await page.focus( 'a.submitdelete' );
await expect( page ).toClick( 'a.submitdelete' );
await page.waitForSelector( '#message' ); await page.waitForSelector( '#message' );
// Verify // Verify