Commit 7ab5850e85 for woocommerce
commit 7ab5850e85c95596a5a2d5266557cdd9a9534e55
Author: Raluca Stan <ralucastn@gmail.com>
Date: Mon Jan 12 16:54:46 2026 +0200
Detect and warn plugins that access blocks dependencies without declaring dependencies (#62229)
* Update local pickup selector to immediately reflect selection in client
* Add changelog
* Update props of local pickup Slot
* Update tests to check selectedInClient parameter
* Fix lint error
* Make isSelectedInClient an optional param
* Pass selected option to callback to allow callback decide what to do
* Dependency injection
* Remove WC_DEBUG_DEPENDENCIES and checks for additional dependencies
* Improve detection of async calls
* Revert "Pass selected option to callback to allow callback decide what to do"
This reverts commit 25ca5e7e55db594df206f47c48b163a2a6160774.
* Revert "Add changelog"
This reverts commit dfbd239f74e380fa981be2d86108ef50da6e9bb7.
* Revert "Update props of local pickup Slot"
This reverts commit ee4a987db10fb532e53350e50557ac064b2a0989.
* Revert "Update tests to check selectedInClient parameter"
This reverts commit 36d625d9ed96c2c16932fa9db536242e5b41d803.
* Revert "Make isSelectedInClient an optional param"
This reverts commit 7d870a3eb7c97f99aec01bc948f0758ad0d5e7bb.
* Revert "Pass selected option to callback to allow callback decide what to do"
This reverts commit 25ca5e7e55db594df206f47c48b163a2a6160774.
* Revert commits from intial branch
* Revert changes from initial branch
* Add a separate JS file for front-end detection logic
* Add all scripts that are exposed on the window.wc object
* Clean up code:
Remove the registering logic for the initially used JS
Rename the file for the current JS logic file
Remove unused AssetApi class usage
* Align WooCommerce URL detection
* Make PHP is the source of truth for the global-to-handle mapping
* Remove proxy set trap and don't use Reflect.set
* Rename and spacing
* Only run logic on pages with the checkout blocks
* Improve proxy creation and registry output
Now hooks directly to wp_head and wp_print_footer_scripts
Avoid registry output if proxy didn't work
* Comment logic for script warnings
* clean js detection:
- remove proxyEnabled, we keep it on the php side
- rename for more clarity
* Remove WordPress internal scripts from the registry
As we output this registry in the front-end we don't need all the registered scripts from WordPress, only the ones coming from plugins
* When we can't identify the calling script from the stack trace, it will warn immediately as an "inline or unknown script" without waiting for or checking the registry.
* improve logic to detect caller url in stack error
* Improve detection to prevent recursice proxy get calls
the wc globals call inside them other exposed wc packages because the webpack externals configuration maps all @woocommerce/* imports to window.wc.*.
example: priceFormat imports @woocommerce/settings which becomes window.wc.wcSettings at runtime, triggering our proxy.
* Run dependency detection only when debugging is enabled
* Simplify recursion prevention in dependency detection
Instead of parsing stack traces to identify recursive calls, we prevent them from running at all.
The isChecking flag blocks nested proxy calls while a dependency check is in progress, eliminating the need to
detect recursion by analyzing stack traces.
* Changelog
* fix PHPStan warnings
* clean comments
* PhPStan baseline file
* Add tests for the dependency js file
This implied exporting parts of the js and using webpack to build the file
PHP now reads from the build folder
* Add PHP tests
* Fix lint issues
* Fix linting
* use wp_parse_url to normalize links
* keep WC_GLOBAL_EXPORTS variable name when minifying
* Add ventor to JS pattern
* Follow unit test guidelines
* Ensure the proxy guard is reset in case caller detection fails
* Move the registry function on the wc global to avoid conflicts
* Use template literals instead of string concatenation
* Update test file
* Follow guidelines in test file
* Convert dependency files to TS
* Improve getFilename
* Handle malformed registry:
Registry entry with missing deps property
Registry entry with non-array deps
Registry itself being null/undefined/not an object
* Ensure stack is a string and handle gracefully
* Ensure currentScript.src is a string
* Ensure line is a string
* fix webpack for dependecy warning assets
* Improve comments on types and shouldSkipLine goal
* Add different stack format handling
* Document that exposed function is internal
* Account for the plugins directory not being /plugins
* Improve substring checks and account for malformed src
* Ensure the detection js file is the blocks folder, and not in the build
so that it's not ignored on live envs
* Handle wp_json_encode() failure for registry
* Add @since 10.5.0 annotations to public methods
* Add null checks and tests
* lint fix
---------
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/dependency-detection b/plugins/woocommerce/changelog/dependency-detection
new file mode 100644
index 0000000000..2144e313ba
--- /dev/null
+++ b/plugins/woocommerce/changelog/dependency-detection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add mechanism to detect scripts not declaring blocks dependencies
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts
new file mode 100644
index 0000000000..e31efc088e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts
@@ -0,0 +1,214 @@
+/**
+ * WooCommerce Dependency Detection - Entry Point
+ *
+ * This file is the entry point for the webpack build that creates
+ * the inline detection script. It imports utils and wraps them in
+ * the IIFE that PHP outputs to the page.
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+ isWooCommerceScript,
+ getFilename,
+ parseStackForCallerUrl,
+ getWarningInfo,
+ createWcProxy,
+ type ScriptRegistry,
+ type WcGlobalExportsMap,
+ type WcGlobalKey,
+ type WcDependencyHandle,
+} from './utils';
+
+declare global {
+ // eslint-disable-next-line no-var, @typescript-eslint/naming-convention
+ var __WC_GLOBAL_EXPORTS_PLACEHOLDER__: WcGlobalExportsMap;
+ // eslint-disable-next-line no-var, @typescript-eslint/naming-convention
+ var __WC_PLUGIN_URL_PLACEHOLDER__: string;
+}
+
+/**
+ * Pending check stored when registry isn't loaded yet.
+ */
+interface PendingCheck {
+ callerUrl: string;
+ wcGlobalKey: WcGlobalKey;
+ requiredDependencyHandle: WcDependencyHandle;
+}
+
+( function () {
+ // Set up a placeholder that will be replaced with the real proxy later.
+ // This ensures we capture window.wc before any WC scripts set it.
+ let originalWc: Record< string, unknown > = window.wc || {};
+ let scriptRegistry: ScriptRegistry = {};
+ let registryLoaded = false;
+ const warnedScripts: Record< string, boolean > = {};
+ let pendingChecks: PendingCheck[] = []; // Queue checks until registry is loaded
+
+ // Maps window.wc.* property names to their required script handles.
+ // Injected by PHP from DependencyDetection::WC_GLOBAL_EXPORTS (source of truth).
+ // eslint-disable-next-line no-undef
+ const WC_GLOBAL_EXPORTS: WcGlobalExportsMap =
+ __WC_GLOBAL_EXPORTS_PLACEHOLDER__;
+
+ // WooCommerce plugin URL, injected by PHP to account for custom plugin directories.
+ // eslint-disable-next-line no-undef
+ const WC_PLUGIN_URL: string = __WC_PLUGIN_URL_PLACEHOLDER__;
+ /**
+ * Get the URL of the script that called this function.
+ *
+ * @return The caller script URL or null.
+ */
+ function getCallerScriptUrl(): string | null {
+ const src = ( document.currentScript as HTMLScriptElement | null )?.src;
+ if ( src && typeof src === 'string' ) {
+ return src.replace( /\?.*$/, '' );
+ }
+
+ // Fallback for scenarios when currentScript isn't available
+ const stack = new Error().stack;
+ return parseStackForCallerUrl(
+ stack ?? null,
+ window.location.pathname
+ );
+ }
+
+ /**
+ * Perform the actual dependency check and warn if missing.
+ *
+ * @param callerUrl - The URL of the calling script.
+ * @param wcGlobalKey - The property being accessed.
+ * @param requiredDependencyHandle - The required dependency handle.
+ */
+ function warnIfMissingDependency(
+ callerUrl: string | null,
+ wcGlobalKey: WcGlobalKey,
+ requiredDependencyHandle: WcDependencyHandle
+ ): void {
+ const warningKey = ( callerUrl || 'inline' ) + ':' + wcGlobalKey;
+
+ // Don't warn twice for the same script + property combination.
+ if ( warnedScripts[ warningKey ] ) {
+ return;
+ }
+
+ const warning = getWarningInfo(
+ callerUrl,
+ wcGlobalKey,
+ requiredDependencyHandle,
+ scriptRegistry,
+ getFilename
+ );
+
+ if ( warning ) {
+ // eslint-disable-next-line no-console
+ console.warn( warning.message );
+ warnedScripts[ warningKey ] = true;
+ }
+ }
+
+ /**
+ * Check if a script has declared the required dependency.
+ *
+ * @param callerUrl - The URL of the calling script.
+ * @param wcGlobalKey - The property being accessed (e.g., 'blocksCheckout').
+ * @param requiredDependencyHandle - The required dependency handle.
+ */
+ function checkDependency(
+ callerUrl: string | null,
+ wcGlobalKey: WcGlobalKey,
+ requiredDependencyHandle: WcDependencyHandle
+ ): void {
+ // For null/unknown callerUrl, warn immediately - no registry needed.
+ // We already know it's an inline or unknown script.
+ if ( ! callerUrl ) {
+ warnIfMissingDependency(
+ callerUrl,
+ wcGlobalKey,
+ requiredDependencyHandle
+ );
+ return;
+ }
+
+ // Skip WooCommerce's own scripts - they manage their own dependencies.
+ if ( isWooCommerceScript( callerUrl, WC_PLUGIN_URL ) ) {
+ return;
+ }
+
+ // If registry not loaded yet, queue the check for later.
+ if ( ! registryLoaded ) {
+ pendingChecks.push( {
+ callerUrl,
+ wcGlobalKey,
+ requiredDependencyHandle,
+ } );
+ return;
+ }
+
+ warnIfMissingDependency(
+ callerUrl,
+ wcGlobalKey,
+ requiredDependencyHandle
+ );
+ }
+
+ // Create the proxy using the utility function.
+ let wcProxy = createWcProxy(
+ originalWc,
+ WC_GLOBAL_EXPORTS,
+ getCallerScriptUrl,
+ checkDependency
+ );
+
+ // Define window.wc as a getter/setter to maintain the proxy.
+ Object.defineProperty( window, 'wc', {
+ get() {
+ return wcProxy;
+ },
+ set( newValue: Record< string, unknown > ) {
+ // When WC scripts set window.wc, wrap the new value.
+ // Handle null/undefined to prevent Proxy TypeError.
+ originalWc = newValue || {};
+ wcProxy = createWcProxy(
+ originalWc,
+ WC_GLOBAL_EXPORTS,
+ getCallerScriptUrl,
+ checkDependency
+ );
+ },
+ configurable: true,
+ enumerable: true,
+ } );
+
+ /**
+ * Update the script registry. Called by WooCommerce PHP to provide
+ * registered script data for dependency checking.
+ *
+ * Not for external use. Calling this will overwrite the registry
+ * provided by WooCommerce.
+ *
+ * @internal
+ */
+ ( window.wc as Record< string, unknown > ).wcUpdateDependencyRegistry =
+ function ( registry: ScriptRegistry ): void {
+ scriptRegistry = registry || {};
+ registryLoaded = true;
+
+ // Process any pending checks now that we have the registry.
+ for ( let i = 0; i < pendingChecks.length; i++ ) {
+ const check = pendingChecks[ i ];
+ warnIfMissingDependency(
+ check.callerUrl,
+ check.wcGlobalKey,
+ check.requiredDependencyHandle
+ );
+ }
+ pendingChecks = [];
+ };
+
+ // eslint-disable-next-line no-console
+ console.info(
+ '[WooCommerce] Dependency detection enabled. Warnings will be shown for scripts that access wc.* globals without proper dependencies.'
+ );
+} )();
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts
new file mode 100644
index 0000000000..a7f86753f8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts
@@ -0,0 +1,708 @@
+/**
+ * Internal dependencies
+ */
+import {
+ isWooCommerceScript,
+ getFilename,
+ shouldSkipLine,
+ detectStackFormat,
+ extractJsUrl,
+ extractJsUrlV8,
+ extractJsUrlSpiderMonkey,
+ parseStackForCallerUrl,
+ getWarningInfo,
+ createWcProxy,
+ type ScriptRegistry,
+ type WcGlobalExportsMap,
+} from '../utils';
+
+describe( 'Dependency Detection Utils', () => {
+ describe( 'isWooCommerceScript', () => {
+ const wcPluginUrl =
+ 'https://example.com/wp-content/plugins/woocommerce/';
+
+ it( 'returns true for WooCommerce core scripts', () => {
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/client/blocks/index.js',
+ wcPluginUrl
+ )
+ ).toBe( true );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/assets/js/frontend.js',
+ wcPluginUrl
+ )
+ ).toBe( true );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/build/bundle.js',
+ wcPluginUrl
+ )
+ ).toBe( true );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/vendor/some-lib.js',
+ wcPluginUrl
+ )
+ ).toBe( true );
+ } );
+
+ it( 'returns false for WooCommerce extensions', () => {
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce-subscriptions/assets/js/index.js',
+ wcPluginUrl
+ )
+ ).toBe( false );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce-payments/build/index.js',
+ wcPluginUrl
+ )
+ ).toBe( false );
+ } );
+
+ it( 'returns false for empty or null URLs', () => {
+ expect( isWooCommerceScript( '', wcPluginUrl ) ).toBe( false );
+ expect( isWooCommerceScript( null, wcPluginUrl ) ).toBe( false );
+ } );
+
+ it( 'falls back to hardcoded pattern when wcPluginUrl is empty', () => {
+ // Standard path should match fallback pattern
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/client/blocks/index.js',
+ ''
+ )
+ ).toBe( true );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/assets/js/frontend.js',
+ ''
+ )
+ ).toBe( true );
+ // Extensions should not match
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce-subscriptions/assets/js/index.js',
+ ''
+ )
+ ).toBe( false );
+ // Custom paths won't work with fallback (expected limitation)
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/app/extensions/woocommerce/client/blocks/index.js',
+ ''
+ )
+ ).toBe( false );
+ } );
+
+ it( 'works with custom plugin directories', () => {
+ const customPluginUrl =
+ 'https://example.com/app/extensions/woocommerce/';
+
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/app/extensions/woocommerce/client/blocks/index.js',
+ customPluginUrl
+ )
+ ).toBe( true );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/app/extensions/woocommerce/assets/js/frontend.js',
+ customPluginUrl
+ )
+ ).toBe( true );
+ // Other plugins in custom directory should not match
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/app/extensions/woocommerce-subscriptions/assets/js/index.js',
+ customPluginUrl
+ )
+ ).toBe( false );
+ } );
+
+ it( 'returns false for scripts in non-asset directories', () => {
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/includes/some-file.js',
+ wcPluginUrl
+ )
+ ).toBe( false );
+ expect(
+ isWooCommerceScript(
+ 'https://example.com/wp-content/plugins/woocommerce/readme.js',
+ wcPluginUrl
+ )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'getFilename', () => {
+ it( 'extracts filename from URL', () => {
+ expect(
+ getFilename( 'https://example.com/path/to/script.js' )
+ ).toBe( 'script.js' );
+ } );
+
+ it( 'removes query strings', () => {
+ expect(
+ getFilename( 'https://example.com/path/to/script.js?ver=1.0.0' )
+ ).toBe( 'script.js' );
+ } );
+
+ it( 'removes hash fragments', () => {
+ expect(
+ getFilename( 'https://example.com/path/to/script.js#section' )
+ ).toBe( 'script.js' );
+ } );
+
+ it( 'returns unknown for empty or null URLs', () => {
+ expect( getFilename( '' ) ).toBe( 'unknown' );
+ expect( getFilename( null ) ).toBe( 'unknown' );
+ } );
+
+ it( 'returns unknown for URL with trailing slash and no filename', () => {
+ expect( getFilename( 'https://example.com/' ) ).toBe( 'unknown' );
+ expect( getFilename( '/' ) ).toBe( 'unknown' );
+ } );
+ } );
+
+ describe( 'shouldSkipLine', () => {
+ it( 'skips lines from current page', () => {
+ // Stack trace format: path appears after opening paren, followed by colon and line number
+ expect(
+ shouldSkipLine( ' at someFunc (/cart/:123:45)', '/cart/' )
+ ).toBe( true );
+ expect(
+ shouldSkipLine(
+ ' at someFunc (/checkout/:123:45)',
+ '/checkout/'
+ )
+ ).toBe( true );
+ } );
+
+ it( 'skips webpack source-mapped files', () => {
+ expect(
+ shouldSkipLine(
+ ' at someFunc (webpack://woocommerce/src/index.js:10:5)',
+ '/cart/'
+ )
+ ).toBe( true );
+ } );
+
+ it( 'does not skip external script URLs', () => {
+ expect(
+ shouldSkipLine(
+ ' at someFunc (https://example.com/script.js:10:5)',
+ '/cart/'
+ )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'detectStackFormat', () => {
+ it( 'detects V8 format (Chrome/Edge/Node)', () => {
+ const v8Stack = `Error
+ at getCallerScriptUrl (checkout/:7:3437)
+ at Object.c [as get] (checkout/:7:2171)
+ at bad-extension.js?ver=1.0.0:31:30`;
+
+ expect( detectStackFormat( v8Stack ) ).toBe( 'v8' );
+ } );
+
+ it( 'detects SpiderMonkey format (Firefox/Safari)', () => {
+ const spiderMonkeyStack = `s@https://store.local/checkout/:7:3437
+c@https://store.local/checkout/:7:2171
+@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:31:7`;
+
+ expect( detectStackFormat( spiderMonkeyStack ) ).toBe(
+ 'spidermonkey'
+ );
+ } );
+
+ it( 'returns v8 as default for empty or invalid input', () => {
+ expect( detectStackFormat( '' ) ).toBe( 'v8' );
+ expect( detectStackFormat( null as unknown as string ) ).toBe(
+ 'v8'
+ );
+ } );
+ } );
+
+ describe( 'extractJsUrlV8', () => {
+ it( 'extracts full URL with protocol', () => {
+ expect(
+ extractJsUrlV8(
+ ' at someFunc (https://example.com/script.js:10:5)'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'extracts bare filename without protocol', () => {
+ expect( extractJsUrlV8( ' at bad-extension.js:31:30' ) ).toBe(
+ null
+ );
+ // Bare filename needs to be in parentheses for V8 format
+ expect(
+ extractJsUrlV8( ' at someFunc (bad-extension.js:31:30)' )
+ ).toBe( 'bad-extension.js' );
+ } );
+
+ it( 'extracts URL with query string', () => {
+ expect(
+ extractJsUrlV8(
+ ' at (https://example.com/script.js?ver=1.0.0:10:5)'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'returns null for non-.js files', () => {
+ expect( extractJsUrlV8( ' at someFunc (cart/:123:45)' ) ).toBe(
+ null
+ );
+ } );
+ } );
+
+ describe( 'extractJsUrlSpiderMonkey', () => {
+ it( 'extracts URL after @ symbol', () => {
+ expect(
+ extractJsUrlSpiderMonkey(
+ '@https://store.local/wp-content/plugins/test/bad-extension.js?ver=1.0.0:31:7'
+ )
+ ).toBe(
+ 'https://store.local/wp-content/plugins/test/bad-extension.js'
+ );
+ } );
+
+ it( 'extracts URL with function name prefix', () => {
+ expect(
+ extractJsUrlSpiderMonkey(
+ 'someFunc@https://example.com/script.js:10:5'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'returns null for V8 format lines', () => {
+ expect(
+ extractJsUrlSpiderMonkey(
+ ' at someFunc (https://example.com/script.js:10:5)'
+ )
+ ).toBe( null );
+ } );
+
+ it( 'returns null for non-.js files', () => {
+ expect(
+ extractJsUrlSpiderMonkey(
+ 's@https://store.local/checkout/:7:3437'
+ )
+ ).toBe( null );
+ } );
+ } );
+
+ describe( 'extractJsUrl', () => {
+ it( 'extracts V8 format URL with explicit format', () => {
+ expect(
+ extractJsUrl(
+ ' at someFunc (https://example.com/script.js:10:5)',
+ 'v8'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'extracts SpiderMonkey format URL with explicit format', () => {
+ expect(
+ extractJsUrl(
+ 'someFunc@https://example.com/script.js:10:5',
+ 'spidermonkey'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'defaults to V8 format when no format specified', () => {
+ expect(
+ extractJsUrl(
+ ' at someFunc (https://example.com/script.js:10:5)'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'extracts URL with query string', () => {
+ expect(
+ extractJsUrl(
+ ' at someFunc (https://example.com/script.js?ver=1.0:10:5)',
+ 'v8'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ expect(
+ extractJsUrl(
+ '@https://example.com/script.js?ver=1.0:10:5',
+ 'spidermonkey'
+ )
+ ).toBe( 'https://example.com/script.js' );
+ } );
+
+ it( 'returns null for lines without .js URLs', () => {
+ expect(
+ extractJsUrl( ' at someFunc (cart/:123:45)', 'v8' )
+ ).toBe( null );
+ expect( extractJsUrl( 'Error: test error', 'v8' ) ).toBe( null );
+ expect(
+ extractJsUrl(
+ 's@https://store.local/checkout/:7:3437',
+ 'spidermonkey'
+ )
+ ).toBe( null );
+ } );
+
+ it( 'handles http URLs', () => {
+ expect(
+ extractJsUrl(
+ ' at someFunc (http://localhost/script.js:10:5)',
+ 'v8'
+ )
+ ).toBe( 'http://localhost/script.js' );
+ expect(
+ extractJsUrl(
+ 'someFunc@http://localhost/script.js:10:5',
+ 'spidermonkey'
+ )
+ ).toBe( 'http://localhost/script.js' );
+ } );
+
+ it( 'returns null for non-string input', () => {
+ expect( extractJsUrl( 123 as unknown as string, 'v8' ) ).toBe(
+ null
+ );
+ expect( extractJsUrl( null as unknown as string, 'v8' ) ).toBe(
+ null
+ );
+ expect( extractJsUrl( {} as unknown as string, 'v8' ) ).toBe(
+ null
+ );
+ } );
+ } );
+
+ describe( 'parseStackForCallerUrl', () => {
+ it( 'returns null for empty stack', () => {
+ expect( parseStackForCallerUrl( null, '/cart/' ) ).toBe( null );
+ expect( parseStackForCallerUrl( '', '/cart/' ) ).toBe( null );
+ } );
+
+ it( 'returns null for non-string stack', () => {
+ expect(
+ parseStackForCallerUrl( 123 as unknown as string, '/cart/' )
+ ).toBe( null );
+ expect(
+ parseStackForCallerUrl( {} as unknown as string, '/cart/' )
+ ).toBe( null );
+ } );
+
+ it( 'finds external script URL in stack trace', () => {
+ const stack = `Error
+ at getCallerScriptUrl (cart/:141:17)
+ at Object.__wcProxyGet [as get] (cart/:286:23)
+ at getBlocksConfiguration (https://example.com/wp-content/plugins/my-plugin/utils.js:10:31)
+ at canMakePayment (https://example.com/wp-content/plugins/my-plugin/index.js:99:32)`;
+
+ expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+ 'https://example.com/wp-content/plugins/my-plugin/utils.js'
+ );
+ } );
+
+ it( 'returns null when no external URL found', () => {
+ const stack = `Error
+ at getCallerScriptUrl (cart/:141:17)
+ at Object.__wcProxyGet [as get] (cart/:286:23)`;
+
+ expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe( null );
+ } );
+
+ it( 'handles real-world V8 stack trace with bare filenames', () => {
+ const stack = `Error
+ at getCallerScriptUrl (cart/:141:17)
+ at Object.__wcProxyGet [as get] (cart/:286:23)
+ at getBlocksConfiguration (utils.js:10:31)
+ at canMakePayment (index.js:99:32)
+ at ExpressPaymentMethodConfig.<anonymous> (payment-method-config-helper.ts:30:41)
+ at checkPaymentMethodsCanPay (check-payment-methods.ts:237:21)
+ at async actions.ts:189:29
+ at async updatePaymentMethods (update-payment-methods.ts:24:2)
+ at async index.ts:126:28`;
+
+ // V8 format can include bare filenames without protocol.
+ // First .js file after skipping cart/ lines should be found.
+ expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+ 'utils.js'
+ );
+ } );
+
+ it( 'handles stack with versioned script URLs', () => {
+ const stack = `Error
+ at getCallerScriptUrl (cart/:141:17)
+ at Object.__wcProxyGet [as get] (cart/:286:23)
+ at C (https://example.com/wp-content/plugins/extension/index.js?ver=7d1eee3294e4247830b6:19:2191)`;
+
+ expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+ 'https://example.com/wp-content/plugins/extension/index.js'
+ );
+ } );
+
+ it( 'handles SpiderMonkey format stack trace (Firefox/Safari)', () => {
+ const stack = `s@https://store.local/checkout/:7:3437
+c@https://store.local/checkout/:7:2171
+@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:31:7
+setTimeout handler*@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:29:11`;
+
+ expect( parseStackForCallerUrl( stack, '/checkout/' ) ).toBe(
+ 'https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js'
+ );
+ } );
+ } );
+
+ describe( 'getWarningInfo', () => {
+ const mockRegistry: ScriptRegistry = {
+ 'https://example.com/registered-with-dep.js': {
+ handle: 'my-script-with-dep',
+ deps: [ 'wc-blocks-checkout' ],
+ },
+ 'https://example.com/registered-without-dep.js': {
+ handle: 'my-script-without-dep',
+ deps: [],
+ },
+ };
+
+ it( 'returns inline warning for null callerUrl', () => {
+ const result = getWarningInfo(
+ null,
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ mockRegistry
+ );
+
+ expect( result?.type ).toBe( 'inline' );
+ expect( result?.message ).toBe(
+ '[WooCommerce] An inline or unknown script accessed wc.blocksCheckout without proper dependency declaration. This script should declare "wc-blocks-checkout" as a dependency.'
+ );
+ } );
+
+ it( 'returns unregistered warning for unknown script URL', () => {
+ const result = getWarningInfo(
+ 'https://example.com/unregistered.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ mockRegistry
+ );
+
+ expect( result?.type ).toBe( 'unregistered' );
+ expect( result?.message ).toBe(
+ '[WooCommerce] Unregistered script "unregistered.js" accessed wc.blocksCheckout. This script should be registered with wp_enqueue_script() and declare "wc-blocks-checkout" as a dependency.'
+ );
+ } );
+
+ it( 'returns missing-dependency warning for registered script without dependency', () => {
+ const result = getWarningInfo(
+ 'https://example.com/registered-without-dep.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ mockRegistry
+ );
+
+ expect( result?.type ).toBe( 'missing-dependency' );
+ expect( result?.message ).toBe(
+ '[WooCommerce] Script "my-script-without-dep" accessed wc.blocksCheckout without declaring "wc-blocks-checkout" as a dependency. Add "wc-blocks-checkout" to the script\'s dependencies array.'
+ );
+ } );
+
+ it( 'returns null for registered script with correct dependency', () => {
+ const result = getWarningInfo(
+ 'https://example.com/registered-with-dep.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ mockRegistry
+ );
+
+ expect( result ).toBe( null );
+ } );
+
+ it( 'returns unregistered warning for malformed registry entry with missing deps', () => {
+ const malformedRegistry = {
+ 'https://example.com/malformed.js': {
+ handle: 'malformed-script',
+ },
+ } as unknown as ScriptRegistry;
+
+ const result = getWarningInfo(
+ 'https://example.com/malformed.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ malformedRegistry
+ );
+
+ expect( result?.type ).toBe( 'unregistered' );
+ } );
+
+ it( 'returns unregistered warning for malformed registry entry with missing handle', () => {
+ const malformedRegistry = {
+ 'https://example.com/malformed.js': {
+ deps: [ 'wc-blocks-checkout' ],
+ },
+ } as unknown as ScriptRegistry;
+
+ const result = getWarningInfo(
+ 'https://example.com/malformed.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ malformedRegistry
+ );
+
+ expect( result?.type ).toBe( 'unregistered' );
+ } );
+
+ it( 'returns unregistered warning for malformed registry entry with non-array deps', () => {
+ const malformedRegistry = {
+ 'https://example.com/malformed.js': {
+ handle: 'malformed-script',
+ deps: 'not-an-array',
+ },
+ } as unknown as ScriptRegistry;
+
+ const result = getWarningInfo(
+ 'https://example.com/malformed.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ malformedRegistry
+ );
+
+ expect( result?.type ).toBe( 'unregistered' );
+ } );
+
+ it( 'returns unregistered warning when registry is not an object', () => {
+ const result = getWarningInfo(
+ 'https://example.com/script.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout',
+ null as unknown as ScriptRegistry
+ );
+
+ expect( result?.type ).toBe( 'unregistered' );
+ } );
+ } );
+
+ describe( 'createWcProxy', () => {
+ it( 'returns value for non-tracked properties', () => {
+ const target: Record< string, unknown > = { someProperty: 'value' };
+ const proxy = createWcProxy(
+ target,
+ {} as WcGlobalExportsMap, // No tracked exports
+ jest.fn(),
+ jest.fn()
+ );
+
+ expect( proxy.someProperty ).toBe( 'value' );
+ } );
+
+ it( 'calls checkDependency for tracked properties', () => {
+ const target: Record< string, unknown > = {
+ blocksCheckout: { Component: () => {} },
+ };
+ const wcGlobalExports = {
+ blocksCheckout: 'wc-blocks-checkout',
+ } as WcGlobalExportsMap;
+ const getCallerScriptUrl = jest
+ .fn()
+ .mockReturnValue( 'https://example.com/script.js' );
+ const checkDependency = jest.fn();
+
+ const proxy = createWcProxy(
+ target,
+ wcGlobalExports,
+ getCallerScriptUrl,
+ checkDependency
+ );
+
+ const result = proxy.blocksCheckout;
+
+ expect( getCallerScriptUrl ).toHaveBeenCalled();
+ expect( checkDependency ).toHaveBeenCalledWith(
+ 'https://example.com/script.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout'
+ );
+ expect( result ).toBe( target.blocksCheckout );
+ } );
+
+ it( 'prevents infinite recursion with guard flag', () => {
+ let accessCount = 0;
+ const target: Record< string, unknown > = {
+ get blocksCheckout(): unknown {
+ accessCount++;
+ // Simulate nested access (like blocksCheckout using wcSettings)
+ if ( accessCount === 1 ) {
+ // First access triggers nested access
+ return this.wcSettings;
+ }
+ return { Component: () => {} };
+ },
+ wcSettings: { currency: 'USD' },
+ };
+
+ const wcGlobalExports = {
+ blocksCheckout: 'wc-blocks-checkout',
+ wcSettings: 'wc-settings',
+ } as WcGlobalExportsMap;
+ const getCallerScriptUrl = jest
+ .fn()
+ .mockReturnValue( 'https://example.com/script.js' );
+ const checkDependency = jest.fn();
+
+ const proxy = createWcProxy(
+ target,
+ wcGlobalExports,
+ getCallerScriptUrl,
+ checkDependency
+ );
+
+ // Access blocksCheckout which internally accesses wcSettings
+ // eslint-disable-next-line no-unused-expressions
+ proxy.blocksCheckout;
+
+ // checkDependency should only be called once (for blocksCheckout),
+ // not twice (the nested wcSettings access should be blocked)
+ expect( checkDependency ).toHaveBeenCalledTimes( 1 );
+ expect( checkDependency ).toHaveBeenCalledWith(
+ 'https://example.com/script.js',
+ 'blocksCheckout',
+ 'wc-blocks-checkout'
+ );
+ } );
+
+ it( 'resets guard flag after access completes', () => {
+ const target: Record< string, unknown > = {
+ blocksCheckout: {},
+ wcSettings: {},
+ };
+ const wcGlobalExports = {
+ blocksCheckout: 'wc-blocks-checkout',
+ wcSettings: 'wc-settings',
+ } as WcGlobalExportsMap;
+ const checkDependency = jest.fn();
+
+ const proxy = createWcProxy(
+ target,
+ wcGlobalExports,
+ jest.fn().mockReturnValue( 'https://example.com/script.js' ),
+ checkDependency
+ );
+
+ // First access
+ // eslint-disable-next-line no-unused-expressions
+ proxy.blocksCheckout;
+ // Second independent access should also trigger check
+ // eslint-disable-next-line no-unused-expressions
+ proxy.wcSettings;
+
+ expect( checkDependency ).toHaveBeenCalledTimes( 2 );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts
new file mode 100644
index 0000000000..5d529e963f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts
@@ -0,0 +1,435 @@
+/**
+ * WooCommerce Dependency Detection - Utility Functions
+ *
+ * Extracted from dependency-detection.js for testability.
+ * These functions are used by the inline detection script.
+ */
+
+/**
+ * Exact mapping of wc.* property names to their required handles.
+ * Must match PHP DependencyDetection::WC_GLOBAL_EXPORTS exactly.
+ *
+ * This interface is used for development-time dependency warnings only.
+ * It is not a public API and may change without notice. Extensions should
+ * not rely on which properties are tracked or the detection behavior.
+ *
+ * @internal
+ */
+export interface WcGlobalExportsMap {
+ wcBlocksRegistry: 'wc-blocks-registry';
+ wcSettings: 'wc-settings';
+ wcBlocksData: 'wc-blocks-data-store';
+ data: 'wc-store-data';
+ wcBlocksSharedContext: 'wc-blocks-shared-context';
+ wcBlocksSharedHocs: 'wc-blocks-shared-hocs';
+ priceFormat: 'wc-price-format';
+ blocksCheckout: 'wc-blocks-checkout';
+ blocksCheckoutEvents: 'wc-blocks-checkout-events';
+ blocksComponents: 'wc-blocks-components';
+ wcTypes: 'wc-types';
+ sanitize: 'wc-sanitize';
+}
+
+/**
+ * Allowed window.wc.* property names that are tracked.
+ *
+ * @internal
+ */
+export type WcGlobalKey = keyof WcGlobalExportsMap;
+
+/**
+ * WooCommerce script dependency handles.
+ *
+ * @internal
+ */
+export type WcDependencyHandle = WcGlobalExportsMap[ WcGlobalKey ];
+
+/**
+ * Script information stored in the registry.
+ */
+export interface ScriptInfo {
+ handle: string;
+ deps: WcDependencyHandle[];
+}
+
+/**
+ * Registry mapping script URLs to their info.
+ */
+export type ScriptRegistry = Record< string, ScriptInfo >;
+
+/**
+ * Warning information returned by getWarningInfo.
+ */
+export interface WarningInfo {
+ type: 'inline' | 'unregistered' | 'missing-dependency';
+ message: string;
+}
+
+/**
+ * WooCommerce asset subdirectories that contain core scripts.
+ */
+const WC_ASSET_DIRS = [ 'client/', 'assets/', 'build/', 'vendor/' ];
+
+/**
+ * Fallback pattern for WooCommerce core scripts when plugin URL is not available.
+ * Matches /plugins/woocommerce/(client|assets|build|vendor)/ but NOT /plugins/woocommerce-subscriptions/ etc.
+ */
+const WC_CORE_SCRIPT_FALLBACK_PATTERN =
+ /\/plugins\/woocommerce\/(client|assets|build|vendor)\//;
+
+/**
+ * Check if a URL belongs to WooCommerce core scripts.
+ *
+ * Uses the plugin URL from the server to account for custom plugin directories
+ * (WP_PLUGIN_DIR, WP_CONTENT_DIR configurations). Falls back to a hardcoded
+ * pattern if the plugin URL is not available.
+ *
+ * @param url - The script URL to check.
+ * @param wcPluginUrl - The WooCommerce plugin URL from the server.
+ * @return True if this is a WooCommerce core script.
+ */
+export function isWooCommerceScript(
+ url: string | null,
+ wcPluginUrl = ''
+): boolean {
+ if ( ! url ) {
+ return false;
+ }
+
+ // If WC_PLUGIN_URL is not available, fall back to hardcoded pattern.
+ // This handles cases where PHP injection failed.
+ if ( ! wcPluginUrl ) {
+ return WC_CORE_SCRIPT_FALLBACK_PATTERN.test( url );
+ }
+
+ // Check if the URL starts with the WooCommerce plugin URL.
+ if ( ! url.startsWith( wcPluginUrl ) ) {
+ return false;
+ }
+
+ // Get the path after the plugin URL.
+ const relativePath = url.substring( wcPluginUrl.length );
+
+ // Check if it's in one of the known WooCommerce asset directories.
+ for ( let i = 0; i < WC_ASSET_DIRS.length; i++ ) {
+ if ( relativePath.startsWith( WC_ASSET_DIRS[ i ] ) ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Extract filename from a URL.
+ *
+ * @param url - The URL to extract filename from.
+ * @return The filename or 'unknown'.
+ */
+export function getFilename( url: string | null ): string {
+ if ( ! url ) {
+ return 'unknown';
+ }
+
+ const lastSegment = url.split( '/' ).pop();
+ if ( ! lastSegment ) {
+ return 'unknown';
+ }
+
+ const filename = lastSegment.split( '?' )[ 0 ].split( '#' )[ 0 ];
+
+ return filename || 'unknown';
+}
+
+/**
+ * Check if a stack trace line should be skipped when searching for the caller.
+ *
+ * We skip internal lines because we want to find the actual third-party script
+ * that accessed wc.*, not the detection code itself. Lines to skip include:
+ * - Current page lines: These are from our inline detection script (the IIFE
+ * output by PHP). They appear as "cart/:123" or "checkout/:456" in the stack.
+ * - Webpack source maps: Internal WooCommerce build artifacts that aren't the
+ * actual caller script.
+ *
+ * @param line - A single line from the stack trace.
+ * @param currentPage - The current page pathname (e.g., '/cart/', '/checkout/').
+ * @return True if this line should be skipped.
+ */
+export function shouldSkipLine( line: string, currentPage: string ): boolean {
+ // Skip lines from the current page (our inline detection script).
+ if ( line.includes( currentPage + ':' ) ) {
+ return true;
+ }
+
+ // Skip webpack source-mapped files (internal build artifacts).
+ if ( line.includes( 'webpack://' ) ) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Stack trace format types.
+ * - 'v8': Chrome, Edge, Node.js - format: "at funcName (url:line:col)" with "Error" header
+ * - 'spidermonkey': Firefox (SpiderMonkey), Safari (JavaScriptCore) - format: "funcName@url:line:col" without header
+ */
+export type StackFormatType = 'v8' | 'spidermonkey';
+
+/**
+ * Detect the stack trace format type from a stack string.
+ *
+ * V8 (Chrome/Edge/Node): Lines contain "at " followed by function name and URL in parentheses.
+ * SpiderMonkey (Firefox/Safari): Lines contain "@" between function name and URL.
+ *
+ * @param stack - The stack trace string.
+ * @return The detected format type, defaults to 'v8' if unknown.
+ */
+export function detectStackFormat( stack: string ): StackFormatType {
+ if ( ! stack || typeof stack !== 'string' ) {
+ return 'v8';
+ }
+
+ // SpiderMonkey format: lines have "@" before the URL (e.g., "funcName@https://...")
+ // V8 format: lines have "at " prefix (e.g., "at funcName (https://...)")
+ const lines = stack.split( '\n' );
+
+ // Start from line 0 because SpiderMonkey stacks may not have an "Error" header.
+ for ( let i = 0; i < lines.length; i++ ) {
+ const line = lines[ i ];
+
+ // SpiderMonkey: "@https://" or "@http://" pattern
+ if ( /@https?:\/\//.test( line ) ) {
+ return 'spidermonkey';
+ }
+
+ // V8: "at " prefix pattern
+ if ( /^\s*at\s/.test( line ) ) {
+ return 'v8';
+ }
+ }
+
+ return 'v8';
+}
+
+/**
+ * Extract a .js URL from a V8-format stack trace line (Chrome/Edge/Node).
+ *
+ * V8 format examples:
+ * - "at funcName (https://example.com/script.js:10:5)" - full URL
+ * - "at funcName (script.js:10:5)" - relative/bare filename
+ *
+ * @param line - A single line from the stack trace.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrlV8( line = '' ): string | null {
+ if ( typeof line !== 'string' ) {
+ return null;
+ }
+ // First try to match full URL with protocol
+ const fullUrlMatch = line.match( /(https?:\/\/[^\s)]+?\.js)(?:[?:#]|$)/ );
+ if ( fullUrlMatch ) {
+ return fullUrlMatch[ 1 ];
+ }
+
+ // Fall back to bare filename (e.g., "script.js" without protocol).
+ // Match inside parentheses: ( followed by path ending in .js
+ const bareMatch = line.match( /\(([^()\s]+\.js)(?:[?:#]|$)/ );
+ return bareMatch ? bareMatch[ 1 ] : null;
+}
+
+/**
+ * Extract a .js URL from a SpiderMonkey-format stack trace line (Firefox/Safari).
+ *
+ * SpiderMonkey format: "funcName@https://example.com/script.js:10:7"
+ * The URL comes after "@", followed by line:col.
+ *
+ * @param line - A single line from the stack trace.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrlSpiderMonkey( line = '' ): string | null {
+ if ( typeof line !== 'string' ) {
+ return null;
+ }
+ // Match URL after "@", ending with .js before query/hash/line number.
+ // Use non-greedy match [^\s]+? to stop at first .js occurrence.
+ const match = line.match( /@(https?:\/\/[^\s]+?\.js)(?:[?:#]|$)/ );
+ return match ? match[ 1 ] : null;
+}
+
+/**
+ * Extract a .js URL from a stack trace line using the specified format.
+ *
+ * @param line - A single line from the stack trace.
+ * @param format - The stack format type to use for extraction.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrl(
+ line = '',
+ format: StackFormatType = 'v8'
+): string | null {
+ if ( typeof line !== 'string' ) {
+ return null;
+ }
+
+ if ( format === 'spidermonkey' ) {
+ return extractJsUrlSpiderMonkey( line );
+ }
+
+ return extractJsUrlV8( line );
+}
+
+/**
+ * Parse an error stack trace to find the calling script URL.
+ *
+ * Detects the stack format (V8 vs SpiderMonkey) once and uses
+ * the appropriate extractor for all lines.
+ *
+ * @param stack - The error stack trace.
+ * @param currentPage - The current page pathname.
+ * @return The caller URL or null if not found.
+ */
+export function parseStackForCallerUrl(
+ stack: string | null,
+ currentPage: string
+): string | null {
+ if ( ! stack || typeof stack !== 'string' ) {
+ return null;
+ }
+
+ // Detect format once for the entire stack.
+ const format = detectStackFormat( stack );
+ const lines = stack.split( '\n' );
+
+ // V8 stacks have "Error" as line 0, so start at 1.
+ // SpiderMonkey stacks start directly with frames, so start at 0.
+ const startLine = format === 'v8' ? 1 : 0;
+
+ for ( let i = startLine; i < lines.length; i++ ) {
+ const line = lines[ i ];
+
+ // Skip internal lines (our script, webpack).
+ if ( shouldSkipLine( line, currentPage ) ) continue;
+
+ // Found an external URL - return it.
+ const url = extractJsUrl( line, format );
+ if ( url ) {
+ return url;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Create the warning message for missing dependencies.
+ *
+ * @param callerUrl - The URL of the calling script.
+ * @param wcGlobalKey - The property being accessed.
+ * @param requiredDependencyHandle - The required dependency handle.
+ * @param scriptRegistry - Registry of scripts with their handles and deps.
+ * @param getFilenameFn - Function to extract filename from URL.
+ * @return Warning info { type, message } or null if no warning needed.
+ */
+export function getWarningInfo(
+ callerUrl: string | null,
+ wcGlobalKey: WcGlobalKey,
+ requiredDependencyHandle: WcDependencyHandle,
+ scriptRegistry: ScriptRegistry,
+ getFilenameFn: ( url: string | null ) => string = getFilename
+): WarningInfo | null {
+ // Case 1: Inline or unknown script.
+ if ( ! callerUrl ) {
+ return {
+ type: 'inline',
+ message: `[WooCommerce] An inline or unknown script accessed wc.${ wcGlobalKey } without proper dependency declaration. This script should declare "${ requiredDependencyHandle }" as a dependency.`,
+ };
+ }
+
+ const scriptInfo =
+ scriptRegistry && typeof scriptRegistry === 'object'
+ ? scriptRegistry[ callerUrl ]
+ : undefined;
+
+ // Case 2: Unregistered script or malformed registry entry.
+ if (
+ ! scriptInfo ||
+ ! scriptInfo.handle ||
+ ! Array.isArray( scriptInfo.deps )
+ ) {
+ return {
+ type: 'unregistered',
+ message: `[WooCommerce] Unregistered script "${ getFilenameFn(
+ callerUrl
+ ) }" accessed wc.${ wcGlobalKey }. This script should be registered with wp_enqueue_script() and declare "${ requiredDependencyHandle }" as a dependency.`,
+ };
+ }
+
+ // Case 3: Missing dependency.
+ if ( scriptInfo.deps.indexOf( requiredDependencyHandle ) === -1 ) {
+ return {
+ type: 'missing-dependency',
+ message: `[WooCommerce] Script "${ scriptInfo.handle }" accessed wc.${ wcGlobalKey } without declaring "${ requiredDependencyHandle }" as a dependency. Add "${ requiredDependencyHandle }" to the script's dependencies array.`,
+ };
+ }
+
+ // No warning needed - dependency is properly declared.
+ return null;
+}
+
+/**
+ * Create a Proxy wrapper for the wc object.
+ *
+ * Intercepts property access on window.wc to check if the calling script
+ * has declared the required dependency. Uses a guard flag (isChecking) to
+ * prevent infinite recursion when accessing a property triggers nested
+ * proxy calls (e.g., wc.blocksCheckout internally uses wc.wcSettings).
+ *
+ * @param target - The object to wrap.
+ * @param wcGlobalExports - Map of wc.* properties to required handles.
+ * @param getCallerScriptUrl - Function to get the caller script URL.
+ * @param checkDependency - Function to check and warn about dependencies.
+ * @return The proxied object.
+ */
+export function createWcProxy< T extends Record< string, unknown > >(
+ target: T,
+ wcGlobalExports: WcGlobalExportsMap,
+ getCallerScriptUrl: () => string | null,
+ checkDependency: (
+ callerUrl: string | null,
+ wcGlobalKey: WcGlobalKey,
+ requiredDependencyHandle: WcDependencyHandle
+ ) => void
+): T {
+ let isChecking = false;
+
+ function __wcProxyGet( obj: T, prop: string ): unknown {
+ // Recursive call - skip checking and just return the value.
+ if ( isChecking ) {
+ return obj[ prop as keyof T ];
+ }
+
+ // Check if this property is a tracked wc global export.
+ // Type guard needed for TypeScript to narrow the type.
+ const isTrackedKey = ( key: string ): key is WcGlobalKey =>
+ key in wcGlobalExports;
+
+ if ( isTrackedKey( prop ) ) {
+ // Set guard before any operations that might trigger nested proxy calls.
+ isChecking = true;
+ try {
+ const callerUrl = getCallerScriptUrl();
+ checkDependency( callerUrl, prop, wcGlobalExports[ prop ] );
+ // Get the value (may trigger nested proxy calls, but isChecking blocks them).
+ return obj[ prop as keyof T ];
+ } finally {
+ // Reset guard only after we have the value, even if an error occurs.
+ isChecking = false;
+ }
+ }
+
+ return obj[ prop as keyof T ];
+ }
+
+ return new Proxy( target, { get: __wcProxyGet } );
+}
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js b/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js
new file mode 100644
index 0000000000..f9a9c23743
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js
@@ -0,0 +1,104 @@
+/**
+ * Webpack config for WooCommerce Dependency Detection script.
+ *
+ * This builds a standalone JS File that PHP inlines into the page.
+ */
+
+const path = require( 'path' );
+const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
+const TerserPlugin = require( 'terser-webpack-plugin' );
+
+/**
+ * Internal dependencies
+ */
+const { getProgressBarPluginConfig } = require( './webpack-helpers' );
+
+const ROOT_DIR = path.resolve( __dirname, '../../../../../' );
+// Output to the standard blocks build directory (gitignored).
+const BUILD_DIR = path.resolve( __dirname, '../build/' );
+const BABEL_CACHE_DIR = path.join(
+ ROOT_DIR,
+ 'node_modules/.cache/babel-loader'
+);
+
+module.exports = {
+ entry: {
+ 'dependency-detection': './assets/js/dependency-detection/index.ts',
+ },
+ output: {
+ path: BUILD_DIR,
+ filename: '[name].js',
+ // IIFE - no module exports or library wrapping.
+ iife: true,
+ clean: false,
+ },
+ module: {
+ rules: [
+ {
+ test: /\.[jt]s$/,
+ exclude: /node_modules/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ presets: [
+ [
+ '@wordpress/babel-preset-default',
+ {
+ modules: false,
+ targets: {
+ browsers: [
+ 'extends @wordpress/browserslist-config',
+ ],
+ },
+ },
+ ],
+ '@babel/preset-typescript',
+ ],
+ cacheDirectory: BABEL_CACHE_DIR,
+ cacheCompression: false,
+ },
+ },
+ },
+ ],
+ },
+ plugins: [
+ new ProgressBarPlugin(
+ getProgressBarPluginConfig( 'Dependency Detection' )
+ ),
+ ],
+ optimization: {
+ // Always minimize - this is an inline script embedded in page HTML.
+ minimize: true,
+ minimizer: [
+ new TerserPlugin( {
+ // Don't extract license comments to a separate file.
+ extractComments: false,
+ terserOptions: {
+ format: {
+ // Remove all comments from output.
+ comments: false,
+ },
+ compress: {
+ // Don't inline variables - we need WC_GLOBAL_EXPORTS to remain
+ // as a variable assignment for PHP placeholder replacement.
+ reduce_vars: false,
+ inline: false,
+ },
+ mangle: {
+ // Don't mangle top-level names.
+ toplevel: false,
+ // Preserve variable names for readability and PHP placeholder replacement.
+ reserved: [ 'WC_GLOBAL_EXPORTS', 'WC_PLUGIN_URL' ],
+ },
+ },
+ } ),
+ ],
+ },
+ // No source maps for inline script.
+ devtool: false,
+ // No externals - this is a standalone script.
+ externals: {},
+ resolve: {
+ extensions: [ '.ts', '.js' ],
+ },
+};
diff --git a/plugins/woocommerce/client/blocks/webpack.config.js b/plugins/woocommerce/client/blocks/webpack.config.js
index b6782d6ff5..2c71a476c0 100644
--- a/plugins/woocommerce/client/blocks/webpack.config.js
+++ b/plugins/woocommerce/client/blocks/webpack.config.js
@@ -15,6 +15,7 @@ const {
const interactivityBlocksConfig = require( './bin/webpack-config-interactive-blocks.js' );
const interactivityAPIConfig = require( './bin/webpack-config-interactivity.js' );
+const dependencyDetectionConfig = require( './bin/webpack-config-dependency-detection.js' );
// Only options shared between all configs should be defined here.
const sharedConfig = {
@@ -104,6 +105,15 @@ const InteractivityAPIConfig = {
...interactivityAPIConfig,
};
+/**
+ * Config for the dependency detection inline script.
+ * This is a standalone IIFE that PHP reads and inlines.
+ */
+const DependencyDetectionConfig = {
+ ...sharedConfig,
+ ...dependencyDetectionConfig,
+};
+
module.exports = [
CartAndCheckoutFrontendConfig,
CoreConfig,
@@ -115,4 +125,5 @@ module.exports = [
StylingConfig,
InteractivityBlocksConfig,
InteractivityAPIConfig,
+ DependencyDetectionConfig,
];
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 383a22b0a9..066199049b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -20130,12 +20130,6 @@ parameters:
count: 1
path: includes/class-wc-tracker.php
- -
- message: '#^Parameter \#1 \$block_name of static method Automattic\\WooCommerce\\Internal\\Utilities\\BlocksUtil\:\:get_blocks_from_widget_area\(\) expects array, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-tracker.php
-
-
message: '#^Parameter \#1 \$size of function wc_let_to_num expects string, string\|false given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/DependencyDetection.php b/plugins/woocommerce/src/Blocks/DependencyDetection.php
new file mode 100644
index 0000000000..d5c24f51a9
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/DependencyDetection.php
@@ -0,0 +1,357 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks;
+
+use Automattic\WooCommerce\Internal\Utilities\BlocksUtil;
+
+/**
+ * DependencyDetection class.
+ *
+ * Provides runtime detection of extensions that use Blocks related WooCommerce globals
+ * (window.wc.*) without properly declaring their PHP script dependencies.
+ *
+ * This runs by default to warn developers about missing dependencies.
+ *
+ * @since 10.5.0
+ * @internal
+ */
+final class DependencyDetection {
+
+ /**
+ * WooCommerce blocks that use the tracked globals.
+ *
+ * Detection script only runs on pages containing these blocks.
+ *
+ * @var array<string>
+ */
+ private const TRACKED_BLOCKS = array(
+ 'woocommerce/checkout',
+ 'woocommerce/cart',
+ 'woocommerce/mini-cart',
+ );
+
+ /**
+ * Maps window.wc.* property names to their required script handles.
+ *
+ * This is the source of truth for both PHP and JS dependency detection.
+ * Based on wcDepMap and wcHandleMap in client/blocks/bin/webpack-helpers.js.
+ *
+ * @var array<string, string>
+ */
+ private const WC_GLOBAL_EXPORTS = array(
+ 'wcBlocksRegistry' => 'wc-blocks-registry',
+ 'wcSettings' => 'wc-settings',
+ 'wcBlocksData' => 'wc-blocks-data-store',
+ 'data' => 'wc-store-data',
+ 'wcBlocksSharedContext' => 'wc-blocks-shared-context',
+ 'wcBlocksSharedHocs' => 'wc-blocks-shared-hocs',
+ 'priceFormat' => 'wc-price-format',
+ 'blocksCheckout' => 'wc-blocks-checkout',
+ 'blocksCheckoutEvents' => 'wc-blocks-checkout-events',
+ 'blocksComponents' => 'wc-blocks-components',
+ 'wcTypes' => 'wc-types',
+ 'sanitize' => 'wc-sanitize',
+ );
+
+ /**
+ * Whether the proxy script was output.
+ *
+ * Used to ensure we only output the registry if the proxy was set up.
+ *
+ * @var bool
+ */
+ private bool $proxy_output = false;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->init();
+ }
+
+ /**
+ * Initialize hooks.
+ *
+ * @since 10.5.0
+ */
+ public function init(): void {
+ // Only run when debugging is enabled.
+ if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
+ return;
+ }
+
+ // Output an early inline script to set up the Proxy before any other scripts run.
+ add_action( 'wp_head', array( $this, 'output_early_proxy_setup' ), 1 );
+ add_action( 'admin_head', array( $this, 'output_early_proxy_setup' ), 1 );
+
+ // Output registry late when all scripts (including IntegrationInterface) are registered.
+ add_action( 'wp_print_footer_scripts', array( $this, 'output_script_registry' ), 1 );
+ add_action( 'admin_print_footer_scripts', array( $this, 'output_script_registry' ), 1 );
+ }
+
+ /**
+ * Output early inline script to set up the Proxy on window.wc.
+ *
+ * This must run before any WooCommerce scripts to intercept access.
+ * The script is loaded from a separate file for better IDE support and testing,
+ * but output inline to ensure correct timing (before any enqueued scripts).
+ *
+ * @since 10.5.0
+ */
+ public function output_early_proxy_setup(): void {
+ // Only run on pages that have the tracked blocks.
+ if ( ! $this->page_has_tracked_blocks() ) {
+ return;
+ }
+
+ // Load from the production assets directory (built by webpack and copied during release build).
+ $script_path = __DIR__ . '/../../assets/client/blocks/dependency-detection.js';
+
+ if ( ! file_exists( $script_path ) ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for inline script output.
+ $script_content = file_get_contents( $script_path );
+
+ if ( ! $script_content ) {
+ return;
+ }
+
+ // Inject the global-to-handle mapping from PHP (source of truth).
+ $mapping_json = \wp_json_encode( self::WC_GLOBAL_EXPORTS );
+ if ( false === $mapping_json ) {
+ return;
+ }
+ $script_content = str_replace(
+ '__WC_GLOBAL_EXPORTS_PLACEHOLDER__',
+ $mapping_json,
+ $script_content
+ );
+
+ // Inject the WooCommerce plugin URL for script origin detection.
+ // This accounts for custom plugin directories (WP_PLUGIN_DIR, WP_CONTENT_DIR).
+ $wc_plugin_url = \plugins_url( '/', WC_PLUGIN_FILE );
+ $script_content = str_replace(
+ '__WC_PLUGIN_URL_PLACEHOLDER__',
+ '"' . esc_js( $wc_plugin_url ) . '"',
+ $script_content
+ );
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Script content is from a trusted local file, JSON is safely encoded.
+ echo '<script id="wc-dependency-detection">' . $script_content . '</script>' . "\n";
+
+ $this->proxy_output = true;
+ }
+
+ /**
+ * Output the script registry JSON for dependency checking.
+ *
+ * This runs late (wp_print_footer_scripts) to ensure all scripts,
+ * including those registered via IntegrationInterface, are captured.
+ *
+ * @since 10.5.0
+ */
+ public function output_script_registry(): void {
+ // Only output registry if the proxy was set up earlier.
+ // This avoids the duplicate page_has_tracked_blocks() check and ensures
+ // we don't output a registry without a proxy to consume it.
+ if ( ! $this->proxy_output ) {
+ return;
+ }
+
+ // Build the script registry mapping URLs to handles and dependencies.
+ $script_registry = $this->build_script_registry();
+ $registry_json = \wp_json_encode( $script_registry );
+
+ if ( false === $registry_json ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON is safely encoded by wp_json_encode.
+ echo '<script id="wc-dependency-detection-registry">if(typeof window.wc.wcUpdateDependencyRegistry==="function"){window.wc.wcUpdateDependencyRegistry(' . $registry_json . ');}</script>' . "\n";
+ }
+
+ /**
+ * Build a registry of all enqueued scripts with their URLs and dependencies.
+ *
+ * @return array<string, array{handle: string, deps: array<string>}>
+ */
+ private function build_script_registry(): array {
+ $wp_scripts = wp_scripts();
+ $registry = array();
+
+ foreach ( $wp_scripts->registered as $handle => $script ) {
+ // Skip scripts without a source URL.
+ if ( empty( $script->src ) ) {
+ continue;
+ }
+
+ // Get the full URL.
+ $src = $script->src;
+ if ( ! is_string( $src ) ) {
+ // Skip malformed src.
+ continue;
+ }
+ if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
+ // Relative URL - make it absolute.
+ $src = $wp_scripts->base_url . $src;
+ }
+
+ // Skip WooCommerce's own scripts - we don't need to check those.
+ if ( $this->is_woocommerce_script( $src ) ) {
+ continue;
+ }
+
+ // Skip WordPress core scripts - they won't use wc.* globals.
+ if ( $this->is_wordpress_core_script( $src ) ) {
+ continue;
+ }
+
+ // Normalize the URL for consistent matching.
+ $src = $this->normalize_url( $src );
+
+ $registry[ $src ] = array(
+ 'handle' => $handle,
+ 'deps' => $this->get_all_dependencies( $script->deps ),
+ );
+ }
+
+ return $registry;
+ }
+
+ /**
+ * Check if a script URL belongs to WooCommerce core.
+ *
+ * Checks if the script is loaded from the WooCommerce core plugin directory,
+ * not from third-party extensions that may use similar handle naming.
+ *
+ * @param string $url Script URL.
+ * @return bool
+ */
+ private function is_woocommerce_script( string $url ): bool {
+ // Get the WooCommerce plugin URL (accounts for custom plugin directories).
+ $wc_plugin_url = \plugins_url( '/', WC_PLUGIN_FILE );
+
+ // Check if the URL starts with the WooCommerce plugin URL and is in a known subdirectory.
+ if ( strpos( $url, $wc_plugin_url ) !== 0 ) {
+ return false;
+ }
+
+ // Get the path after the WooCommerce plugin URL.
+ $relative_path = substr( $url, strlen( $wc_plugin_url ) );
+
+ // Check if it's in one of the known WooCommerce asset directories.
+ return (bool) preg_match( '#^(client|assets|build|vendor)/#', $relative_path );
+ }
+
+ /**
+ * Check if a script URL belongs to WordPress core.
+ *
+ * WordPress core scripts (wp-includes, wp-admin) won't use wc.* globals,
+ * so we can skip them to reduce registry size.
+ *
+ * @param string $url Script URL.
+ * @return bool
+ */
+ private function is_wordpress_core_script( string $url ): bool {
+ return (bool) preg_match( '#/(wp-includes|wp-admin)/#', $url );
+ }
+
+ /**
+ * Recursively get all dependencies including nested ones.
+ *
+ * @param array<string> $deps Direct dependencies.
+ * @return array<string> All dependencies (flattened).
+ */
+ private function get_all_dependencies( array $deps ): array {
+ $wp_scripts = wp_scripts();
+ $all_deps = array();
+ $deps_to_process = $deps;
+
+ while ( ! empty( $deps_to_process ) ) {
+ $handle = array_shift( $deps_to_process );
+
+ if ( in_array( $handle, $all_deps, true ) ) {
+ continue;
+ }
+
+ $all_deps[] = $handle;
+
+ // Add nested dependencies to process.
+ if ( isset( $wp_scripts->registered[ $handle ] ) ) {
+ foreach ( $wp_scripts->registered[ $handle ]->deps as $nested_dep ) {
+ if ( ! in_array( $nested_dep, $all_deps, true ) ) {
+ $deps_to_process[] = $nested_dep;
+ }
+ }
+ }
+ }
+
+ // Filter to only include WooCommerce handles we care about.
+ $wc_handles = array_values( self::WC_GLOBAL_EXPORTS );
+ return array_values(
+ array_filter(
+ $all_deps,
+ function ( $dep ) use ( $wc_handles ) {
+ return in_array( $dep, $wc_handles, true );
+ }
+ )
+ );
+ }
+
+ /**
+ * Check if the current page contains any of the tracked blocks.
+ * Checks post content, widget areas, and template parts (header) for blocks.
+ *
+ * @return bool True if page has tracked blocks.
+ */
+ private function page_has_tracked_blocks(): bool {
+ // Check post content for blocks.
+ foreach ( self::TRACKED_BLOCKS as $block_name ) {
+ if ( \has_block( $block_name ) ) {
+ return true;
+ }
+ }
+
+ // Check widget areas for mini-cart (classic themes).
+ $mini_cart_in_widgets = BlocksUtil::get_blocks_from_widget_area( 'woocommerce/mini-cart' );
+ if ( ! empty( $mini_cart_in_widgets ) ) {
+ return true;
+ }
+
+ // Check header template part for mini-cart (block themes).
+ try {
+ $mini_cart_in_header = BlocksUtil::get_block_from_template_part( 'woocommerce/mini-cart', 'header' );
+ if ( ! empty( $mini_cart_in_header ) ) {
+ return true;
+ }
+ } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Template part may not exist in all themes, silently continue.
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalize a URL by removing query strings and hash fragments.
+ *
+ * This helps match URLs in stack traces which don't include query strings.
+ *
+ * @param string $url URL to normalize.
+ * @return string Normalized URL without query string or hash.
+ */
+ private function normalize_url( string $url ): string {
+ $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ $path = wp_parse_url( $url, PHP_URL_PATH );
+
+ if ( $scheme && $host && $path ) {
+ $port = wp_parse_url( $url, PHP_URL_PORT );
+ return $scheme . '://' . $host . ( $port ? ':' . $port : '' ) . $path;
+ }
+
+ return $url;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 91552b4f77..93c7b9b3b3 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\BlockPatterns;
use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTypesController;
+use Automattic\WooCommerce\Blocks\DependencyDetection;
use Automattic\WooCommerce\Blocks\Patterns\AIPatterns;
use Automattic\WooCommerce\Blocks\Patterns\PatternRegistry;
use Automattic\WooCommerce\Blocks\Patterns\PTKClient;
@@ -134,6 +135,7 @@ class Bootstrap {
$this->container->get( CheckoutLink::class )->init();
$this->container->get( AssetDataRegistry::class );
$this->container->get( AssetsController::class );
+ $this->container->get( DependencyDetection::class );
// Load assets in admin and on the frontend.
if ( ! $is_rest ) {
@@ -222,6 +224,12 @@ class Bootstrap {
return new AssetsController( $container->get( AssetApi::class ) );
}
);
+ $this->container->register(
+ DependencyDetection::class,
+ function () {
+ return new DependencyDetection();
+ }
+ );
$this->container->register(
PaymentMethodRegistry::class,
function () {
diff --git a/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php b/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
index c4417d21eb..77a78a4601 100644
--- a/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
@@ -33,7 +33,7 @@ class BlocksUtil {
/**
* Get all instances of the specified block from the widget area.
*
- * @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
+ * @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_blocks_from_widget_area( $block_name ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php b/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php
new file mode 100644
index 0000000000..fa98d06d33
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php
@@ -0,0 +1,310 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks;
+
+use Automattic\WooCommerce\Blocks\DependencyDetection;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the DependencyDetection class.
+ */
+class DependencyDetectionTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var DependencyDetection
+ */
+ private DependencyDetection $sut;
+
+ /**
+ * Reflection class for accessing private methods.
+ *
+ * @var \ReflectionClass
+ */
+ private \ReflectionClass $reflection;
+
+ /**
+ * Set up the test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new DependencyDetection();
+ $this->reflection = new \ReflectionClass( DependencyDetection::class );
+ }
+
+ /**
+ * Clean up after each test.
+ */
+ public function tearDown(): void {
+ // Clean up any registered test scripts.
+ wp_deregister_script( 'test-plugin-script' );
+ wp_deregister_script( 'test-plugin-with-deps' );
+ wp_deregister_script( 'test-nested-dep' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Helper to invoke a private method.
+ *
+ * @param string $method_name The method name.
+ * @param array $args The arguments to pass.
+ * @return mixed The method result.
+ */
+ private function invoke_private_method( string $method_name, array $args = [] ) {
+ $method = $this->reflection->getMethod( $method_name );
+ $method->setAccessible( true );
+ return $method->invokeArgs( $this->sut, $args );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns true for WooCommerce core client path.
+ */
+ public function test_is_woocommerce_script_returns_true_for_client_path(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ $url = $wc_plugin_url . 'client/blocks/index.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns true for WooCommerce core assets path.
+ */
+ public function test_is_woocommerce_script_returns_true_for_assets_path(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ $url = $wc_plugin_url . 'assets/js/frontend.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns true for WooCommerce core build path.
+ */
+ public function test_is_woocommerce_script_returns_true_for_build_path(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ $url = $wc_plugin_url . 'build/bundle.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns true for WooCommerce core vendor path.
+ */
+ public function test_is_woocommerce_script_returns_true_for_vendor_path(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ $url = $wc_plugin_url . 'vendor/some-lib.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns false for scripts in root directory (not in asset dirs).
+ */
+ public function test_is_woocommerce_script_returns_false_for_root_scripts(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ $url = $wc_plugin_url . 'readme.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns false for WooCommerce extensions.
+ */
+ public function test_is_woocommerce_script_returns_false_for_subscriptions(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ // Replace /woocommerce/ with /woocommerce-subscriptions/ in the URL.
+ $url = str_replace( '/woocommerce/', '/woocommerce-subscriptions/', $wc_plugin_url ) . 'assets/js/index.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns false for WooCommerce Payments.
+ */
+ public function test_is_woocommerce_script_returns_false_for_payments(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ // Replace /woocommerce/ with /woocommerce-payments/ in the URL.
+ $url = str_replace( '/woocommerce/', '/woocommerce-payments/', $wc_plugin_url ) . 'build/index.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @testdox is_woocommerce_script returns false for third-party plugins.
+ */
+ public function test_is_woocommerce_script_returns_false_for_third_party(): void {
+ $wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+ // Replace /woocommerce/ with /my-plugin/ in the URL.
+ $url = str_replace( '/woocommerce/', '/my-plugin/', $wc_plugin_url ) . 'assets/js/index.js';
+ $result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @testdox get_all_dependencies returns empty array for empty input.
+ */
+ public function test_get_all_dependencies_returns_empty_for_empty_input(): void {
+ $result = $this->invoke_private_method( 'get_all_dependencies', array( array() ) );
+ $this->assertSame( array(), $result );
+ }
+
+ /**
+ * @testdox get_all_dependencies filters to WC handles only.
+ */
+ public function test_get_all_dependencies_filters_to_wc_handles(): void {
+ // Register a script with mixed dependencies.
+ wp_register_script( 'test-nested-dep', '', array( 'wc-blocks-checkout', 'jquery' ), '1.0.0', true );
+
+ $result = $this->invoke_private_method( 'get_all_dependencies', array( array( 'test-nested-dep' ) ) );
+
+ // Should only include WC handles, not jquery.
+ $this->assertContains( 'wc-blocks-checkout', $result );
+ $this->assertNotContains( 'jquery', $result );
+ $this->assertNotContains( 'test-nested-dep', $result );
+ }
+
+ /**
+ * @testdox get_all_dependencies handles circular dependencies.
+ */
+ public function test_get_all_dependencies_handles_circular_deps(): void {
+ // This test verifies the method doesn't infinite loop.
+ // The WC handles filtering means we won't see the circular dep in output.
+ $result = $this->invoke_private_method( 'get_all_dependencies', array( array( 'wc-blocks-checkout' ) ) );
+
+ // Should complete without error and return the WC handle.
+ $this->assertContains( 'wc-blocks-checkout', $result );
+ }
+
+ /**
+ * @testdox build_script_registry includes third-party plugin scripts.
+ */
+ public function test_build_script_registry_includes_plugin_scripts(): void {
+ // Register a third-party plugin script.
+ wp_register_script(
+ 'test-plugin-script',
+ 'https://example.com/wp-content/plugins/my-plugin/script.js',
+ array( 'wc-blocks-checkout' ),
+ '1.0.0',
+ true
+ );
+
+ $result = $this->invoke_private_method( 'build_script_registry', array() );
+
+ // Find the script in the registry by checking for normalized URL.
+ $found = false;
+ foreach ( $result as $url => $info ) {
+ if ( strpos( $url, 'my-plugin/script.js' ) !== false ) {
+ $found = true;
+ $this->assertSame( 'test-plugin-script', $info['handle'] );
+ $this->assertContains( 'wc-blocks-checkout', $info['deps'] );
+ break;
+ }
+ }
+ $this->assertTrue( $found, 'Third-party plugin script should be in registry' );
+ }
+
+ /**
+ * @testdox build_script_registry excludes WooCommerce core scripts.
+ */
+ public function test_build_script_registry_excludes_woocommerce_scripts(): void {
+ $result = $this->invoke_private_method( 'build_script_registry', array() );
+
+ // Check that no WooCommerce core scripts are in the registry.
+ foreach ( $result as $url => $info ) {
+ $this->assertStringNotContainsString(
+ '/plugins/woocommerce/client/',
+ $url,
+ 'WooCommerce client scripts should be excluded'
+ );
+ $this->assertStringNotContainsString(
+ '/plugins/woocommerce/assets/',
+ $url,
+ 'WooCommerce assets scripts should be excluded'
+ );
+ $this->assertStringNotContainsString(
+ '/plugins/woocommerce/build/',
+ $url,
+ 'WooCommerce build scripts should be excluded'
+ );
+ }
+ }
+
+ /**
+ * @testdox build_script_registry excludes WordPress core scripts.
+ */
+ public function test_build_script_registry_excludes_wordpress_scripts(): void {
+ $result = $this->invoke_private_method( 'build_script_registry', array() );
+
+ // Check that no WordPress core scripts are in the registry.
+ foreach ( $result as $url => $info ) {
+ $this->assertStringNotContainsString(
+ '/wp-includes/',
+ $url,
+ 'WordPress wp-includes scripts should be excluded'
+ );
+ $this->assertStringNotContainsString(
+ '/wp-admin/',
+ $url,
+ 'WordPress wp-admin scripts should be excluded'
+ );
+ }
+ }
+
+ /**
+ * @testdox build_script_registry normalizes URLs by removing query strings.
+ */
+ public function test_build_script_registry_normalizes_urls(): void {
+ // Register a script (WordPress adds version query string automatically).
+ wp_register_script(
+ 'test-plugin-script',
+ 'https://example.com/wp-content/plugins/my-plugin/script.js',
+ array(),
+ '1.0.0',
+ true
+ );
+
+ $result = $this->invoke_private_method( 'build_script_registry', array() );
+
+ // Check that URLs don't have query strings.
+ foreach ( $result as $url => $info ) {
+ $this->assertStringNotContainsString(
+ '?',
+ $url,
+ 'Registry URLs should not contain query strings'
+ );
+ }
+ }
+
+ /**
+ * @testdox output_early_proxy_setup outputs nothing when no tracked blocks are present.
+ */
+ public function test_output_early_proxy_setup_outputs_nothing_without_tracked_blocks(): void {
+ // Create a post without any WooCommerce blocks.
+ $post_id = $this->factory->post->create(
+ array(
+ 'post_content' => '<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->',
+ )
+ );
+ $this->go_to( get_permalink( $post_id ) );
+
+ ob_start();
+ $this->sut->output_early_proxy_setup();
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * @testdox output_script_registry outputs nothing when proxy was not output.
+ */
+ public function test_output_script_registry_outputs_nothing_without_proxy(): void {
+ // Don't call output_early_proxy_setup first.
+ ob_start();
+ $this->sut->output_script_registry();
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+}