diff --git a/composer.json b/composer.json index 27cd7d9af..2d277b84f 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "require": { "composer/installers": "~1.0", "php": ">=7|^8", + "ext-dom": "*", "ext-json": "*" }, "scripts": { diff --git a/composer.lock b/composer.lock index 6a9ae7127..3d552f874 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": "2dcd132f2c017c64da30a4a4b6f78f29", + "content-hash": "343d54db29b4354eed69ae570fa85247", "packages": [ { "name": "composer/installers", @@ -236,30 +236,30 @@ }, { "name": "doctrine/instantiator", - "version": "1.5.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -286,7 +286,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -302,7 +302,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "myclabs/deep-copy", @@ -365,25 +365,27 @@ }, { "name": "nikic/php-parser", - "version": "v4.17.1", + "version": "v5.0.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -391,7 +393,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -415,9 +417,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" }, - "time": "2023-08-13T19:53:39+00:00" + "time": "2024-01-07T17:17:35+00:00" }, { "name": "phar-io/manifest", @@ -532,25 +534,27 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.4.0", + "version": "v6.4.1", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264" + "reference": "6d6063cf9464a306ca2a0529705d41312b08500b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/286d42eeb44c6808633cc59b8dbb9aa75fe41264", - "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/6d6063cf9464a306ca2a0529705d41312b08500b", + "reference": "6d6063cf9464a306ca2a0529705d41312b08500b", "shasum": "" }, "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^4.13", "php": "^7.4 || ~8.0.0", "php-stubs/generator": "^0.8.3", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpstan": "^1.10.12", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.8" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -571,9 +575,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.4.0" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.4.1" }, - "time": "2023-11-08T07:02:08+00:00" + "time": "2023-11-10T00:33:47+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -639,29 +643,29 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.1.2", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5" + "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/746c3190ba8eb2f212087c947ba75f4f5b9a58d5", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", + "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.0.8", - "squizlabs/php_codesniffer": "^3.7.1" + "phpcsstandards/phpcsutils": "^1.0.9", + "squizlabs/php_codesniffer": "^3.8.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcsstandards/phpcsdevcs": "^1.1.6", "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.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -696,35 +700,50 @@ ], "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" + } + ], + "time": "2023-12-08T16:49:07+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.0.8", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7" + "reference": "908247bc65010c7b7541a9551e002db12e9dae70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/69465cab9d12454e5e7767b9041af0cd8cd13be7", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/908247bc65010c7b7541a9551e002db12e9dae70", + "reference": "908247bc65010c7b7541a9551e002db12e9dae70", "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.8.0 || 4.0.x-dev@dev" }, "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" + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -769,9 +788,24 @@ "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" + } + ], + "time": "2023-12-08T14:50:00+00:00" }, { "name": "phpstan/extension-installer", @@ -819,16 +853,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.24.2", + "version": "1.25.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "bcad8d995980440892759db0c32acae7c8e79442" + "reference": "bd84b629c8de41aa2ae82c067c955e06f1b00240" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/bcad8d995980440892759db0c32acae7c8e79442", - "reference": "bcad8d995980440892759db0c32acae7c8e79442", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/bd84b629c8de41aa2ae82c067c955e06f1b00240", + "reference": "bd84b629c8de41aa2ae82c067c955e06f1b00240", "shasum": "" }, "require": { @@ -860,22 +894,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.25.0" }, - "time": "2023-09-26T12:28:12+00:00" + "time": "2024-01-04T17:06:16+00:00" }, { "name": "phpstan/phpstan", - "version": "1.10.41", + "version": "1.10.55", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76" + "reference": "9a88f9d18ddf4cf54c922fbeac16c4cb164c5949" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6174523c2a69231df55bdc65b61655e72876d76", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9a88f9d18ddf4cf54c922fbeac16c4cb164c5949", + "reference": "9a88f9d18ddf4cf54c922fbeac16c4cb164c5949", "shasum": "" }, "require": { @@ -924,7 +958,7 @@ "type": "tidelift" } ], - "time": "2023-11-05T12:57:57+00:00" + "time": "2024-01-08T12:32:40+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -1028,23 +1062,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.29", + "version": "9.2.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76" + "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ca2bd87d2f9215904682a9cb9bb37dda98e76089", + "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -1094,7 +1128,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.29" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.30" }, "funding": [ { @@ -1102,7 +1136,7 @@ "type": "github" } ], - "time": "2023-09-19T04:57:46+00:00" + "time": "2023-12-22T06:47:57+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1347,16 +1381,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.13", + "version": "9.6.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be" + "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f3d767f7f9e191eab4189abe41ab37797e30b1be", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/05017b80304e0eb3f31d90194a563fd53a6021f1", + "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1", "shasum": "" }, "require": { @@ -1430,7 +1464,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.13" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.15" }, "funding": [ { @@ -1446,7 +1480,7 @@ "type": "tidelift" } ], - "time": "2023-09-19T05:39:22+00:00" + "time": "2023-12-01T16:55:19+00:00" }, { "name": "sebastian/cli-parser", @@ -1691,20 +1725,20 @@ }, { "name": "sebastian/complexity", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -1736,7 +1770,7 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" }, "funding": [ { @@ -1744,7 +1778,7 @@ "type": "github" } ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2023-12-22T06:19:30+00:00" }, { "name": "sebastian/diff", @@ -2018,20 +2052,20 @@ }, { "name": "sebastian/lines-of-code", - "version": "1.0.3", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -2063,7 +2097,7 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" }, "funding": [ { @@ -2071,7 +2105,7 @@ "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2023-12-22T06:20:34+00:00" }, { "name": "sebastian/object-enumerator", @@ -2479,16 +2513,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "version": "3.8.1", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "14f5fff1e64118595db5408e946f3a22c75807f7" }, "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/14f5fff1e64118595db5408e946f3a22c75807f7", + "reference": "14f5fff1e64118595db5408e946f3a22c75807f7", "shasum": "" }, "require": { @@ -2498,11 +2532,11 @@ "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": { @@ -2517,22 +2551,45 @@ "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" + } + ], + "time": "2024-01-11T20:47:48+00:00" }, { "name": "symfony/polyfill-php73", @@ -2677,16 +2734,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", "shasum": "" }, "require": { @@ -2715,7 +2772,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.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.2" }, "funding": [ { @@ -2723,7 +2780,7 @@ "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2023-11-20T00:12:19+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -2793,7 +2850,7 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "5.9.7", + "version": "5.9.8", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", @@ -2907,6 +2964,7 @@ "prefer-lowest": false, "platform": { "php": ">=7|^8", + "ext-dom": "*", "ext-json": "*" }, "platform-dev": [], diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index f42bdce21..0a69d25c2 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -132,6 +132,8 @@ public function __construct( string $html ) { * A generator is used so that when iterating at a specific tag, additional information about the tag at that point * can be queried from the class. Similarly, mutations may be performed when iterating at an open tag. * + * @since n.e.x.t + * * @return Generator Tag name of current open tag. */ public function open_tags(): Generator { @@ -191,7 +193,11 @@ public function open_tags(): Generator { yield $tag_name; // Immediately pop off self-closing tags. - if ( in_array( $tag_name, self::VOID_TAGS, true ) ) { + if ( + in_array( $tag_name, self::VOID_TAGS, true ) + || + ( $p->has_self_closing_flag() && $this->is_foreign_element() ) + ) { array_pop( $this->open_stack_tags ); } } else { @@ -200,32 +206,21 @@ public function open_tags(): Generator { continue; } - // Since SVG and MathML can have a lot more self-closing/empty tags, potentially pop off the stack until getting to the open tag. - $did_splice = false; - if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) { - $i = array_search( $tag_name, $this->open_stack_tags, true ); - if ( false !== $i ) { - array_splice( $this->open_stack_tags, $i ); - $did_splice = true; - } - } - - if ( ! $did_splice ) { - $popped_tag_name = array_pop( $this->open_stack_tags ); - if ( $popped_tag_name !== $tag_name && function_exists( 'wp_trigger_error' ) ) { - wp_trigger_error( - __METHOD__, - esc_html( - sprintf( - /* translators: 1: Popped tag name, 2: Closing tag name */ - __( 'Expected popped tag stack element %1$s to match the currently visited closing tag %2$s.', 'performance-lab' ), - $popped_tag_name, - $tag_name - ) + $popped_tag_name = array_pop( $this->open_stack_tags ); + if ( $popped_tag_name !== $tag_name && function_exists( 'wp_trigger_error' ) ) { + wp_trigger_error( + __METHOD__, + esc_html( + sprintf( + /* translators: 1: Popped tag name, 2: Closing tag name */ + __( 'Expected popped tag stack element %1$s to match the currently visited closing tag %2$s.', 'performance-lab' ), + $popped_tag_name, + $tag_name ) - ); - } + ) + ); } + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); } } @@ -236,6 +231,8 @@ public function open_tags(): Generator { * * A breadcrumb consists of a tag name and its sibling index. * + * @since n.e.x.t + * * @return Generator Breadcrumb. */ private function get_breadcrumbs(): Generator { @@ -244,12 +241,30 @@ private function get_breadcrumbs(): Generator { } } + /** + * Determines whether currently inside a foreign element (MATH or SVG). + * + * @since n.e.x.t + * + * @return bool In foreign element. + */ + private function is_foreign_element(): bool { + foreach ( $this->open_stack_tags as $open_stack_tag ) { + if ( 'MATH' === $open_stack_tag || 'SVG' === $open_stack_tag ) { + return true; + } + } + return false; + } + /** * Gets XPath for the current open tag. * * It would be nicer if this were like `/html[1]/body[2]` but in XPath the position() here refers to the * index of the preceding node set. So it has to rather be written `/*[1][self::html]/*[2][self::body]`. * + * @since n.e.x.t + * * @return string XPath. */ public function get_xpath(): string { @@ -266,6 +281,7 @@ public function get_xpath(): string { * This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of * methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated. * + * @since n.e.x.t * @see WP_HTML_Tag_Processor::get_attribute() * * @param string $name Name of attribute whose value is requested. @@ -281,6 +297,7 @@ public function get_attribute( string $name ) { * This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of * methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated. * + * @since n.e.x.t * @see WP_HTML_Tag_Processor::set_attribute() * * @param string $name The attribute name to target. @@ -297,6 +314,7 @@ public function set_attribute( string $name, $value ): bool { * This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of * methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated. * + * @since n.e.x.t * @see WP_HTML_Tag_Processor::remove_attribute() * * @param string $name The attribute name to remove. @@ -312,6 +330,7 @@ public function remove_attribute( string $name ): bool { * This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of * methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated. * + * @since n.e.x.t * @see WP_HTML_Tag_Processor::get_updated_html() * * @return string The processed HTML. diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 288d45714..7eb999ec6 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -33,7 +33,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ * * @param int $detection_time_window Detection time window in milliseconds. */ - $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); + $detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 ); $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 6e5a2c13d..1408014c0 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -24,32 +24,65 @@ function ilo_maybe_add_template_output_buffer_filter() { } add_action( 'wp', 'ilo_maybe_add_template_output_buffer_filter' ); +/** + * Determines whether the current response can be optimized. + * + * Only search results are not eligible by default for optimization. This is because there is no predictability in + * whether posts in the loop will have featured images assigned or not. If a theme template for search results doesn't + * even show featured images, then this isn't an issue. + * + * @since n.e.x.t + * @access private + * + * @return bool Whether response can be optimized. + */ +function ilo_can_optimize_response(): bool { + $able = ! ( + // Since the URL space is infinite. + is_search() || + // Since injection of inline-editing controls interfere with breadcrumbs, while also just not necessary in this context. + is_customize_preview() || + // The images detected in the response body of a POST request cannot, by definition, be cached. + 'GET' !== $_SERVER['REQUEST_METHOD'] + ); + + /** + * Filters whether the current response can be optimized. + * + * @since n.e.x.t + * + * @param bool $able Whether response can be optimized. + */ + return (bool) apply_filters( 'ilo_can_optimize_response', $able ); +} + /** * Constructs preload links. * * @since n.e.x.t * @access private * - * @param array $lcp_images_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. + * @param array $lcp_elements_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. * @return string Markup for zero or more preload link tags. */ -function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widths ): string { +function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths ): string { $preload_links = array(); // This uses a for loop to be able to access the following element within the iteration, using a numeric index. - $minimum_viewport_widths = array_keys( $lcp_images_by_minimum_viewport_widths ); + $minimum_viewport_widths = array_keys( $lcp_elements_by_minimum_viewport_widths ); for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { - $lcp_element = $lcp_images_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ]; - if ( false === $lcp_element || empty( $lcp_element['attributes'] ) ) { - // No LCP element at this breakpoint, so nothing to preload. + $lcp_element = $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ]; + if ( false === $lcp_element ) { + // No supported LCP element at this breakpoint, so nothing to preload. continue; } - $img_attributes = $lcp_element['attributes']; + // TODO: Add support for background images. + $attributes = $lcp_element['attributes']; // Prevent preloading src for browsers that don't support imagesrcset on the link element. - if ( isset( $img_attributes['src'], $img_attributes['srcset'] ) ) { - unset( $img_attributes['src'] ); + if ( isset( $attributes['src'], $attributes['srcset'] ) ) { + unset( $attributes['src'] ); } // Add media query if it's going to be something other than just `min-width: 0px`. @@ -60,12 +93,12 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt if ( null !== $maximum_viewport_width ) { $media_query .= sprintf( ' and ( max-width: %dpx )', $maximum_viewport_width ); } - $img_attributes['media'] = $media_query; + $attributes['media'] = $media_query; } // Construct preload link. $link_tag = ' $value ) { + foreach ( array_filter( $attributes ) as $name => $value ) { // Map img attribute name to link attribute name. if ( 'srcset' === $name || 'sizes' === $name ) { $name = 'image' . $name; @@ -107,7 +140,12 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. - $needs_detection = ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ); + $needs_detection = in_array( + true, + // Each array item is array{int, bool}, with the second item being whether the viewport width is needed. + array_column( $needed_minimum_viewport_widths, 1 ), + true + ); $breakpoint_max_widths = ilo_get_breakpoint_max_widths(); $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); @@ -169,10 +207,13 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $processor->remove_attribute( 'fetchpriority' ); } + // TODO: If the image is visible (intersectionRatio!=0) in any of the URL metrics, remove loading=lazy. + // TODO: Conversely, if an image is the LCP element for one breakpoint but not another, add loading=lazy. This won't hurt performance since the image is being preloaded. + // Capture the attributes from the LCP elements to use in preload links. if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] ) ) { $attributes = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) { $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); } foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 35eda471b..09840b205 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -31,38 +31,6 @@ function ilo_get_url_metric_freshness_ttl(): int { return (int) apply_filters( 'ilo_url_metric_freshness_ttl', DAY_IN_SECONDS ); } -/** - * Determines whether the current response can be optimized. - * - * Only search results are not eligible by default for optimization. This is because there is no predictability in - * whether posts in the loop will have featured images assigned or not. If a theme template for search results doesn't - * even show featured images, then this isn't an issue. - * - * @since n.e.x.t - * @access private - * - * @return bool Whether response can be optimized. - */ -function ilo_can_optimize_response(): bool { - $able = ! ( - // Since the URL space is infinite. - is_search() || - // Since injection of inline-editing controls interfere with breadcrumbs, while also just not necessary in this context. - is_customize_preview() || - // The images detected in the response body of a POST request cannot, by definition, be cached. - 'GET' !== $_SERVER['REQUEST_METHOD'] - ); - - /** - * Filters whether the current response can be optimized. - * - * @since n.e.x.t - * - * @param bool $able Whether response can be optimized. - */ - return (bool) apply_filters( 'ilo_can_optimize_response', $able ); -} - /** * Gets the normalized query vars for the current request. * @@ -136,12 +104,10 @@ function ilo_get_url_metrics_storage_nonce( string $slug ): string { * * @param string $nonce URL metrics storage nonce. * @param string $slug URL metrics slug. - * @return int 1 if the nonce is valid and generated between 0-12 hours ago, - * 2 if the nonce is valid and generated between 12-24 hours ago. - * 0 if the nonce is invalid. + * @return bool Whether the nonce is valid. */ -function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): int { - return (int) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); +function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bool { + return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); } /** @@ -150,33 +116,40 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): in * @since n.e.x.t * @access private * - * @param array $url_metrics URL metrics. + * @param array $url_metrics URL metrics. Each URL metric is expected to have a timestamp key. * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). - * @return array Updated URL metrics. + * @param int[] $breakpoints Breakpoint max widths. + * @param int $sample_size Sample size for URL metrics at a given breakpoint. + * @return array Updated URL metrics, with timestamp key added. */ -function ilo_unshift_url_metrics( array $url_metrics, array $validated_url_metric ): array { +function ilo_unshift_url_metrics( array $url_metrics, array $validated_url_metric, array $breakpoints, int $sample_size ): array { + $validated_url_metric['timestamp'] = microtime( true ); array_unshift( $url_metrics, $validated_url_metric ); - $breakpoints = ilo_get_breakpoint_max_widths(); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); - foreach ( $grouped_url_metrics as &$breakpoint_url_metrics ) { - if ( count( $breakpoint_url_metrics ) > $sample_size ) { - - // Sort URL metrics in descending order by timestamp. - usort( - $breakpoint_url_metrics, - static function ( $a, $b ) { - if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { - return 0; + // Make sure there is at most $sample_size number of URL metrics for each breakpoint. + $grouped_url_metrics = array_map( + static function ( $breakpoint_url_metrics ) use ( $sample_size ) { + if ( count( $breakpoint_url_metrics ) > $sample_size ) { + + // Sort URL metrics in descending order by timestamp. + usort( + $breakpoint_url_metrics, + static function ( $a, $b ) { + if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { + return 0; + } + return $b['timestamp'] <=> $a['timestamp']; } - return $b['timestamp'] <=> $a['timestamp']; - } - ); + ); - $breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $sample_size ); - } - } + // Only keep the sample size of the newest URL metrics. + $breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $sample_size ); + } + return $breakpoint_url_metrics; + }, + $grouped_url_metrics + ); return array_merge( ...$grouped_url_metrics ); } @@ -266,24 +239,23 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { function ilo_group_url_metrics_by_breakpoint( array $url_metrics, array $breakpoints ): array { // Convert breakpoint max widths into viewport minimum widths. - $viewport_minimum_widths = array_map( + $minimum_viewport_widths = array_map( static function ( $breakpoint ) { return $breakpoint + 1; }, $breakpoints ); - $grouped = array_fill_keys( array_merge( array( 0 ), $viewport_minimum_widths ), array() ); + $grouped = array_fill_keys( array_merge( array( 0 ), $minimum_viewport_widths ), array() ); foreach ( $url_metrics as $url_metric ) { if ( ! isset( $url_metric['viewport']['width'] ) ) { continue; } - $viewport_width = $url_metric['viewport']['width']; $current_minimum_viewport = 0; - foreach ( $viewport_minimum_widths as $viewport_minimum_width ) { - if ( $viewport_width > $viewport_minimum_width ) { + foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { + if ( $url_metric['viewport']['width'] > $viewport_minimum_width ) { $current_minimum_viewport = $viewport_minimum_width; } else { break; @@ -299,15 +271,16 @@ static function ( $breakpoint ) { * Gets the LCP element for each breakpoint. * * The array keys are the minimum viewport width required for the element to be LCP. If there are URL metrics for a - * given breakpoint and yet there is no LCP element, then the array value is `false`. If there is an LCP element at the - * breakpoint, then the array value is an array representing that element, including its breadcrumbs. If two adjoining - * breakpoints have the same value, then the latter is dropped. + * given breakpoint and yet there is no supported LCP element, then the array value is `false`. (Currently only IMG is + * a supported LCP element.) If there is a supported LCP element at the breakpoint, then the array value is an array + * representing that element, including its breadcrumbs. If two adjoining breakpoints have the same value, then the + * latter is dropped. * * @since n.e.x.t * @access private * * @param array $grouped_url_metrics URL metrics grouped by breakpoint. See `ilo_group_url_metrics_by_breakpoint()`. - * @return array LCP elements keyed by its minimum viewport width. If there is no LCP element at a breakpoint, then `false` is used. + * @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used. */ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics ): array { @@ -382,8 +355,8 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { * @since n.e.x.t * @access private * - * @param array $url_metrics URL metrics. - * @param float $current_time Current time as returned by microtime(true). + * @param array $url_metrics URL metrics. + * @param float $current_time Current time as returned by `microtime(true)`. * @param int[] $breakpoint_max_widths Breakpoint max widths. * @param int $sample_size Sample size for viewports in a breakpoint. * @param int $freshness_ttl Freshness TTL for a URL metric. @@ -412,21 +385,3 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr return $needed_minimum_viewport_widths; } - -/** - * Checks whether there is a URL metric needed for one of the breakpoints. - * - * @since n.e.x.t - * @access private - * - * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. - * @return bool Whether a URL metric is needed. - */ -function ilo_needs_url_metric_for_breakpoint( array $needed_minimum_viewport_widths ): bool { - foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { - if ( $is_needed ) { - return true; - } - } - return false; -} diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index a95a71176..00d397af0 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -110,7 +110,40 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { ); $url_metrics = array(); } - return $url_metrics; + + return array_values( + array_filter( + $url_metrics, + static function ( $url_metric ) use ( $trigger_error ) { + // TODO: If we wanted, we could use the JSON Schema to validate the stored metrics. + $is_valid = ( + is_array( $url_metric ) + && + isset( + $url_metric['viewport']['width'], + $url_metric['viewport']['height'], + $url_metric['elements'] + ) + && + is_int( $url_metric['viewport']['width'] ) + && + is_array( $url_metric['elements'] ) + ); + + if ( ! $is_valid ) { + $trigger_error( + sprintf( + /* translators: %s is post type slug */ + __( 'Unexpected shape to JSON array in post_content of %s post type.', 'performance-lab' ), + ILO_URL_METRICS_POST_TYPE + ) + ); + } + + return $is_valid; + } + ) + ); } /** @@ -119,13 +152,12 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { * @since n.e.x.t * @access private * - * @param string $url URL for the URL metrics. This is used purely as metadata. - * @param string $slug URL metrics slug (computed from query vars). + * @param string $url URL for the URL metrics. This is used purely as metadata. + * @param string $slug URL metrics slug (computed from query vars). * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). * @return int|WP_Error Post ID or WP_Error otherwise. */ function ilo_store_url_metric( string $url, string $slug, array $validated_url_metric ) { - $validated_url_metric['timestamp'] = microtime( true ); // TODO: What about storing a version identifier? $post_data = array( @@ -143,7 +175,9 @@ function ilo_store_url_metric( string $url, string $slug, array $validated_url_m $url_metrics = array(); } - $url_metrics = ilo_unshift_url_metrics( $url_metrics, $validated_url_metric ); + $breakpoints = ilo_get_breakpoint_max_widths(); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $url_metrics = ilo_unshift_url_metrics( $url_metrics, $validated_url_metric, $breakpoints, $sample_size ); $post_data['post_content'] = wp_json_encode( $url_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index f12f3c36c..7ce1b06c9 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -105,12 +105,12 @@ function ilo_register_endpoint() { 'required' => true, 'properties' => array( 'width' => array( - 'type' => 'int', + 'type' => 'integer', 'required' => true, 'minimum' => 0, ), 'height' => array( - 'type' => 'int', + 'type' => 'integer', 'required' => true, 'minimum' => 0, ), @@ -119,16 +119,17 @@ function ilo_register_endpoint() { 'elements' => array( 'description' => __( 'Element metrics', 'performance-lab' ), 'type' => 'array', + 'required' => true, 'items' => array( // See the ElementMetrics in detect.js. 'type' => 'object', 'properties' => array( 'isLCP' => array( - 'type' => 'bool', + 'type' => 'boolean', 'required' => true, ), 'isLCPCandidate' => array( - 'type' => 'bool', + 'type' => 'boolean', ), 'xpath' => array( 'type' => 'string', @@ -171,16 +172,28 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_get_url_metrics_breakpoint_sample_size(), ilo_get_url_metric_freshness_ttl() ); - if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { + + // Block the request if URL metrics aren't needed for the provided viewport width. + // This logic is the same as the isViewportNeeded() function in detect.js. + $viewport_width = $request->get_param( 'viewport' )['width']; + $last_was_needed = false; + foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { + if ( $viewport_width >= $minimum_viewport_width ) { + $last_was_needed = $is_needed; + } else { + break; + } + } + if ( ! $last_was_needed ) { return new WP_Error( 'no_url_metric_needed', - __( 'No URL metric needed for any of the breakpoints.', 'performance-lab' ), + __( 'No URL metric needed for the provided viewport width.', 'performance-lab' ), array( 'status' => 403 ) ); } ilo_set_url_metric_storage_lock(); - $new_url_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); + $new_url_metric = wp_array_slice_assoc( $request->get_params(), array( 'viewport', 'elements' ) ); $result = ilo_store_url_metric( $request->get_param( 'url' ), @@ -195,8 +208,6 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { return new WP_REST_Response( array( 'success' => true, - 'post_id' => $result, - 'data' => ilo_parse_stored_url_metrics( ilo_get_url_metrics_post( $request->get_param( 'slug' ) ) ), // TODO: Remove this debug data. ) ); } diff --git a/tests/admin/load-tests.php b/tests/admin/load-tests.php index c7746f9bf..83e4555d8 100644 --- a/tests/admin/load-tests.php +++ b/tests/admin/load-tests.php @@ -91,7 +91,7 @@ public function test_perflab_add_modules_page() { remove_all_filters( 'plugin_action_links_' . plugin_basename( PERFLAB_MAIN_FILE ) ); // Does not register the page if the perflab_active_modules filter is used. - add_filter( 'perflab_active_modules', '__return_null' ); + add_filter( 'perflab_active_modules', '__return_array' ); $hook_suffix = perflab_add_modules_page(); $this->assertFalse( $hook_suffix ); $this->assertFalse( isset( $_wp_submenu_nopriv['options-general.php'][ PERFLAB_MODULES_SCREEN ] ) ); diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php new file mode 100644 index 000000000..6cd0c7bbb --- /dev/null +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -0,0 +1,333 @@ + array( + 'document' => ' + + + + + Foo + + +

+ Foo! +
+ Foo +

+
The end!
+ + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'BODY', 'P', 'BR', 'IMG', 'FOOTER' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[0][self::META]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[1][self::TITLE]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[0][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[1][self::IMG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::FOOTER]', + ), + ), + 'foreign-elements' => array( + 'document' => ' + + + + + + + + + + + + + 1 + + 2 + + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[0][self::PATH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[1][self::CIRCLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[2][self::G]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[3][self::RECT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[0][self::MN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[1][self::MSPACE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[2][self::MN]', + ), + ), + 'closing-void-tag' => array( + 'document' => ' + + + + 1 +

+ 2 + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'BR', 'SPAN' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SPAN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::SPAN]', + ), + ), + 'void-tags' => array( + 'document' => ' + + + + + + + +
+ + + +
+ + + + + + + + + + + +
+ + + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::AREA]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::BASE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::BASEFONT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[3][self::BGSOUND]', + '/*[0][self::HTML]/*[1][self::BODY]/*[4][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[5][self::COL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[6][self::EMBED]', + '/*[0][self::HTML]/*[1][self::BODY]/*[7][self::FRAME]', + '/*[0][self::HTML]/*[1][self::BODY]/*[8][self::HR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[9][self::IMG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[10][self::INPUT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[11][self::KEYGEN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[12][self::LINK]', + '/*[0][self::HTML]/*[1][self::BODY]/*[13][self::META]', + '/*[0][self::HTML]/*[1][self::BODY]/*[14][self::PARAM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[15][self::SOURCE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[16][self::TRACK]', + '/*[0][self::HTML]/*[1][self::BODY]/*[17][self::WBR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]/*[0][self::SPAN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]/*[0][self::SPAN]/*[0][self::EM]', + ), + ), + 'optional-closing-p' => array( + 'document' => ' + + + + +

First +

Second +

Third + + +

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+


+

+

+

+

    +

    
    +							

    +

    +

    +

      + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[0][self::EM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ADDRESS]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ARTICLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ASIDE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::BLOCKQUOTE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DETAILS]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIELDSET]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIGCAPTION]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIGURE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FOOTER]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FORM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H1]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H2]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H3]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H4]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H5]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H6]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HEADER]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HGROUP]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::MAIN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::MENU]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::NAV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::OL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::PRE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SEARCH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SECTION]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::TABLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::UL]', + ), + ), + ); + } + + /** + * Test open_tags() and get_xpath(). + * + * @covers ::open_tags + * @covers ::get_xpath + * + * @dataProvider data_provider_sample_documents + */ + public function test_open_tags_and_get_xpath( string $document, array $open_tags, array $xpaths ) { + $p = new ILO_HTML_Tag_Processor( $document ); + $this->assertSame( '', $p->get_xpath(), 'Expected empty XPath since iteration has not started.' ); + $actual_open_tags = array(); + $actual_xpaths = array(); + foreach ( $p->open_tags() as $open_tag ) { + $actual_open_tags[] = $open_tag; + $actual_xpaths[] = $p->get_xpath(); + } + + $this->assertSame( $actual_open_tags, $open_tags, "Expected list of open tags to match.\nSnapshot: " . $this->export_array_snapshot( $actual_open_tags, true ) ); + $this->assertSame( $actual_xpaths, $xpaths, "Expected list of XPaths to match.\nSnapshot:" . $this->export_array_snapshot( $actual_xpaths ) ); + } + + /** + * Test get_attribute(), set_attribute(), remove_attribute(), and get_updated_html(). + * + * @covers ::get_attribute + * @covers ::set_attribute + * @covers ::remove_attribute + * @covers ::get_updated_html + */ + public function test_html_tag_processor_wrapper_methods() { + $processor = new ILO_HTML_Tag_Processor( '' ); + foreach ( $processor->open_tags() as $open_tag ) { + if ( 'HTML' === $open_tag ) { + $this->assertSame( 'en', $processor->get_attribute( 'lang' ) ); + $processor->set_attribute( 'lang', 'es' ); + $processor->remove_attribute( 'xml:lang' ); + } + } + $this->assertSame( '', $processor->get_updated_html() ); + } + + /** + * Export an array as a PHP literal to use as a snapshot. + */ + private function export_array_snapshot( array $data, bool $one_line = false ): string { + $php = preg_replace( '/^\s*\d+\s*=>\s*/m', '', var_export( $data, true ) ); + if ( $one_line ) { + $php = str_replace( "\n", ' ', $php ); + } + return $php; + } +} diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php new file mode 100644 index 000000000..712f31aec --- /dev/null +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -0,0 +1,74 @@ +}> + */ + public function data_provider_ilo_get_detection_script(): array { + return array( + 'unfiltered' => array( + 'set_up' => static function () {}, + 'expected_exports' => array( + 'detectionTimeWindow' => 5000, + 'storageLockTTL' => MINUTE_IN_SECONDS, + ), + ), + 'filtered' => array( + 'set_up' => static function () { + add_filter( + 'ilo_detection_time_window', + static function (): int { + return 2500; + } + ); + add_filter( + 'ilo_url_metric_storage_lock_ttl', + static function (): int { + return HOUR_IN_SECONDS; + } + ); + }, + 'expected_exports' => array( + 'detectionTimeWindow' => 2500, + 'storageLockTTL' => HOUR_IN_SECONDS, + ), + ), + ); + } + + /** + * Make sure the expected script is printed. + * + * @covers ::ilo_get_detection_script + * + * @dataProvider data_provider_ilo_get_detection_script + * + * @param Closure $set_up Set up callback. + * @param array}> $expected_exports Expected exports. + */ + public function test_ilo_get_detection_script_returns_script( Closure $set_up, array $expected_exports ) { + $set_up(); + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + $needed_minimum_viewport_widths = array( + array( 480, false ), + array( 600, false ), + array( 782, true ), + ); + $script = ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + + $this->assertStringContainsString( ' + + + Foo + + + ', + ), + + 'common-lcp-image-with-fully-populated-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'isLCP' => true, + ), + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + Foo + Bar + + + ', + 'expected' => ' + + + + ... + + + + Foo + Bar + + + ', + ), + + 'fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + Foo + + + ', + ), + + 'url-metric-only-captured-for-one-breakpoint' => array( + 'set_up' => function () { + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 400, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + ', + ), + + 'different-lcp-elements-for-different-breakpoints' => array( + 'set_up' => function () { + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 400, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 800, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo + Desktop Logo + + + ', + 'expected' => ' + + + + ... + + + + + + Mobile Logo + Desktop Logo + + + ', + ), + + ); + } + + /** + * Test ilo_optimize_template_output_buffer(). + * + * @covers ::ilo_optimize_template_output_buffer + * + * @dataProvider data_provider_test_ilo_optimize_template_output_buffer + */ + public function test_ilo_optimize_template_output_buffer( Closure $set_up, string $buffer, string $expected ) { + $set_up(); + $this->assertEquals( + $this->parse_html_document( $expected ), + $this->parse_html_document( ilo_optimize_template_output_buffer( $buffer ) ) + ); + } + + /** + * Normalizes whitespace. + * + * @param string $str String to normalize. + * @return string Normalized string. + */ + private function normalize_whitespace( string $str ): string { + return preg_replace( '/\s+/', ' ', trim( $str ) ); + } + + /** + * Gets a validated URL metric. + * + * @param int $viewport_width Viewport width for the URL metric. + * @return array URL metric. + */ + private function get_validated_url_metric( int $viewport_width, array $elements = array() ): array { + return array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => 800, + ), + 'elements' => array_map( + static function ( array $element ): array { + return array_merge( + array( + 'isLCPCandidate' => true, + 'intersectionRatio' => 1, + ), + $element + ); + }, + $elements + ), + ); + } + + /** + * Parse an HTML markup fragment and normalize for comparison. + * + * @param string $markup Markup. + * @return DOMDocument Document containing the normalized markup fragment. + */ + protected function parse_html_document( string $markup ): DOMDocument { + $dom = new DOMDocument(); + $dom->loadHTML( trim( $markup ) ); + + // Remove all whitespace nodes. + $xpath = new DOMXPath( $dom ); + foreach ( $xpath->query( '//text()' ) as $node ) { + /** @var DOMText $node */ + if ( preg_match( '/^\s+$/', $node->nodeValue ) ) { + $node->nodeValue = ''; + } + } + + // Insert a newline before each node to make the diff easier to read. + foreach ( $xpath->query( '/html//*' ) as $node ) { + /** @var DOMElement $node */ + $node->parentNode->insertBefore( $dom->createTextNode( "\n" ), $node ); + } + + // Normalize contents of module script output by ilo_get_detection_script(). + foreach ( $xpath->query( '//script[ contains( text(), "import detect" ) ]' ) as $script ) { + $script->textContent = '/* import detect ... */'; + } + + return $dom; + } +} diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php new file mode 100644 index 000000000..d14d47740 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -0,0 +1,563 @@ +assertSame( DAY_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + + add_filter( + 'ilo_url_metric_freshness_ttl', + static function (): int { + return HOUR_IN_SECONDS; + } + ); + + $this->assertSame( HOUR_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_ilo_get_normalized_query_vars(): array { + return array( + 'homepage' => array( + 'set_up' => function (): array { + $this->go_to( home_url( '/' ) ); + return array(); + }, + ), + 'post' => array( + 'set_up' => function (): array { + $post_id = self::factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + return array( 'p' => (string) $post_id ); + }, + ), + 'date-archive' => array( + 'set_up' => function (): array { + $post_id = self::factory()->post->create(); + $date = get_post_datetime( $post_id ); + + $this->go_to( + add_query_arg( + array( + 'day' => $date->format( 'j' ), + 'year' => $date->format( 'Y' ), + 'monthnum' => $date->format( 'm' ), + 'bogus' => 'ignore me', + ), + home_url() + ) + ); + return array( + 'year' => $date->format( 'Y' ), + 'monthnum' => $date->format( 'm' ), + 'day' => $date->format( 'j' ), + ); + }, + ), + '404' => array( + 'set_up' => function () { + $this->go_to( home_url( '/?p=1000000' ) ); + return array( 'error' => 404 ); + }, + ), + ); + } + + /** + * Test ilo_get_normalized_query_vars(). + * + * @covers ::ilo_get_normalized_query_vars + * + * @dataProvider data_provider_test_ilo_get_normalized_query_vars + */ + public function test_ilo_get_normalized_query_vars( Closure $set_up ) { + $expected = $set_up(); + $this->assertSame( $expected, ilo_get_normalized_query_vars() ); + } + + /** + * Test ilo_get_url_metrics_slug(). + * + * @covers ::ilo_get_url_metrics_slug + */ + public function test_ilo_get_url_metrics_slug() { + $first = ilo_get_url_metrics_slug( array() ); + $second = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); + $this->assertNotEquals( $second, $first ); + foreach ( array( $first, $second ) as $slug ) { + $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $slug ); + } + } + + /** + * Test ilo_get_url_metrics_storage_nonce(). + * + * @covers ::ilo_get_url_metrics_storage_nonce + * @covers ::ilo_verify_url_metrics_storage_nonce + */ + public function test_ilo_get_url_metrics_storage_nonce_and_ilo_verify_url_metrics_storage_nonce() { + $user_id = self::factory()->user->create(); + + $nonce_life_actions = array(); + add_filter( + 'nonce_life', + static function ( int $life, string $action ) use ( &$nonce_life_actions ): int { + $nonce_life_actions[] = $action; + return $life; + }, + 10, + 2 + ); + + // Create first nonce for unauthenticated user. + $slug = ilo_get_url_metrics_slug( array() ); + $nonce1 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); + $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertCount( 2, $nonce_life_actions ); + + // Create second nonce for unauthenticated user. + $nonce2 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertSame( $nonce1, $nonce2 ); + $this->assertCount( 3, $nonce_life_actions ); + + // Create third nonce, this time for authenticated user. + wp_set_current_user( $user_id ); + $nonce3 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertNotEquals( $nonce3, $nonce2 ); + $this->assertFalse( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); + $this->assertCount( 6, $nonce_life_actions ); + + foreach ( $nonce_life_actions as $nonce_life_action ) { + $this->assertSame( "store_url_metrics:{$slug}", $nonce_life_action ); + } + } + + public function data_provider_sample_size_and_breakpoints(): array { + return array( + '3 sample size and 2 breakpoints' => array( + 'sample_size' => 3, + 'breakpoints' => array( 480, 782 ), + 'viewport_widths' => array( 400, 600, 800 ), + ), + '1 sample size and 1 breakpoint' => array( + 'sample_size' => 1, + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 800 ), + ), + ); + } + + /** + * Test ilo_unshift_url_metrics(). + * + * @covers ::ilo_unshift_url_metrics + * + * @dataProvider data_provider_sample_size_and_breakpoints + */ + public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { + $old_timestamp = 1701978742; + + // Fully populate the sample size for the breakpoints. + $all_url_metrics = array(); + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $all_url_metrics = ilo_unshift_url_metrics( + $all_url_metrics, + $this->get_validated_url_metric( $viewport_width ), + $breakpoints, + $sample_size + ); + } + } + $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); + $this->assertCount( + $max_possible_url_metrics_count, + $all_url_metrics, + sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) + ); + + // Make sure that ilo_unshift_url_metrics() added a timestamp and then force them to all be old. + $all_url_metrics = array_map( + function ( $url_metric ) use ( $old_timestamp ) { + $this->assertArrayHasKey( 'timestamp', $url_metric, 'Expected a timestamp to have been added to a URL metric.' ); + $url_metric['timestamp'] = $old_timestamp; + return $url_metric; + }, + $all_url_metrics + ); + + // Try adding one URL metric for each breakpoint group. + foreach ( $viewport_widths as $viewport_width ) { + $all_url_metrics = ilo_unshift_url_metrics( + $all_url_metrics, + $this->get_validated_url_metric( $viewport_width ), + $breakpoints, + $sample_size + ); + } + $this->assertCount( + $max_possible_url_metrics_count, + $all_url_metrics, + 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' + ); + $new_count = 0; + foreach ( $all_url_metrics as $url_metric ) { + if ( $url_metric['timestamp'] > $old_timestamp ) { + ++$new_count; + } + } + $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL metric.' ); + $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL metrics to all have been added.' ); + } + + /** + * Test ilo_get_breakpoint_max_widths(). + * + * @covers ::ilo_get_breakpoint_max_widths + */ + public function test_ilo_get_breakpoint_max_widths() { + $this->assertSame( + array( 480, 600, 782 ), + ilo_get_breakpoint_max_widths() + ); + + $filtered_breakpoints = array( 2000, 500, '1000', 3000 ); + + add_filter( + 'ilo_breakpoint_max_widths', + static function () use ( $filtered_breakpoints ) { + return $filtered_breakpoints; + } + ); + + $filtered_breakpoints = array_map( 'intval', $filtered_breakpoints ); + sort( $filtered_breakpoints ); + $this->assertSame( $filtered_breakpoints, ilo_get_breakpoint_max_widths() ); + } + + /** + * Test ilo_get_url_metrics_breakpoint_sample_size(). + * + * @covers ::ilo_get_url_metrics_breakpoint_sample_size + */ + public function test_ilo_get_url_metrics_breakpoint_sample_size() { + $this->assertSame( 3, ilo_get_url_metrics_breakpoint_sample_size() ); + + add_filter( + 'ilo_url_metrics_breakpoint_sample_size', + static function () { + return '1'; + } + ); + + $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); + } + + public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array { + return array( + '2-breakpoints-and-3-viewport-widths' => array( + 'breakpoints' => array( 480, 640 ), + 'viewport_widths' => array( 400, 480, 800 ), + ), + '1-breakpoint-and-4-viewport-widths' => array( + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 600, 800, 1000 ), + ), + ); + } + + /** + * Test ilo_group_url_metrics_by_breakpoint(). + * + * @covers ::ilo_group_url_metrics_by_breakpoint + * + * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint + */ + public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, array $viewport_widths ) { + $url_metrics = array_map( + function ( $viewport_width ) { + return $this->get_validated_url_metric( $viewport_width ); + }, + $viewport_widths + ); + + $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); + $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics, 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); + $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + $this->assertSame( 0, array_shift( $minimum_viewport_widths ), 'Expected the first minimum viewport width to always be zero.' ); + foreach ( $breakpoints as $breakpoint ) { + $this->assertSame( $breakpoint + 1, array_shift( $minimum_viewport_widths ) ); + } + + $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { + $minimum_viewport_width = $minimum_viewport_widths[ $i ]; + $maximum_viewport_width = $minimum_viewport_widths[ $i + 1 ] ?? null; + if ( 0 === $i ) { + $this->assertSame( 0, $minimum_viewport_width ); + } else { + $this->assertGreaterThan( 0, $minimum_viewport_width ); + } + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); + } + + $this->assertIsArray( $grouped_url_metrics[ $minimum_viewport_width ] ); + foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { + $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric['viewport']['width'] ); + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric['viewport']['width'] ); + } + } + } + } + + public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths(): array { + return array( + 'common_lcp_element_across_breakpoints' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 600 => array( + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 800 => array( + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + ), + ), + 'different_lcp_elements_across_breakpoint' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 600 => array( + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'same_lcp_element_across_non_consecutive_breakpoints' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 400 => array( + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), + ), + 600 => array( + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 400 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. + 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'no_lcp_image_elements' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + 600 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => false, + ), + ), + ); + } + + /** + * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). + * + * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths + * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths + */ + public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics, array $expected_lcp_element_xpaths ) { + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); + + $lcp_element_xpaths_by_minimum_viewport_widths = array(); + foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { + $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); + if ( is_array( $lcp_element ) ) { + $this->assertTrue( $lcp_element['isLCP'] ); + $this->assertTrue( $lcp_element['isLCPCandidate'] ); + $this->assertIsString( $lcp_element['xpath'] ); + $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; + } else { + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; + } + } + + $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): array { + $current_time = microtime( true ); + + $none_needed_data = array( + 'url_metrics' => ( function () use ( $current_time ): array { + return array_merge( + array_fill( + 0, + 3, + array_merge( $this->get_validated_url_metric( 400 ), array( 'timestamp' => $current_time ) ) + ), + array_fill( + 0, + 3, + array_merge( $this->get_validated_url_metric( 600 ), array( 'timestamp' => $current_time ) ) + ) + ); + } )(), + 'current_time' => $current_time, + 'breakpoint_max_widths' => array( 480 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, + ); + + return array( + 'none-needed' => array_merge( + $none_needed_data, + array( + 'expected' => array( + array( 0, false ), + array( 481, false ), + ), + ) + ), + + 'not-enough-url-metrics' => array_merge( + $none_needed_data, + array( + 'sample_size' => $none_needed_data['sample_size'] + 1, + ), + array( + 'expected' => array( + array( 0, true ), + array( 481, true ), + ), + ) + ), + + 'url-metric-too-old' => array_merge( + ( static function ( $data ): array { + $data['url_metrics'][0]['timestamp'] -= $data['freshness_ttl'] + 1; + return $data; + } )( $none_needed_data ), + array( + 'expected' => array( + array( 0, true ), + array( 481, false ), + ), + ) + ), + ); + } + + /** + * Test ilo_get_needed_minimum_viewport_widths(). + * + * @covers ::ilo_get_needed_minimum_viewport_widths + * + * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths + */ + public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl, array $expected ) { + $this->assertSame( + $expected, + ilo_get_needed_minimum_viewport_widths( $url_metrics, $current_time, $breakpoint_max_widths, $sample_size, $freshness_ttl ) + ); + } + + /** + * Gets a validated URL metric for testing. + * + * @param int $viewport_width Viewport width. + * @param string[] $breadcrumbs Breadcrumb tags. + * @param bool $is_lcp Whether LCP. + * @return array Validated URL metric. + */ + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): array { + return array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => 640, + ), + 'elements' => array( + array( + 'isLCP' => $is_lcp, + 'isLCPCandidate' => $is_lcp, + 'xpath' => $this->get_xpath( ...$breadcrumbs ), + 'intersectionRatio' => 1, + ), + ), + ); + } + + /** + * Gets sample XPath. + * + * @param string ...$breadcrumbs List of tags. + * @return string XPath. + */ + private function get_xpath( ...$breadcrumbs ): string { + return implode( + '', + array_map( + static function ( $tag ) { + return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); + }, + $breadcrumbs + ) + ); + } +} diff --git a/tests/modules/images/image-loading-optimization/storage/lock-tests.php b/tests/modules/images/image-loading-optimization/storage/lock-tests.php new file mode 100644 index 000000000..dfedb566d --- /dev/null +++ b/tests/modules/images/image-loading-optimization/storage/lock-tests.php @@ -0,0 +1,129 @@ + + */ + public function data_provider_ilo_get_url_metric_storage_lock_ttl(): array { + return array( + 'unfiltered' => array( + 'set_up' => static function () {}, + 'expected' => MINUTE_IN_SECONDS, + ), + 'filtered_hour' => array( + 'set_up' => static function () { + add_filter( + 'ilo_url_metric_storage_lock_ttl', + static function (): int { + return HOUR_IN_SECONDS; + } + ); + }, + 'expected' => HOUR_IN_SECONDS, + ), + 'filtered_negative' => array( + 'set_up' => static function () { + add_filter( + 'ilo_url_metric_storage_lock_ttl', + static function (): int { + return -100; + } + ); + }, + 'expected' => 0, + ), + ); + } + + /** + * Test ilo_get_url_metric_storage_lock_ttl(). + * + * @covers ::ilo_get_url_metric_storage_lock_ttl + * + * @dataProvider data_provider_ilo_get_url_metric_storage_lock_ttl + * + * @param Closure $set_up Set up. + * @param int $expected Expected value. + */ + public function test_ilo_get_url_metric_storage_lock_ttl( Closure $set_up, int $expected ) { + $set_up(); + $this->assertSame( $expected, ilo_get_url_metric_storage_lock_ttl() ); + } + + /** + * Test ilo_get_url_metric_storage_lock_transient_key(). + * + * @covers ::ilo_get_url_metric_storage_lock_transient_key + */ + public function test_ilo_get_url_metric_storage_lock_transient_key() { + unset( $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_X_FORWARDED_FOR'] ); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $first_key = ilo_get_url_metric_storage_lock_transient_key(); + $this->assertStringStartsWith( 'url_metrics_storage_lock_', $first_key ); + + $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.2'; + $second_key = ilo_get_url_metric_storage_lock_transient_key(); + $this->assertStringStartsWith( 'url_metrics_storage_lock_', $second_key ); + + $this->assertNotEquals( $second_key, $first_key, 'Expected setting HTTP_X_FORWARDED_FOR header to take precedence over REMOTE_ADDR.' ); + } + + /** + * Test ilo_set_url_metric_storage_lock() and ilo_is_url_metric_storage_locked(). + * + * @covers ::ilo_set_url_metric_storage_lock + * @covers ::ilo_is_url_metric_storage_locked + */ + public function test_ilo_set_url_metric_storage_lock_and_ilo_is_url_metric_storage_locked() { + $key = ilo_get_url_metric_storage_lock_transient_key(); + $ttl = ilo_get_url_metric_storage_lock_ttl(); + + $transient_value = null; + $transient_expiration = null; + add_action( + "set_transient_{$key}", + static function ( $filtered_value, $filtered_expiration ) use ( &$transient_value, &$transient_expiration ) { + $transient_value = $filtered_value; + $transient_expiration = $filtered_expiration; + return $filtered_value; + }, + 10, + 2 + ); + + // Set the lock. + ilo_set_url_metric_storage_lock(); + $this->assertSame( $ttl, $transient_expiration ); + $this->assertLessThanOrEqual( microtime( true ), $transient_value ); + $this->assertEquals( $transient_value, get_transient( $key ) ); + $this->assertTrue( ilo_is_url_metric_storage_locked() ); + + // Simulate expired lock. + set_transient( $key, microtime( true ) - HOUR_IN_SECONDS ); + $this->assertFalse( ilo_is_url_metric_storage_locked() ); + + // Clear the lock. + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + ilo_set_url_metric_storage_lock(); + $this->assertFalse( get_transient( $key ) ); + $this->assertFalse( ilo_is_url_metric_storage_locked() ); + } +} diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php new file mode 100644 index 000000000..15b298016 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -0,0 +1,159 @@ +assertSame( 10, has_action( 'init', 'ilo_register_url_metrics_post_type' ) ); + $post_type_object = get_post_type_object( ILO_URL_METRICS_POST_TYPE ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); + $this->assertFalse( $post_type_object->public ); + } + + /** + * Test ilo_get_url_metrics_post() when there is no post. + * + * @covers ::ilo_get_url_metrics_post + */ + public function test_ilo_get_url_metrics_post_when_absent() { + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + $this->assertNull( ilo_get_url_metrics_post( $slug ) ); + } + + /** + * Test ilo_get_url_metrics_post() when there is a post. + * + * @covers ::ilo_get_url_metrics_post + */ + public function test_ilo_get_url_metrics_post_when_present() { + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => ILO_URL_METRICS_POST_TYPE, + 'post_name' => $slug, + ) + ); + + $post = ilo_get_url_metrics_post( $slug ); + $this->assertInstanceOf( WP_Post::class, $post ); + $this->assertSame( $post_id, $post->ID ); + } + + /** + * Data provider for test_ilo_parse_stored_url_metrics. + * + * @return array + */ + public function data_provider_test_ilo_parse_stored_url_metrics(): array { + $valid_content = array( + array( + 'viewport' => array( + 'width' => 640, + 'height' => 480, + ), + 'elements' => array(), + ), + ); + + return array( + 'malformed_json' => array( + 'post_content' => '{"bad":', + 'expected_value' => array(), + ), + 'not_array_json' => array( + 'post_content' => '{"cool":"beans"}', + 'expected_value' => array(), + ), + 'missing_keys' => array( + 'post_content' => '[{},{},{}]', + 'expected_value' => array(), + ), + 'valid' => array( + 'post_content' => wp_json_encode( $valid_content ), + 'expected_value' => $valid_content, + ), + ); + } + + /** + * Test ilo_parse_stored_url_metrics(). + * + * @covers ::ilo_parse_stored_url_metrics + * + * @dataProvider data_provider_test_ilo_parse_stored_url_metrics + */ + public function test_ilo_parse_stored_url_metrics( string $post_content, array $expected_value ) { + $post = self::factory()->post->create_and_get( + array( + 'post_type' => ILO_URL_METRICS_POST_TYPE, + 'post_content' => $post_content, + ) + ); + + $url_metrics = ilo_parse_stored_url_metrics( $post ); + $this->assertSame( $expected_value, $url_metrics ); + } + + /** + * Test ilo_store_url_metric(). + * + * @covers ::ilo_store_url_metric + */ + public function test_ilo_store_url_metric() { + $url = home_url( '/' ); + $slug = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); + + $validated_url_metric = array( + 'viewport' => array( + 'width' => 480, + 'height' => 640, + ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ); + + $post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); + $this->assertIsInt( $post_id ); + + $post = ilo_get_url_metrics_post( $slug ); + $this->assertInstanceOf( WP_Post::class, $post ); + $this->assertSame( $post_id, $post->ID ); + + $url_metrics = ilo_parse_stored_url_metrics( $post ); + $this->assertCount( 1, $url_metrics ); + $this->assertArrayHasKey( 'timestamp', $url_metrics[0] ); + $this->assertIsFloat( $url_metrics[0]['timestamp'] ); + $this->assertLessThanOrEqual( microtime( true ), $url_metrics[0]['timestamp'] ); + + $again_post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); + $post = get_post( $again_post_id ); + $this->assertSame( $post_id, $again_post_id ); + $url_metrics = ilo_parse_stored_url_metrics( $post ); + $this->assertCount( 2, $url_metrics ); + + foreach ( $url_metrics as $url_metric ) { + $this->assertArrayHasKey( 'timestamp', $url_metric ); + $this->assertIsFloat( $url_metric['timestamp'] ); + $this->assertLessThanOrEqual( microtime( true ), $url_metric['timestamp'] ); + unset( $url_metric['timestamp'] ); + $this->assertSame( $validated_url_metric, $url_metric ); + } + } +} diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php new file mode 100644 index 000000000..0594a5f2d --- /dev/null +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -0,0 +1,258 @@ +assertSame( 10, has_action( 'rest_api_init', 'ilo_register_endpoint' ) ); + } + + /** + * Test good params. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_good_params() { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $valid_params = $this->get_valid_params(); + $this->assertCount( 0, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); + $request->set_body_params( $valid_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + + $this->assertCount( 1, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); + $post = ilo_get_url_metrics_post( $valid_params['slug'] ); + $this->assertInstanceOf( WP_Post::class, $post ); + + $url_metrics = ilo_parse_stored_url_metrics( $post ); + $this->assertCount( 1, $url_metrics ); + foreach ( array( 'viewport', 'elements' ) as $key ) { + $this->assertSame( $valid_params[ $key ], $url_metrics[0][ $key ] ); + } + $this->assertArrayHasKey( 'timestamp', $url_metrics[0] ); + } + + /** + * Data provider for test_rest_request_bad_params. + * + * @return array + */ + public function data_provider_invalid_params(): array { + $valid_element = $this->get_valid_params()['elements'][0]; + + return array_map( + function ( $params ) { + return array( + 'params' => array_merge( $this->get_valid_params(), $params ), + ); + }, + array( + 'bad_url' => array( + 'url' => 'bad://url', + ), + 'other_origin_url' => array( + 'url' => 'https://bogus.example.com/', + ), + 'bad_slug' => array( + 'slug' => '', + ), + 'bad_nonce' => array( + 'nonce' => 'not even a hash', + ), + 'invalid_nonce' => array( + 'nonce' => ilo_get_url_metrics_storage_nonce( ilo_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ), + ), + 'invalid_viewport_type' => array( + 'viewport' => '640x480', + ), + 'invalid_viewport_values' => array( + 'viewport' => array( + 'breadth' => 100, + 'depth' => 200, + ), + ), + 'invalid_elements_type' => array( + 'elements' => 'bad', + ), + 'invalid_elements_prop_is_lcp' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'isLCP' => 'totally!', + ) + ), + ), + ), + 'invalid_elements_prop_xpath' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => 'html > body img', + ) + ), + ), + ), + 'invalid_elements_prop_intersection_ratio' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'intersectionRatio' => - 1, + ) + ), + ), + ), + ) + ); + } + + /** + * Test bad params. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + * + * @dataProvider data_provider_invalid_params + */ + public function test_rest_request_bad_params( array $params ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + } + + /** + * Test REST API request when metric storage is locked. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_locked() { + ilo_set_url_metric_storage_lock(); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'url_metric_storage_locked', $response->get_data()['code'] ); + } + + /** + * Test sending viewport data that isn't needed for a specific breakpoint. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First fully populate the sample for all breakpoints. + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $valid_params = $this->get_valid_params(); + $valid_params['viewport']['width'] = $viewport_width; + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $valid_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + } + + // The next request with the same sample size will be rejected. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Test sending viewport data that isn't needed for any breakpoint. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First fully populate the sample for a given breakpoint. + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + for ( $i = 0; $i < $sample_size; $i++ ) { + $valid_params = $this->get_valid_params(); + $valid_params['viewport']['width'] = 480; + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $valid_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + + // The next request with the same sample size will be rejected. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Gets valid params. + * + * @return array + */ + private function get_valid_params(): array { + $slug = ilo_get_url_metrics_slug( array() ); + return array_merge( + array( + 'url' => home_url( '/' ), + 'slug' => $slug, + 'nonce' => ilo_get_url_metrics_storage_nonce( $slug ), + ), + $this->get_sample_validated_url_metric() + ); + } + + /** + * Gets sample validated URL metric data. + * + * @return array + */ + private function get_sample_validated_url_metric(): array { + return array( + 'viewport' => array( + 'width' => 480, + 'height' => 640, + ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ); + } +} diff --git a/tests/modules/images/webp-uploads/helper-tests.php b/tests/modules/images/webp-uploads/helper-tests.php index 9fa6fbbd6..5ff31c75a 100644 --- a/tests/modules/images/webp-uploads/helper-tests.php +++ b/tests/modules/images/webp-uploads/helper-tests.php @@ -364,6 +364,7 @@ public function it_should_return_empty_array_when_filter_returns_empty_array() { * @test */ public function it_should_return_default_transforms_when_filter_returns_non_array_type() { + /** @phpstan-ignore-next-line */ add_filter( 'webp_uploads_upload_image_mime_transforms', '__return_null' ); $default_transforms = array(