Commit 971f21e4bc2 for woocommerce
commit 971f21e4bc22474c09151ca4dd50ebbb245fda21
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Jul 14 18:52:40 2026 +0300
Hide shipping details for non-shipping orders in admin (#66488)
* fix: hide shipping for non-shipping admin orders
Store API checkout can retain billing-derived shipping fields for orders that do not need fulfillment. The admin order summary treated any formatted shipping address as displayable, exposing compatibility data for virtual-only orders.
Gate the read-only shipping address and supplemental fields through needs_shipping_address(). Keep persisted data and editable inputs intact.
Refs #39300
* chore: add admin shipping display changelog
Record the merchant-facing virtual-order display correction for the WooCommerce release notes.
Refs #39300
* fix: preserve custom admin shipping fields
The initial display guard suppressed the complete filtered shipping field array for non-shipping orders. That could hide extension-provided merchant data unrelated to WooCommerce core address presentation.
Continue rendering filtered custom fields while suppressing only the built-in copied phone alongside the formatted core address.
Refs #39300
* fix: scope admin shipping suppression to virtual orders
Store API checkout persists billing-derived shipping data for compatibility, but virtual-only orders should not show that data in the read-only admin summary.
Using needs_shipping_address() as the display predicate also hides legitimate explicit data for REST-created orders and physical local-pickup orders.
Require both Store API provenance and an entirely virtual, resolvable product-line set before suppressing core shipping details. Preserve ambiguous orders and cover the affected creation and pickup paths.
Refs #39300
* Fix admin shipping suppression to use persisted order shipping items
The read-only admin shipping summary decided whether to hide billing-derived
shipping details by inspecting the current virtual state of each ordered product
via get_product(). Because that reads live catalog data, editing a product to
virtual after purchase retroactively hid the persisted shipping address and phone
of historical physical orders that still carry a shipping line item.
Base the suppression on the order's own persisted shipping line items
(get_shipping_methods()) instead. This is order-time evidence immune to later
catalog edits, and it keeps store-api local-pickup orders showing their shipping
details (they retain a shipping line) unlike gating on needs_shipping_address().
Removing the product-iterating helper also drops the inline @var docblock that
failed the required branch lint (Generic.Commenting.DocComment.MissingShort).
Add a regression test covering a physical store-api order whose product is later
changed to virtual.
* Preserve explicitly edited shipping on Store API virtual orders
Suppressing the admin shipping summary on Store API orders without a shipping line
item also hid shipping values that a merchant or integration explicitly edited: the
edit form saves the address and phone without adding a shipping line, so the order
still looked shippingless and the collapsed summary reported 'No shipping address
set.' — inconsistent with the persisted data and the edit form.
Store API copies the billing address into the shipping address when a purchase
needs no fulfillment, and leaves no persisted marker for that copy. Treat the
shipping address as billing-derived (and safe to hide) only while every copied
field still matches billing; once any field diverges, the values are explicit and
are shown. This is added alongside the existing created_via and shipping-item
checks, each of which guards a distinct case (auto-copy source, real fulfillment /
catalog edits).
Add a test covering an explicitly edited, divergent shipping address on a Store API
order without shipping, and give the shared fixture a billing phone so the
billing-derived path matches the Store API's copy.
* fix: compare persisted shipping address values
Store API virtual orders use matching billing and shipping values to identify compatibility copies in the admin summary.
The comparison used filterable view-context getters, allowing extensions to normalize distinct persisted values into an apparent match and hide explicitly saved shipping data.
Compare raw edit-context values instead. Exercise the actual admin meta-box save path and cover filtered comparisons and phone-only edits.
Refs #39300
* fix: make admin shipping suppression filterable
Store API orders without shipping can retain billing-derived shipping data that the admin summary now hides. Extensions have no focused way to opt out when that heuristic does not fit their order flow.
Expose the computed decision through a documented filter with the order as context. Cover the override behavior and document why persisted edit-context address values drive the heuristic.
Refs #39300
diff --git a/plugins/woocommerce/changelog/fix-39300-virtual-order-admin-shipping b/plugins/woocommerce/changelog/fix-39300-virtual-order-admin-shipping
new file mode 100644
index 00000000000..4375ad6b95a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39300-virtual-order-admin-shipping
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Hide shipping details for non-shipping orders in the admin order summary.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
index fafe0da7833..e09e220375d 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
@@ -184,6 +184,48 @@ class WC_Meta_Box_Order_Data {
self::$shipping_fields = self::get_shipping_fields();
}
+ /**
+ * Whether an order was placed without any shipping, based on persisted order data.
+ *
+ * A purchase that needs no fulfillment is saved without a shipping line item. Relying
+ * on the order's own shipping items — rather than the current virtual state of the
+ * catalog products, which a merchant can change after the order is placed — keeps a
+ * catalog edit from ever rewriting a historical order's shipping summary, and keeps
+ * orders that retain a shipping line (including Store API local pickup) showing.
+ *
+ * @param WC_Order $order Order object.
+ * @return bool
+ */
+ private static function order_has_no_shipping( $order ) {
+ return 0 === count( $order->get_shipping_methods() );
+ }
+
+ /**
+ * Whether an order's shipping address is still the billing-derived copy.
+ *
+ * Store API checkout copies the billing address into the shipping address for
+ * backwards compatibility when a purchase needs no fulfillment. There is no persisted
+ * marker for that copy, so the shipping address is treated as billing-derived only
+ * while every copied field still matches billing. Once a merchant or integration edits
+ * the shipping fields to diverge, the values are explicit and must be shown.
+ * Individual getters use the edit context instead of get_address() so view filters
+ * cannot make distinct persisted values appear equal.
+ *
+ * @param WC_Order $order Order object.
+ * @return bool
+ */
+ private static function order_shipping_matches_billing( $order ) {
+ $copied_fields = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' );
+
+ foreach ( $copied_fields as $field ) {
+ if ( $order->{"get_shipping_$field"}( 'edit' ) !== $order->{"get_billing_$field"}( 'edit' ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* Output the metabox.
*
@@ -571,8 +613,21 @@ class WC_Meta_Box_Order_Data {
if ( $order->get_user_id() !== 0 && is_wp_error( $user ) ) {
echo '<p>' . esc_html( $details_not_available_message ) . '</p>';
} else {
- if ( $order->get_formatted_shipping_address() ) {
- echo '<p>' . wp_kses( $order->get_formatted_shipping_address(), array( 'br' => array() ) ) . '</p>';
+ $hide_core_shipping_details = 'store-api' === $order->get_created_via() && self::order_has_no_shipping( $order ) && self::order_shipping_matches_billing( $order );
+
+ /**
+ * Filters whether billing-derived shipping details are hidden in the order admin summary.
+ *
+ * @param bool $hide_core_shipping_details Whether core shipping details are hidden.
+ * @param WC_Order $order Order object.
+ *
+ * @since 11.1.0
+ */
+ $hide_core_shipping_details = apply_filters( 'woocommerce_hide_order_admin_shipping_details', $hide_core_shipping_details, $order );
+ $shipping_address = $hide_core_shipping_details ? '' : $order->get_formatted_shipping_address();
+
+ if ( $shipping_address ) {
+ echo '<p>' . wp_kses( $shipping_address, array( 'br' => array() ) ) . '</p>';
} else {
echo '<p class="none_set">' . esc_html__( 'No shipping address set.', 'woocommerce' ) . '</p>';
}
@@ -581,6 +636,10 @@ class WC_Meta_Box_Order_Data {
if ( ! empty( $shipping_fields ) ) {
foreach ( $shipping_fields as $key => $field ) {
+ if ( $hide_core_shipping_details && 'phone' === $key && ! isset( $field['value'] ) ) {
+ continue;
+ }
+
if ( isset( $field['show'] ) && false === $field['show'] ) {
continue;
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
new file mode 100644
index 00000000000..0f7d48cdb94
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
@@ -0,0 +1,433 @@
+<?php
+/**
+ * Tests for the order data meta box.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+require_once WC_ABSPATH . 'includes/admin/wc-meta-box-functions.php';
+require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-order-data.php';
+
+/**
+ * Tests for WC_Meta_Box_Order_Data.
+ */
+class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Orders created by a test.
+ *
+ * @var WC_Order[]
+ */
+ private $orders = array();
+
+ /**
+ * Products created by a test.
+ *
+ * @var WC_Product[]
+ */
+ private $products = array();
+
+ /**
+ * Whether the shipping calculation option existed before the test.
+ *
+ * @var bool
+ */
+ private $had_shipping_calculation_option;
+
+ /**
+ * Original shipping calculation option value.
+ *
+ * @var mixed
+ */
+ private $original_shipping_calculation_option;
+
+ /**
+ * Whether the global order existed before the test.
+ *
+ * @var bool
+ */
+ private $had_global_order;
+
+ /**
+ * Original global order value.
+ *
+ * @var mixed
+ */
+ private $original_global_order;
+
+ /**
+ * Shipping fields filter added by a test.
+ *
+ * @var callable|null
+ */
+ private $shipping_fields_filter;
+
+ /**
+ * Set up test state.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->had_shipping_calculation_option = false !== get_option( 'woocommerce_calc_shipping', false );
+ $this->original_shipping_calculation_option = get_option( 'woocommerce_calc_shipping' );
+ $this->had_global_order = array_key_exists( 'theorder', $GLOBALS );
+ $this->original_global_order = $GLOBALS['theorder'] ?? null;
+
+ update_option( 'woocommerce_calc_shipping', 'yes' );
+ }
+
+ /**
+ * Restore test state.
+ */
+ public function tearDown(): void {
+ if ( $this->shipping_fields_filter ) {
+ remove_filter( 'woocommerce_admin_shipping_fields', $this->shipping_fields_filter );
+ }
+
+ foreach ( $this->orders as $order ) {
+ $order->delete( true );
+ }
+
+ foreach ( $this->products as $product ) {
+ $product->delete( true );
+ }
+
+ if ( $this->had_shipping_calculation_option ) {
+ update_option( 'woocommerce_calc_shipping', $this->original_shipping_calculation_option );
+ } else {
+ delete_option( 'woocommerce_calc_shipping' );
+ }
+
+ if ( $this->had_global_order ) {
+ $GLOBALS['theorder'] = $this->original_global_order;
+ } else {
+ unset( $GLOBALS['theorder'] );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox The read-only summary hides persisted shipping details when the order does not need shipping.
+ */
+ public function test_hides_shipping_details_when_order_does_not_need_shipping(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'The virtual-only order should not need a shipping address.' );
+ $this->assertTrue( $order->has_shipping_address(), 'The order should retain its Store API compatibility shipping data.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'No shipping address set.', $summary );
+ $this->assertStringNotContainsString( 'Virtual Customer', $summary );
+ $this->assertStringNotContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringNotContainsString( '555-0100', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary allows extensions to display otherwise suppressed shipping details.
+ */
+ public function test_filter_can_display_suppressed_shipping_details(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $show_shipping_details = function ( $hide_shipping_details, $filtered_order ) use ( $order ) {
+ $this->assertTrue( $hide_shipping_details, 'The filter should receive the computed suppression decision.' );
+ $this->assertSame( $order, $filtered_order, 'The filter should receive the order being rendered.' );
+
+ return false;
+ };
+ add_filter( 'woocommerce_hide_order_admin_shipping_details', $show_shipping_details, 10, 2 );
+
+ try {
+ $summary = $this->render_shipping_address_summary( $order );
+ } finally {
+ remove_filter( 'woocommerce_hide_order_admin_shipping_details', $show_shipping_details );
+ }
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary displays persisted shipping details when the order needs shipping.
+ */
+ public function test_displays_shipping_details_when_order_needs_shipping(): void {
+ $order = $this->create_order_with_shipping_data( true );
+
+ $this->assertTrue( $order->needs_shipping_address(), 'The flat-rate order should need a shipping address.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary preserves explicit shipping details for non-Store API orders.
+ */
+ public function test_displays_explicit_shipping_details_for_non_store_api_order(): void {
+ $order = $this->create_order_with_shipping_data( false, 'rest-api' );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'The order should exercise the no-shipping-address path.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary preserves shipping details for physical Store API pickup orders.
+ */
+ public function test_displays_shipping_details_for_store_api_pickup_order(): void {
+ $order = $this->create_order_with_shipping_data( true, 'store-api', 'local_pickup' );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'Local pickup should exercise the no-shipping-address path.' );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary keeps shipping details after an ordered product is later made virtual.
+ */
+ public function test_preserves_shipping_details_after_product_becomes_virtual(): void {
+ $order = $this->create_order_with_shipping_data( true );
+
+ $this->assertTrue( $order->needs_shipping_address(), 'The flat-rate order should need a shipping address.' );
+
+ // Simulate a merchant editing the ordered catalog product to be virtual after the order was placed.
+ foreach ( $this->products as $product ) {
+ $product->set_virtual( true );
+ $product->save();
+ }
+
+ // Re-read the order so its line items resolve the updated catalog product state.
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Virtual Customer', $summary );
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0100', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary shows explicitly edited shipping details on a Store API order without shipping.
+ */
+ public function test_displays_explicitly_edited_shipping_details_on_store_api_order(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->assertFalse( $order->needs_shipping_address(), 'The virtual-only order should not need a shipping address.' );
+
+ // phpcs:disable WordPress.Security.NonceVerification.Missing -- Simulate the nonce-verified meta-box save request.
+ $previous_post = $_POST;
+ $_POST = array(
+ 'order_status' => $order->get_status(),
+ '_payment_method' => $order->get_payment_method(),
+ 'customer_user' => 0,
+ '_shipping_address_1' => '742 Evergreen Terrace',
+ '_shipping_city' => 'Springfield',
+ '_shipping_phone' => '555-0199',
+ );
+
+ try {
+ WC_Meta_Box_Order_Data::save( $order->get_id() );
+ } finally {
+ $_POST = $previous_post;
+ }
+ // phpcs:enable WordPress.Security.NonceVerification.Missing
+
+ // Re-read the order so the summary reflects the persisted, explicit values.
+ $order = wc_get_order( $order->get_id() );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertCount( 0, $order->get_shipping_methods(), 'Editing shipping fields should not add a shipping method.' );
+ $this->assertSame( '742 Evergreen Terrace', $order->get_shipping_address_1( 'edit' ) );
+ $this->assertSame( '555-0199', $order->get_shipping_phone( 'edit' ) );
+ $this->assertStringContainsString( '742 Evergreen Terrace', $summary );
+ $this->assertStringContainsString( 'Springfield', $summary );
+ $this->assertStringContainsString( '555-0199', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary uses persisted address values when view filters make billing and shipping appear equal.
+ */
+ public function test_displays_persisted_shipping_details_when_view_filters_make_addresses_match(): void {
+ $order = $this->create_order_with_shipping_data( false );
+ $order->set_shipping_address_1( '742 Evergreen Terrace' );
+ $order->save();
+
+ $normalize_address = static function () {
+ return 'Normalized address';
+ };
+ add_filter( 'woocommerce_order_get_billing_address_1', $normalize_address );
+ add_filter( 'woocommerce_order_get_shipping_address_1', $normalize_address );
+
+ try {
+ $summary = $this->render_shipping_address_summary( $order );
+ } finally {
+ remove_filter( 'woocommerce_order_get_billing_address_1', $normalize_address );
+ remove_filter( 'woocommerce_order_get_shipping_address_1', $normalize_address );
+ }
+
+ $this->assertStringContainsString( '742 Evergreen Terrace', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary shows a persisted shipping phone that differs from billing.
+ */
+ public function test_displays_shipping_details_when_only_phone_differs_from_billing(): void {
+ $order = $this->create_order_with_shipping_data( false );
+ $order->set_shipping_phone( '555-0199' );
+ $order->save();
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( '500 Billing Avenue', $summary );
+ $this->assertStringContainsString( '555-0199', $summary );
+ $this->assertStringNotContainsString( 'No shipping address set.', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary preserves custom fields when the order does not need shipping.
+ */
+ public function test_displays_custom_fields_when_order_does_not_need_shipping(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->shipping_fields_filter = static function ( array $fields ): array {
+ $fields['order_routing_reference'] = array(
+ 'label' => 'Order routing reference',
+ 'value' => 'Routing reference 39300',
+ );
+
+ return $fields;
+ };
+ add_filter( 'woocommerce_admin_shipping_fields', $this->shipping_fields_filter );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Order routing reference: Routing reference 39300', $summary );
+ }
+
+ /**
+ * @testdox The read-only summary preserves a filtered phone field value when the order does not need shipping.
+ */
+ public function test_displays_filtered_phone_value_when_order_does_not_need_shipping(): void {
+ $order = $this->create_order_with_shipping_data( false );
+
+ $this->shipping_fields_filter = static function ( array $fields ): array {
+ $fields['phone'] = array(
+ 'label' => 'Extension contact reference',
+ 'value' => 'Extension reference 39300',
+ );
+
+ return $fields;
+ };
+ add_filter( 'woocommerce_admin_shipping_fields', $this->shipping_fields_filter );
+
+ $summary = $this->render_shipping_address_summary( $order );
+
+ $this->assertStringContainsString( 'Extension contact reference: Extension reference 39300', $summary );
+ }
+
+ /**
+ * Create an order with billing-derived shipping data.
+ *
+ * @param bool $add_shipping_method Whether to add a shipping method to the order.
+ * @param string $created_via Order creation source.
+ * @param string $shipping_method_id Shipping method ID.
+ * @return WC_Order
+ */
+ private function create_order_with_shipping_data( bool $add_shipping_method, string $created_via = 'store-api', string $shipping_method_id = 'flat_rate' ): WC_Order {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Order admin display product' );
+ $product->set_virtual( ! $add_shipping_method );
+ $product->save();
+
+ $order = wc_create_order( array( 'customer_id' => 0 ) );
+ $order->set_created_via( $created_via );
+
+ $item = new WC_Order_Item_Product();
+ $item->set_product( $product );
+ $item->set_quantity( 1 );
+ $order->add_item( $item );
+
+ $order->set_billing_first_name( 'Virtual' );
+ $order->set_billing_last_name( 'Customer' );
+ $order->set_billing_address_1( '500 Billing Avenue' );
+ $order->set_billing_city( 'San Francisco' );
+ $order->set_billing_state( 'CA' );
+ $order->set_billing_postcode( '94105' );
+ $order->set_billing_country( 'US' );
+ $order->set_billing_phone( '555-0100' );
+ $order->set_shipping_first_name( 'Virtual' );
+ $order->set_shipping_last_name( 'Customer' );
+ $order->set_shipping_address_1( '500 Billing Avenue' );
+ $order->set_shipping_city( 'San Francisco' );
+ $order->set_shipping_state( 'CA' );
+ $order->set_shipping_postcode( '94105' );
+ $order->set_shipping_country( 'US' );
+ $order->set_shipping_phone( '555-0100' );
+
+ if ( $add_shipping_method ) {
+ $shipping_item = new WC_Order_Item_Shipping();
+ $shipping_item->set_method_title( 'Shipping method' );
+ $shipping_item->set_method_id( $shipping_method_id );
+ $order->add_item( $shipping_item );
+ }
+
+ $order->save();
+
+ $this->products[] = $product;
+ $this->orders[] = $order;
+
+ return $order;
+ }
+
+ /**
+ * Render the read-only shipping address summary.
+ *
+ * @param WC_Order $order Order to render.
+ * @return string
+ */
+ private function render_shipping_address_summary( WC_Order $order ): string {
+ $GLOBALS['theorder'] = null;
+
+ ob_start();
+ WC_Meta_Box_Order_Data::output( $order );
+ $output = (string) ob_get_clean();
+
+ $document = new DOMDocument();
+ $previous_state = libxml_use_internal_errors( true );
+ $loaded = $document->loadHTML( '<!DOCTYPE html><html><body>' . $output . '</body></html>' );
+ libxml_clear_errors();
+ libxml_use_internal_errors( $previous_state );
+
+ $this->assertTrue( $loaded, 'The order data meta box output should be valid enough for DOM parsing.' );
+
+ $xpath = new DOMXPath( $document );
+ $nodes = $xpath->query( "//div[contains(concat(' ', normalize-space(@class), ' '), ' order_data_column_shipping ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' address ')]" );
+
+ $this->assertNotFalse( $nodes, 'The shipping summary XPath query should be valid.' );
+ $this->assertSame( 1, $nodes->length, 'The meta box should contain one read-only shipping summary.' );
+
+ return trim( (string) preg_replace( '/\s+/', ' ', $nodes->item( 0 )->textContent ) );
+ }
+}