diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php index 83d17a3497..097d972357 100644 --- a/bin/GoogleAdsCleanupServices.php +++ b/bin/GoogleAdsCleanupServices.php @@ -26,7 +26,7 @@ class GoogleAdsCleanupServices { * * @var string */ - protected $version = 'V20'; + protected $version = 'V22'; /** * @var Event Composer event. @@ -52,12 +52,11 @@ class GoogleAdsCleanupServices { * @var string[] List of Service to NOT remove even when usage is not found. */ protected $avoid_cleanup = [ - // Some methods like `ResourceNames::forGeoTargetConstant` are changed to use - // `BatchJobServiceClient` class instead of `GoogleAdsServiceClient` when - // upgrading from v18 to v20, so we need to keep this service. See: - // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V18/ResourceNames.php#L1704-L1710 + // ConversionValueRuleService is now used in `ResourceNames::forGeoTargetConstant` in V22. + // instead of the previous BatchJobServiceClient. See: // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439 - 'BatchJob', + // - https://github.com/googleads/google-ads-php/blob/v31.1.0/src/Google/Ads/GoogleAds/Util/V22/ResourceNames.php#L1457-L1463 + 'ConversionValueRule', ]; /** diff --git a/composer.json b/composer.json index 1e1c3daf22..d03f8f9d76 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "ext-json": "*", "google/apiclient": "^2.16", "google/apiclient-services": "^0.350.0", - "googleads/google-ads-php": "dev-legacy-v31.0.1", + "googleads/google-ads-php": "dev-legacy-v31.1.0", "league/container": "^4.2", "league/iso3166": "^4.1", "phpseclib/bcmath_compat": "^2.0", @@ -60,7 +60,7 @@ "Google\\Task\\Composer::cleanup", "Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\SymfonyPolyfillCleanup::remove", "Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\GoogleAdsCleanupServices::remove", - "composer run-script remove-google-ads-api-version-support -- 18 19 21", + "composer run-script remove-google-ads-api-version-support -- 18 19 20 21", "php ./bin/prefix-vendor-namespace.php", "bash ./bin/cleanup-vendor-files.sh", "composer dump-autoload" diff --git a/composer.lock b/composer.lock index 6f979bc7be..76f49a8b7b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34bf83c9b42bffe4e88d69b8031e6cfc", + "content-hash": "29f7915131697943e319a56e9a05e9a0", "packages": [ { "name": "firebase/php-jwt", @@ -349,20 +349,20 @@ }, { "name": "google/longrunning", - "version": "0.4.7", + "version": "0.6.0", "source": { "type": "git", "url": "https://github.com/googleapis/php-longrunning.git", - "reference": "624cabb874c10e5ddc9034c999f724894b70a3d3" + "reference": "226d3b5166eaa13754cc5e452b37872478e23375" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/624cabb874c10e5ddc9034c999f724894b70a3d3", - "reference": "624cabb874c10e5ddc9034c999f724894b70a3d3", + "url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/226d3b5166eaa13754cc5e452b37872478e23375", + "reference": "226d3b5166eaa13754cc5e452b37872478e23375", "shasum": "" }, "require-dev": { - "google/gax": "^1.36.0", + "google/gax": "^1.38.0", "phpunit/phpunit": "^9.0" }, "type": "library", @@ -387,9 +387,9 @@ ], "description": "Google LongRunning Client for PHP", "support": { - "source": "https://github.com/googleapis/php-longrunning/tree/v0.4.7" + "source": "https://github.com/googleapis/php-longrunning/tree/v0.6.0" }, - "time": "2025-01-24T21:24:06+00:00" + "time": "2025-10-07T18:41:09+00:00" }, { "name": "google/protobuf", @@ -437,19 +437,20 @@ }, { "name": "googleads/google-ads-php", - "version": "dev-legacy-v31.0.1", + "version": "dev-legacy-v31.1.0", "source": { "type": "git", "url": "https://github.com/googleads/google-ads-php.git", - "reference": "e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb" + "reference": "21ca3b959893a027145fa72b4c882ecd463e2de3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb", - "reference": "e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb", + "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/21ca3b959893a027145fa72b4c882ecd463e2de3", + "reference": "21ca3b959893a027145fa72b4c882ecd463e2de3", "shasum": "" }, "require": { + "google/auth": "^1.30 || ^2.0", "google/gax": "^1.19.1", "google/protobuf": "^3.21.5 || >=4.26 <=4.30.0", "grpc/grpc": ">=1.36.0 <=1.57.0", @@ -494,28 +495,28 @@ "homepage": "https://github.com/googleads/google-ads-php", "support": { "issues": "https://github.com/googleads/google-ads-php/issues", - "source": "https://github.com/googleads/google-ads-php/tree/legacy-v31.0.1" + "source": "https://github.com/googleads/google-ads-php/tree/legacy-v31.1.0" }, - "time": "2025-08-28T15:13:29+00:00" + "time": "2026-01-21T18:06:16+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -606,7 +607,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -622,20 +623,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -643,7 +644,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -689,7 +690,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -705,20 +706,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -734,7 +735,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -805,7 +806,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -821,7 +822,7 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "league/container", @@ -907,16 +908,16 @@ }, { "name": "league/iso3166", - "version": "4.3.2", + "version": "4.4.0", "source": { "type": "git", "url": "https://github.com/alcohol/iso3166.git", - "reference": "5133fed7d54728222f4058702487dccedda20472" + "reference": "928ac7ecc569db9123a83ef5b1c6efc279e7cb49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/alcohol/iso3166/zipball/5133fed7d54728222f4058702487dccedda20472", - "reference": "5133fed7d54728222f4058702487dccedda20472", + "url": "https://api.github.com/repos/alcohol/iso3166/zipball/928ac7ecc569db9123a83ef5b1c6efc279e7cb49", + "reference": "928ac7ecc569db9123a83ef5b1c6efc279e7cb49", "shasum": "" }, "require": { @@ -970,20 +971,20 @@ "type": "github" } ], - "time": "2024-10-10T07:39:24+00:00" + "time": "2026-01-02T09:49:36+00:00" }, { "name": "monolog/monolog", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "5cf826f2991858b54d5c3809bee745560a1042a7" + "reference": "37308608e599f34a1a4845b16440047ec98a172a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5cf826f2991858b54d5c3809bee745560a1042a7", - "reference": "5cf826f2991858b54d5c3809bee745560a1042a7", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/37308608e599f34a1a4845b16440047ec98a172a", + "reference": "37308608e599f34a1a4845b16440047ec98a172a", "shasum": "" }, "require": { @@ -1001,7 +1002,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2@dev", "guzzlehttp/guzzle": "^7.4", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "phpspec/prophecy": "^1.15", "phpstan/phpstan": "^1.10", @@ -1060,7 +1061,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.10.0" + "source": "https://github.com/Seldaek/monolog/tree/2.11.0" }, "funding": [ { @@ -1072,20 +1073,20 @@ "type": "tidelift" } ], - "time": "2024-11-12T12:43:37+00:00" + "time": "2026-01-01T13:05:00+00:00" }, { "name": "paragonie/constant_time_encoding", - "version": "v2.7.0", + "version": "v2.8.2", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105" + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/52a0d99e69f56b9ec27ace92ba56897fe6993105", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226", + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226", "shasum": "" }, "require": { @@ -1139,7 +1140,7 @@ "issues": "https://github.com/paragonie/constant_time_encoding/issues", "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2024-05-08T12:18:48+00:00" + "time": "2025-09-24T15:12:37+00:00" }, { "name": "paragonie/random_compat", @@ -1255,16 +1256,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.43", + "version": "3.0.48", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", "shasum": "" }, "require": { @@ -1345,7 +1346,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" }, "funding": [ { @@ -1361,7 +1362,7 @@ "type": "tidelift" } ], - "time": "2024-12-14T21:12:59+00:00" + "time": "2025-12-15T11:51:42+00:00" }, { "name": "psr/cache", @@ -1783,7 +1784,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -1842,7 +1843,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -1853,6 +1854,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -1862,7 +1867,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -1923,7 +1928,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -1934,6 +1939,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -1943,7 +1952,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2004,7 +2013,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -2015,6 +2024,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2024,7 +2037,7 @@ }, { "name": "symfony/polyfill-php73", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -2042,8 +2055,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -2080,7 +2093,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" }, "funding": [ { @@ -2091,6 +2104,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2100,7 +2117,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -2160,7 +2177,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -2171,6 +2188,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2180,7 +2201,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -2236,7 +2257,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" }, "funding": [ { @@ -2247,6 +2268,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2276,12 +2301,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -2524,16 +2549,16 @@ }, { "name": "dg/bypass-finals", - "version": "v1.6.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/dg/bypass-finals.git", - "reference": "efe2fe04bae9f0de271dd462afc049067889e6d1" + "reference": "920a7da2f3c1422fd83f9ec4df007af53dc4018b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dg/bypass-finals/zipball/efe2fe04bae9f0de271dd462afc049067889e6d1", - "reference": "efe2fe04bae9f0de271dd462afc049067889e6d1", + "url": "https://api.github.com/repos/dg/bypass-finals/zipball/920a7da2f3c1422fd83f9ec4df007af53dc4018b", + "reference": "920a7da2f3c1422fd83f9ec4df007af53dc4018b", "shasum": "" }, "require": { @@ -2552,8 +2577,8 @@ "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" + "GPL-2.0-only", + "GPL-3.0-only" ], "authors": [ { @@ -2571,9 +2596,9 @@ ], "support": { "issues": "https://github.com/dg/bypass-finals/issues", - "source": "https://github.com/dg/bypass-finals/tree/v1.6.0" + "source": "https://github.com/dg/bypass-finals/tree/v1.9.0" }, - "time": "2023-11-19T22:19:30+00:00" + "time": "2025-01-16T00:46:05+00:00" }, { "name": "doctrine/instantiator", @@ -2705,16 +2730,16 @@ }, { "name": "gettext/gettext", - "version": "v4.8.11", + "version": "v4.8.12", "source": { "type": "git", "url": "https://github.com/php-gettext/Gettext.git", - "reference": "b632aaf5e4579d0b2ae8bc61785e238bff4c5156" + "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/b632aaf5e4579d0b2ae8bc61785e238bff4c5156", - "reference": "b632aaf5e4579d0b2ae8bc61785e238bff4c5156", + "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/11af89ee6c087db3cf09ce2111a150bca5c46e12", + "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12", "shasum": "" }, "require": { @@ -2766,7 +2791,7 @@ "support": { "email": "oom@oscarotero.com", "issues": "https://github.com/oscarotero/Gettext/issues", - "source": "https://github.com/php-gettext/Gettext/tree/v4.8.11" + "source": "https://github.com/php-gettext/Gettext/tree/v4.8.12" }, "funding": [ { @@ -2782,20 +2807,20 @@ "type": "patreon" } ], - "time": "2023-08-14T15:15:05+00:00" + "time": "2024-05-18T10:25:07+00:00" }, { "name": "gettext/languages", - "version": "2.10.0", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/php-gettext/Languages.git", - "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab" + "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-gettext/Languages/zipball/4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", - "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", + "url": "https://api.github.com/repos/php-gettext/Languages/zipball/0b0b0851c55168e1dfb14305735c64019732b5f1", + "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1", "shasum": "" }, "require": { @@ -2805,7 +2830,8 @@ "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4" }, "bin": [ - "bin/export-plural-rules" + "bin/export-plural-rules", + "bin/import-cldr-data" ], "type": "library", "autoload": { @@ -2844,7 +2870,7 @@ ], "support": { "issues": "https://github.com/php-gettext/Languages/issues", - "source": "https://github.com/php-gettext/Languages/tree/2.10.0" + "source": "https://github.com/php-gettext/Languages/tree/2.12.1" }, "funding": [ { @@ -2856,20 +2882,20 @@ "type": "github" } ], - "time": "2022-10-18T15:00:10+00:00" + "time": "2025-03-19T11:14:02+00:00" }, { "name": "mck89/peast", - "version": "v1.15.4", + "version": "v1.17.4", "source": { "type": "git", "url": "https://github.com/mck89/peast.git", - "reference": "1df4dc28a6b5bb7ab117ab073c1712256e954e18" + "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mck89/peast/zipball/1df4dc28a6b5bb7ab117ab073c1712256e954e18", - "reference": "1df4dc28a6b5bb7ab117ab073c1712256e954e18", + "url": "https://api.github.com/repos/mck89/peast/zipball/c6a63f32410d2e4ee2cd20fe94b35af147fb852d", + "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d", "shasum": "" }, "require": { @@ -2882,7 +2908,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.15.4-dev" + "dev-master": "1.17.4-dev" } }, "autoload": { @@ -2903,72 +2929,22 @@ "description": "Peast is PHP library that generates AST for JavaScript code", "support": { "issues": "https://github.com/mck89/peast/issues", - "source": "https://github.com/mck89/peast/tree/v1.15.4" + "source": "https://github.com/mck89/peast/tree/v1.17.4" }, - "time": "2023-08-12T08:29:29+00:00" - }, - { - "name": "mustache/mustache", - "version": "v2.14.2", - "source": { - "type": "git", - "url": "https://github.com/bobthecow/mustache.php.git", - "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/bobthecow/mustache.php/zipball/e62b7c3849d22ec55f3ec425507bf7968193a6cb", - "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb", - "shasum": "" - }, - "require": { - "php": ">=5.2.4" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~1.11", - "phpunit/phpunit": "~3.7|~4.0|~5.0" - }, - "type": "library", - "autoload": { - "psr-0": { - "Mustache": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" - } - ], - "description": "A Mustache implementation in PHP.", - "homepage": "https://github.com/bobthecow/mustache.php", - "keywords": [ - "mustache", - "templating" - ], - "support": { - "issues": "https://github.com/bobthecow/mustache.php/issues", - "source": "https://github.com/bobthecow/mustache.php/tree/v2.14.2" - }, - "time": "2022-08-23T13:07:01+00:00" + "time": "2025-10-10T12:53:17+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -3007,7 +2983,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -3015,20 +2991,20 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -3047,7 +3023,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -3071,9 +3047,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -3195,29 +3171,29 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.1.2", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5" + "reference": "b598aa890815b8df16363271b659d73280129101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/746c3190ba8eb2f212087c947ba75f4f5b9a58d5", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.0.8", - "squizlabs/php_codesniffer": "^3.7.1" + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcsstandards/phpcsdevcs": "^1.1.6", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", "phpcsstandards/phpcsdevtools": "^1.2.1", - "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "type": "phpcodesniffer-standard", "extra": { @@ -3252,35 +3228,54 @@ ], "support": { "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", "source": "https://github.com/PHPCSStandards/PHPCSExtra" }, - "time": "2023-09-20T22:06:18+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.0.8", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7" + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/69465cab9d12454e5e7767b9041af0cd8cd13be7", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.7.1 || 4.0.x-dev@dev" + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "ext-filter": "*", "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcsstandards/phpcsdevcs": "^1.1.6", - "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0" + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -3317,6 +3312,7 @@ "phpcodesniffer-standard", "phpcs", "phpcs3", + "phpcs4", "standards", "static analysis", "tokens", @@ -3325,9 +3321,28 @@ "support": { "docs": "https://phpcsutils.com/", "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", "source": "https://github.com/PHPCSStandards/PHPCSUtils" }, - "time": "2023-07-16T21:39:41+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3650,16 +3665,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.21", + "version": "9.6.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa" + "reference": "945d0b7f346a084ce5549e95289962972c4272e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5", "shasum": "" }, "require": { @@ -3670,7 +3685,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -3681,11 +3696,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.9", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -3733,7 +3748,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.21" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" }, "funding": [ { @@ -3744,12 +3759,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-09-19T10:50:18+00:00" + "time": "2025-12-06T07:45:52+00:00" }, { "name": "sebastian/cli-parser", @@ -3920,16 +3943,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", "shasum": "" }, "require": { @@ -3982,15 +4005,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2025-08-10T06:51:50+00:00" }, { "name": "sebastian/complexity", @@ -4180,16 +4215,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -4245,28 +4280,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -4309,15 +4356,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -4490,16 +4549,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { @@ -4541,15 +4600,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { "name": "sebastian/resource-operations", @@ -4716,16 +4787,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "version": "3.13.5", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -4735,18 +4806,13 @@ "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" + "bin/phpcbf", + "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -4754,35 +4820,62 @@ "authors": [ { "name": "Greg Sherwood", - "role": "lead" + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", "standards", "static analysis" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, - "time": "2023-02-22T23:07:41+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" }, { "name": "symfony/finder", - "version": "v5.4.27", + "version": "v5.4.45", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d" + "reference": "63741784cd7b9967975eec610b256eed3ede022b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ff4bce3c33451e7ec778070e45bd23f74214cd5d", - "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", "shasum": "" }, "require": { @@ -4816,7 +4909,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.27" + "source": "https://github.com/symfony/finder/tree/v5.4.45" }, "funding": [ { @@ -4832,20 +4925,20 @@ "type": "tidelift" } ], - "time": "2023-07-31T08:02:31+00:00" + "time": "2024-09-28T13:32:08+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4874,7 +4967,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4882,31 +4975,31 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "wp-cli/i18n-command", - "version": "v2.4.4", + "version": "v2.6.6", "source": { "type": "git", "url": "https://github.com/wp-cli/i18n-command.git", - "reference": "7d82e675f271359b1af614e6325d8eeaeb7d7474" + "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/7d82e675f271359b1af614e6325d8eeaeb7d7474", - "reference": "7d82e675f271359b1af614e6325d8eeaeb7d7474", + "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/94f72ddc4be8919f2cea181ba39cd140dd480d64", + "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64", "shasum": "" }, "require": { "eftec/bladeone": "3.52", "gettext/gettext": "^4.8", "mck89/peast": "^1.13.11", - "wp-cli/wp-cli": "^2.5" + "wp-cli/wp-cli": "^2.12" }, "require-dev": { "wp-cli/scaffold-command": "^1.2 || ^2", - "wp-cli/wp-cli-tests": "^4" + "wp-cli/wp-cli-tests": "^5.0.0" }, "suggest": { "ext-json": "Used for reading and generating JSON translation files", @@ -4914,17 +5007,18 @@ }, "type": "wp-cli-package", "extra": { - "branch-alias": { - "dev-main": "2.x-dev" - }, "bundled": true, "commands": [ "i18n", "i18n make-pot", "i18n make-json", "i18n make-mo", + "i18n make-php", "i18n update-po" - ] + ], + "branch-alias": { + "dev-main": "2.x-dev" + } }, "autoload": { "files": [ @@ -4948,9 +5042,61 @@ "homepage": "https://github.com/wp-cli/i18n-command", "support": { "issues": "https://github.com/wp-cli/i18n-command/issues", - "source": "https://github.com/wp-cli/i18n-command/tree/v2.4.4" + "source": "https://github.com/wp-cli/i18n-command/tree/v2.6.6" }, - "time": "2023-08-30T18:00:10+00:00" + "time": "2025-11-21T04:23:34+00:00" + }, + { + "name": "wp-cli/mustache", + "version": "v2.14.99", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/mustache.php.git", + "reference": "ca23b97ac35fbe01c160549eb634396183d04a59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/mustache.php/zipball/ca23b97ac35fbe01c160549eb634396183d04a59", + "reference": "ca23b97ac35fbe01c160549eb634396183d04a59", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "replace": { + "mustache/mustache": "^2.14.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.19.3", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Mustache": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "A Mustache implementation in PHP.", + "homepage": "https://github.com/bobthecow/mustache.php", + "keywords": [ + "mustache", + "templating" + ], + "support": { + "source": "https://github.com/wp-cli/mustache.php/tree/v2.14.99" + }, + "time": "2025-05-06T16:15:37+00:00" }, { "name": "wp-cli/mustangostang-spyc", @@ -5005,29 +5151,29 @@ }, { "name": "wp-cli/php-cli-tools", - "version": "v0.11.21", + "version": "v0.12.7", "source": { "type": "git", "url": "https://github.com/wp-cli/php-cli-tools.git", - "reference": "b3457a8d60cd0b1c48cab76ad95df136d266f0b6" + "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/b3457a8d60cd0b1c48cab76ad95df136d266f0b6", - "reference": "b3457a8d60cd0b1c48cab76ad95df136d266f0b6", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", + "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", "shasum": "" }, "require": { - "php": ">= 5.3.0" + "php": ">= 7.2.24" }, "require-dev": { "roave/security-advisories": "dev-latest", - "wp-cli/wp-cli-tests": "^4" + "wp-cli/wp-cli-tests": "^5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "0.11.x-dev" + "dev-main": "0.12.x-dev" } }, "autoload": { @@ -5062,39 +5208,38 @@ ], "support": { "issues": "https://github.com/wp-cli/php-cli-tools/issues", - "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.21" + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.7" }, - "time": "2023-09-29T15:28:10+00:00" + "time": "2026-01-20T20:31:49+00:00" }, { "name": "wp-cli/wp-cli", - "version": "v2.9.0", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/wp-cli/wp-cli.git", - "reference": "8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7" + "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7", - "reference": "8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7", + "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/03d30d4138d12b4bffd8b507b82e56e129e0523f", + "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f", "shasum": "" }, "require": { "ext-curl": "*", - "mustache/mustache": "^2.14.1", "php": "^5.6 || ^7.0 || ^8.0", "symfony/finder": ">2.7", + "wp-cli/mustache": "^2.14.99", "wp-cli/mustangostang-spyc": "^0.6.3", - "wp-cli/php-cli-tools": "~0.11.2" + "wp-cli/php-cli-tools": "~0.12.4" }, "require-dev": { - "roave/security-advisories": "dev-latest", "wp-cli/db-command": "^1.3 || ^2", "wp-cli/entity-command": "^1.2 || ^2", "wp-cli/extension-command": "^1.1 || ^2", "wp-cli/package-command": "^1 || ^2", - "wp-cli/wp-cli-tests": "^4.0.1" + "wp-cli/wp-cli-tests": "^4.3.10" }, "suggest": { "ext-readline": "Include for a better --prompt implementation", @@ -5107,7 +5252,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9.x-dev" + "dev-main": "2.12.x-dev" } }, "autoload": { @@ -5134,20 +5279,20 @@ "issues": "https://github.com/wp-cli/wp-cli/issues", "source": "https://github.com/wp-cli/wp-cli" }, - "time": "2023-10-25T09:06:37+00:00" + "time": "2025-05-07T01:16:12+00:00" }, { "name": "wp-coding-standards/wpcs", - "version": "3.0.1", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "b4caf9689f1a0e4a4c632679a44e638c1c67aff1" + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/b4caf9689f1a0e4a4c632679a44e638c1c67aff1", - "reference": "b4caf9689f1a0e4a4c632679a44e638c1c67aff1", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", "shasum": "" }, "require": { @@ -5155,17 +5300,17 @@ "ext-libxml": "*", "ext-tokenizer": "*", "ext-xmlreader": "*", - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.1.0", - "phpcsstandards/phpcsutils": "^1.0.8", - "squizlabs/php_codesniffer": "^3.7.2" + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^8.0 || ^9.0" }, "suggest": { "ext-iconv": "For improved results", @@ -5196,24 +5341,24 @@ }, "funding": [ { - "url": "https://opencollective.com/thewpcc/contribute/wp-php-63406", + "url": "https://opencollective.com/php_codesniffer", "type": "custom" } ], - "time": "2023-09-14T07:06:09+00:00" + "time": "2025-11-25T12:08:04+00:00" }, { "name": "yoast/phpunit-polyfills", - "version": "1.1.0", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "224e4a1329c03d8bad520e3fc4ec980034a4b212" + "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/224e4a1329c03d8bad520e3fc4ec980034a4b212", - "reference": "224e4a1329c03d8bad520e3fc4ec980034a4b212", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/41aaac462fbd80feb8dd129e489f4bbc53fe26b0", + "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0", "shasum": "" }, "require": { @@ -5221,12 +5366,14 @@ "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "require-dev": { - "yoast/yoastcs": "^2.3.0" + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.x-dev" + "dev-main": "4.x-dev" } }, "autoload": { @@ -5258,9 +5405,10 @@ ], "support": { "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2023-08-19T14:25:08+00:00" + "time": "2025-08-10T04:54:36+00:00" } ], "aliases": [], @@ -5274,9 +5422,9 @@ "php": ">=7.4", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "7.4.30" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.9.0" } diff --git a/jest.config.js b/jest.config.js index e642a11e17..1695b281e3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,7 +12,8 @@ module.exports = { ], moduleNameMapper: { '\\.(png|jpg)$': '/tests/mocks/assets/imageMock.js', - '\\.svg$': '/tests/mocks/assets/svgrMock.js', + '\\.svg\\?inline$': '/tests/mocks/assets/svgrMock.js', + '\\.svg$': '/tests/mocks/assets/svgFileMock.js', '\\.scss$': '/tests/mocks/assets/styleMock.js', // Transform our `~/` alias. '^~/(.*)$': '/js/src/$1', diff --git a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap new file mode 100644 index 0000000000..a4de36ac77 --- /dev/null +++ b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = ` + +
+
+
+
+
+
+
+
+ Review Your AI Suggestions +
+
+ Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand. +
+
+
+
+ Notice +
+
+
+
+ +
+
+

+ Text assets were auto-populated with Google AI +

+
+
+
+
+
+
+
+
+ Google's Gen AI illustration +
+
+
+
+ ), + generateButtonPluralText: __( + 'Generate descriptions', + 'google-listings-and-ads' + ), + generateButtonSingularText: __( + 'Generate description', + 'google-listings-and-ads' + ), }, ]; diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js index b49d43f491..1ea1a7b6e4 100644 --- a/js/src/components/paid-ads/campaign-assets-form.js +++ b/js/src/components/paid-ads/campaign-assets-form.js @@ -1,13 +1,20 @@ /** * External dependencies */ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; import { useState, useMemo } from '@wordpress/element'; import { isPlainObject } from 'lodash'; /** * Internal dependencies */ -import { ASSET_GROUP_KEY, ASSET_FORM_KEY } from '~/constants'; +import { + ASSET_GROUP_KEY, + ASSET_FORM_KEY, + GEN_AI_ASSET_TYPES, +} from '~/constants'; import AdaptiveForm from '~/components/adaptive-form'; import AppSpinner from '~/components/app-spinner'; import validateCampaign from '~/components/paid-ads/validateCampaign'; @@ -16,7 +23,10 @@ import useAdsCurrency from '~/hooks/useAdsCurrency'; import useBudgetRecommendation from '~/hooks/useBudgetRecommendation'; import useRaiseBudgetRecommendations from '~/hooks/useRaiseBudgetRecommendations'; import useEventPropertiesFilter from '~/hooks/useEventPropertiesFilter'; +import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices'; +import useCreateGenAIAssets from '~/hooks/useCreateGenAIAssets'; import { FILTER_BUDGET_RECOMMENDATIONS } from '~/utils/tracks'; +import { API_NAMESPACE } from '~/data/constants'; import round from '~/utils/round'; /** @@ -41,6 +51,18 @@ const emptyAssetGroup = { [ ASSET_FORM_KEY.YOUTUBE_VIDEO ]: [], }; +const REQUIRED_TEXT_ASSET_KEYS = [ + ASSET_FORM_KEY.LONG_HEADLINE, + ASSET_FORM_KEY.HEADLINE, + ASSET_FORM_KEY.DESCRIPTION, +]; + +const REQUIRED_MEDIA_ASSET_KEYS = [ + ASSET_FORM_KEY.MARKETING_IMAGE, + ASSET_FORM_KEY.SQUARE_MARKETING_IMAGE, + ASSET_FORM_KEY.PORTRAIT_MARKETING_IMAGE, +]; + /** * Converts the asset entity group data to the assets form values. * @@ -143,6 +165,30 @@ function resolveInitialCampaign( return injectDailyBudget( values, budgetRecommendation ); } +function hasValidAIGeneratedAssets( assetKeys, data ) { + if ( ! data || typeof data !== 'object' ) { + return false; + } + + // Ensure object isn't empty + if ( Object.keys( data ).length === 0 ) { + return false; + } + + // Ensure required keys exist + contain at least 1 non-empty string + return assetKeys.every( ( key ) => { + const value = data[ key ]; + + return ( + Array.isArray( value ) && + value.length > 0 && + value.some( + ( item ) => typeof item === 'string' && item.trim().length > 0 + ) + ); + } ); +} + /** * Renders a form based on AdaptiveForm for managing campaign and assets. * @@ -158,15 +204,23 @@ export default function CampaignAssetsForm( { countryCodes, ...adaptiveFormProps } ) { + const { generateAssets, isGeneratingAssets, abortGenerateAssets } = + useCreateGenAIAssets(); + const [ isFetchingAssets, setIsFetchingAssets ] = useState( false ); const initialAssetGroup = useMemo( () => { return convertAssetEntityGroupToFormValues( assetEntityGroup ); }, [ assetEntityGroup ] ); const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup ); const [ hasImportedAssets, setHasImportedAssets ] = useState( false ); + const [ hasAISuggestedTextAssets, setHasAISuggestedTextAssets ] = + useState( false ); + const [ hasAISuggestedMediaAssets, setHasAISuggestedMediaAssets ] = + useState( false ); const { formatAmount } = useAdsCurrency(); const { data: budgetRecommendationData, hasResolved } = useBudgetRecommendation( countryCodes ); + const { createNotice } = useDispatchCoreNotices(); const budgetRecommendation = budgetRecommendationData || {}; @@ -196,6 +250,84 @@ export default function CampaignAssetsForm( { const assetGroupErrors = validateAssetGroup( formContext.values ); const finalUrl = assetEntityGroup?.[ ASSET_GROUP_KEY.FINAL_URL ]; + const fetchAssets = async ( id, type ) => { + try { + setIsFetchingAssets( true ); + + const path = addQueryArgs( + `${ API_NAMESPACE }/assets/suggestions`, + { + id, + type, + } + ); + + const assetSuggestions = await apiFetch( { path } ); + const url = assetSuggestions[ ASSET_GROUP_KEY.FINAL_URL ]; + + if ( ! url ) { + return assetSuggestions; + } + + try { + const generatedGenAIAssets = await generateAssets( url, [ + { type: GEN_AI_ASSET_TYPES.TEXT }, + { type: GEN_AI_ASSET_TYPES.MEDIA }, + ] ); + + if ( ! generatedGenAIAssets ) { + return assetSuggestions; + } + + const textAssetsData = + generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ]; + const mediaAssetsData = + generatedGenAIAssets[ GEN_AI_ASSET_TYPES.MEDIA ]; + + const hasSuggestedTextAssets = hasValidAIGeneratedAssets( + REQUIRED_TEXT_ASSET_KEYS, + textAssetsData + ); + + const hasSuggestedMediaAssets = hasValidAIGeneratedAssets( + REQUIRED_MEDIA_ASSET_KEYS, + mediaAssetsData + ); + + setHasAISuggestedTextAssets( hasSuggestedTextAssets ); + setHasAISuggestedMediaAssets( hasSuggestedMediaAssets ); + + return { + ...assetSuggestions, + ...( hasSuggestedTextAssets ? textAssetsData : {} ), + }; + } catch ( genAIError ) { + createNotice( + 'error', + __( + 'Unable to generate AI suggested assets.', + 'google-listings-and-ads' + ) + ); + + return assetSuggestions; + } + } catch ( error ) { + setHasAISuggestedTextAssets( false ); + setHasAISuggestedMediaAssets( false ); + + createNotice( + 'error', + __( + 'Unable to load assets data.', + 'google-listings-and-ads' + ) + ); + } finally { + setIsFetchingAssets( false ); + } + }; + return { countryCodes, budgetRecommendation: selectedBudgetRecommendation, @@ -234,8 +366,15 @@ export default function CampaignAssetsForm( { setHasImportedAssets( hasNonEmptyAssets ); setBaseAssetGroup( nextAssetGroup ); + formContext.adapter.hideValidation(); }, + isFetchingAssets, + isGeneratingAssets, + hasAISuggestedTextAssets, + hasAISuggestedMediaAssets, + fetchAssets, + abortGenerateAssets, }; }; diff --git a/js/src/components/paid-ads/gen-ai-card.js b/js/src/components/paid-ads/gen-ai-card.js index 7851c943dc..baec6d18fd 100644 --- a/js/src/components/paid-ads/gen-ai-card.js +++ b/js/src/components/paid-ads/gen-ai-card.js @@ -2,25 +2,22 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; -import { external as externalIcon } from '@wordpress/icons'; -import { createInterpolateElement } from '@wordpress/element'; -import { Icon, Flex, FlexBlock, CardBody } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { + Flex, + FlexBlock, + FlexItem, + CardBody, + Notice, +} from '@wordpress/components'; /** * Internal dependencies */ -import Badge from '~/components/badge'; import Section from '~/components/section'; -import AppButton from '~/components/app-button'; -import useGoogleAdsAccount from '~/hooks/useGoogleAdsAccount'; -import AppDocumentationLink from '~/components/app-documentation-link'; import genAIImageURL from '~/images/pmax-assets-improvements/gen-ai.svg'; -import { GOOGLE_ADS_ACCOUNT_STATUS } from '~/constants'; import './gen-ai-card.scss'; -const { CONNECTED, INCOMPLETE } = GOOGLE_ADS_ACCOUNT_STATUS; - /** * GenAICard component displays a promotional card for Google AI-powered asset generation * within Performance Max campaigns. It provides information about the feature, a link to @@ -30,93 +27,68 @@ const { CONNECTED, INCOMPLETE } = GOOGLE_ADS_ACCOUNT_STATUS; * @return {JSX.Element} The rendered GenAICard component. */ const GenAICard = () => { - const { googleAdsAccount } = useGoogleAdsAccount(); - const hasAdsAccount = [ CONNECTED, INCOMPLETE ].includes( - googleAdsAccount?.status - ); - const queryArgs = {}; - - if ( googleAdsAccount?.ocid ) { - queryArgs.ocid = googleAdsAccount.ocid; - } else if ( googleAdsAccount?.id ) { - queryArgs.ecid = googleAdsAccount.id; - } - - const recommendationsURL = addQueryArgs( - 'https://ads.google.com/aw/recommendations', - queryArgs - ); - return ( - - { __( - 'Now available', - 'google-listings-and-ads' - ) } - -
- + { __( - 'You can use Google AI to help build Performance Max assets with a few clicks.', + 'Review Your AI Suggestions', 'google-listings-and-ads' ) }
- { createInterpolateElement( - __( - 'Starting with your website, Google AI will understand what you’re advertising and can generate or suggest text, image, logo, and video assets for you. Learn more', - 'google-listings-and-ads' - ), - { - link: ( - - ), - } + { __( + 'Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand.', + 'google-listings-and-ads' ) }
- } - iconPosition="right" - href={ recommendationsURL } - disabled={ ! hasAdsAccount } - target="_blank" - isSecondary - > - { __( - 'Generate assets with GenAI', - 'google-listings-and-ads' - ) } - + + + + + + + +

+ { __( + 'Text assets were auto-populated with Google AI', + 'google-listings-and-ads' + ) } +

+
+
+
- + { - +
diff --git a/js/src/components/paid-ads/gen-ai-card.scss b/js/src/components/paid-ads/gen-ai-card.scss index fed8366217..b9f63b840d 100644 --- a/js/src/components/paid-ads/gen-ai-card.scss +++ b/js/src/components/paid-ads/gen-ai-card.scss @@ -1,21 +1,20 @@ .gla-gen-ai-card { - :where(.gla-gen-ai-card__wrapper) { - flex-direction: column-reverse; + :where(.gla-section-card-title) { + font-size: 16px; + } - @media (min-width: $break-small) { - flex-direction: row; + .components-notice__content { + :where(svg) { + display: block; + fill: $gla-color-green-70; } - } - :where(.gla-section-card-title) { - font-size: 16px; - margin-bottom: 8px; + p { + margin: 0; + } } - img { - display: block; - max-height: 100%; - margin: 0 auto; - max-width: 100%; + :where(.is-success) { + width: 100%; } } diff --git a/js/src/components/paid-ads/gen-ai-card.test.js b/js/src/components/paid-ads/gen-ai-card.test.js index d9945dde24..ab0321040b 100644 --- a/js/src/components/paid-ads/gen-ai-card.test.js +++ b/js/src/components/paid-ads/gen-ai-card.test.js @@ -20,73 +20,31 @@ describe( 'GenAICard', () => { jest.clearAllMocks(); } ); - const getGenAIButton = () => - screen.queryByRole( 'button', { - name: /Generate Assets with GenAI/i, - } ) || - screen.queryByRole( 'link', { name: /Generate Assets with GenAI/i } ); - describe( 'Generate assets with GenAI button', () => { - it( 'disables the button if googleAdsAccount is missing', () => { + it( 'Card should have title "Review Your AI Suggestions"', () => { useGoogleAdsAccount.mockReturnValue( {} ); render( ); - expect( getGenAIButton() ).toBeDisabled(); - } ); - it( 'disables the button if googleAdsAccount.status is not "connected"', () => { - useGoogleAdsAccount.mockReturnValue( { - googleAdsAccount: { id: '123', ocid: '456', status: 'pending' }, - } ); - render( ); - expect( getGenAIButton() ).toBeDisabled(); + expect( + screen.getByText( 'Review Your AI Suggestions' ) + ).toBeInTheDocument(); } ); - it( 'enables the button if googleAdsAccount.status is "connected"', () => { - useGoogleAdsAccount.mockReturnValue( { - googleAdsAccount: { - id: '123', - ocid: '456', - status: 'connected', - }, - } ); + it( 'Card should have the expected description', () => { + useGoogleAdsAccount.mockReturnValue( {} ); render( ); - expect( getGenAIButton() ).not.toBeDisabled(); - } ); - it( 'generates the correct recommendations URL when both ecid and ocid are available', () => { - useGoogleAdsAccount.mockReturnValue( { - googleAdsAccount: { - id: '123', - ocid: '456', - status: 'connected', - }, - } ); - render( ); - const button = getGenAIButton(); - expect( button ).toHaveAttribute( - 'href', - expect.stringContaining( 'ocid=456' ) - ); - expect( button ).not.toHaveAttribute( - 'href', - expect.stringContaining( 'ecid=' ) - ); + expect( + screen.getByText( + 'Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand.' + ) + ).toBeInTheDocument(); } ); - it( 'generates the correct recommendations URL with only ecid', () => { - useGoogleAdsAccount.mockReturnValue( { - googleAdsAccount: { id: '123', status: 'connected' }, - } ); - render( ); - const button = getGenAIButton(); - expect( button ).toHaveAttribute( - 'href', - expect.stringContaining( 'ecid=123' ) - ); - expect( button ).not.toHaveAttribute( - 'href', - expect.stringContaining( 'ocid=' ) - ); + it( 'Match the snapshot', () => { + useGoogleAdsAccount.mockReturnValue( {} ); + const { asFragment } = render( ); + expect( asFragment() ).toMatchSnapshot(); } ); } ); } ); diff --git a/js/src/components/paid-ads/gen-ai-progress/index.js b/js/src/components/paid-ads/gen-ai-progress/index.js new file mode 100644 index 0000000000..7784880860 --- /dev/null +++ b/js/src/components/paid-ads/gen-ai-progress/index.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +// eslint-disable-next-line import/named, @woocommerce/dependency-group -- ProgressBar exists in @wordpress/components build output but isn't exported from index.ts (not part of the public API maybe). +import { ProgressBar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ProgressGraphics from '~/images/pmax-assets-improvements/gen-ai-progress.svg'; +import SkipButton from './skip-button'; +import './index.scss'; + +/** + * Component to display the progress of Gen AI asset generation, including a progress bar and a skip button. + * + * @return {JSX.Element} The GenAIProgress component. + */ +const GenAIProgress = () => { + return ( +
+ Gen AI Progress + +
+

+ { __( 'Generating assets', 'google-listings-and-ads' ) } +

+ + + +

+ { __( + 'Google AI is analyzing your campaign’s URL to automatically generate your ad assets', + 'google-listings-and-ads' + ) } +

+ +
+ +
+
+
+ ); +}; + +export default GenAIProgress; diff --git a/js/src/components/paid-ads/gen-ai-progress/index.scss b/js/src/components/paid-ads/gen-ai-progress/index.scss new file mode 100644 index 0000000000..4822ecf9b7 --- /dev/null +++ b/js/src/components/paid-ads/gen-ai-progress/index.scss @@ -0,0 +1,29 @@ +.gen-ai-progress { + --wp-components-color-foreground: #3858e9; + text-align: center; + padding: $grid-unit-80 0; + + .gen-ai-progress__bar { + height: $grid-unit-05; + width: 100%; + } + + .gen-ai-progress__text-content { + margin: $grid-unit-40 auto $grid-unit-20; + max-width: 520px; + + h2 { + font-size: $gla-font-small-medium; + margin: 0 0 $grid-unit-20; + } + + p { + margin: $grid-unit-20 0 0; + } + } + + .gen-ai-progress__actions { + margin-block-start: $grid-unit-10; + min-height: $gla-size-control-height; + } +} diff --git a/js/src/components/paid-ads/gen-ai-progress/skip-button.js b/js/src/components/paid-ads/gen-ai-progress/skip-button.js new file mode 100644 index 0000000000..cd4e079930 --- /dev/null +++ b/js/src/components/paid-ads/gen-ai-progress/skip-button.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useAdaptiveFormContext } from '~/components/adaptive-form'; +import AppButton from '~/components/app-button'; + +/** + * Triggered when the skip button is clicked during Gen AI asset generation progress. + * + * @event gla_gen_ai_progress_skip_button_click + */ + +/** + * Component for the skip button displayed during Gen AI asset generation progress. + * + * This button allows users to abort the asset generation process if they choose to skip it. + * + * @fires gla_gen_ai_progress_skip_button_click when the skip button is clicked. + * + * @return {JSX.Element|null} The SkipButton component, or null if not currently generating assets. + */ +const SkipButton = () => { + const { adapter } = useAdaptiveFormContext(); + const { abortGenerateAssets, isGeneratingAssets } = adapter; + + if ( ! isGeneratingAssets ) { + return null; + } + + return ( + + { __( 'Skip', 'google-listings-and-ads' ) } + + ); +}; + +export default SkipButton; diff --git a/js/src/constants.js b/js/src/constants.js index e6bd810469..e9621c1aed 100644 --- a/js/src/constants.js +++ b/js/src/constants.js @@ -151,3 +151,8 @@ export const CAMPAIGN_BUDGET = 'CAMPAIGN_BUDGET'; export const MARGINAL_ROI_CAMPAIGN_BUDGET = 'MARGINAL_ROI_CAMPAIGN_BUDGET'; export const PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH = 'IMPROVE_PERFORMANCE_MAX_AD_STRENGTH'; + +export const GEN_AI_ASSET_TYPES = { + TEXT: 'text', + MEDIA: 'media', +}; diff --git a/js/src/data/action-types.js b/js/src/data/action-types.js index 11b8233271..fde463ff1e 100644 --- a/js/src/data/action-types.js +++ b/js/src/data/action-types.js @@ -59,6 +59,8 @@ const TYPES = { RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE: 'RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE', RECEIVE_ADS_RECOMMENDATIONS: 'RECEIVE_ADS_RECOMMENDATIONS', + RECEIVE_GEN_AI_MEDIA_ASSETS: 'RECEIVE_GEN_AI_MEDIA_ASSETS', + RECEIVE_GEN_AI_TEXT_ASSETS: 'RECEIVE_GEN_AI_TEXT_ASSETS', }; export default TYPES; diff --git a/js/src/data/actions.js b/js/src/data/actions.js index 5f6181578b..726275cfe2 100644 --- a/js/src/data/actions.js +++ b/js/src/data/actions.js @@ -15,7 +15,7 @@ import { EMPTY_ASSET_ENTITY_GROUP, } from './constants'; import { handleApiError } from '~/utils/handleError'; -import { adaptAdsCampaign } from './adapters'; +import { adaptAdsCampaign, adaptGenAIAssets } from './adapters'; import { isWCIos, isWCAndroid } from '~/utils/isMobileApp'; import { convertKeysFromSnakeCaseToCamelCase } from './utils'; @@ -1358,6 +1358,42 @@ export function* receiveAdsRecommendations( }; } +export function* receiveGenAIMediaAssets( url, data, assetType ) { + if ( ! data?.items ) { + return { + type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS, + url, + assetType, + data: {}, + }; + } + + return { + type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS, + url, + assetType, + data: adaptGenAIAssets( data.items, 'temporary_image_url', assetType ), + }; +} + +export function* receiveGenAITextAssets( url, data, assetType ) { + if ( ! data?.items ) { + return { + type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS, + url, + assetType, + data: {}, + }; + } + + return { + type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS, + url, + assetType, + data: adaptGenAIAssets( data.items, 'text', assetType ), + }; +} + export function* fetchYouTubeAccount() { try { const response = yield apiFetch( { diff --git a/js/src/data/adapters.js b/js/src/data/adapters.js index e4d8d8097b..6861f11cf3 100644 --- a/js/src/data/adapters.js +++ b/js/src/data/adapters.js @@ -3,7 +3,10 @@ */ import { ASSET_TEXT_SPECS } from '~/components/paid-ads/assetSpecs'; import getCharacterCounter from '~/utils/getCharacterCounter'; -import { convertKeysFromSnakeCaseToCamelCase } from './utils'; +import { + convertKeysFromSnakeCaseToCamelCase, + applyAssetTextCharacterLimits, +} from './utils'; /** * @typedef {import('~/data/actions').Campaign} Campaign @@ -262,3 +265,37 @@ export function adaptRaiseAdsBudgetRecommendations( rawData ) { return finalData; } + +/** + * Formats raw API items into a grouped object by type. + * @param {Array} items The raw items array from API. + * @param {string} valueKey The key to extract (e.g., 'text' or 'temporary_image_url'). + * @param {string} [filterType] Optional type to filter by. Possible values can be headline, description, long_headline, marketing_image, square_marketing_image, portrait_marketing_image. + * @return {Object} Groups of assets keyed by their type. + */ +export function adaptGenAIAssets( items = [], valueKey, filterType ) { + const data = {}; + + for ( const item of items ) { + const { type, [ valueKey ]: value } = item; + + // Skip if: + // We have a filter and it doesn't match + // The value for the specified key is empty/null + if ( ( filterType && type !== filterType ) || ! value ) { + continue; + } + + if ( ! data[ type ] ) { + data[ type ] = []; + } + + data[ type ].push( value ); + } + + if ( valueKey === 'text' ) { + return applyAssetTextCharacterLimits( data, ASSET_TEXT_SPECS ); + } + + return data; +} diff --git a/js/src/data/adapters.test.js b/js/src/data/adapters.test.js index 0e59a3acc0..9dd00ecd4d 100644 --- a/js/src/data/adapters.test.js +++ b/js/src/data/adapters.test.js @@ -345,23 +345,10 @@ describe( 'adaptAssetGroup', () => { it( 'When the first text has an invalid character count, it should move the valid one to the first', () => { assetGroup.assets[ DESCRIPTION ].reverse(); - assetGroup.assets[ HEADLINE ] = [ - { content: text20Count }, - { content: text30Count }, - { content: text15Count }, - { content: text10Count }, - ]; const { assets } = adaptAssetGroup( assetGroup ); const descriptions = assets[ DESCRIPTION ].map( mapContent ); - const headlines = assets[ HEADLINE ].map( mapContent ); expect( descriptions ).toEqual( [ text60Count, text90Count ] ); - expect( headlines ).toEqual( [ - text15Count, - text20Count, - text30Count, - text10Count, - ] ); } ); } ); } ); diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js index e1fbce3b92..581f3bd139 100644 --- a/js/src/data/reducer.js +++ b/js/src/data/reducer.js @@ -84,6 +84,7 @@ const DEFAULT_STATE = { }, summary: {}, }, + gen_ai_assets: {}, }; /** @@ -632,6 +633,57 @@ const reducer = ( state = DEFAULT_STATE, action ) => { ); } + case TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS: { + const { url, data, assetType } = action; + const existingMedia = state.gen_ai_assets?.[ url ]?.media ?? {}; + + const updatedMedia = assetType + ? { + ...existingMedia, + [ assetType ]: [ + ...new Set( [ + ...( existingMedia[ assetType ] ?? [] ), + ...( data[ assetType ] ?? [] ), + ] ), + ], + } + : { + ...existingMedia, + ...data, + }; + + return setIn( + state, + [ 'gen_ai_assets', url, 'media' ], + updatedMedia + ); + } + + case TYPES.RECEIVE_GEN_AI_TEXT_ASSETS: { + const { url, data, assetType } = action; + const existingText = state.gen_ai_assets?.[ url ]?.text ?? {}; + + const updatedText = assetType + ? { + ...existingText, + [ assetType ]: [ + ...( existingText[ assetType ] ?? [] ), + ...( data[ assetType ] ?? [] ), + ], + } + : { + ...existingText, + ...data, + }; + + return setIn( + state, + [ 'gen_ai_assets', url, 'text' ], + updatedText + ); + } + + // Page will be reloaded after all accounts have been disconnected, so no need to mutate state. case TYPES.RECEIVE_ACCOUNTS_YOUTUBE: { return setIn( state, 'mc.accounts.youtube', action.account ); } diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js index 5425acdfe0..3dd252e0c7 100644 --- a/js/src/data/selectors.js +++ b/js/src/data/selectors.js @@ -500,3 +500,47 @@ export const getAdsRecommendations = ( state, types, campaign_id = null ) => { const key = arrayToUnderscoreKey( keyToHash ); return state.ads.recommendations[ key ] || null; }; + +/** + * Retrieves the GenAI media assets from the state for a given URL and type. + * + * @param {Object} state - The Redux state object containing GenAI assets data. + * @param {string} url - The URL associated with the GenAI assets. + * @param {'marketing_image'|'square_marketing_image'|'portrait_marketing_image'|undefined} [assetType] - The type of media asset to retrieve. + * @return {Array} The media assets for the specified URL and type, or an empty array if not found. + */ +export const getGenAIMediaAssets = ( state, url, assetType ) => { + const mediaAssets = state.gen_ai_assets?.[ url ]?.media; + + if ( ! url || ! mediaAssets ) { + return []; + } + + if ( assetType ) { + return mediaAssets[ assetType ] ?? []; + } + + return mediaAssets; +}; + +/** + * Retrieves the GenAI text assets from the state for a given URL and type. + * + * @param {Object} state - The Redux state object containing GenAI assets data. + * @param {string} url - The URL associated with the GenAI assets. + * @param {'headline'|'long_headline'|'description'|undefined} [assetType] - The type of text asset to retrieve. + * @return {Array} The text assets for the specified URL and type, or an empty array if not found. + */ +export const getGenAITextAssets = ( state, url, assetType ) => { + const textAssets = state.gen_ai_assets?.[ url ]?.text; + + if ( ! url || ! textAssets ) { + return []; + } + + if ( assetType ) { + return textAssets[ assetType ] ?? []; + } + + return textAssets; +}; diff --git a/js/src/data/test/reducer.test.js b/js/src/data/test/reducer.test.js index 06f9afb36b..825d6bcab9 100644 --- a/js/src/data/test/reducer.test.js +++ b/js/src/data/test/reducer.test.js @@ -86,6 +86,7 @@ describe( 'reducer', () => { }, summary: {}, }, + gen_ai_assets: {}, } ); prepareState = prepareImmutableState.bind( null, defaultState ); diff --git a/js/src/data/utils.js b/js/src/data/utils.js index 7aef933ca1..57cf06f65e 100644 --- a/js/src/data/utils.js +++ b/js/src/data/utils.js @@ -263,6 +263,90 @@ export function convertKeysFromSnakeCaseToCamelCase( data ) { }, {} ); } +/** + * Applies character limits to asset texts based on provided specifications. + * + * @param {Object} assets The asset texts to apply character limits to. + * @param {Array} specs The specifications defining character limits for each asset type. + * @return {Object} The asset texts with character limits applied. + */ +export function applyAssetTextCharacterLimits( assets, specs ) { + return Object.fromEntries( + Object.entries( assets ).map( ( [ type, values ] ) => { + const spec = specs.find( ( s ) => s.key === type ); + if ( ! spec ) { + return [ type, values ]; + } + + const limits = Array.isArray( spec.maxCharacterCounts ) + ? spec.maxCharacterCounts + : Array.from( + { length: values.length }, + () => spec.maxCharacterCounts + ); + + const ellipsis = '…'; + + // Prepare positions with numeric limits (we’ll fill these first). + const positions = limits + .map( ( max, index ) => + typeof max === 'number' ? { index, max } : null + ) + .filter( Boolean ); + + // Sort positions by max ascending (tightest slots first). + positions.sort( ( a, b ) => a.max - b.max ); + + // Keep texts with their original index so ties preserve original order. + const texts = values.map( ( text, index ) => ( { text, index } ) ); + + // Sort texts by length ascending (shortest first). + // Tie-breaker keeps original order. + texts.sort( + ( a, b ) => a.text.length - b.text.length || a.index - b.index + ); + + const out = new Array( values.length ); + const usedTextIndexes = new Set(); + + // Assign shortest texts to tightest positions. + for ( let i = 0; i < positions.length; i++ ) { + const { index: posIndex, max } = positions[ i ]; + const picked = texts[ i ]; + + if ( ! picked ) { + break; + } + + usedTextIndexes.add( picked.index ); + + if ( picked.text.length <= max ) { + out[ posIndex ] = picked.text; + } else { + const sliceLength = Math.max( max - ellipsis.length, 0 ); + out[ posIndex ] = + picked.text.slice( 0, sliceLength ) + ellipsis; + } + } + + // Fill any remaining slots (including positions without max limits) + // with remaining texts in original order. + const remainingTexts = values.filter( + ( _, i ) => ! usedTextIndexes.has( i ) + ); + + let r = 0; + for ( let i = 0; i < out.length; i++ ) { + if ( out[ i ] === undefined ) { + out[ i ] = remainingTexts[ r++ ]; + } + } + + return [ type, out ]; + } ) + ); +} + /** * Report fields fetched from report API. * diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js new file mode 100644 index 0000000000..866a46a9b9 --- /dev/null +++ b/js/src/hooks/useCreateGenAIAssets.js @@ -0,0 +1,198 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useState, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useAppDispatch } from '~/data'; +import { GEN_AI_ASSET_TYPES } from '~/constants'; +import { API_NAMESPACE, REQUEST_ACTIONS } from '~/data/constants'; +import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices'; + +/** + * Custom hook to generate Gen AI assets for a given URL and asset requests. + * + * @return {Object} An object containing the generateAssets function, isGeneratingAssets boolean, and abortGenerateAssets function. + */ +const useCreateGenAIAssets = () => { + const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false ); + const { createNotice } = useDispatchCoreNotices(); + const abortControllerRef = useRef( null ); + const { receiveGenAITextAssets, receiveGenAIMediaAssets } = + useAppDispatch(); + + /** + * Aborts any ongoing Gen AI asset generation requests. + */ + const abortGenerateAssets = useCallback( () => { + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + }, [] ); + + /** + * Helper function to process Gen AI API responses, handling both success and error cases. + * + * @param {Object} result - The result object from Promise.allSettled. + * @return {Object|null} - The parsed JSON data from the response, or null if there was an error. + */ + const processGenAIResponse = useCallback( + async ( result ) => { + // Handle rejected promises (Network errors or apiFetch-thrown errors) + if ( result.status === 'rejected' ) { + const errorResponse = result.reason; + + // Silently handle 400 errors (URL not eligible for suggestions) + if ( errorResponse && errorResponse.status === 400 ) { + return null; + } + + createNotice( + 'error', + errorResponse?.statusText || + __( + 'Unable to load AI-generated assets suggestions.', + 'google-listings-and-ads' + ) + ); + + return null; + } + + const response = result.value; + try { + const responseClone = response.clone(); + return await responseClone.json(); + } catch ( e ) { + createNotice( + 'error', + __( + 'An error occurred while processing AI-generated assets suggestions.', + 'google-listings-and-ads' + ) + ); + return null; + } + }, + [ createNotice ] + ); + + /** + * Generates Gen AI assets based on the provided URL and asset requests. + * + * @param {string} url - The final URL for which to generate assets. + * @param {Array} requests - An array of asset generation requests, each containing a type and an optional assetKey. type can be 'text' or 'media'. assetKey can be 'headline' for text or 'marketing_image' for media, or it can be undefined to fetch all types. + * @return {Promise} - A promise that resolves to the generated assets data, or undefined if no requests are processed. + */ + const generateAssets = useCallback( + async ( url, requests = [] ) => { + if ( ! url || requests.length === 0 ) { + return; + } + + abortControllerRef.current = new AbortController(); + const { signal } = abortControllerRef.current; + + setIsGeneratingAssets( true ); + + // Initialize as empty arrays to avoid overwriting multiple requests of same type + const generatedAssets = { + [ GEN_AI_ASSET_TYPES.TEXT ]: [], + [ GEN_AI_ASSET_TYPES.MEDIA ]: [], + }; + + try { + const promises = requests.map( ( request ) => { + const isText = request.type === GEN_AI_ASSET_TYPES.TEXT; + const path = isText + ? `${ API_NAMESPACE }/ads/assets/generate-text` + : `${ API_NAMESPACE }/ads/assets/generate-images`; + + return apiFetch( { + path, + signal, + method: REQUEST_ACTIONS.POST, + parse: false, + data: { + final_url: url, + ...( request.assetKey + ? { types: [ request.assetKey ] } + : {} ), + }, + } ); + } ); + + const results = await Promise.allSettled( promises ); + + if ( signal.aborted ) { + return; + } + + for ( let index = 0; index < results.length; index++ ) { + const { type, assetKey } = requests[ index ]; + const data = await processGenAIResponse( results[ index ] ); + + if ( ! data || ! data.items ) { + continue; + } + + if ( type === GEN_AI_ASSET_TYPES.TEXT ) { + const { data: textData } = await receiveGenAITextAssets( + url, + data, + assetKey + ); + + generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ] = { + ...generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ], + ...textData, + }; + } else if ( type === GEN_AI_ASSET_TYPES.MEDIA ) { + const { data: mediaData } = + await receiveGenAIMediaAssets( + url, + data, + assetKey + ); + + generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ] = { + ...generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ], + ...mediaData, + }; + } + } + + return generatedAssets; + } catch ( error ) { + if ( signal.aborted ) { + return; + } + + // Catch unexpected runtime errors + createNotice( + 'error', + __( + 'An unexpected error occurred.', + 'google-listings-and-ads' + ) + ); + } finally { + setIsGeneratingAssets( false ); + } + }, + [ + processGenAIResponse, + receiveGenAITextAssets, + receiveGenAIMediaAssets, + createNotice, + ] + ); + + return { generateAssets, isGeneratingAssets, abortGenerateAssets }; +}; + +export default useCreateGenAIAssets; diff --git a/js/src/hooks/useGenAIMediaAssets.js b/js/src/hooks/useGenAIMediaAssets.js new file mode 100644 index 0000000000..9a7294d7c6 --- /dev/null +++ b/js/src/hooks/useGenAIMediaAssets.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '~/data/constants'; + +const selectorName = 'getGenAIMediaAssets'; + +const useGenAIMediaAssets = ( url, assetType ) => { + return useSelect( + ( select ) => { + const assets = select( STORE_KEY )[ selectorName ]( + url, + assetType + ); + + return { + assets, + }; + }, + [ url, assetType ] + ); +}; + +export default useGenAIMediaAssets; diff --git a/js/src/hooks/useGenAITextAssets.js b/js/src/hooks/useGenAITextAssets.js new file mode 100644 index 0000000000..dd5fcafc43 --- /dev/null +++ b/js/src/hooks/useGenAITextAssets.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '~/data/constants'; + +const selectorName = 'getGenAITextAssets'; + +const useGenAITextAssets = ( url, assetType ) => { + return useSelect( + ( select ) => { + const assets = select( STORE_KEY )[ selectorName ]( + url, + assetType + ); + + return { + assets, + }; + }, + [ url, assetType ] + ); +}; + +export default useGenAITextAssets; diff --git a/js/src/images/ai-icon.svg b/js/src/images/ai-icon.svg new file mode 100644 index 0000000000..23b02113d1 --- /dev/null +++ b/js/src/images/ai-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg b/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg new file mode 100644 index 0000000000..8f283f9378 --- /dev/null +++ b/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg @@ -0,0 +1,3 @@ + + + diff --git a/js/src/images/pmax-assets-improvements/gen-ai-progress.svg b/js/src/images/pmax-assets-improvements/gen-ai-progress.svg new file mode 100644 index 0000000000..d0ba29c20b --- /dev/null +++ b/js/src/images/pmax-assets-improvements/gen-ai-progress.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/src/images/pmax-assets-improvements/gen-ai.svg b/js/src/images/pmax-assets-improvements/gen-ai.svg index 098022ed98..73ea45c64b 100644 --- a/js/src/images/pmax-assets-improvements/gen-ai.svg +++ b/js/src/images/pmax-assets-improvements/gen-ai.svg @@ -1 +1,40 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/src/pages/create-paid-ads-campaign/index.js b/js/src/pages/create-paid-ads-campaign/index.js index 1e30e7abde..5ef242d765 100644 --- a/js/src/pages/create-paid-ads-campaign/index.js +++ b/js/src/pages/create-paid-ads-campaign/index.js @@ -173,16 +173,18 @@ const CreatePaidAdsCampaign = () => { 'google-listings-and-ads' ) } context={ eventContext } - continueButton={ ( formContext ) => ( - { - handleContinueClick( - STEP.ASSET_GROUP - ); - } } - /> - ) } + continueButton={ ( formContext ) => { + return ( + { + handleContinueClick( + STEP.ASSET_GROUP + ); + } } + /> + ); + } } /> ), onClick: handleStepperClick, diff --git a/package-lock.json b/package-lock.json index 954ed8fb62..d94ec5bd0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@hapi/h2o2": "^10.0.4", "@hapi/hapi": "^21.3.10", "@playwright/test": "^1.56.1", + "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^16.0.0", diff --git a/package.json b/package.json index 741d7b1eab..fd88af507c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@hapi/h2o2": "^10.0.4", "@hapi/hapi": "^21.3.10", "@playwright/test": "^1.56.1", + "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^16.0.0", @@ -148,11 +149,11 @@ }, { "path": "./js/build/index.js", - "maxSize": "19.1 kB" + "maxSize": "19.85 kB" }, { "path": "./js/build/commons.js", - "maxSize": "65.63 kB" + "maxSize": "68.1 kB" }, { "path": "./js/build/vendors.js", @@ -168,7 +169,7 @@ }, { "path": "./google-listings-and-ads.zip", - "maxSize": "8.24 mB", + "maxSize": "8.30 mB", "compression": "none" } ], diff --git a/src/API/Google/Ads.php b/src/API/Google/Ads.php index 97bda86023..971ee743fb 100644 --- a/src/API/Google/Ads.php +++ b/src/API/Google/Ads.php @@ -13,12 +13,12 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface; use Exception; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Enums\AccessRoleEnum\AccessRole; -use Google\Ads\GoogleAds\V20\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus; -use Google\Ads\GoogleAds\V20\Resources\ProductLinkInvitation; -use Google\Ads\GoogleAds\V20\Services\ListAccessibleCustomersRequest; -use Google\Ads\GoogleAds\V20\Services\UpdateProductLinkInvitationRequest; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Enums\AccessRoleEnum\AccessRole; +use Google\Ads\GoogleAds\V22\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus; +use Google\Ads\GoogleAds\V22\Resources\ProductLinkInvitation; +use Google\Ads\GoogleAds\V22\Services\ListAccessibleCustomersRequest; +use Google\Ads\GoogleAds\V22\Services\UpdateProductLinkInvitationRequest; use Google\ApiCore\ApiException; use Google\ApiCore\ValidationException; diff --git a/src/API/Google/AdsAsset.php b/src/API/Google/AdsAsset.php index c481d5f28f..2088293616 100644 --- a/src/API/Google/AdsAsset.php +++ b/src/API/Google/AdsAsset.php @@ -6,17 +6,17 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType; -use Google\Ads\GoogleAds\V20\Resources\Asset; -use Google\Ads\GoogleAds\V20\Services\AssetOperation; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Common\TextAsset; -use Google\Ads\GoogleAds\V20\Common\ImageAsset; -use Google\Ads\GoogleAds\V20\Common\CallToActionAsset; -use Google\Ads\GoogleAds\V20\Common\YoutubeVideoAsset; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType; +use Google\Ads\GoogleAds\V22\Resources\Asset; +use Google\Ads\GoogleAds\V22\Services\AssetOperation; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Common\TextAsset; +use Google\Ads\GoogleAds\V22\Common\ImageAsset; +use Google\Ads\GoogleAds\V22\Common\CallToActionAsset; +use Google\Ads\GoogleAds\V22\Common\YoutubeVideoAsset; use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP; use Google\ApiCore\ApiException; use Exception; diff --git a/src/API/Google/AdsAssetGroup.php b/src/API/Google/AdsAssetGroup.php index b036b696dd..fe474c18f3 100644 --- a/src/API/Google/AdsAssetGroup.php +++ b/src/API/Google/AdsAssetGroup.php @@ -7,18 +7,18 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource; -use Google\Ads\GoogleAds\V20\Enums\AssetGroupStatusEnum\AssetGroupStatus; -use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType; -use Google\Ads\GoogleAds\V20\Resources\AssetGroup; -use Google\Ads\GoogleAds\V20\Resources\AssetGroupListingGroupFilter; -use Google\Ads\GoogleAds\V20\Services\AssetGroupListingGroupFilterOperation; -use Google\Ads\GoogleAds\V20\Services\AssetGroupOperation; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupServiceClient; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource; +use Google\Ads\GoogleAds\V22\Enums\AssetGroupStatusEnum\AssetGroupStatus; +use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType; +use Google\Ads\GoogleAds\V22\Resources\AssetGroup; +use Google\Ads\GoogleAds\V22\Resources\AssetGroupListingGroupFilter; +use Google\Ads\GoogleAds\V22\Services\AssetGroupListingGroupFilterOperation; +use Google\Ads\GoogleAds\V22\Services\AssetGroupOperation; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupServiceClient; use Google\ApiCore\ApiException; use Google\ApiCore\ValidationException; use Google\Protobuf\FieldMask; @@ -27,8 +27,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface; -use Google\Ads\GoogleAds\V20\Resources\AssetGroupAsset; -use Google\Ads\GoogleAds\V20\Services\AssetGroupAssetOperation; +use Google\Ads\GoogleAds\V22\Resources\AssetGroupAsset; +use Google\Ads\GoogleAds\V22\Services\AssetGroupAssetOperation; /** * Class AdsAssetGroup diff --git a/src/API/Google/AdsAssetGroupAsset.php b/src/API/Google/AdsAssetGroupAsset.php index 9d642f2ba2..011c2ccbd9 100644 --- a/src/API/Google/AdsAssetGroupAsset.php +++ b/src/API/Google/AdsAssetGroupAsset.php @@ -7,13 +7,13 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Resources\AssetGroupAsset; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Resources\AssetGroupAsset; use Google\ApiCore\ApiException; use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\V20\Services\AssetGroupAssetOperation; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Services\AssetGroupAssetOperation; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php index 10806f54a9..ebd969de67 100644 --- a/src/API/Google/AdsCampaign.php +++ b/src/API/Google/AdsCampaign.php @@ -23,18 +23,21 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC; use Google\Ads\GoogleAds\Util\FieldMasks; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Common\MaximizeConversionValue; -use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType as AdsAssetType; -use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; -use Google\Ads\GoogleAds\V20\Resources\Campaign; -use Google\Ads\GoogleAds\V20\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus; -use Google\Ads\GoogleAds\V20\Resources\Campaign\ShoppingSetting; -use Google\Ads\GoogleAds\V20\Services\Client\CampaignServiceClient; -use Google\Ads\GoogleAds\V20\Services\CampaignOperation; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Common\MaximizeConversionValue; +use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType as AdsAssetType; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; +use Google\Ads\GoogleAds\V22\Resources\Campaign; +use Google\Ads\GoogleAds\V22\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus; +use Google\Ads\GoogleAds\V22\Resources\Campaign\ShoppingSetting; +use Google\Ads\GoogleAds\V22\Services\Client\CampaignServiceClient; +use Google\Ads\GoogleAds\V22\Services\CampaignOperation; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Resources\Campaign\AssetAutomationSetting; +use Google\Ads\GoogleAds\V22\Enums\AssetAutomationTypeEnum\AssetAutomationType; +use Google\Ads\GoogleAds\V22\Enums\AssetAutomationStatusEnum\AssetAutomationStatus; use Google\ApiCore\ApiException; use Google\ApiCore\ValidationException; use Exception; @@ -537,7 +540,14 @@ protected function create_operation( string $campaign_name, ?string $country, bo 'status' => CampaignStatus::number( 'enabled' ), 'campaign_budget' => $this->budget->temporary_resource_name(), 'maximize_conversion_value' => new MaximizeConversionValue(), - 'url_expansion_opt_out' => false, + 'asset_automation_settings' => [ + new AssetAutomationSetting( + [ + 'asset_automation_type' => AssetAutomationType::FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION, + 'asset_automation_status' => AssetAutomationStatus::OPTED_IN, + ] + ), + ], 'contains_eu_political_advertising' => $is_eu_political ? EuPoliticalAdvertisingStatus::CONTAINS_EU_POLITICAL_ADVERTISING : EuPoliticalAdvertisingStatus::DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING, ]; @@ -549,6 +559,9 @@ protected function create_operation( string $campaign_name, ?string $country, bo 'feed_label' => $country, ] ); + } else { + // Turn off brand guidelines for non-shopping campaigns. + $campaign_data['brand_guidelines_enabled'] = false; } $campaign = new Campaign( $campaign_data ); diff --git a/src/API/Google/AdsCampaignAsset.php b/src/API/Google/AdsCampaignAsset.php index e4e13a61a1..67ad3ecb98 100644 --- a/src/API/Google/AdsCampaignAsset.php +++ b/src/API/Google/AdsCampaignAsset.php @@ -6,11 +6,11 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Resources\CampaignAsset; -use Google\Ads\GoogleAds\V20\Services\CampaignAssetOperation; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\V20\Enums\AssetFieldTypeEnum\AssetFieldType as AssetFieldTypeEnum; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Resources\CampaignAsset; +use Google\Ads\GoogleAds\V22\Services\CampaignAssetOperation; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType as AssetFieldTypeEnum; /** * Class AdsCampaignAsset diff --git a/src/API/Google/AdsCampaignBudget.php b/src/API/Google/AdsCampaignBudget.php index 602a564b7b..80c2d50564 100644 --- a/src/API/Google/AdsCampaignBudget.php +++ b/src/API/Google/AdsCampaignBudget.php @@ -9,11 +9,11 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; use Google\Ads\GoogleAds\Util\FieldMasks; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Resources\CampaignBudget; -use Google\Ads\GoogleAds\V20\Services\CampaignBudgetOperation; -use Google\Ads\GoogleAds\V20\Services\Client\CampaignBudgetServiceClient; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Resources\CampaignBudget; +use Google\Ads\GoogleAds\V22\Services\CampaignBudgetOperation; +use Google\Ads\GoogleAds\V22\Services\Client\CampaignBudgetServiceClient; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; use Google\ApiCore\ValidationException; use Exception; diff --git a/src/API/Google/AdsCampaignCriterion.php b/src/API/Google/AdsCampaignCriterion.php index d3e77e3eac..08dfe877a8 100644 --- a/src/API/Google/AdsCampaignCriterion.php +++ b/src/API/Google/AdsCampaignCriterion.php @@ -3,12 +3,12 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Common\LocationInfo; -use Google\Ads\GoogleAds\V20\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus; -use Google\Ads\GoogleAds\V20\Resources\CampaignCriterion; -use Google\Ads\GoogleAds\V20\Services\CampaignCriterionOperation; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Common\LocationInfo; +use Google\Ads\GoogleAds\V22\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus; +use Google\Ads\GoogleAds\V22\Resources\CampaignCriterion; +use Google\Ads\GoogleAds\V22\Services\CampaignCriterionOperation; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; /** * Class AdsCampaignCriterion diff --git a/src/API/Google/AdsCampaignLabel.php b/src/API/Google/AdsCampaignLabel.php index cc50a0dc6b..22259c8e71 100644 --- a/src/API/Google/AdsCampaignLabel.php +++ b/src/API/Google/AdsCampaignLabel.php @@ -7,13 +7,13 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Resources\Label; -use Google\Ads\GoogleAds\V20\Resources\CampaignLabel; -use Google\Ads\GoogleAds\V20\Services\LabelOperation; -use Google\Ads\GoogleAds\V20\Services\CampaignLabelOperation; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Resources\Label; +use Google\Ads\GoogleAds\V22\Resources\CampaignLabel; +use Google\Ads\GoogleAds\V22\Services\LabelOperation; +use Google\Ads\GoogleAds\V22\Services\CampaignLabelOperation; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest; /** * Class AdsCampaignLabel diff --git a/src/API/Google/AdsConversionAction.php b/src/API/Google/AdsConversionAction.php index 42dbe04e5b..4e9895773b 100644 --- a/src/API/Google/AdsConversionAction.php +++ b/src/API/Google/AdsConversionAction.php @@ -8,19 +8,19 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; use Exception; -use Google\Ads\GoogleAds\V20\Common\TagSnippet; -use Google\Ads\GoogleAds\V20\Enums\ConversionActionCategoryEnum\ConversionActionCategory; -use Google\Ads\GoogleAds\V20\Enums\ConversionActionStatusEnum\ConversionActionStatus; -use Google\Ads\GoogleAds\V20\Enums\ConversionActionTypeEnum\ConversionActionType; -use Google\Ads\GoogleAds\V20\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat; -use Google\Ads\GoogleAds\V20\Enums\TrackingCodeTypeEnum\TrackingCodeType; -use Google\Ads\GoogleAds\V20\Resources\ConversionAction; -use Google\Ads\GoogleAds\V20\Resources\ConversionAction\ValueSettings; -use Google\Ads\GoogleAds\V20\Services\ConversionActionOperation; -use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\MutateConversionActionResult; -use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsRequest; +use Google\Ads\GoogleAds\V22\Common\TagSnippet; +use Google\Ads\GoogleAds\V22\Enums\ConversionActionCategoryEnum\ConversionActionCategory; +use Google\Ads\GoogleAds\V22\Enums\ConversionActionStatusEnum\ConversionActionStatus; +use Google\Ads\GoogleAds\V22\Enums\ConversionActionTypeEnum\ConversionActionType; +use Google\Ads\GoogleAds\V22\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat; +use Google\Ads\GoogleAds\V22\Enums\TrackingCodeTypeEnum\TrackingCodeType; +use Google\Ads\GoogleAds\V22\Resources\ConversionAction; +use Google\Ads\GoogleAds\V22\Resources\ConversionAction\ValueSettings; +use Google\Ads\GoogleAds\V22\Services\ConversionActionOperation; +use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\MutateConversionActionResult; +use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsRequest; use Google\ApiCore\ApiException; /** diff --git a/src/API/Google/AdsReport.php b/src/API/Google/AdsReport.php index 5c257c97ae..ced62e7c07 100644 --- a/src/API/Google/AdsReport.php +++ b/src/API/Google/AdsReport.php @@ -15,8 +15,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; use DateTime; -use Google\Ads\GoogleAds\V20\Common\Segments; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Common\Segments; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; use Google\ApiCore\ApiException; /** diff --git a/src/API/Google/AssetFieldType.php b/src/API/Google/AssetFieldType.php index ed152c9aee..160298f6ce 100644 --- a/src/API/Google/AssetFieldType.php +++ b/src/API/Google/AssetFieldType.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\V20\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType; +use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping; use UnexpectedValueException; diff --git a/src/API/Google/BillingSetupStatus.php b/src/API/Google/BillingSetupStatus.php index 9bcfdbd32d..547e117aea 100644 --- a/src/API/Google/BillingSetupStatus.php +++ b/src/API/Google/BillingSetupStatus.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\V20\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus; +use Google\Ads\GoogleAds\V22\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping; /** diff --git a/src/API/Google/BudgetMetrics.php b/src/API/Google/BudgetMetrics.php index 6582533838..ed78a3e4e4 100644 --- a/src/API/Google/BudgetMetrics.php +++ b/src/API/Google/BudgetMetrics.php @@ -12,14 +12,14 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper; -use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; -use Google\Ads\GoogleAds\V20\Enums\BiddingStrategyTypeEnum\BiddingStrategyType; -use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\AssetGroupInfo; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BiddingInfo; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BudgetInfo; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; +use Google\Ads\GoogleAds\V22\Enums\BiddingStrategyTypeEnum\BiddingStrategyType; +use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\AssetGroupInfo; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BiddingInfo; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BudgetInfo; use Google\ApiCore\ApiException; /** diff --git a/src/API/Google/BudgetRecommendations.php b/src/API/Google/BudgetRecommendations.php index 262b141687..9d4c04c43f 100644 --- a/src/API/Google/BudgetRecommendations.php +++ b/src/API/Google/BudgetRecommendations.php @@ -12,13 +12,13 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper; -use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; -use Google\Ads\GoogleAds\V20\Enums\BiddingStrategyTypeEnum\BiddingStrategyType; -use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\AssetGroupInfo; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BiddingInfo; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; +use Google\Ads\GoogleAds\V22\Enums\BiddingStrategyTypeEnum\BiddingStrategyType; +use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\AssetGroupInfo; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BiddingInfo; use Google\ApiCore\ApiException; /** diff --git a/src/API/Google/CallToActionType.php b/src/API/Google/CallToActionType.php index faab4e5403..bf85248317 100644 --- a/src/API/Google/CallToActionType.php +++ b/src/API/Google/CallToActionType.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\V20\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType; +use Google\Ads\GoogleAds\V22\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping; diff --git a/src/API/Google/CampaignStatus.php b/src/API/Google/CampaignStatus.php index 3a59b8595f..b0d6fb9550 100644 --- a/src/API/Google/CampaignStatus.php +++ b/src/API/Google/CampaignStatus.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\V20\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus; +use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping; /** diff --git a/src/API/Google/CampaignType.php b/src/API/Google/CampaignType.php index 447178591a..ed2d7d1f9b 100644 --- a/src/API/Google/CampaignType.php +++ b/src/API/Google/CampaignType.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google; -use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType; use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping; /** diff --git a/src/API/Google/MerchantMetrics.php b/src/API/Google/MerchantMetrics.php index 9a73125659..cad3fe3177 100644 --- a/src/API/Google/MerchantMetrics.php +++ b/src/API/Google/MerchantMetrics.php @@ -15,7 +15,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse; use DateTime; use Exception; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; use Google\ApiCore\PagedListResponse; /** diff --git a/src/API/Google/Query/AdsQuery.php b/src/API/Google/Query/AdsQuery.php index 20850e2f2e..8b3023369c 100644 --- a/src/API/Google/Query/AdsQuery.php +++ b/src/API/Google/Query/AdsQuery.php @@ -5,9 +5,9 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty; use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\SearchGoogleAdsRequest; -use Google\Ads\GoogleAds\V20\Services\SearchSettings; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest; +use Google\Ads\GoogleAds\V22\Services\SearchSettings; use Google\ApiCore\ApiException; defined( 'ABSPATH' ) || exit; diff --git a/src/API/Google/Query/AdsReportQuery.php b/src/API/Google/Query/AdsReportQuery.php index 121fde8b9b..30e8a1354c 100644 --- a/src/API/Google/Query/AdsReportQuery.php +++ b/src/API/Google/Query/AdsReportQuery.php @@ -3,7 +3,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query; -use Google\Ads\GoogleAds\V20\Resources\ShoppingPerformanceView; +use Google\Ads\GoogleAds\V22\Resources\ShoppingPerformanceView; defined( 'ABSPATH' ) || exit; diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php new file mode 100644 index 0000000000..54f8f07263 --- /dev/null +++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php @@ -0,0 +1,228 @@ +service = $service; + } + + /** + * Register rest routes with WordPress. + */ + public function register_routes(): void { + $this->register_route( + 'ads/assets/generate-text', + [ + [ + 'methods' => TransportMethods::CREATABLE, + 'callback' => $this->get_generate_text_callback(), + 'permission_callback' => $this->get_permission_callback(), + 'args' => $this->get_generate_text_params(), + ], + 'schema' => $this->get_api_response_schema_callback(), + ] + ); + + $this->register_route( + 'ads/assets/generate-images', + [ + [ + 'methods' => TransportMethods::CREATABLE, + 'callback' => $this->get_generate_images_callback(), + 'permission_callback' => $this->get_permission_callback(), + 'args' => $this->get_generate_images_params(), + ], + 'schema' => $this->get_api_response_schema_callback(), + ] + ); + } + + /** + * Get the parameters for the generate-text endpoint. + * + * @return array + */ + protected function get_generate_text_params(): array { + return [ + 'final_url' => [ + 'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ), + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'esc_url_raw', + 'validate_callback' => 'rest_validate_request_arg', + ], + 'types' => [ + 'description' => __( 'Asset types to generate', 'google-listings-and-ads' ), + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'string', + 'enum' => AdsAssetGenerationService::VALID_TEXT_TYPES, + ], + 'sanitize_callback' => function ( $types ) { + return array_map( 'sanitize_text_field', $types ); + }, + 'validate_callback' => 'rest_validate_request_arg', + ], + ]; + } + + /** + * Get the parameters for the generate-images endpoint. + * + * @return array + */ + protected function get_generate_images_params(): array { + return [ + 'final_url' => [ + 'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ), + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'esc_url_raw', + 'validate_callback' => 'rest_validate_request_arg', + ], + 'types' => [ + 'description' => __( 'Asset types to generate', 'google-listings-and-ads' ), + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'string', + 'enum' => AdsAssetGenerationService::VALID_IMAGE_TYPES, + ], + 'sanitize_callback' => function ( $types ) { + return array_map( 'sanitize_text_field', $types ); + }, + 'validate_callback' => 'rest_validate_request_arg', + ], + ]; + } + + /** + * Get the callback function for the generate-text request. + * + * @return callable + */ + protected function get_generate_text_callback(): callable { + return function ( Request $request ) { + set_time_limit( 90 ); // AI text generation can take time. + + try { + $final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url(); + $types = $request->get_param( 'types' ) ?: []; + + // Call service with lowercase types. + $items = $this->service->generate_text( + [ + 'final_url' => $final_url, + 'asset_field_types' => $types, + ] + ); + + return [ + 'final_url' => $final_url, + 'items' => $items, + ]; + } catch ( Exception $e ) { + return $this->response_from_exception( $e ); + } + }; + } + + /** + * Get the callback function for the generate-images request. + * + * @return callable + */ + protected function get_generate_images_callback(): callable { + return function ( Request $request ) { + set_time_limit( 90 ); // AI image generation can take time. + + try { + $final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url(); + $types = $request->get_param( 'types' ) ?: []; + + // Call service with lowercase types. + $args = [ 'final_url' => $final_url ]; + if ( ! empty( $types ) ) { + $args['asset_field_types'] = $types; + } + $items = $this->service->generate_images( $args ); + + return [ + 'final_url' => $final_url, + 'items' => $items, + ]; + } catch ( Exception $e ) { + return $this->response_from_exception( $e ); + } + }; + } + + + /** + * Get the item schema properties for the controller. + * + * @return array + */ + protected function get_schema_properties(): array { + return [ + 'final_url' => [ + 'type' => 'string', + 'description' => __( 'The final URL used for generation', 'google-listings-and-ads' ), + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'items' => [ + 'type' => 'array', + 'description' => __( 'Generated assets', 'google-listings-and-ads' ), + 'context' => [ 'view' ], + 'readonly' => true, + ], + ]; + } + + /** + * Get the item schema name for the controller. + * + * Used for building the API response schema. + * + * @return string + */ + protected function get_schema_title(): string { + return 'asset_generation'; + } +} diff --git a/src/API/Site/Controllers/Ads/SetupCompleteController.php b/src/API/Site/Controllers/Ads/SetupCompleteController.php index e6585d59a6..6f67a0b426 100644 --- a/src/API/Site/Controllers/Ads/SetupCompleteController.php +++ b/src/API/Site/Controllers/Ads/SetupCompleteController.php @@ -62,7 +62,7 @@ public function register_routes() { * @return callable */ protected function get_setup_complete_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found do_action( 'woocommerce_gla_ads_setup_completed' ); /** diff --git a/src/API/Site/Controllers/DisconnectController.php b/src/API/Site/Controllers/DisconnectController.php index f0edeb40db..78313f87fd 100644 --- a/src/API/Site/Controllers/DisconnectController.php +++ b/src/API/Site/Controllers/DisconnectController.php @@ -40,7 +40,7 @@ public function register_routes() { * @return callable */ protected function get_disconnect_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $endpoints = [ 'ads/connection', 'mc/connection', diff --git a/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php b/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php index a49c7b82b0..5784a6c350 100644 --- a/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php +++ b/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php @@ -61,7 +61,7 @@ public function register_routes() { * @return callable */ protected function get_sync_endpoint_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found try { $this->settings->sync_taxes(); $this->settings->sync_shipping(); diff --git a/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php b/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php index ccf4e03798..889d96ca20 100644 --- a/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php +++ b/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php @@ -86,7 +86,7 @@ protected function get_syncable_products_count_callback(): callable { * @return callable */ protected function update_syncable_products_count_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT ); $this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA ); diff --git a/src/API/Site/Controllers/OnboardingController.php b/src/API/Site/Controllers/OnboardingController.php index d90d8e07a1..dac7006b7e 100644 --- a/src/API/Site/Controllers/OnboardingController.php +++ b/src/API/Site/Controllers/OnboardingController.php @@ -46,7 +46,7 @@ public function register_routes(): void { * @return callable */ protected function get_complete_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found do_action( 'woocommerce_gla_onboarding_completed' ); return new Response( @@ -65,7 +65,7 @@ protected function get_complete_callback(): callable { * @return callable */ protected function get_delete_callback(): callable { - return function ( Request $request ) { + return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $this->options->delete( OptionsInterface::ONBOARDING_COMPLETED_AT ); return new Response( diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php new file mode 100644 index 0000000000..7dd90a2231 --- /dev/null +++ b/src/Ads/AdsAssetGenerationService.php @@ -0,0 +1,240 @@ +google_ads_client = $client; + $this->client = $client->getAssetGenerationServiceClient(); + } + + /** + * Generate text assets using Google's AI. + * + * @param array $args { + * Optional. Arguments for generating text assets. + * + * @type string $final_url The final URL - defaults to the Site URL. + * @type array $asset_field_types Can be one or more of: headline, long_headline, description. + * } + * @return array Array of generated text objects with 'text' and 'type' keys. + * @throws Exception If the text assets can't be generated. + */ + public function generate_text( array $args = [] ): array { + $customer_id = $this->options->get_ads_id(); + if ( empty( $customer_id ) ) { + throw new Exception( __( 'Ads account ID is required.', 'google-listings-and-ads' ) ); + } + + $final_url = $args['final_url'] ?? $this->get_site_url(); + + // Default to all text types if not specified. + if ( empty( $args['asset_field_types'] ) ) { + $args['asset_field_types'] = [ 'headline', 'long_headline', 'description' ]; + } + + // Convert asset field types from lowercase strings to enum numbers. + $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], self::VALID_TEXT_TYPES ); + + $request = new GenerateTextRequest( + [ + 'customer_id' => $customer_id, + 'final_url' => $final_url, + 'advertising_channel_type' => AdvertisingChannelType::PERFORMANCE_MAX, + 'asset_field_types' => $asset_field_types, + ] + ); + + try { + $response = $this->client->generateText( $request ); + + $results = []; + foreach ( $response->getGeneratedText() as $text_asset ) { + $asset_field_type_number = $text_asset->getAssetFieldType(); + $asset_field_type_label = AssetFieldType::label( $asset_field_type_number ); + $results[] = [ + 'text' => $text_asset->getText(), + 'type' => $asset_field_type_label, + ]; + } + + return $results; + } catch ( ApiException $e ) { + do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); + + $errors = $this->get_exception_errors( $e ); + + throw new ExceptionWithResponseData( + /* translators: %s Error message */ + sprintf( __( 'Unable to generate text assets: %s', 'google-listings-and-ads' ), reset( $errors ) ), + $this->map_grpc_code_to_http_status_code( $e ), + $e, + [ 'errors' => $errors ] + ); + } + } + + /** + * Generate image assets using Google's AI. + * + * @param array $args { + * Optional. Arguments for generating image assets. + * + * @type string $final_url The final URL - defaults to the Site URL. + * @type array $asset_field_types Can be one or more of: marketing_image, square_marketing_image, portrait_marketing_image. + * } + * @return array Array of generated image objects with 'temporary_image_url' and 'type' keys. + * @throws Exception If the image assets can't be generated. + */ + public function generate_images( array $args = [] ): array { + $customer_id = $this->options->get_ads_id(); + if ( empty( $customer_id ) ) { + throw new Exception( __( 'Ads account ID is required.', 'google-listings-and-ads' ) ); + } + + $final_url = $args['final_url'] ?? $this->get_site_url(); + + // Convert asset field types from lowercase strings to enum numbers (if provided). + $asset_field_types = []; + if ( ! empty( $args['asset_field_types'] ) ) { + $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], self::VALID_IMAGE_TYPES ); + } + + $request_data = [ + 'customer_id' => $customer_id, + 'advertising_channel_type' => AdvertisingChannelType::PERFORMANCE_MAX, + 'final_url_generation' => new FinalUrlImageGenerationInput( + [ + 'final_url' => $final_url, + ] + ), + ]; + + // Add asset_field_types only if provided. + if ( ! empty( $asset_field_types ) ) { + $request_data['asset_field_types'] = $asset_field_types; + } + + $request = new GenerateImagesRequest( $request_data ); + + try { + $response = $this->client->generateImages( $request ); + + $results = []; + foreach ( $response->getGeneratedImages() as $image_asset ) { + $asset_field_type_number = $image_asset->getAssetFieldType(); + $asset_field_type_label = AssetFieldType::label( $asset_field_type_number ); + $results[] = [ + 'temporary_image_url' => $image_asset->getImageTemporaryUrl(), + 'type' => $asset_field_type_label, + ]; + } + + return $results; + } catch ( ApiException $e ) { + do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); + + $errors = $this->get_exception_errors( $e ); + + throw new ExceptionWithResponseData( + /* translators: %s Error message */ + sprintf( __( 'Unable to generate image assets: %s', 'google-listings-and-ads' ), reset( $errors ) ), + $this->map_grpc_code_to_http_status_code( $e ), + $e, + [ 'errors' => $errors ] + ); + } + } + + /** + * Convert asset field types from lowercase strings to enum numbers. + * + * @param array $types Array of lowercase type strings. + * @param array $allowed_types Optional. Array of AssetFieldType constants to filter by. + * @return array Array of enum numbers. + */ + protected function convert_types_to_enums( array $types, array $allowed_types = [] ): array { + $enums = []; + foreach ( $types as $type ) { + // Filter by allowed types if specified. + if ( ! empty( $allowed_types ) && ! in_array( $type, $allowed_types, true ) ) { + continue; + } + + $enums[] = AssetFieldType::number( $type ); + } + + return $enums; + } +} diff --git a/src/Ads/AdsRecommendationsService.php b/src/Ads/AdsRecommendationsService.php index f182924653..8d9716730a 100644 --- a/src/Ads/AdsRecommendationsService.php +++ b/src/Ads/AdsRecommendationsService.php @@ -15,8 +15,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException; -use Google\Ads\GoogleAds\V20\Resources\Recommendation; -use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType; +use Google\Ads\GoogleAds\V22\Resources\Recommendation; +use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType; use Exception; defined( 'ABSPATH' ) || exit; diff --git a/src/Coupon/WCCouponAdapter.php b/src/Coupon/WCCouponAdapter.php index 948929aee4..f33359090f 100644 --- a/src/Coupon/WCCouponAdapter.php +++ b/src/Coupon/WCCouponAdapter.php @@ -383,8 +383,11 @@ public function get_wc_coupon_id(): int { } /** + * Set the target country for the coupon. + * * @param string $targetCountry - * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase + * + * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase */ public function setTargetCountry( $targetCountry ) { // set the new target country @@ -418,7 +421,7 @@ private function get_product_ids_in_brand( WC_Coupon $wc_coupon, bool $is_exclud $meta_key = $is_exclude ? 'exclude_product_brands' : 'product_brands'; // Get the brand term IDs if brand restriction is set. - $brand_term_ids = get_post_meta( $coupon_id, $meta_key ); + $brand_term_ids = get_post_meta( $coupon_id, $meta_key, true ); if ( ! is_array( $brand_term_ids ) ) { return []; diff --git a/src/Google/Ads/ServiceClientFactoryTrait.php b/src/Google/Ads/ServiceClientFactoryTrait.php index 2b9e867bfa..afdb6642c2 100644 --- a/src/Google/Ads/ServiceClientFactoryTrait.php +++ b/src/Google/Ads/ServiceClientFactoryTrait.php @@ -13,25 +13,26 @@ use Google\Ads\GoogleAds\Constants; use Google\Ads\GoogleAds\Lib\ConfigurationTrait; -use Google\Ads\GoogleAds\V20\Services\Client\AccountLinkServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AdGroupAdLabelServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AdGroupAdServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AdGroupCriterionServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AdGroupServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AdServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupListingGroupFilterServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\BillingSetupServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CampaignBudgetServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CampaignCriterionServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CampaignServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CustomerServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CustomerUserAccessServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\GeoTargetConstantServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\ProductLinkInvitationServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\RecommendationServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AccountLinkServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AdGroupAdLabelServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AdGroupAdServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AdGroupCriterionServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AdGroupServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AdServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupListingGroupFilterServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\BillingSetupServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CampaignBudgetServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CampaignCriterionServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CampaignServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CustomerServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CustomerUserAccessServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\GeoTargetConstantServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\ProductLinkInvitationServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\RecommendationServiceClient; /** * Contains service client factory methods. @@ -209,4 +210,11 @@ public function getProductLinkInvitationServiceClient(): ProductLinkInvitationSe public function getRecommendationServiceClient(): RecommendationServiceClient { return new RecommendationServiceClient( $this->getGoogleAdsClientOptions() ); } + + /** + * @return AssetGenerationServiceClient + */ + public function getAssetGenerationServiceClient(): AssetGenerationServiceClient { + return new AssetGenerationServiceClient( $this->getGoogleAdsClientOptions() ); + } } diff --git a/src/Internal/DependencyManagement/GoogleServiceProvider.php b/src/Internal/DependencyManagement/GoogleServiceProvider.php index ea16162014..2aba7e0bfc 100644 --- a/src/Internal/DependencyManagement/GoogleServiceProvider.php +++ b/src/Internal/DependencyManagement/GoogleServiceProvider.php @@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement; use Automattic\Jetpack\Connection\Manager; +use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService; use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsRecommendationsService; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup; @@ -50,7 +51,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\Definition; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\RequestInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface; -use Google\Ads\GoogleAds\Util\V20\GoogleAdsFailures; +use Google\Ads\GoogleAds\Util\V22\GoogleAdsFailures; use Jetpack_Options; defined( 'ABSPATH' ) || exit; @@ -89,6 +90,7 @@ class GoogleServiceProvider extends AbstractServiceProvider { AdsConversionAction::class => true, AdsReport::class => true, AdsRecommendationsService::class => true, + AdsAssetGenerationService::class => true, AdsAssetGroupAsset::class => true, AdsAsset::class => true, BudgetMetrics::class => true, @@ -128,6 +130,7 @@ public function register(): void { $this->share( AdsConversionAction::class, GoogleAdsClient::class ); $this->share( AdsReport::class, GoogleAdsClient::class ); $this->share( AdsRecommendationsService::class, GoogleAdsClient::class ); + $this->share( AdsAssetGenerationService::class, GoogleAdsClient::class ); $this->share( BudgetMetrics::class, GoogleAdsClient::class ); $this->share( BudgetRecommendations::class, GoogleAdsClient::class ); diff --git a/src/Internal/DependencyManagement/RESTServiceProvider.php b/src/Internal/DependencyManagement/RESTServiceProvider.php index b79ed57aa6..d13b64ae4a 100644 --- a/src/Internal/DependencyManagement/RESTServiceProvider.php +++ b/src/Internal/DependencyManagement/RESTServiceProvider.php @@ -5,6 +5,7 @@ use Automattic\Jetpack\Connection\Manager; use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AccountService as AdsAccountService; +use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService; use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AssetSuggestionsService as AdsAssetSuggestionsService; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign; @@ -23,6 +24,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\ReportsController as AdsReportsController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\SetupCompleteController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGroupController as AdsAssetGroupController; +use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGenerationController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetSuggestionsController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\RecommendationsController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\GTINMigrationController; @@ -147,6 +149,7 @@ public function register(): void { $this->share( DisconnectController::class ); $this->share( SetupCompleteController::class, MerchantMetrics::class ); $this->share( AssetSuggestionsController::class, AdsAssetSuggestionsService::class ); + $this->share( AssetGenerationController::class, AdsAssetGenerationService::class ); $this->share( SyncableProductsCountController::class, JobRepository::class ); $this->share( PolicyComplianceCheckController::class, PolicyComplianceCheck::class ); $this->share( AttributeMappingDataController::class, AttributeMappingHelper::class ); diff --git a/src/Tracking/README.md b/src/Tracking/README.md index 9a16447f6d..f97eaba149 100644 --- a/src/Tracking/README.md +++ b/src/Tracking/README.md @@ -553,6 +553,22 @@ Saving changes of audience and/or shipping settings to the product feed. #### Emitters - [`exports`](../../js/src/pages/shipping/index.js#L46) +### [`gla_gen_ai_image_picker_add_selected_images_click`](../../js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js#L22) +Triggered when the "Add selected images" button is clicked. +#### Properties +| name | type | description | +| ---- | ---- | ----------- | +`finalUrl` | `string` | The final URL for which the images were generated. +`assetKey` | `string` | The asset key for which the images were generated. +`numberOfSelectedImages` | `number` | The number of images that were selected to be added. +#### Emitters +- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js#L41) when the "Add selected images" button is clicked. + +### [`gla_gen_ai_progress_skip_button_click`](../../js/src/components/paid-ads/gen-ai-progress/skip-button.js#L12) +Triggered when the skip button is clicked during Gen AI asset generation progress. +#### Emitters +- [`SkipButton`](../../js/src/components/paid-ads/gen-ai-progress/skip-button.js#L27) when the skip button is clicked. + ### [`gla_google_account_connect_button_click`](../../js/src/utils/tracks.js#L185) Clicking on the button to connect Google account. #### Properties @@ -593,14 +609,14 @@ Clicking on a Google Merchant Center link. #### Emitters - [`HelpIconButton`](../../js/src/components/help-icon-button/index.js#L31) -### [`gla_import_assets_by_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L80) +### [`gla_import_assets_by_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L83) Clicking on the "Scan for assets" button. #### Properties | name | type | description | | ---- | ---- | ----------- | `type` | `string` | The type of the selected Final URL suggestion to be imported. Possible values: `post`, `term`, `homepage`. #### Emitters -- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L96) +- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L99) ### [`gla_launch_paid_campaign_button_click`](../../js/src/utils/tracks.js#L173) Triggered when the "Launch paid campaign" button is clicked to add a new paid campaign in the Google Ads setup flow. @@ -922,10 +938,10 @@ Triggered when the request review is successful #### Emitters - [`ReviewRequestModal`](../../js/src/pages/product-feed/review-request/review-request-modal.js#L58) -### [`gla_reselect_another_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L23) +### [`gla_reselect_another_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L24) Clicking on the "Or, select another page" button. #### Emitters -- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L39) +- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L40) ### [`gla_setup_ads`](../../js/src/utils/tracks.js#L203) Triggered on events during ads onboarding @@ -1062,6 +1078,16 @@ Sorting table - [`AppTableCard`](../../js/src/components/app-table-card/index.js#L74) upon sorting table by column - [`recordTableSortEvent`](../../js/src/components/app-table-card/index.js#L55) with given props. +### [`gla_texts_editor_generate_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js#L35) +Triggered when the generate texts button is clicked in the TextsEditor component. Event properties include finalUrl and assetKey. +#### Properties +| name | type | description | +| ---- | ---- | ----------- | +`finalUrl` | `string` | The final URL for the ad. +`assetKey` | `string` | The key of the text asset. +#### Emitters +- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js#L62) when the generate texts button is clicked in the TextsEditor component. + ### [`gla_tooltip_viewed`](../../js/src/components/help-popover/index.js#L16) Viewing tooltip #### Properties diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php index 6bca837c3c..6e923ff60d 100644 --- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php +++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php @@ -10,62 +10,62 @@ use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient; use Exception; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; -use Google\Ads\GoogleAds\V20\Common\LocationInfo; -use Google\Ads\GoogleAds\V20\Common\Metrics; -use Google\Ads\GoogleAds\V20\Common\Segments; -use Google\Ads\GoogleAds\V20\Common\TagSnippet; -use Google\Ads\GoogleAds\V20\Common\ImageAsset; -use Google\Ads\GoogleAds\V20\Common\TextAsset; -use Google\Ads\GoogleAds\V20\Common\CallToActionAsset; -use Google\Ads\GoogleAds\V20\Common\ImageDimension; -use Google\Ads\GoogleAds\V20\Common\YoutubeVideoAsset; -use Google\Ads\GoogleAds\V20\Enums\AccessRoleEnum\AccessRole; -use Google\Ads\GoogleAds\V20\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus; -use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType; -use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType; -use Google\Ads\GoogleAds\V20\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat; -use Google\Ads\GoogleAds\V20\Enums\TrackingCodeTypeEnum\TrackingCodeType; -use Google\Ads\GoogleAds\V20\Resources\BillingSetup; -use Google\Ads\GoogleAds\V20\Resources\Campaign; -use Google\Ads\GoogleAds\V20\Resources\Label; -use Google\Ads\GoogleAds\V20\Resources\Asset; -use Google\Ads\GoogleAds\V20\Resources\AssetGroup; -use Google\Ads\GoogleAds\V20\Resources\AssetGroupAsset; -use Google\Ads\GoogleAds\V20\Services\AssetGroupAssetOperation; -use Google\Ads\GoogleAds\V20\Resources\CampaignBudget; -use Google\Ads\GoogleAds\V20\Resources\CampaignCriterion; -use Google\Ads\GoogleAds\V20\Resources\Campaign\ShoppingSetting; -use Google\Ads\GoogleAds\V20\Resources\ConversionAction; -use Google\Ads\GoogleAds\V20\Resources\Customer; -use Google\Ads\GoogleAds\V20\Resources\CustomerUserAccess; -use Google\Ads\GoogleAds\V20\Resources\GeoTargetConstant; -use Google\Ads\GoogleAds\V20\Resources\Recommendation; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\RecommendationImpact; -use Google\Ads\GoogleAds\V20\Resources\Recommendation\RecommendationMetrics; -use Google\Ads\GoogleAds\V20\Resources\ShoppingPerformanceView; -use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\CustomerServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\ProductLinkInvitationServiceClient; -use Google\Ads\GoogleAds\V20\Services\Client\RecommendationServiceClient; -use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsResponse; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\ListAccessibleCustomersResponse; -use Google\Ads\GoogleAds\V20\Services\MutateCampaignResult; -use Google\Ads\GoogleAds\V20\Services\MutateLabelResult; -use Google\Ads\GoogleAds\V20\Services\MutateConversionActionResult; -use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsRequest; -use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsResponse; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest; -use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsResponse; -use Google\Ads\GoogleAds\V20\Services\MutateOperationResponse; -use Google\Ads\GoogleAds\V20\Services\MutateOperation; -use Google\Ads\GoogleAds\V20\Services\MutateAssetGroupResult; -use Google\Ads\GoogleAds\V20\Services\MutateAssetResult; -use Google\Ads\GoogleAds\V20\Services\SearchGoogleAdsResponse; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; +use Google\Ads\GoogleAds\V22\Common\LocationInfo; +use Google\Ads\GoogleAds\V22\Common\Metrics; +use Google\Ads\GoogleAds\V22\Common\Segments; +use Google\Ads\GoogleAds\V22\Common\TagSnippet; +use Google\Ads\GoogleAds\V22\Common\ImageAsset; +use Google\Ads\GoogleAds\V22\Common\TextAsset; +use Google\Ads\GoogleAds\V22\Common\CallToActionAsset; +use Google\Ads\GoogleAds\V22\Common\ImageDimension; +use Google\Ads\GoogleAds\V22\Common\YoutubeVideoAsset; +use Google\Ads\GoogleAds\V22\Enums\AccessRoleEnum\AccessRole; +use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType; +use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType; +use Google\Ads\GoogleAds\V22\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat; +use Google\Ads\GoogleAds\V22\Enums\TrackingCodeTypeEnum\TrackingCodeType; +use Google\Ads\GoogleAds\V22\Resources\BillingSetup; +use Google\Ads\GoogleAds\V22\Resources\Campaign; +use Google\Ads\GoogleAds\V22\Resources\Label; +use Google\Ads\GoogleAds\V22\Resources\Asset; +use Google\Ads\GoogleAds\V22\Resources\AssetGroup; +use Google\Ads\GoogleAds\V22\Resources\AssetGroupAsset; +use Google\Ads\GoogleAds\V22\Services\AssetGroupAssetOperation; +use Google\Ads\GoogleAds\V22\Resources\CampaignBudget; +use Google\Ads\GoogleAds\V22\Resources\CampaignCriterion; +use Google\Ads\GoogleAds\V22\Resources\Campaign\ShoppingSetting; +use Google\Ads\GoogleAds\V22\Resources\ConversionAction; +use Google\Ads\GoogleAds\V22\Resources\Customer; +use Google\Ads\GoogleAds\V22\Resources\CustomerUserAccess; +use Google\Ads\GoogleAds\V22\Resources\GeoTargetConstant; +use Google\Ads\GoogleAds\V22\Resources\Recommendation; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\RecommendationImpact; +use Google\Ads\GoogleAds\V22\Resources\Recommendation\RecommendationMetrics; +use Google\Ads\GoogleAds\V22\Resources\ShoppingPerformanceView; +use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\CustomerServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\ProductLinkInvitationServiceClient; +use Google\Ads\GoogleAds\V22\Services\Client\RecommendationServiceClient; +use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsResponse; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\ListAccessibleCustomersResponse; +use Google\Ads\GoogleAds\V22\Services\MutateCampaignResult; +use Google\Ads\GoogleAds\V22\Services\MutateLabelResult; +use Google\Ads\GoogleAds\V22\Services\MutateConversionActionResult; +use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsRequest; +use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsResponse; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest; +use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsResponse; +use Google\Ads\GoogleAds\V22\Services\MutateOperationResponse; +use Google\Ads\GoogleAds\V22\Services\MutateOperation; +use Google\Ads\GoogleAds\V22\Services\MutateAssetGroupResult; +use Google\Ads\GoogleAds\V22\Services\MutateAssetResult; +use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsResponse; use Google\ApiCore\ApiException; use Google\ApiCore\Page; use Google\ApiCore\PagedListResponse; @@ -94,6 +94,9 @@ trait GoogleAdsClientTrait { /** @var MockObject|GoogleAdsServiceClient $service_client */ protected $service_client; + /** @var MockObject|\Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient $asset_generation_service */ + protected $asset_generation_service; + /** @var int $ads_id */ protected $ads_id; @@ -115,6 +118,12 @@ protected function ads_client_setup() { $this->recommendation_service = $this->createMock( RecommendationServiceClient::class ); $this->client->method( 'getRecommendationServiceClient' )->willReturn( $this->recommendation_service ); + + $this->asset_generation_service = $this->getMockBuilder( \Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'generateText', 'generateImages' ] ) + ->getMock(); + $this->client->method( 'getAssetGenerationServiceClient' )->willReturn( $this->asset_generation_service ); } /** @@ -1182,8 +1191,82 @@ protected function generate_asset_create_operation( int $asset_id, string $field ); return ( new MutateOperation() )->setAssetOperation( - ( new \Google\Ads\GoogleAds\V20\Services\AssetOperation() ) + ( new \Google\Ads\GoogleAds\V22\Services\AssetOperation() ) ->setCreate( $asset ) ); } + + /** + * Generates a mocked response for text asset generation. + * + * @param array $text_assets Array of text assets with 'text' and 'type' keys (type in lowercase like 'headline'). + */ + protected function generate_text_assets_mock( array $text_assets ) { + $type_mapping = [ + 'headline' => AssetFieldType::HEADLINE, + 'long_headline' => AssetFieldType::LONG_HEADLINE, + 'description' => AssetFieldType::DESCRIPTION, + ]; + + $text_asset_objects = []; + foreach ( $text_assets as $asset ) { + $text_asset = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GeneratedText::class ); + $text_asset->method( 'getText' )->willReturn( $asset['text'] ); + $type_label = $type_mapping[ $asset['type'] ] ?? AssetFieldType::HEADLINE; + $type_number = AssetFieldType::number( $type_label ); + $text_asset->method( 'getAssetFieldType' )->willReturn( $type_number ); + $text_asset_objects[] = $text_asset; + } + + $response = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GenerateTextResponse::class ); + $response->method( 'getGeneratedText' )->willReturn( $text_asset_objects ); + + $this->asset_generation_service->method( 'generateText' )->willReturn( $response ); + } + + /** + * Generates a mocked exception when text assets are requested. + * + * @param ApiException $exception + */ + protected function generate_text_assets_mock_exception( ApiException $exception ) { + $this->asset_generation_service->method( 'generateText' )->willThrowException( $exception ); + } + + /** + * Generates a mocked response for image asset generation. + * + * @param array $image_assets Array of image assets with 'temporary_image_url' and 'type' keys (type in lowercase like 'marketing_image'). + */ + protected function generate_image_assets_mock( array $image_assets ) { + $type_mapping = [ + 'marketing_image' => AssetFieldType::MARKETING_IMAGE, + 'square_marketing_image' => AssetFieldType::SQUARE_MARKETING_IMAGE, + 'portrait_marketing_image' => AssetFieldType::PORTRAIT_MARKETING_IMAGE, + ]; + + $image_asset_objects = []; + foreach ( $image_assets as $asset ) { + $image_asset = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GeneratedImage::class ); + $image_asset->method( 'getImageTemporaryUrl' )->willReturn( $asset['temporary_image_url'] ); + $type_label = $type_mapping[ $asset['type'] ] ?? AssetFieldType::MARKETING_IMAGE; + $type_number = AssetFieldType::number( $type_label ); + $image_asset->method( 'getAssetFieldType' )->willReturn( $type_number ); + $image_asset_objects[] = $image_asset; + } + + $response = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GenerateImagesResponse::class ); + $response->method( 'getGeneratedImages' )->willReturn( $image_asset_objects ); + + $this->asset_generation_service->method( 'generateImages' )->willReturn( $response ); + } + + /** + * Generates a mocked exception when image assets are requested. + * + * @param ApiException $exception + */ + protected function generate_image_assets_mock_exception( ApiException $exception ) { + $this->asset_generation_service->method( 'generateImages' )->willThrowException( $exception ); + } } diff --git a/tests/Unit/API/ClientTest.php b/tests/Unit/API/ClientTest.php index 1bc14525a1..c031560bd7 100644 --- a/tests/Unit/API/ClientTest.php +++ b/tests/Unit/API/ClientTest.php @@ -206,7 +206,7 @@ public function test_add_auth_header() { $this->note->expects( $this->once() )->method( 'delete' ); $this->invoke_handler( 'add_auth_header' )( - function ( $request, $options ) { + function ( $request, $options ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed unset( $options ); $this->assertStringStartsWith( 'X_JP_Auth token=', $request->getHeader( 'Authorization' )[0] ); } @@ -239,7 +239,7 @@ public function test_plugin_version_headers(): void { $request = new Request( 'GET', 'https://testing.local' ); $this->invoke_handler( 'add_plugin_version_header' )( - function ( $request, $options ) { + function ( $request, $options ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed unset( $options ); $this->assertEquals( $this->get_client_name(), $request->getHeader( 'x-client-name' )[0] ); $this->assertEquals( $this->get_version(), $request->getHeader( 'x-client-version' )[0] ); diff --git a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php index e291401875..b9c88e1d42 100644 --- a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php +++ b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php @@ -10,8 +10,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait; use PHPUnit\Framework\MockObject\MockObject; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType; -use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; +use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; defined( 'ABSPATH' ) || exit; diff --git a/tests/Unit/API/Google/AdsAssetGroupTest.php b/tests/Unit/API/Google/AdsAssetGroupTest.php index 761bca5db2..f7927df030 100644 --- a/tests/Unit/API/Google/AdsAssetGroupTest.php +++ b/tests/Unit/API/Google/AdsAssetGroupTest.php @@ -10,9 +10,9 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait; -use Google\Ads\GoogleAds\V20\Enums\AssetGroupStatusEnum\AssetGroupStatus; -use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource; -use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType; +use Google\Ads\GoogleAds\V22\Enums\AssetGroupStatusEnum\AssetGroupStatus; +use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource; +use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType; use PHPUnit\Framework\MockObject\MockObject; use Google\ApiCore\ApiException; use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData; diff --git a/tests/Unit/API/Google/AdsAssetTest.php b/tests/Unit/API/Google/AdsAssetTest.php index e001147544..273569a61a 100644 --- a/tests/Unit/API/Google/AdsAssetTest.php +++ b/tests/Unit/API/Google/AdsAssetTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CallToActionType; -use Google\Ads\GoogleAds\Util\V20\ResourceNames; +use Google\Ads\GoogleAds\Util\V22\ResourceNames; use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP; use Exception; use WP_Error; diff --git a/tests/Unit/API/Google/AdsCampaignCriterionTest.php b/tests/Unit/API/Google/AdsCampaignCriterionTest.php index cecdde30e1..17be13ccf4 100644 --- a/tests/Unit/API/Google/AdsCampaignCriterionTest.php +++ b/tests/Unit/API/Google/AdsCampaignCriterionTest.php @@ -6,7 +6,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignCriterion; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait; -use Google\Ads\GoogleAds\V20\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus; +use Google\Ads\GoogleAds\V22\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus; defined( 'ABSPATH' ) || exit; diff --git a/tests/Unit/API/Google/AdsConversionActionTest.php b/tests/Unit/API/Google/AdsConversionActionTest.php index d692ee2823..5f6056b96b 100644 --- a/tests/Unit/API/Google/AdsConversionActionTest.php +++ b/tests/Unit/API/Google/AdsConversionActionTest.php @@ -8,7 +8,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait; use Exception; -use Google\Ads\GoogleAds\V20\Enums\ConversionActionStatusEnum\ConversionActionStatus; +use Google\Ads\GoogleAds\V22\Enums\ConversionActionStatusEnum\ConversionActionStatus; use Google\ApiCore\ApiException; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/API/Google/AdsTest.php b/tests/Unit/API/Google/AdsTest.php index 24b3bc64f3..7dea294866 100644 --- a/tests/Unit/API/Google/AdsTest.php +++ b/tests/Unit/API/Google/AdsTest.php @@ -10,10 +10,10 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait; use Exception; -use Google\Ads\GoogleAds\V20\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus; -use Google\Ads\GoogleAds\V20\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus; -use Google\Ads\GoogleAds\V20\Resources\MerchantCenterLinkInvitationIdentifier; -use Google\Ads\GoogleAds\V20\Resources\ProductLinkInvitation; +use Google\Ads\GoogleAds\V22\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus; +use Google\Ads\GoogleAds\V22\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus; +use Google\Ads\GoogleAds\V22\Resources\MerchantCenterLinkInvitationIdentifier; +use Google\Ads\GoogleAds\V22\Resources\ProductLinkInvitation; use Google\ApiCore\ApiException; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/API/Google/MerchantMetricsTest.php b/tests/Unit/API/Google/MerchantMetricsTest.php index 841134a23f..980d6720bf 100644 --- a/tests/Unit/API/Google/MerchantMetricsTest.php +++ b/tests/Unit/API/Google/MerchantMetricsTest.php @@ -16,9 +16,9 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Resource\Reports; use DateTime; -use Google\Ads\GoogleAds\V20\Common\Metrics as AdMetrics; -use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow; -use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient; +use Google\Ads\GoogleAds\V22\Common\Metrics as AdMetrics; +use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow; +use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient; use Google\ApiCore\Page; use Google\ApiCore\PagedListResponse; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/API/Google/MiddlewareTest.php b/tests/Unit/API/Google/MiddlewareTest.php index bb44e4826a..0523d54c57 100644 --- a/tests/Unit/API/Google/MiddlewareTest.php +++ b/tests/Unit/API/Google/MiddlewareTest.php @@ -459,7 +459,7 @@ public function test_get_sdi_merchant_update_endpoint() { public function test_get_sdi_merchant_update_endpoint_with_site_url_having_path() { add_filter( 'woocommerce_gla_site_url', - function ( $home_url ) { + function ( $home_url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found return 'http://example.org/shop'; } ); diff --git a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php new file mode 100644 index 0000000000..c7a8768599 --- /dev/null +++ b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php @@ -0,0 +1,357 @@ +service = $this->createMock( AdsAssetGenerationService::class ); + $this->controller = new AssetGenerationController( $this->server, $this->service ); + $this->controller->register(); + } + + public function test_generate_text_with_defaults() { + // Service expects empty array when no types provided (service handles defaults). + $this->service->expects( $this->once() ) + ->method( 'generate_text' ) + ->with( + [ + 'final_url' => self::TEST_SITE_URL, + 'asset_field_types' => [], + ] + ) + ->willReturn( + [ + [ + 'text' => 'Test headline', + 'type' => 'headline', + ], + [ + 'text' => 'Test long headline', + 'type' => 'long_headline', + ], + [ + 'text' => 'Test description', + 'type' => 'description', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( self::TEST_SITE_URL, $data['final_url'] ); + $this->assertCount( 3, $data['items'] ); + // Verify types are lowercase in response. + $this->assertEquals( 'headline', $data['items'][0]['type'] ); + $this->assertEquals( 'long_headline', $data['items'][1]['type'] ); + $this->assertEquals( 'description', $data['items'][2]['type'] ); + $this->assertEquals( 'Test headline', $data['items'][0]['text'] ); + } + + public function test_generate_text_with_custom_url() { + $params = [ + 'final_url' => 'https://custom-url.com', + ]; + + $this->service->expects( $this->once() ) + ->method( 'generate_text' ) + ->with( + [ + 'final_url' => 'https://custom-url.com', + 'asset_field_types' => [], + ] + ) + ->willReturn( + [ + [ + 'text' => 'Custom headline', + 'type' => 'headline', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'https://custom-url.com', $data['final_url'] ); + $this->assertEquals( 'headline', $data['items'][0]['type'] ); + } + + public function test_generate_text_with_specific_types() { + $params = [ + 'types' => [ 'headline' ], + ]; + + $this->service->expects( $this->once() ) + ->method( 'generate_text' ) + ->with( + [ + 'final_url' => self::TEST_SITE_URL, + 'asset_field_types' => [ 'headline' ], + ] + ) + ->willReturn( + [ + [ + 'text' => 'Headline only', + 'type' => 'headline', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data['items'] ); + $this->assertEquals( 'headline', $data['items'][0]['type'] ); + } + + public function test_generate_text_type_conversion() { + $params = [ + 'types' => [ 'headline', 'description' ], + ]; + + // Verify lowercase input is converted to uppercase for service. + $this->service->expects( $this->once() ) + ->method( 'generate_text' ) + ->with( + $this->callback( + function ( $args ) { + return $args['asset_field_types'] === [ 'headline', 'description' ]; + } + ) + ) + ->willReturn( + [ + [ + 'text' => 'Test', + 'type' => 'headline', + ], + [ + 'text' => 'Test', + 'type' => 'description', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params ); + + // Verify uppercase response is converted to lowercase. + $data = $response->get_data(); + $this->assertEquals( 'headline', $data['items'][0]['type'] ); + $this->assertEquals( 'description', $data['items'][1]['type'] ); + } + + public function test_generate_text_exception() { + $this->service + ->method( 'generate_text' ) + ->willThrowException( new Exception( 'Service error', 500 ) ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' ); + + $this->assertEquals( 'Service error', $response->get_data()['message'] ); + $this->assertEquals( 500, $response->get_status() ); + } + + public function test_generate_images_with_defaults() { + // Service expects empty array for types (API generates all). + $this->service->expects( $this->once() ) + ->method( 'generate_images' ) + ->with( + [ + 'final_url' => self::TEST_SITE_URL, + ] + ) + ->willReturn( + [ + [ + 'temporary_image_url' => 'https://example.com/image-marketing.jpg', + 'type' => 'marketing_image', + ], + [ + 'temporary_image_url' => 'https://example.com/image-square.jpg', + 'type' => 'square_marketing_image', + ], + [ + 'temporary_image_url' => 'https://example.com/image-portrait.jpg', + 'type' => 'portrait_marketing_image', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( self::TEST_SITE_URL, $data['final_url'] ); + $this->assertCount( 3, $data['items'] ); + // Verify types are lowercase in response. + $this->assertEquals( 'marketing_image', $data['items'][0]['type'] ); + $this->assertEquals( 'square_marketing_image', $data['items'][1]['type'] ); + $this->assertEquals( 'portrait_marketing_image', $data['items'][2]['type'] ); + } + + public function test_generate_images_with_custom_url() { + $params = [ + 'final_url' => 'https://custom-url.com', + ]; + + $this->service->expects( $this->once() ) + ->method( 'generate_images' ) + ->with( + [ + 'final_url' => 'https://custom-url.com', + ] + ) + ->willReturn( + [ + [ + 'temporary_image_url' => 'https://example.com/custom-image.jpg', + 'type' => 'marketing_image', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'https://custom-url.com', $data['final_url'] ); + $this->assertEquals( 'marketing_image', $data['items'][0]['type'] ); + } + + public function test_generate_images_with_specific_types() { + $params = [ + 'types' => [ 'marketing_image' ], + ]; + + // Service expects uppercase types. + $this->service->expects( $this->once() ) + ->method( 'generate_images' ) + ->with( + [ + 'final_url' => self::TEST_SITE_URL, + 'asset_field_types' => [ 'marketing_image' ], + ] + ) + ->willReturn( + [ + [ + 'temporary_image_url' => 'https://example.com/image.jpg', + 'type' => 'marketing_image', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params ); + + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + // Verify type is lowercase in response. + $this->assertEquals( 'marketing_image', $data['items'][0]['type'] ); + } + + public function test_generate_images_type_conversion() { + $params = [ + 'types' => [ 'marketing_image', 'square_marketing_image' ], + ]; + + // Verify lowercase input is converted to uppercase for service. + $this->service->expects( $this->once() ) + ->method( 'generate_images' ) + ->with( + $this->callback( + function ( $args ) { + return $args['asset_field_types'] === [ 'marketing_image', 'square_marketing_image' ]; + } + ) + ) + ->willReturn( + [ + [ + 'temporary_image_url' => 'https://example.com/image1.jpg', + 'type' => 'marketing_image', + ], + [ + 'temporary_image_url' => 'https://example.com/image2.jpg', + 'type' => 'square_marketing_image', + ], + ] + ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params ); + + // Verify uppercase response is converted to lowercase. + $data = $response->get_data(); + $this->assertEquals( 'marketing_image', $data['items'][0]['type'] ); + $this->assertEquals( 'square_marketing_image', $data['items'][1]['type'] ); + } + + public function test_generate_images_exception() { + $this->service + ->method( 'generate_images' ) + ->willThrowException( new Exception( 'Service error', 500 ) ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' ); + + $this->assertEquals( 'Service error', $response->get_data()['message'] ); + $this->assertEquals( 500, $response->get_status() ); + } + + public function test_generate_text_without_permission() { + // Remove admin capabilities. + wp_set_current_user( 0 ); + + $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' ); + + $this->assertEquals( 401, $response->get_status() ); + } + + public function test_generate_images_without_permission() { + // Remove admin capabilities. + wp_set_current_user( 0 ); + + $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' ); + + $this->assertEquals( 401, $response->get_status() ); + } +} diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php new file mode 100644 index 0000000000..bf56872435 --- /dev/null +++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php @@ -0,0 +1,245 @@ +ads_client_setup(); + + $this->options = $this->createMock( OptionsInterface::class ); + $this->service = new AdsAssetGenerationService( $this->client ); + $this->service->set_options_object( $this->options ); + + $this->options->method( 'get_ads_id' )->willReturn( self::TEST_ADS_ID ); + } + + public function test_generate_text_with_defaults() { + $expected_text_assets = [ + [ + 'text' => 'Generated headline text example.', + 'type' => 'headline', + ], + [ + 'text' => 'Generated long headline text example.', + 'type' => 'long_headline', + ], + [ + 'text' => 'Generated description text example.', + 'type' => 'description', + ], + ]; + + $this->generate_text_assets_mock( $expected_text_assets ); + + $result = $this->service->generate_text( + [ + 'final_url' => self::TEST_SITE_URL, + 'asset_field_types' => [ 'headline', 'long_headline', 'description' ], + ] + ); + + $this->assertEquals( $expected_text_assets, $result ); + } + + public function test_generate_text_with_custom_final_url() { + $final_url = 'https://custom-url.com'; + $expected_text_assets = [ + [ + 'text' => 'Custom headline', + 'type' => 'headline', + ], + ]; + + $this->generate_text_assets_mock( $expected_text_assets ); + + $result = $this->service->generate_text( + [ + 'final_url' => $final_url, + 'asset_field_types' => [ + 'headline', + 'long_headline', + 'description', + ], + ] + ); + + $this->assertEquals( $expected_text_assets, $result ); + } + + public function test_generate_text_with_specific_types() { + $expected_text_assets = [ + [ + 'text' => 'Headline only', + 'type' => 'headline', + ], + ]; + + $this->generate_text_assets_mock( $expected_text_assets ); + + $result = $this->service->generate_text( [ 'asset_field_types' => [ 'headline' ] ] ); + + $this->assertEquals( $expected_text_assets, $result ); + } + + public function test_generate_text_exception() { + $this->generate_text_assets_mock_exception( + new ApiException( 'API error', 7 ) + ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Unable to generate text assets' ); + + $this->service->generate_text( + [ + 'asset_field_types' => [ 'headline' ], + ] + ); + $this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) ); + } + + public function test_generate_text_no_ads_id() { + // Create a new options mock that returns 0 for get_ads_id + $options = $this->createMock( OptionsInterface::class ); + $options->method( 'get_ads_id' )->willReturn( 0 ); + $this->service->set_options_object( $options ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Ads account ID is required' ); + + $this->service->generate_text( [] ); + } + + public function test_generate_text_uses_defaults_when_no_types_provided() { + $expected_text_assets = [ + [ + 'text' => 'Default headline', + 'type' => 'headline', + ], + [ + 'text' => 'Default long headline', + 'type' => 'long_headline', + ], + [ + 'text' => 'Default description', + 'type' => 'description', + ], + ]; + + $this->generate_text_assets_mock( $expected_text_assets ); + + $result = $this->service->generate_text( [ 'final_url' => 'https://example.com' ] ); + + $this->assertEquals( $expected_text_assets, $result ); + } + + public function test_generate_images_with_defaults() { + $expected_image_assets = [ + [ + 'temporary_image_url' => 'https://example.com/temporary_image_url-marketing.jpg', + 'type' => 'marketing_image', + ], + [ + 'temporary_image_url' => 'https://example.com/temporary_image_url-square.jpg', + 'type' => 'square_marketing_image', + ], + [ + 'temporary_image_url' => 'https://example.com/temporary_image_url-portrait.jpg', + 'type' => 'portrait_marketing_image', + ], + ]; + + $this->generate_image_assets_mock( $expected_image_assets ); + + $result = $this->service->generate_images( [] ); + + $this->assertEquals( $expected_image_assets, $result ); + } + + public function test_generate_images_with_custom_final_url() { + $final_url = 'https://custom-url.com'; + $expected_image_assets = [ + [ + 'temporary_image_url' => 'https://example.com/custom-image.jpg', + 'type' => 'marketing_image', + ], + ]; + + $this->generate_image_assets_mock( $expected_image_assets ); + + $result = $this->service->generate_images( [ 'final_url' => $final_url ] ); + + $this->assertEquals( $expected_image_assets, $result ); + } + + public function test_generate_images_with_specific_types() { + $expected_image_assets = [ + [ + 'temporary_image_url' => 'https://example.com/marketing-image.jpg', + 'type' => 'marketing_image', + ], + ]; + + $this->generate_image_assets_mock( $expected_image_assets ); + + $result = $this->service->generate_images( [ 'asset_field_types' => [ 'marketing_image' ] ] ); + + $this->assertEquals( $expected_image_assets, $result ); + } + + public function test_generate_images_exception() { + $this->generate_image_assets_mock_exception( + new ApiException( 'API error', 7 ) + ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Unable to generate image assets' ); + + $this->service->generate_images( [] ); + $this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) ); + } + + public function test_generate_images_no_ads_id() { + // Create a new options mock that returns 0 for get_ads_id + $options = $this->createMock( OptionsInterface::class ); + $options->method( 'get_ads_id' )->willReturn( 0 ); + $this->service->set_options_object( $options ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Ads account ID is required' ); + + $this->service->generate_images( [] ); + } +} diff --git a/tests/Unit/Coupon/WCCouponAdapterTest.php b/tests/Unit/Coupon/WCCouponAdapterTest.php index e8e0932aef..d00adc985c 100644 --- a/tests/Unit/Coupon/WCCouponAdapterTest.php +++ b/tests/Unit/Coupon/WCCouponAdapterTest.php @@ -280,13 +280,13 @@ public function test_brand_restrictions() { $coupon->set_product_ids( [ $product_3_id ] ); // Include brand 1 (product 1 and 2) for the coupon. - update_post_meta( $coupon->get_id(), 'product_brands', $brand_1['term_id'] ); + update_post_meta( $coupon->get_id(), 'product_brands', [ $brand_1['term_id'] ] ); // Exclude product 2 for the coupon. $coupon->set_excluded_product_ids( [ $product_2_id ] ); // Exclude brand 2 (product 3) for the coupon. - update_post_meta( $coupon->get_id(), 'exclude_product_brands', $brand_2['term_id'] ); + update_post_meta( $coupon->get_id(), 'exclude_product_brands', [ $brand_2['term_id'] ] ); $coupon->save(); diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js index d1b265f198..9448e3e148 100644 --- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js +++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js @@ -6,15 +6,22 @@ import { expect, test } from '@playwright/test'; /** * Internal dependencies */ -import { clearOnboardedMerchant, setOnboardedMerchant } from '../../utils/api'; +import { + clearOnboardedMerchant, + setOnboardedMerchant, + setCompletedAdsSetup, + clearCompletedAdsSetup, +} from '../../utils/api'; import DashboardPage from '../../utils/pages/dashboard'; import SetupAdsAccountsPage from '../../utils/pages/ads-onboarding/setup-ads-accounts'; import SetupBudgetPage from '../../utils/pages/ads-onboarding/setup-budget'; +import CreateCampaignPage from '../../utils/pages/create-campaign'; import { LOAD_STATE } from '../../utils/constants'; import { getFAQPanelTitle, getFAQPanelRow, checkFAQExpandable, + checkSnackBarMessage, } from '../../utils/page'; const ADS_ACCOUNTS = [ @@ -37,6 +44,11 @@ test.describe.configure( { mode: 'serial' } ); */ let dashboardPage = null; +/** + * @type {import('../../utils/pages/create-campaign').default} createCampaignPage + */ +let createCampaignPage = null; + /** * @type {import('../../utils/pages/ads-onboarding/setup-ads-accounts').default} setupAdsAccounts */ @@ -52,12 +64,13 @@ let setupBudgetPage = null; */ let page = null; -test.describe( 'Set up Ads account', () => { +test.describe( 'Add paid campaign', () => { test.beforeAll( async ( { browser } ) => { page = await browser.newPage(); dashboardPage = new DashboardPage( page ); setupAdsAccounts = new SetupAdsAccountsPage( page ); setupBudgetPage = new SetupBudgetPage( page ); + createCampaignPage = new CreateCampaignPage( page ); await setOnboardedMerchant(); await setupAdsAccounts.mockAdsAccountsResponse( [] ); await setupBudgetPage.fulfillBillingStatusRequest( { @@ -116,389 +129,981 @@ test.describe( 'Set up Ads account', () => { await expect( dashboardPage.addPaidCampaignButton ).toBeEnabled(); } ); - test.describe( 'Set up your accounts page', async () => { - test.beforeAll( async () => { - await setupAdsAccounts.mockAdsAccountsResponse( [] ); - await dashboardPage.addPaidCampaignButton.click(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); - } ); - - test( 'Page header should be "Set up your accounts"', async () => { - await expect( - page.getByRole( 'heading', { name: 'Set up your accounts' } ) - ).toBeVisible(); - await expect( - page.getByText( - 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.' - ) - ).toBeVisible(); - } ); + test.describe( 'With Ads account not connected', async () => { + test.describe( 'Set up your accounts page', async () => { + test.beforeAll( async () => { + await setupAdsAccounts.mockAdsAccountsResponse( [] ); + await dashboardPage.addPaidCampaignButton.click(); + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + } ); - test( 'Google Account should show as connected', async () => { - await expect( - page.getByText( - 'This Google account is connected to your store’s product feed.' - ) - ).toBeVisible(); - } ); + test( 'Page header should be "Set up your accounts"', async () => { + await expect( + page.getByRole( 'heading', { + name: 'Set up your accounts', + } ) + ).toBeVisible(); + await expect( + page.getByText( + 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.' + ) + ).toBeVisible(); + } ); - test( 'Continue Button should be disabled', async () => { - await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled(); - } ); - } ); + test( 'Google Account should show as connected', async () => { + await expect( + page.getByText( + 'This Google account is connected to your store’s product feed.' + ) + ).toBeVisible(); + } ); - test.describe( 'Add campaigns with no Ads account', async () => { - test( 'Create an account should be visible', async () => { - const createAccountButton = page.getByRole( 'button', { - name: 'Create account', + test( 'Continue Button should be disabled', async () => { + await expect( + setupAdsAccounts.getContinueButton() + ).toBeDisabled(); } ); + } ); - await expect( createAccountButton ).toBeVisible(); + test.describe( 'Add campaigns with no Ads account', async () => { + test( 'Create an account should be visible', async () => { + const createAccountButton = page.getByRole( 'button', { + name: 'Create account', + } ); - await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled(); + await expect( createAccountButton ).toBeVisible(); - await expect( - page.getByText( - 'Required to set up conversion measurement and create campaigns.' - ) - ).toBeVisible(); + await expect( + setupAdsAccounts.getContinueButton() + ).toBeDisabled(); - await createAccountButton.click(); - } ); + await expect( + page.getByText( + 'Required to set up conversion measurement and create campaigns.' + ) + ).toBeVisible(); - test( 'Create account button should be disable if the ToS have not been accepted.', async () => { - await expect( - page.getByRole( 'heading', { - name: 'Create Google Ads Account', - } ) - ).toBeVisible(); - - await expect( - page.getByText( - 'By creating a Google Ads account, you agree to the following terms and conditions:' - ) - ).toBeVisible(); - - await expect( - setupAdsAccounts.getCreateAdsAccountButtonModal() - ).toBeDisabled(); - } ); + await createAccountButton.click(); + } ); - test( 'Accept terms and conditions to enable the create account button', async () => { - await setupAdsAccounts.getAcceptTermCreateAccount().check(); + test( 'Create account button should be disable if the ToS have not been accepted.', async () => { + await expect( + page.getByRole( 'heading', { + name: 'Create Google Ads Account', + } ) + ).toBeVisible(); - await expect( - setupAdsAccounts.getCreateAdsAccountButtonModal() - ).toBeEnabled(); - } ); + await expect( + page.getByText( + 'By creating a Google Ads account, you agree to the following terms and conditions:' + ) + ).toBeVisible(); - test( 'Create an Ads account', async () => { - // Intercept Ads connection request. - const connectAdsAccountRequest = - setupAdsAccounts.registerConnectAdsAccountRequests(); + await expect( + setupAdsAccounts.getCreateAdsAccountButtonModal() + ).toBeDisabled(); + } ); - await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS ); + test( 'Accept terms and conditions to enable the create account button', async () => { + await setupAdsAccounts.getAcceptTermCreateAccount().check(); - // Mock request to fulfill Ads connection. - await setupAdsAccounts.fulfillAdsConnection( { - id: ADS_ACCOUNTS[ 0 ].id, - currency: 'USD', - symbol: '$', - status: 'incomplete', - step: 'account_access', + await expect( + setupAdsAccounts.getCreateAdsAccountButtonModal() + ).toBeEnabled(); } ); - await setupAdsAccounts.mockAdsStatusNotClaimed(); + test( 'Create an Ads account', async () => { + // Intercept Ads connection request. + const connectAdsAccountRequest = + setupAdsAccounts.registerConnectAdsAccountRequests(); - await setupAdsAccounts.getCreateAdsAccountButtonModal().click(); + await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS ); - await connectAdsAccountRequest; + // Mock request to fulfill Ads connection. + await setupAdsAccounts.fulfillAdsConnection( { + id: ADS_ACCOUNTS[ 0 ].id, + currency: 'USD', + symbol: '$', + status: 'incomplete', + step: 'account_access', + } ); - const modal = setupAdsAccounts.getAcceptAccountModal(); - await expect( modal ).toBeVisible(); - } ); + await setupAdsAccounts.mockAdsStatusNotClaimed(); - test( 'Show Unclaimed Ads account', async () => { - await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal(); + await setupAdsAccounts.getCreateAdsAccountButtonModal().click(); - const claimButton = setupAdsAccounts.getAdsClaimAccountButton(); - const claimText = setupAdsAccounts.getAdsClaimAccountText(); + await connectAdsAccountRequest; - await expect( claimButton ).toBeVisible(); - await expect( claimText ).toBeVisible(); + const modal = setupAdsAccounts.getAcceptAccountModal(); + await expect( modal ).toBeVisible(); + } ); - await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled(); - } ); + test( 'Show Unclaimed Ads account', async () => { + await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal(); + + const claimButton = setupAdsAccounts.getAdsClaimAccountButton(); + const claimText = setupAdsAccounts.getAdsClaimAccountText(); - test( 'Show Claimed Ads account', async () => { - // Intercept Ads connection request. - await setupAdsAccounts.fulfillAdsConnection( { - id: ADS_ACCOUNTS[ 0 ].id, - currency: 'USD', - symbol: '$', - status: 'connected', - step: '', + await expect( claimButton ).toBeVisible(); + await expect( claimText ).toBeVisible(); + + await expect( + setupAdsAccounts.getContinueButton() + ).toBeDisabled(); } ); - await setupAdsAccounts.mockAdsStatusClaimed(); + test( 'Show Claimed Ads account', async () => { + // Intercept Ads connection request. + await setupAdsAccounts.fulfillAdsConnection( { + id: ADS_ACCOUNTS[ 0 ].id, + currency: 'USD', + symbol: '$', + status: 'connected', + step: '', + } ); - await page.dispatchEvent( 'body', 'blur' ); - await page.dispatchEvent( 'body', 'focus' ); + await setupAdsAccounts.mockAdsStatusClaimed(); - await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled(); + await page.dispatchEvent( 'body', 'blur' ); + await page.dispatchEvent( 'body', 'focus' ); - await expect( - page.getByRole( 'link', { - name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`, - } ) - ).toBeVisible(); + await expect( + setupAdsAccounts.getContinueButton() + ).toBeEnabled(); - await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled(); - } ); - } ); + await expect( + page.getByRole( 'link', { + name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`, + } ) + ).toBeVisible(); - test.describe( 'Add campaigns with existing Ads accounts', () => { - test.beforeAll( async () => { - await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS ); - //Disconnect the account from the previous test - setupAdsAccounts.fulfillAdsConnection( { - id: ADS_ACCOUNTS[ 1 ].id, - currency: 'EUR', - symbol: '\u20ac', - status: 'disconnected', + await expect( + setupAdsAccounts.getContinueButton() + ).toBeEnabled(); } ); - - await page.reload(); } ); - test( 'Select one existing account', async () => { - const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`; + test.describe( 'Add campaigns with existing Ads accounts', () => { + test.beforeAll( async () => { + await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS ); + //Disconnect the account from the previous test + setupAdsAccounts.fulfillAdsConnection( { + id: ADS_ACCOUNTS[ 1 ].id, + currency: 'EUR', + symbol: '\u20ac', + status: 'disconnected', + } ); + + await page.reload(); + } ); - await setupAdsAccounts.selectAnExistingAdsAccount( - adsAccountSelected - ); + test( 'Select one existing account', async () => { + const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`; - //Intercept Ads connection request - const connectAdsAccountRequest = - setupAdsAccounts.registerConnectAdsAccountRequests( + await setupAdsAccounts.selectAnExistingAdsAccount( adsAccountSelected ); - //Mock request to fulfill Ads connection - setupAdsAccounts.fulfillAdsConnection( { - id: ADS_ACCOUNTS[ 1 ].id, - currency: 'EUR', - symbol: '\u20ac', - status: 'connected', - } ); + //Intercept Ads connection request + const connectAdsAccountRequest = + setupAdsAccounts.registerConnectAdsAccountRequests( + adsAccountSelected + ); - await setupAdsAccounts.clickConnectAds(); - await connectAdsAccountRequest; + //Mock request to fulfill Ads connection + setupAdsAccounts.fulfillAdsConnection( { + id: ADS_ACCOUNTS[ 1 ].id, + currency: 'EUR', + symbol: '\u20ac', + status: 'connected', + } ); - await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled(); - } ); - } ); + await setupAdsAccounts.clickConnectAds(); + await connectAdsAccountRequest; - test.describe( 'Create your campaign', () => { - test( 'Continue to create your campaign', async () => { - await setupAdsAccounts.clickContinue(); - await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); - await expect( - page.getByRole( 'heading', { - name: 'Create your campaign', - } ) - ).toBeVisible(); - - await expect( - page.getByRole( 'heading', { name: 'Set your budget' } ) - ).toBeVisible(); - - await expect( - page.getByRole( 'link', { - name: 'See what your ads will look like.', - } ) - ).toBeVisible(); + await expect( + setupAdsAccounts.getContinueButton() + ).toBeEnabled(); + } ); } ); - test.describe( 'Preview product ad', () => { - test( 'Preview product ad should be visible', async () => { + test.describe( 'Create your campaign', () => { + test( 'Continue to create your campaign', async () => { + await setupAdsAccounts.clickContinue(); + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); await expect( - page.getByText( 'Preview product ad' ) + page.getByRole( 'heading', { + name: 'Create your campaign', + } ) ).toBeVisible(); + await expect( - page.getByText( - "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats." - ) + page.getByRole( 'heading', { name: 'Set your budget' } ) ).toBeVisible(); - } ); - - test( 'Change image buttons should be enabled', async () => { - const buttonsToChangeImage = page.locator( - '.gla-campaign-preview-card__moving-button' - ); - - expect( buttonsToChangeImage ).toHaveCount( 2 ); - for ( const button of await buttonsToChangeImage.all() ) { - await expect( button ).toBeEnabled(); - } + await expect( + page.getByRole( 'link', { + name: 'See what your ads will look like.', + } ) + ).toBeVisible(); } ); - } ); - test.describe( 'FAQ panels', () => { - test( 'should see five questions in FAQ', async () => { - const faqTitles = getFAQPanelTitle( page ); - await expect( faqTitles ).toHaveCount( 5 ); + test.describe( 'Preview product ad', () => { + test( 'Preview product ad should be visible', async () => { + await expect( + page.getByText( 'Preview product ad' ) + ).toBeVisible(); + await expect( + page.getByText( + "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats." + ) + ).toBeVisible(); + } ); + + test( 'Change image buttons should be enabled', async () => { + const buttonsToChangeImage = page.locator( + '.gla-campaign-preview-card__moving-button' + ); + + expect( buttonsToChangeImage ).toHaveCount( 2 ); + + for ( const button of await buttonsToChangeImage.all() ) { + await expect( button ).toBeEnabled(); + } + } ); } ); - test( 'should not see FAQ rows when FAQ titles are not clicked', async () => { - const faqRows = getFAQPanelRow( page ); - await expect( faqRows ).toHaveCount( 0 ); + test.describe( 'FAQ panels', () => { + test( 'should see five questions in FAQ', async () => { + const faqTitles = getFAQPanelTitle( page ); + await expect( faqTitles ).toHaveCount( 5 ); + } ); + + test( 'should not see FAQ rows when FAQ titles are not clicked', async () => { + const faqRows = getFAQPanelRow( page ); + await expect( faqRows ).toHaveCount( 0 ); + } ); + + // eslint-disable-next-line jest/expect-expect + test( 'should see FAQ rows when all FAQ titles are clicked', async () => { + await checkFAQExpandable( page ); + } ); } ); + } ); - // eslint-disable-next-line jest/expect-expect - test( 'should see FAQ rows when all FAQ titles are clicked', async () => { - await checkFAQExpandable( page ); + test.describe( 'Create Ads with billing data already setup', () => { + test.describe( 'Set the budget', async () => { + test( 'Continue button should be disabled if budget is 0', async () => { + await setupBudgetPage.fillBudget( '0' ); + + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeDisabled(); + } ); + + test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => { + await setupBudgetPage.fillBudget( '0' ); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeDisabled(); + + await page.getByLabel( 'low' ).click(); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeEnabled(); + + await page.getByLabel( 'custom' ).click(); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeDisabled(); + + await page.getByLabel( 'high' ).click(); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeEnabled(); + + await page.getByLabel( 'custom' ).click(); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeDisabled(); + + await page.getByLabel( 'recommended' ).click(); + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeEnabled(); + } ); + + test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => { + await setupBudgetPage.fillBudget( '2' ); + + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeDisabled(); + } ); + + test( 'User is notified of the minimum value', async () => { + await setupBudgetPage.fillBudget( '3' ); + await setupBudgetPage.getBudgetInput().blur(); + + await expect( + page.getByText( + 'Please make sure daily average cost is at least €4.00' + ) + ).toBeVisible(); + } ); + + test( 'Continue button should be enabled if budget is above the recommended value', async () => { + await setupBudgetPage.fillBudget( '5' ); + + await expect( + setupBudgetPage.getCreateCampaignButton() + ).toBeEnabled(); + } ); + + test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => { + await setupBudgetPage.fillBudget( '6' ); + + await expect( + page.getByText( + `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.` + ) + ).toBeVisible(); + } ); } ); - } ); - } ); - test.describe( 'Create Ads with billing data already setup', () => { - test.describe( 'Set the budget', async () => { - test( 'Continue button should be disabled if budget is 0', async () => { - await setupBudgetPage.fillBudget( '0' ); + test( 'It should show the campaign creation success message', async () => { + await setupBudgetPage.fillBudget( '6' ); + await setupBudgetPage.getCreateCampaignButton().click(); + const cancelButton = page.getByRole( 'button', { + name: 'Cancel', + } ); await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeDisabled(); - } ); + page.getByText( 'This offer won’t last long!' ) + ).toBeVisible(); + await expect( cancelButton ).toBeEnabled(); - test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => { - await setupBudgetPage.fillBudget( '0' ); - await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeDisabled(); + await cancelButton.click(); - await page.getByLabel( 'low' ).click(); - await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeEnabled(); + await expect( cancelButton ).not.toBeVisible(); - await page.getByLabel( 'custom' ).click(); - await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeDisabled(); + // Mock the campaign creation request. + const campaignCreation = + setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( + '6', + [ 'US' ] + ); + + await setupBudgetPage.getCreateCampaignButton().click(); + + await campaignCreation; + + //It should redirect to the dashboard page + await page.waitForURL( + '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success', + { + waitUntil: LOAD_STATE.DOM_CONTENT_LOADED, + } + ); - await page.getByLabel( 'high' ).click(); await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeEnabled(); + page.getByRole( 'heading', { + name: "You've set up a Performance Max Campaign!", + } ) + ).toBeVisible(); - await page.getByLabel( 'custom' ).click(); await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeDisabled(); + page.getByRole( 'button', { + name: 'Create another campaign', + } ) + ).toBeEnabled(); - await page.getByLabel( 'recommended' ).click(); await expect( - setupBudgetPage.getCreateCampaignButton() + page.getByRole( 'button', { + name: 'Got It', + } ) ).toBeEnabled(); - } ); - test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => { - await setupBudgetPage.fillBudget( '2' ); + await page + .getByRole( 'button', { + name: 'Got It', + } ) + .click(); - await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeDisabled(); + await expect( page.getByRole( 'dialog' ) ).not.toBeVisible(); } ); + } ); + } ); + + test.describe( 'With connected Ads account', async () => { + test.beforeAll( async () => { + await setCompletedAdsSetup(); + await createCampaignPage.mockRequests(); + await createCampaignPage.mockOptimizeCampaignRequests(); + await createCampaignPage.mockGenerateTextAssetsSuccess(); + await createCampaignPage.mockGenerateImageAssetsSuccess(); + createCampaignPage.goto(); + } ); - test( 'User is notified of the minimum value', async () => { - await setupBudgetPage.fillBudget( '3' ); - await setupBudgetPage.getBudgetInput().blur(); + test.afterAll( async () => { + await clearCompletedAdsSetup(); + await page.close(); + } ); + test.describe( 'Create Campaign page', async () => { + test( 'Page header should be "Create your campaign"', async () => { + await expect( + page.getByRole( 'heading', { + name: 'Create your campaign', + } ) + ).toBeVisible(); await expect( page.getByText( - 'Please make sure daily average cost is at least €4.00' + 'Performance Max campaigns are automatically optimized for you by Google.' ) ).toBeVisible(); } ); - test( 'Continue button should be enabled if budget is above the recommended value', async () => { - await setupBudgetPage.fillBudget( '5' ); - - await expect( - setupBudgetPage.getCreateCampaignButton() - ).toBeEnabled(); - } ); - - test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => { - await setupBudgetPage.fillBudget( '6' ); + test( 'Clicking the "Continue" button takes you to the "Optimize your campaign" step', async () => { + const continueButton = createCampaignPage.getContinueButton(); + continueButton.click(); await expect( - page.getByText( - `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.` - ) + page.getByRole( 'heading', { + name: 'Optimize your campaign', + } ) ).toBeVisible(); } ); } ); - test( 'It should show the campaign creation success message', async () => { - await setupBudgetPage.fillBudget( '6' ); - await setupBudgetPage.getCreateCampaignButton().click(); - - const cancelButton = page.getByRole( 'button', { name: 'Cancel' } ); - await expect( - page.getByText( 'This offer won’t last long!' ) - ).toBeVisible(); - await expect( cancelButton ).toBeEnabled(); - - await cancelButton.click(); + test.describe( 'Optimize your campaign step', async () => { + test( 'Final URL should be selected by default', async () => { + const finalUrlCard = createCampaignPage.getFinalUrlCard(); + await expect( finalUrlCard ).toContainText( + 'https://woo.com/shop/' + ); + } ); - await expect( cancelButton ).not.toBeVisible(); + test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => { + const selectDifferentFinalUrlButton = + createCampaignPage.getSelectDifferentFinalUrlButton(); + await selectDifferentFinalUrlButton.click(); - // Mock the campaign creation request. - const campaignCreation = - setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion( - '6', - [ 'US' ] - ); + const createCampaignButton = + createCampaignPage.getCreateCampaignButton(); + await expect( createCampaignButton ).toBeDisabled(); + } ); - await setupBudgetPage.getCreateCampaignButton().click(); + test( 'Selecting final URL enables Create Campaign button', async () => { + await createCampaignPage.selectUrlOption(); - await campaignCreation; + const createCampaignButton = + createCampaignPage.getCreateCampaignButton(); + await expect( createCampaignButton ).toBeEnabled(); + } ); - //It should redirect to the dashboard page - await page.waitForURL( - '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success', - { - waitUntil: LOAD_STATE.DOM_CONTENT_LOADED, - } - ); - - await expect( - page.getByRole( 'heading', { - name: "You've set up a Performance Max Campaign!", - } ) - ).toBeVisible(); - - await expect( - page.getByRole( 'button', { - name: 'Create another campaign', - } ) - ).toBeEnabled(); - - await expect( - page.getByRole( 'button', { - name: 'Got It', - } ) - ).toBeEnabled(); - - await page - .getByRole( 'button', { - name: 'Got It', - } ) - .click(); + test.describe( 'Gen AI', () => { + test.describe( 'Text Assets', () => { + test.describe( 'Headlines', () => { + test.describe( 'Visibility', () => { + test( 'Generate headline button is hidden when all inputs are filled', async () => { + const generateHeadlineButton = + createCampaignPage.getGenerateHeadlineButton(); + await expect( + generateHeadlineButton + ).not.toBeVisible(); + } ); + + test( 'Generate headline button is visible when at least one input is empty', async () => { + const generateHeadlineButton = + createCampaignPage.getGenerateHeadlineButton(); + await expect( + generateHeadlineButton + ).not.toBeVisible(); + + const headlineInputs = + await createCampaignPage.getHeadlineInputs(); + const lastHeadlineInput = headlineInputs.last(); + await lastHeadlineInput.fill( '' ); + + await expect( + generateHeadlineButton + ).toBeVisible(); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateTextAssetsSuccess(); + } ); + + test( 'Clicking generate headline sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateTextRequest( + 'https://woo.com/shop/', + [ 'headline' ] + ); + + const generateHeadlineButton = + createCampaignPage.getGenerateHeadlineButton(); + await generateHeadlineButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Clicking generate headline fills empty headline inputs', async () => { + const headlineInputsValues = + await createCampaignPage.getHeadlineInputsValues(); + const lastValue = + headlineInputsValues[ + headlineInputsValues.length - 1 + ]; + expect( lastValue ).toBe( + 'Fast Shipping Available' + ); + } ); + } ); + } ); + + test.describe( 'Long Headlines', () => { + test.describe( 'Visibility', () => { + test( 'Generate long headline button is hidden when all inputs are filled', async () => { + const generateLongHeadlineButton = + createCampaignPage.getGenerateLongHeadlineButton(); + await expect( + generateLongHeadlineButton + ).not.toBeVisible(); + } ); + + test( 'Generate long headline button is visible when at least one input is empty', async () => { + const generateLongHeadlineButton = + createCampaignPage.getGenerateLongHeadlineButton(); + await expect( + generateLongHeadlineButton + ).not.toBeVisible(); + + const longHeadlineInputs = + await createCampaignPage.getLongHeadlineInputs(); + const lastLongHeadlineInput = + longHeadlineInputs.last(); + await lastLongHeadlineInput.fill( '' ); + + await expect( + generateLongHeadlineButton + ).toBeVisible(); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateTextAssetsSuccess(); + } ); + + test( 'Clicking generate long headline sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateTextRequest( + 'https://woo.com/shop/', + [ 'long_headline' ] + ); + + const generateLongHeadlineButton = + createCampaignPage.getGenerateLongHeadlineButton(); + await generateLongHeadlineButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Clicking generate long headline fills empty long headline inputs', async () => { + const longHeadlineInputsValues = + await createCampaignPage.getLongHeadlineInputsValues(); + const lastValue = + longHeadlineInputsValues[ + longHeadlineInputsValues.length - 1 + ]; + expect( lastValue ).toBe( + 'Upgrade your everyday shopping experience' + ); + } ); + } ); + } ); + + test.describe( 'Descriptions', () => { + test.describe( 'Visibility', () => { + test( 'Generate description button is hidden when all inputs are filled', async () => { + const generateDescriptionButton = + createCampaignPage.getGenerateDescriptionButton(); + await expect( + generateDescriptionButton + ).not.toBeVisible(); + } ); + + test( 'Generate description button is visible when at least one input is empty', async () => { + const generateDescriptionButton = + createCampaignPage.getGenerateDescriptionButton(); + await expect( + generateDescriptionButton + ).not.toBeVisible(); + + const descriptionInputs = + await createCampaignPage.getDescriptionInputs(); + const lastDescriptionInput = + descriptionInputs.last(); + await lastDescriptionInput.fill( '' ); + + await expect( + generateDescriptionButton + ).toBeVisible(); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateTextAssetsSuccess(); + } ); + + test( 'Clicking generate description sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateTextRequest( + 'https://woo.com/shop/', + [ 'description' ] + ); + + const generateDescriptionButton = + createCampaignPage.getGenerateDescriptionButton(); + await generateDescriptionButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Clicking generate description fills empty description inputs', async () => { + const descriptionInputsValues = + await createCampaignPage.getDescriptionInputsValues(); + const lastValue = + descriptionInputsValues[ + descriptionInputsValues.length - 1 + ]; + expect( lastValue ).toBe( + 'Browse top picks and enjoy exclusive savings.' + ); + } ); + } ); + } ); + + test.describe( 'AI Icon', () => { + test( 'is visible next to generated text assets and not visible if changed', async () => { + const descriptionInputs = + createCampaignPage.getDescriptionInputs(); + const lastDescriptionInput = + descriptionInputs.last(); + + // Move one level up + const row = lastDescriptionInput.locator( '..' ); + const aiIcon = row.locator( + '.gla-texts-editor__ai-icon' + ); + + await expect( aiIcon ).toHaveCount( 1 ); + + await lastDescriptionInput.fill( + 'Custom description text' + ); + + await expect( aiIcon ).toHaveCount( 0 ); + } ); + } ); + + test.describe( 'Error', () => { + test.beforeEach( async () => { + createCampaignPage.mockEmptyGenerateTextAssets(); + } ); + + test( 'Displays error message when there are no more generated text', async () => { + const descriptionInputs = + await createCampaignPage.getDescriptionInputs(); + const lastDescriptionInput = + descriptionInputs.last(); + await lastDescriptionInput.fill( '' ); + + const generateDescriptionButton = + createCampaignPage.getGenerateDescriptionButton(); + await generateDescriptionButton.click(); + + await checkSnackBarMessage( + page, + 'No texts were generated. Please try again.' + ); + } ); + } ); + } ); + + test.describe( 'Image Assets', () => { + test.describe( 'Landscape images', () => { + test.describe( 'Visibility', () => { + test( 'Generate landscape images button is visible', async () => { + const generateLandscapeImagesButton = + createCampaignPage.getGenerateLandscapeImagesButton(); + await expect( + generateLandscapeImagesButton + ).toBeVisible(); + } ); + + test( 'There is only one image loaded for the campaign', async () => { + const campaignImages = + createCampaignPage.getCampaignLandscapeImageItems(); + await expect( campaignImages ).toHaveCount( 1 ); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateImageAssetsSuccess(); + } ); + + test( 'Clicking generate landscape images sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateImageRequest( + 'https://woo.com/shop/', + [ 'marketing_image' ] + ); + + const generateLandscapeImagesButton = + createCampaignPage.getGenerateLandscapeImagesButton(); + await generateLandscapeImagesButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Image picker is displayed', async () => { + const imagePicker = + createCampaignPage.getLandscapeImagesSectionImagePicker(); + await expect( imagePicker ).toBeVisible(); + } ); + + test( 'Image picker renders generated images', async () => { + const generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + await expect( generatedImages ).toHaveCount( + 4 + ); + } ); + } ); + } ); + + test.describe( 'Image Picker', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateImageAssetsSuccess(); + } ); + + test( '"Add selected images" button is disabled if no image is selected', async () => { + const generateLandscapeImagesButton = + createCampaignPage.getGenerateLandscapeImagesButton(); + await generateLandscapeImagesButton.click(); + + const addSelectedImagesButton = + createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton(); + await expect( + addSelectedImagesButton + ).toBeDisabled(); + } ); + + test( 'Clicking an image enables the "Add selected images" button', async () => { + const generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + generatedImages.first().click(); + + const addSelectedImagesButton = + createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton(); + await expect( + addSelectedImagesButton + ).toBeEnabled(); + } ); + + test( 'Clicking the "Add selected images" button adds the selected images to the campaign and remove them from the image picker ', async () => { + let generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + const firstGeneratedImageUrl = await generatedImages + .first() + .locator( 'img' ) + .getAttribute( 'src' ); + const addSelectedImagesButton = + createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton(); + await addSelectedImagesButton.click(); + + generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + await expect( generatedImages ).toHaveCount( 3 ); + + const campaignImages = + createCampaignPage.getCampaignLandscapeImageItems(); + const campaignLastImageUrl = await campaignImages + .last() + .locator( 'img' ) + .getAttribute( 'src' ); + expect( campaignLastImageUrl ).toEqual( + firstGeneratedImageUrl + ); + await expect( campaignImages ).toHaveCount( 2 ); + } ); + + test( 'Adding all generated images hides the image picker', async () => { + const generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + await generatedImages.nth( 0 ).click(); + await generatedImages.nth( 1 ).click(); + await generatedImages.nth( 2 ).click(); + + const addSelectedImagesButton = + createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton(); + await addSelectedImagesButton.click(); + + const imagePicker = + createCampaignPage.getLandscapeImagesSectionImagePicker(); + await expect( imagePicker ).not.toBeVisible(); + + const campaignImages = + createCampaignPage.getCampaignLandscapeImageItems(); + await expect( campaignImages ).toHaveCount( 5 ); + } ); + + test( 'Removing an image from the campaign shows it back in the image picker', async () => { + const campaignImageItems = + createCampaignPage.getCampaignLandscapeImageItems(); + const lastCampaignImageItem = + campaignImageItems.last(); + await lastCampaignImageItem.hover(); + const lastCampaignImageUrl = + await lastCampaignImageItem + .locator( 'img' ) + .getAttribute( 'src' ); + + const removeButton = lastCampaignImageItem.locator( + '.gla-media-selector__remove-medium-button' + ); + await removeButton.click(); + + const imagePicker = + createCampaignPage.getLandscapeImagesSectionImagePicker(); + await expect( imagePicker ).toBeVisible(); + + const generatedImages = + createCampaignPage.getLandscapeGeneratedImages(); + const firstGeneratedImageUrl = await generatedImages + .first() + .locator( 'img' ) + .getAttribute( 'src' ); + expect( firstGeneratedImageUrl ).toEqual( + lastCampaignImageUrl + ); + } ); + } ); + } ); + + test.describe( 'Square images', () => { + test.describe( 'Visibility', () => { + test( 'Generate square images button is visible', async () => { + const generateSquareImagesButton = + createCampaignPage.getGenerateSquareImagesButton(); + await expect( + generateSquareImagesButton + ).toBeVisible(); + } ); + + test( 'There is only one image loaded for the campaign', async () => { + const campaignImages = + createCampaignPage.getCampaignSquareImageItems(); + await expect( campaignImages ).toHaveCount( 1 ); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateImageAssetsSuccess(); + } ); + + test( 'Clicking generate square images sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateImageRequest( + 'https://woo.com/shop/', + [ 'square_marketing_image' ] + ); + + const generateSquareImagesButton = + createCampaignPage.getGenerateSquareImagesButton(); + await generateSquareImagesButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Image picker is displayed', async () => { + const imagePicker = + createCampaignPage.getSquareImagesSectionImagePicker(); + await expect( imagePicker ).toBeVisible(); + } ); + + test( 'Image picker renders generated images', async () => { + const generatedImages = + createCampaignPage.getSquareGeneratedImages(); + await expect( generatedImages ).toHaveCount( 3 ); + } ); + } ); + } ); + + test.describe( 'Portrait images', () => { + test.describe( 'Visibility', () => { + test( 'Generate portrait images button is visible', async () => { + const generatePortraitImagesButton = + createCampaignPage.getGeneratePortraitImagesButton(); + await expect( + generatePortraitImagesButton + ).toBeVisible(); + } ); + + test( 'There are no images loaded for the campaign', async () => { + const campaignImages = + createCampaignPage.getCampaignPortraitImageItems(); + await expect( campaignImages ).toHaveCount( 0 ); + } ); + } ); + + test.describe( 'Generate action', () => { + test.beforeEach( async () => { + createCampaignPage.mockGenerateImageAssetsSuccess(); + } ); + + test( 'Clicking generate portrait images sends the correct POST request', async () => { + const generateRequest = + createCampaignPage.awaitForGenerateImageRequest( + 'https://woo.com/shop/', + [ 'portrait_marketing_image' ] + ); + + const generatePortraitImagesButton = + createCampaignPage.getGeneratePortraitImagesButton(); + await generatePortraitImagesButton.click(); + await generateRequest; + } ); + } ); + + test.describe( 'Success', () => { + test( 'Image picker is displayed', async () => { + const imagePicker = + createCampaignPage.getPortraitImagesSectionImagePicker(); + await expect( imagePicker ).toBeVisible(); + } ); + + test( 'Image picker renders generated images', async () => { + const generatedImages = + createCampaignPage.getPortraitGeneratedImages(); + await expect( generatedImages ).toHaveCount( 2 ); + } ); + } ); + } ); + } ); } ); } ); } ); diff --git a/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js b/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js index 4668f323ee..c4251a33b6 100644 --- a/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js +++ b/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js @@ -108,7 +108,18 @@ test.describe( 'Optimize campaign for Ads only merchants', () => { } ); test.describe( 'Optimize campaign', () => { - test( 'Create Campaign button should be disabled if no URL selected', async () => { + test( 'Final URL should be selected by default', async () => { + const finalUrlCard = createCampaignPage.getFinalUrlCard(); + await expect( finalUrlCard ).toContainText( + 'https://woo.com/shop/' + ); + } ); + + test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => { + const selectDifferentFinalUrlButton = + optimizeCampaignPage.getSelectDifferentFinalUrlButton(); + await selectDifferentFinalUrlButton.click(); + const createCampaignButton = optimizeCampaignPage.getCreateCampaignButton(); await expect( createCampaignButton ).toBeDisabled(); @@ -122,23 +133,12 @@ test.describe( 'Optimize campaign for Ads only merchants', () => { await expect( createCampaignButton ).toBeEnabled(); } ); - test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => { - const selectDifferentFinalUrlButton = - optimizeCampaignPage.getSelectDifferentFinalUrlButton(); - await selectDifferentFinalUrlButton.click(); - - const createCampaignButton = - optimizeCampaignPage.getCreateCampaignButton(); - await expect( createCampaignButton ).toBeDisabled(); - } ); - test( '"Skip this step" button should not be present in the last step of onboarding', async () => { const skipThisStepButton = page.locator( 'text="Skip this step"' ); await expect( skipThisStepButton ).toHaveCount( 0 ); } ); test( 'Clicking the "Create Campaign" button navigates to the dashboard and should see the setup success modal', async () => { - await optimizeCampaignPage.selectUrlOption(); const createCampaignButton = optimizeCampaignPage.getCreateCampaignButton(); diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js index 7f36d96b0f..beef6acf6f 100644 --- a/tests/e2e/utils/mock-requests.js +++ b/tests/e2e/utils/mock-requests.js @@ -1247,4 +1247,36 @@ export default class MockRequests { [ 'GET' ] ); } + + /** + * Fulfill generate text assets request. + * + * @param {Object} payload - The response payload to return. + * @param {number} status - The HTTP status in the response. + * @return {Promise} + */ + async fulfillGenerateTextAssetsRequest( payload, status = 200 ) { + await this.fulfillRequest( + /\/wc\/gla\/ads\/assets\/generate-text\b/, + payload, + status, + [ 'POST' ] + ); + } + + /** + * Fulfill generate image assets request. + * + * @param {Object} payload - The response payload to return. + * @param {number} status - The HTTP status in the response. + * @return {Promise} + */ + async fulfillGenerateImageAssetsRequest( payload, status = 200 ) { + await this.fulfillRequest( + /\/wc\/gla\/ads\/assets\/generate-images\b/, + payload, + status, + [ 'POST' ] + ); + } } diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js new file mode 100644 index 0000000000..d61700304c --- /dev/null +++ b/tests/e2e/utils/pages/create-campaign.js @@ -0,0 +1,811 @@ +/** + * Internal dependencies + */ +import { LOAD_STATE } from '../constants'; +import MockRequests from '../mock-requests'; + +/** + * Dashboard page object class. + */ +export default class CreateCampaignPage extends MockRequests { + /** + * @param {import('@playwright/test').Page} page + */ + constructor( page ) { + super( page ); + this.page = page; + } + + /** + * Close the current page. + * + * @return {Promise} + */ + async closePage() { + await this.page.close(); + } + + /** + * Mock all requests related to external accounts such as Merchant Center, Google, etc. + * + * @return {Promise} + */ + async mockRequests() { + // Mock Reports Programs + await this.fulfillJetPackConnection( { + active: 'yes', + owner: 'yes', + displayName: 'John', + email: 'john@email.com', + } ); + + await this.mockGoogleConnected(); + await this.mockAdsAccountConnected(); + } + + /** + * Go to the Create Campaign page. + * + * @return {Promise} + */ + async goto() { + await this.page.goto( + '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&subpath=%2Fcampaigns%2Fcreate', + { waitUntil: LOAD_STATE.DOM_CONTENT_LOADED } + ); + } + + /** + * Get the Continue button. + * + * @return {import('@playwright/test').Locator} Continue button. + */ + getContinueButton() { + return this.page.getByRole( 'button', { + name: 'Continue', + exact: true, + } ); + } + + /** + * Get create campaign button. + * + * @return {import('@playwright/test').Locator} Get create campaign button. + */ + getCreateCampaignButton() { + // Intentionally not using getByRole here, as another button with the same accessible name exists in the Stepper header. + return this.page.locator( + 'button[data-action="submit-campaign-and-assets"]' + ); + } + + /** + * Click create campaign button. + * + * @return {Promise} + */ + async clickCreateCampaignButton() { + const createCampaignButton = this.getCreateCampaignButton(); + await createCampaignButton.click(); + } + + /** + * Get final URL select dropdown. + * + * @return {import('@playwright/test').Locator} Get final URL select dropdown. + */ + getFinalUrlSelect() { + return this.page.getByRole( 'combobox' ); + } + + /** + * Get select button. + * + * @return {import('@playwright/test').Locator} Get select button. + */ + getSelectButton() { + return this.page.getByRole( 'button', { name: 'Select' } ); + } + + /** + * Get select different final URL button. + * + * @return {import('@playwright/test').Locator} Get select different final URL button. + */ + getSelectDifferentFinalUrlButton() { + return this.page.getByRole( 'button', { + name: 'Or, select a different Final URL', + } ); + } + + /** + * Get Add headline button. + * + * @return {import('@playwright/test').Locator} Get Add headline button. + */ + getAddHeadlineButton() { + return this.page + .getByRole( 'button', { name: 'Add text' } ) + .filter( { hasText: 'Add headline' } ); + } + + /** + * Get Add long headline button. + * + * @return {import('@playwright/test').Locator} Get Add long headline button. + */ + getAddLongHeadlineButton() { + return this.page + .getByRole( 'button', { name: 'Add text' } ) + .filter( { hasText: 'Add long headline' } ); + } + + /** + * Get Add description button. + * + * @return {import('@playwright/test').Locator} Get Add description button. + */ + getAddDescriptionButton() { + return this.page + .getByRole( 'button', { name: 'Add text' } ) + .filter( { hasText: 'Add description' } ); + } + + /** + * Get generate headline button. + * + * @return {import('@playwright/test').Locator} Get generate headline button. + */ + getGenerateHeadlineButton() { + return this.page.getByRole( 'button', { + name: 'Generate headline', + } ); + } + + /** + * Get generate headlines button. + * + * @return {import('@playwright/test').Locator} Get generate headlines button. + */ + getGenerateHeadlinesButton() { + return this.page.getByRole( 'button', { + name: 'Generate headlines', + } ); + } + + /** + * Get generate long headline button. + * + * @return {import('@playwright/test').Locator} Get generate long headline button. + */ + getGenerateLongHeadlineButton() { + return this.page.getByRole( 'button', { + name: 'Generate long headline', + } ); + } + + /** + * Get generate long headlines button. + * + * @return {import('@playwright/test').Locator} Get generate long headlines button. + */ + getGenerateLongHeadlinesButton() { + return this.page.getByRole( 'button', { + name: 'Generate long headlines', + } ); + } + + /** + * Get generate description button. + * + * @return {import('@playwright/test').Locator} Get generate description button. + */ + getGenerateDescriptionButton() { + return this.page.getByRole( 'button', { + name: 'Generate description', + } ); + } + + /** + * Get generate descriptions button. + * + * @return {import('@playwright/test').Locator} Get generate descriptions button. + */ + getGenerateDescriptionsButton() { + return this.page.getByRole( 'button', { + name: 'Generate descriptions', + } ); + } + + /** + * Get headlines section. + * + * @return {import('@playwright/test').Locator} Get headlines section. + */ + getHeadlinesSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Headlines"))' + ) + .first(); + } + + /** + * Get headline inputs. + * + * @return {import('@playwright/test').Locator} Get headline inputs. + */ + getHeadlineInputs() { + const headlinesSection = this.getHeadlinesSection(); + const headlineInputs = headlinesSection.locator( + 'input[placeholder="Headline"]' + ); + + return headlineInputs; + } + + /** + * Get headline inputs values. + * + * @return {Promise} Get headline inputs values. + */ + async getHeadlineInputsValues() { + const headlineInputs = this.getHeadlineInputs(); + const values = await headlineInputs.evaluateAll( ( inputs ) => + inputs.map( ( input ) => input.value ) + ); + + return values; + } + + /** + * Get long headlines section. + * + * @return {import('@playwright/test').Locator} Get long headlines section. + */ + getLongHeadlinesSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Long headlines"))' + ) + .first(); + } + + /** + * Get headline inputs. + * + * @return {import('@playwright/test').Locator} Get headline inputs. + */ + getLongHeadlineInputs() { + const longHeadlinesSection = this.getLongHeadlinesSection(); + const longHeadlineInputs = longHeadlinesSection.locator( + 'input[placeholder="Long headline"]' + ); + + return longHeadlineInputs; + } + + /** + * Get descriptions section. + * + * @return {import('@playwright/test').Locator} Get descriptions section. + */ + getDescriptionsSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Descriptions"))' + ) + .first(); + } + + /** + * Get description inputs. + * + * @return {import('@playwright/test').Locator} Get description inputs. + */ + getDescriptionInputs() { + const descriptionsSection = this.getDescriptionsSection(); + const descriptionInputs = descriptionsSection.locator( + 'input[placeholder="Description"]' + ); + + return descriptionInputs; + } + + /** + * Get description inputs values. + * + * @return {Promise} Get description inputs values. + */ + async getDescriptionInputsValues() { + const descriptionInputs = this.getDescriptionInputs(); + const values = await descriptionInputs.evaluateAll( ( inputs ) => + inputs.map( ( input ) => input.value ) + ); + + return values; + } + + /** + * Get long headline inputs values. + * + * @return {Promise} Get long headline inputs values. + */ + async getLongHeadlineInputsValues() { + const longHeadlineInputs = this.getLongHeadlineInputs(); + const values = await longHeadlineInputs.evaluateAll( ( inputs ) => + inputs.map( ( input ) => input.value ) + ); + + return values; + } + + /** + * Get landscape images section. + * + * @return {import('@playwright/test').Locator} Get landscape images section. + */ + getLandscapeImagesSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Landscape images"))' + ) + .first(); + } + + /** + * Get generate landscape images button. + * + * @return {import('@playwright/test').Locator} Get generate landscape images button. + */ + getGenerateLandscapeImagesButton() { + return this.page.getByRole( 'button', { + name: 'Generate landscape images', + } ); + } + + /** + * Get landscape images section image picker. + * + * @return {import('@playwright/test').Locator} Get landscape images section image picker. + */ + getLandscapeImagesSectionImagePicker() { + const landscapeImagesSection = this.getLandscapeImagesSection(); + return landscapeImagesSection.locator( '.gla-gen-ai-image-picker' ); + } + + /** + * Get landscape generated images. + * + * @return {import('@playwright/test').Locator} Get landscape generated images. + */ + getLandscapeGeneratedImages() { + const landscapeImagesSection = this.getLandscapeImagesSection(); + return landscapeImagesSection.locator( + '.gla-gen-ai-image-picker__medium-button' + ); + } + + /** + * Get landscape image picker add selected images button. + * + * @return {import('@playwright/test').Locator} Get landscape image picker add selected images button. + */ + getLandscapeImagePickerAddSelectedImagesButton() { + const landscapeImagesSection = this.getLandscapeImagesSection(); + return landscapeImagesSection.getByRole( 'button', { + name: 'Add selected images', + } ); + } + + /** + * Get landscape campaign images. + * + * @return {import('@playwright/test').Locator} Get landscape campaign images. + */ + getCampaignLandscapeImageItems() { + const landscapeImagesSection = this.getLandscapeImagesSection(); + return landscapeImagesSection.locator( '.gla-media-selector__item' ); + } + + /** + * Get square images section. + * + * @return {import('@playwright/test').Locator} Get square images section. + */ + getSquareImagesSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Square images"))' + ) + .first(); + } + + /** + * Get square campaign images. + * + * @return {import('@playwright/test').Locator} Get square campaign images. + */ + getCampaignSquareImageItems() { + const squareImagesSection = this.getSquareImagesSection(); + return squareImagesSection.locator( '.gla-media-selector__item' ); + } + + /** + * Get square images section image picker. + * + * @return {import('@playwright/test').Locator} Get square images section image picker. + */ + getSquareImagesSectionImagePicker() { + const squareImagesSection = this.getSquareImagesSection(); + return squareImagesSection.locator( '.gla-gen-ai-image-picker' ); + } + + /** + * Get generate square images button. + * + * @return {import('@playwright/test').Locator} Get generate square images button. + */ + getGenerateSquareImagesButton() { + return this.page.getByRole( 'button', { + name: 'Generate square images', + } ); + } + + /** + * Get square generated images. + * + * @return {import('@playwright/test').Locator} Get square generated images. + */ + getSquareGeneratedImages() { + const squareImagesSection = this.getSquareImagesSection(); + return squareImagesSection.locator( + '.gla-gen-ai-image-picker__medium-button' + ); + } + + /** + * Get portrait section. + * + * @return {import('@playwright/test').Locator} Get portrait images section. + */ + getPortraitImagesSection() { + return this.page + .locator( + '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Portrait images"))' + ) + .first(); + } + + /** + * Get generate portrait images button. + * + * @return {import('@playwright/test').Locator} Get generate portrait images button. + */ + getGeneratePortraitImagesButton() { + return this.page.getByRole( 'button', { + name: 'Generate portrait images', + } ); + } + + /** + * Get portrait campaign images. + * + * @return {import('@playwright/test').Locator} Get portrait campaign images. + */ + getCampaignPortraitImageItems() { + const portraitImagesSection = this.getPortraitImagesSection(); + return portraitImagesSection.locator( '.gla-media-selector__item' ); + } + + /** + * Get portrait images section image picker. + * + * @return {import('@playwright/test').Locator} Get portrait images section image picker. + */ + getPortraitImagesSectionImagePicker() { + const portraitImagesSection = this.getPortraitImagesSection(); + return portraitImagesSection.locator( '.gla-gen-ai-image-picker' ); + } + + /** + * Get portrait generated images. + * + * @return {import('@playwright/test').Locator} Get portrait generated images. + */ + getPortraitGeneratedImages() { + const portraitImagesSection = this.getPortraitImagesSection(); + return portraitImagesSection.locator( + '.gla-gen-ai-image-picker__medium-button' + ); + } + + /** + * Get final URL card. + * + * @return {import('@playwright/test').Locator} Get final URL card. + */ + getFinalUrlCard() { + return this.page.locator( '.gla-final-url-card' ); + } + + /** + * Select URL option. + * + * @return {Promise} + */ + async selectUrlOption() { + const finalUrlSelect = this.getFinalUrlSelect(); + await finalUrlSelect.focus(); + const option = this.page.getByRole( 'option', { + name: 'Shop', + } ); + await option.click(); + + const selectButton = this.getSelectButton(); + await selectButton.click(); + } + + /** + * Mock optimize campaign requests. + * + * @return {Promise} + */ + async mockOptimizeCampaignRequests() { + await this.mockFinalUrlSuggestions( [ + { + id: 0, + type: 'homepage', + title: 'Homepage', + url: 'https://woo.com', + }, + { + id: 7, + type: 'post', + title: 'Shop', + url: 'https://woo.com/shop/', + }, + ] ); + + await this.mockAssetSuggestions( { + logo: [ + 'https://tpc.googlesyndication.com/simgad/2643735098967285793', + ], + business_name: 'My Woo Store', + square_marketing_image: [ + 'https://tpc.googlesyndication.com/simgad/2643735098967285793', + ], + marketing_image: [ + 'https://tpc.googlesyndication.com/simgad/6792129722137622820', + ], + portrait_marketing_image: [], + call_to_action_selection: null, + final_url: 'https://woo.com/shop/', + display_url_path: [ 'shop', '' ], + headline: [ 'My Woo Store', 'Shop', 'Buy Now' ], + description: [ 'Best products available here.', 'Shop today!' ], + long_headline: [ 'My Woo Store: Shop' ], + } ); + + await this.fulfillAssetGroupsForCampaign( 1, [ + { + id: 1, + final_url: '', + display_url_path: [ '', '' ], + assets: {}, + }, + ] ); + } + + /** + * Await for the generate text assets request. + * + * @param {string} finalUrl The final URL. + * @param {Array} types The requested asset types. + * @return {Promise} The request. + */ + async awaitForGenerateTextRequest( finalUrl, types ) { + return this.page.waitForRequest( ( request ) => { + if ( + ! request.url().includes( '/gla/ads/assets/generate-text' ) || + request.method() !== 'POST' + ) { + return false; + } + + const payload = request.postDataJSON(); + + return ( + payload.final_url === finalUrl && + Array.isArray( payload.types ) && + types.every( ( type ) => payload.types.includes( type ) ) + ); + } ); + } + + /** + * Mock generate text assets success response. + * + * @return {Promise} + */ + async mockGenerateTextAssetsSuccess() { + await this.fulfillGenerateTextAssetsRequest( { + final_url: 'https://woo.com/shop/', + items: [ + // Headlines + { + text: 'Latest Deals', + type: 'headline', + }, + { + text: 'Limited-Time Offers', + type: 'headline', + }, + { + text: 'New Arrivals In Store', + type: 'headline', + }, + { + text: 'Top Deals This Week', + type: 'headline', + }, + { + text: 'Fast Shipping Available', + type: 'headline', + }, + + // Long headlines + { + text: 'Discover quality products at great prices', + type: 'long_headline', + }, + { + text: 'Everything you need, delivered fast', + type: 'long_headline', + }, + { + text: 'Upgrade your everyday shopping experience', + type: 'long_headline', + }, + { + text: 'Find your next favorite product today', + type: 'long_headline', + }, + { + text: 'Smart shopping starts right here', + type: 'long_headline', + }, + + // Descriptions + { + text: 'Browse top picks and enjoy exclusive savings.', + type: 'description', + }, + { + text: 'Shop trusted products with fast delivery.', + type: 'description', + }, + { + text: 'Great value items curated just for you.', + type: 'description', + }, + { + text: 'Simple shopping with reliable service.', + type: 'description', + }, + { + text: 'Quality products backed by great support.', + type: 'description', + }, + ], + } ); + } + + /** + * Mock generate text assets empty response. + * + * @return {Promise} + */ + async mockEmptyGenerateTextAssets() { + await this.fulfillGenerateTextAssetsRequest( { + final_url: 'https://woo.com/shop/', + items: [], + } ); + } + + /** + * Mock generate media assets success response. + * + * @return {Promise} + */ + async mockGenerateImageAssetsSuccess() { + await this.fulfillGenerateImageAssetsRequest( { + final_url: 'https://woo.com/shop/', + items: [ + { + temporary_image_url: + 'https://placehold.co/400x225?text=Marketing+Image+1', + type: 'marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/400x225?text=Marketing+Image+2', + type: 'marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/400x225?text=Marketing+Image+3', + type: 'marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/400x225?text=Marketing+Image+4', + type: 'marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/200x200?text=Square+Marketing+Image+1', + type: 'square_marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/200x200?text=Square+Marketing+Image+2', + type: 'square_marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/200x200?text=Square+Marketing+Image+3', + type: 'square_marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/200x300?text=Portrait+Marketing+Image+1', + type: 'portrait_marketing_image', + }, + { + temporary_image_url: + 'https://placehold.co/200x300?text=Portrait+Marketing+Image+2', + type: 'portrait_marketing_image', + }, + ], + } ); + } + + /** + * Mock generate image assets empty response. + * + * @return {Promise} + */ + async mockEmptyGenerateImageAssets() { + await this.fulfillGenerateImageAssetsRequest( { + final_url: 'https://woo.com/shop/', + items: [], + } ); + } + + /** + * Await for the generate image assets request. + * + * @param {string} finalUrl The final URL. + * @param {Array} types The requested asset types. + * @return {Promise} The request. + */ + async awaitForGenerateImageRequest( finalUrl, types ) { + return this.page.waitForRequest( ( request ) => { + if ( + ! request.url().includes( '/gla/ads/assets/generate-images' ) || + request.method() !== 'POST' + ) { + return false; + } + + const payload = request.postDataJSON(); + + return ( + payload.final_url === finalUrl && + Array.isArray( payload.types ) && + types.every( ( type ) => payload.types.includes( type ) ) + ); + } ); + } +} diff --git a/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js b/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js index bc0f9c19cb..2590aec4ad 100644 --- a/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js +++ b/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js @@ -38,4 +38,36 @@ export default class CreateCampaign extends CompleteCampaign { await button.click(); await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); } + + /** + * Get final URL card. + * + * @return {import('@playwright/test').Locator} Get final URL card. + */ + getFinalUrlCard() { + return this.page.locator( '.gla-final-url-card' ); + } + + /** + * Get select different final URL button. + * + * @return {import('@playwright/test').Locator} Get select different final URL button. + */ + getSelectDifferentFinalUrlButton() { + return this.page.getByRole( 'button', { + name: 'Or, select a different Final URL', + } ); + } + + /** + * Get create campaign button. + * + * @return {import('@playwright/test').Locator} Get create campaign button. + */ + getCreateCampaignButton() { + // Intentionally not using getByRole here, as another button with the same accessible name exists in the Stepper header. + return this.page.locator( + 'button[data-action="submit-campaign-and-assets"]' + ); + } } diff --git a/tests/mocks/assets/svgFileMock.js b/tests/mocks/assets/svgFileMock.js new file mode 100644 index 0000000000..95b0b6d997 --- /dev/null +++ b/tests/mocks/assets/svgFileMock.js @@ -0,0 +1 @@ +module.exports = 'SvgrURL'; diff --git a/tests/mocks/assets/svgrMock.js b/tests/mocks/assets/svgrMock.js index 99b5ffa206..146f6be6dd 100644 --- a/tests/mocks/assets/svgrMock.js +++ b/tests/mocks/assets/svgrMock.js @@ -1,10 +1,14 @@ /** * External dependencies */ -import { forwardRef } from '@wordpress/element'; +import { createElement, forwardRef } from '@wordpress/element'; -export const ReactComponent = forwardRef( ( props, ref ) => ( - -) ); +const SvgMock = forwardRef( ( props, ref ) => + createElement( 'svg', { ref, ...props } ) +); -export default 'SvgrURL'; +// Common SVGR compatibility: named export ReactComponent +export const ReactComponent = SvgMock; + +// Default export should be the component for `?inline` usage +export default SvgMock; diff --git a/webpack.config.js b/webpack.config.js index b500499b56..ab72b0a344 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -22,7 +22,49 @@ const webpackConfig = { // Remove `@wordpress/` rules for SVGs. ...defaultConfig.module.rules.filter( exceptSVGAndPNGRule ), { - test: /\.(svg|png|jpe?g|gif)$/i, + test: /\.svg$/i, + oneOf: [ + { + resourceQuery: /inline/, + issuer: /\.[jt]sx?$/, + use: [ + { + loader: require.resolve( '@svgr/webpack' ), + options: { + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, + }, + }, + }, + { + name: 'prefixIds', + params: { + prefix: true, + }, + }, + ], + }, + exportType: 'default', + }, + }, + ], + }, + // Default: emit SVG as a file + { + type: 'asset/resource', + generator: { + filename: 'images/[path][contenthash].[name][ext]', + }, + }, + ], + }, + { + test: /\.(png|jpe?g|gif)$/i, type: 'asset/resource', generator: { filename: 'images/[path][contenthash].[name][ext]',