From a1f8b1d34b9c11df5f386bd455cec2c5138bea71 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:15:25 +0100 Subject: [PATCH 01/47] Update packages Refresh the root and fixture Composer locks and the npm lock to the newest releases their constraints allow. The Composer locks move only roave/security-advisories; the npm lock takes patch releases of semver, enhanced-resolve and the browser data packages, and npm rehoists cosmiconfig and yaml without changing a version a package resolves to. The core, settings and woocommerce personas' locks recorded wordpress-configs' source over SSH, because Composer clones over SSH when the GitHub API refuses an unauthenticated request; they read HTTPS again, as the other fixture locks do. configs, WordPress's core pins and WooCommerce stay where they are: configs' trunk is still b107001, 7.1.4 and 6.3.14 are their branches' newest patches, and WooCommerce 11.3.0 is not yet released. Assisted-by: Claude Code:claude-opus-5-5 --- composer.lock | 13 +- package-lock.json | 572 ++++++++++-------- tests/Fixtures/consumer-a/composer.lock | 13 +- tests/Fixtures/consumer-b/composer.lock | 13 +- .../Fixtures/personas/bootstrap/composer.lock | 13 +- tests/Fixtures/personas/core/composer.lock | 19 +- .../Fixtures/personas/settings/composer.lock | 19 +- tests/Fixtures/personas/shared/composer.lock | 13 +- .../personas/woocommerce/composer.lock | 19 +- 9 files changed, 405 insertions(+), 289 deletions(-) diff --git a/composer.lock b/composer.lock index 8f22bc7..8e4cbc5 100644 --- a/composer.lock +++ b/composer.lock @@ -2566,12 +2566,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2872,7 +2872,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2950,6 +2950,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3733,8 +3734,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sebastian/cli-parser", diff --git a/package-lock.json b/package-lock.json index 220ebec..1ff8ad7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.12", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", @@ -142,6 +152,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.29.12", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.12.tgz", @@ -164,6 +184,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.29.10", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.10.tgz", @@ -182,6 +212,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", @@ -1426,6 +1466,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", @@ -1680,6 +1730,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -5992,6 +6052,63 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@svgr/core/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/core/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -6055,6 +6172,63 @@ "@svgr/core": "*" } }, + "node_modules/@svgr/plugin-svgo/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@svgr/plugin-svgo/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/plugin-svgo/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/webpack": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", @@ -6613,19 +6787,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.72.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", @@ -7492,33 +7653,6 @@ } } }, - "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@wordpress/eslint-plugin/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/@wordpress/hooks": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz", @@ -8848,6 +8982,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", @@ -8907,9 +9051,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", - "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9516,6 +9660,16 @@ "node": ">=8" } }, + "node_modules/check-node-version/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -10049,60 +10203,20 @@ } }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dev": true, "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=10" } }, "node_modules/crc-32": { @@ -10199,19 +10313,6 @@ } } }, - "node_modules/css-loader/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -10802,6 +10903,22 @@ "node": ">= 6.0.0" } }, + "node_modules/docker-compose/node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -10961,9 +11078,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.472", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", - "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", + "version": "1.5.473", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.473.tgz", + "integrity": "sha512-9njcRQK+6FOXNAWw5g+h2RPiyazqo5RFMVJ39ZMHjDHUNnnFlhnOGV7O0WmJetODDx19ES5p+FLi+KCVFQGugw==", "dev": true, "license": "ISC" }, @@ -11022,9 +11139,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", - "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "version": "5.26.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz", + "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==", "dev": true, "license": "MIT", "dependencies": { @@ -11628,6 +11745,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jest": { "version": "29.17.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz", @@ -11738,19 +11865,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -11980,6 +12094,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -13853,19 +13977,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14346,9 +14457,9 @@ } }, "node_modules/is-unsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", - "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz", + "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==", "dev": true, "funding": [ { @@ -14697,19 +14808,6 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -16469,9 +16567,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz", - "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.8.tgz", + "integrity": "sha512-dHbDC5V1g/EI8OLEtL4Iuwbu0fgzmyykxenn2KnlsX9XdhTXL3gWnfbbP+ab+oUcgV6CFpb8QGyQt/ZXA0UXig==", "dev": true, "license": "MIT", "bin": { @@ -16547,6 +16645,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-releases": { "version": "2.0.63", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", @@ -16573,19 +16681,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -16676,6 +16771,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/npm-package-json-lint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/npm-package-json-lint/node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -16686,6 +16788,33 @@ "node": ">=8" } }, + "node_modules/npm-package-json-lint/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/npm-package-json-lint/node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -16707,6 +16836,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-package-json-lint/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/npm-package-json-lint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -16714,19 +16866,6 @@ "dev": true, "license": "MIT" }, - "node_modules/npm-package-json-lint/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/npm-package-json-lint/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -17977,46 +18116,6 @@ "webpack": "^5.0.0" } }, - "node_modules/postcss-loader/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-loader/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-loader/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", @@ -19020,9 +19119,9 @@ } }, "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz", + "integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==", "dev": true, "license": "ISC", "bin": { @@ -19824,13 +19923,16 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -23352,19 +23454,13 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "node": ">= 6" } }, "node_modules/yargs": { diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock index 30f3b0b..e3c74ed 100644 --- a/tests/Fixtures/consumer-a/composer.lock +++ b/tests/Fixtures/consumer-a/composer.lock @@ -2169,12 +2169,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2475,7 +2475,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2553,6 +2553,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3336,8 +3337,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock index c6836c7..066b535 100644 --- a/tests/Fixtures/consumer-b/composer.lock +++ b/tests/Fixtures/consumer-b/composer.lock @@ -2083,12 +2083,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2389,7 +2389,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2467,6 +2467,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3250,8 +3251,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock index e082697..e7a0cc4 100644 --- a/tests/Fixtures/personas/bootstrap/composer.lock +++ b/tests/Fixtures/personas/bootstrap/composer.lock @@ -1682,12 +1682,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -1988,7 +1988,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2066,6 +2066,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -2849,8 +2850,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock index a7ac4ca..38b1e68 100644 --- a/tests/Fixtures/personas/core/composer.lock +++ b/tests/Fixtures/personas/core/composer.lock @@ -12,7 +12,7 @@ "version": "dev-trunk", "source": { "type": "git", - "url": "git@github.com:ahegyes/wordpress-configs.git", + "url": "https://github.com/ahegyes/wordpress-configs.git", "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { @@ -142,6 +142,10 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", + "support": { + "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", + "issues": "https://github.com/ahegyes/wordpress-configs/issues" + }, "time": "2026-11-09T15:51:41+00:00" }, { @@ -2044,12 +2048,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2350,7 +2354,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2428,6 +2432,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3211,8 +3216,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock index 835d7dc..1dc2a35 100644 --- a/tests/Fixtures/personas/settings/composer.lock +++ b/tests/Fixtures/personas/settings/composer.lock @@ -12,7 +12,7 @@ "version": "dev-trunk", "source": { "type": "git", - "url": "git@github.com:ahegyes/wordpress-configs.git", + "url": "https://github.com/ahegyes/wordpress-configs.git", "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { @@ -142,6 +142,10 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", + "support": { + "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", + "issues": "https://github.com/ahegyes/wordpress-configs/issues" + }, "time": "2026-11-09T15:51:41+00:00" }, { @@ -1887,12 +1891,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2193,7 +2197,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2271,6 +2275,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3054,8 +3059,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock index 61b049f..c4cf4c5 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -1686,12 +1686,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -1992,7 +1992,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2070,6 +2070,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -2853,8 +2854,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock index 1e76be5..357f4bf 100644 --- a/tests/Fixtures/personas/woocommerce/composer.lock +++ b/tests/Fixtures/personas/woocommerce/composer.lock @@ -12,7 +12,7 @@ "version": "dev-trunk", "source": { "type": "git", - "url": "git@github.com:ahegyes/wordpress-configs.git", + "url": "https://github.com/ahegyes/wordpress-configs.git", "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { @@ -142,6 +142,10 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", + "support": { + "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", + "issues": "https://github.com/ahegyes/wordpress-configs/issues" + }, "time": "2026-11-09T15:51:41+00:00" }, { @@ -1973,12 +1977,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2279,7 +2283,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2357,6 +2361,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3140,8 +3145,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", From e38ad3d1cf8c06667c655579cf043021a20800b6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:23:53 +0100 Subject: [PATCH 02/47] Add rows for spoken notices, entity ids, block help and nested groups The port audit found four places where form and notice markup gives assistive technology the wrong text or structure. A block-editor notice is read aloud with its tags as spaces and its entities spelled out. A field id holding an entity leaves its row label pointing at nothing on post meta boxes, profile and term forms and options pages. Help holding paragraphs or lists breaks out of the paragraph the control names. And a radio or checkboxes group on the product data panel prints a fieldset without a legend inside the row's own. These rows pin each fix and fail on the current code: the delivery carries a plain-text copy and the script depends on wp-a11y, which the end-to-end spec hears in the live regions; each host's label points at its control for an id holding &; help keeps its paragraphs and lists inside the described element; and a group without a title prints no fieldset of its own, so each choice links the help instead. Rows that render a titled group pass a title, and the product panel's group rows find the help by its class. Assisted-by: Claude Code:claude-opus-5-5 --- tests/EndToEnd/inline-scripts.spec.js | 28 +++++++++++- tests/Fixtures/end-to-end/acme-end-to-end.php | 2 +- .../AdminNoticesBlockEditorTest.php | 14 ++++-- .../Settings/AdminPage/OptionsPageTest.php | 8 ++++ .../EditScreen/Hosts/PostMetaBoxTest.php | 11 +++++ .../EditScreen/Hosts/UserProfileTest.php | 10 +++++ .../Settings/FormsRenderControlTest.php | 45 +++++++++++++++++-- .../ProductData/ProductDataTabTest.php | 41 ++++++++++++++++- 8 files changed, 150 insertions(+), 9 deletions(-) diff --git a/tests/EndToEnd/inline-scripts.spec.js b/tests/EndToEnd/inline-scripts.spec.js index 5659153..acfb368 100644 --- a/tests/EndToEnd/inline-scripts.spec.js +++ b/tests/EndToEnd/inline-scripts.spec.js @@ -47,7 +47,7 @@ test.describe( 'Inline scripts', () => { ).toBeVisible(); } ); - test( 'a post saved in the block editor keeps its object form and shows its delivered notice as markup', async ( { + test( 'a post saved in the block editor keeps its object form and shows its delivered notice as markup, read aloud as text', async ( { admin, editor, page, @@ -77,12 +77,38 @@ test.describe( 'Inline scripts', () => { } ); await expect( nonce ).not.toHaveValue( 'stale' ); + // The live regions keep only their latest message, so every message they receive is recorded. + await page.evaluate( () => { + window.acmeSpoken = []; + const observer = new window.MutationObserver( () => + document + .querySelectorAll( '.a11y-speak-region' ) + .forEach( ( region ) => { + if ( region.textContent ) { + window.acmeSpoken.push( region.textContent ); + } + } ) + ); + document + .querySelectorAll( '.a11y-speak-region' ) + .forEach( ( region ) => + observer.observe( region, { + childList: true, + characterData: true, + subtree: true, + } ) + ); + } ); + await editor.saveDraft(); await expect( page .locator( '.components-notice' ) .getByRole( 'link', { name: 'the guide' } ) ).toBeVisible(); + await expect + .poll( () => page.evaluate( () => window.acmeSpoken ) ) + .toContain( 'Saved & filed. Read the guide.' ); await page.reload(); await page diff --git a/tests/Fixtures/end-to-end/acme-end-to-end.php b/tests/Fixtures/end-to-end/acme-end-to-end.php index 367e3c2..ffab61b 100644 --- a/tests/Fixtures/end-to-end/acme-end-to-end.php +++ b/tests/Fixtures/end-to-end/acme-end-to-end.php @@ -65,7 +65,7 @@ public function get_object_form( string $form_id, int $object_id ): array { static function ( int $post_id, WP_Post $post ) use ( $acme_notices ): void { // Opening a new post saves an auto-draft, whose notice the editor would deliver as it loads. if ( 'auto-draft' !== $post->post_status ) { - $acme_notices->add( new Notice( 'saved', 'Saved. Read the guide.', NoticeType::Success ) ); + $acme_notices->add( new Notice( 'saved', 'Saved & filed. Read the guide.', NoticeType::Success ) ); } }, 10, diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index 778b666..63e21bf 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -86,6 +86,13 @@ public function test_a_delivered_message_is_filtered_and_keeps_only_post_markup( self::assertSame( wp_kses_post( 'Read the guide. ' ), $this->deliver()->get_data()[0]['content'] ); } + public function test_a_delivered_message_carries_its_plain_text_for_screen_readers(): void { + $this->notices()->add( $this->notice( 'saved', message: 'Saved & filed. Read the “guide”.' ), $this->admin ); + wp_set_current_user( $this->admin ); + + self::assertSame( 'Saved & filed. Read the “guide”.', $this->deliver()->get_data()[0]['spoken'] ); + } + public static function other_screens(): array { return array( 'site-editor' => array( 'site-editor', true ), @@ -111,7 +118,7 @@ public function test_the_script_loads_after_its_dependencies_with_the_delivery_p $settings = $this->load_editor( $this->admin ); self::assertTrue( wp_script_is( $this->script_handle() ) ); - self::assertSame( array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), wp_scripts()->registered[ $this->script_handle() ]->deps ); + self::assertSame( array( 'wp-a11y', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), wp_scripts()->registered[ $this->script_handle() ]->deps ); self::assertSame( array( 'prefix' => $this->prefix, @@ -157,7 +164,7 @@ protected function every_one_shot(): array { return array( $this->delivered( 'site', 'migrated', 'success', 'Notice migrated.' ), $this->delivered( 'site', 'news', 'error', 'Notice news.' ), - $this->delivered( 'user', 'saved', 'error', 'Saved it.' ), + $this->delivered( 'user', 'saved', 'error', 'Saved it.', spoken: 'Saved it.' ), $this->delivered( 'user', 'pinned', 'warning', 'Notice pinned.', false ), ); } @@ -170,11 +177,12 @@ protected function assert_only_the_others_wait(): void { self::assertSame( 'delivered', get_user_option( "{$this->prefix}_hidden_notices", $this->admin )['site:news']['state'] ?? null ); } - protected function delivered( string $scope, string $id, string $status, string $content, bool $dismissible = true ): array { + protected function delivered( string $scope, string $id, string $status, string $content, bool $dismissible = true, ?string $spoken = null ): array { return array( 'id' => "{$this->prefix}-$scope-notice-$id", 'status' => $status, 'content' => $content, + 'spoken' => $spoken ?? $content, 'isDismissible' => $dismissible, ); } diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 60fcd2a..d3b8d37 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -439,6 +439,14 @@ public function test_the_submenu_entry_and_the_page_exist_only_for_users_holding self::assertStringNotContainsString( 'Acme settings', $editor_page['body'] ); } + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $form = self::document( $this->open( $this->spec( array( self::field( 'text', 'r&d' ) ) ), $this->admin )['body'] )->querySelector( 'form[action="options.php"]' ); + + $id = (string) $form?->querySelector( 'td input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "{$this->option}.r&d", $id ); + self::assertSame( $id, $form?->querySelector( 'th label' )?->getAttribute( 'for' ) ); + } + public function test_a_form_posted_back_with_one_edit_changes_only_that_field_and_labels_point_at_single_controls(): void { $stored = array( 'name' => 'old', diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php index 535a3cc..1861e6b 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php @@ -160,6 +160,17 @@ public function test_a_single_control_is_labelled_by_its_row(): void { self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); } + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $post_id = $this->make_post(); + $spec = $this->spec( array( $this->field( 'text', 'r&d' ) ) ); + + $box = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) ); + + $id = (string) $box->querySelector( 'input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "{$this->id}._{$this->prefix}_r&d", $id ); + self::assertSame( $id, $box->querySelector( 'label.post-attributes-label' )?->getAttribute( 'for' ) ); + } + #[DataProvider( 'group_fields' )] public function test_a_group_is_one_fieldset_named_by_its_own_legend_under_its_rows_title( array $keys ): void { $post_id = $this->make_post(); diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php index 942b43e..6ffe6a4 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php @@ -86,6 +86,16 @@ public function test_the_form_shows_as_a_titled_section_of_labelled_rows( string self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) fieldset' ) ); } + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $spec = $this->spec( array( $this->field( 'text', 'r&d' ) ) ); + + $section = self::document( $this->open( $spec, 'own' )['body'] )->querySelector( "form#your-profile input[name=\"{$this->id}_nonce\"] ~ h2 + table.form-table" ); + + $id = (string) $section?->querySelector( 'td > input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "{$this->id}._{$this->prefix}_r&d", $id ); + self::assertSame( $id, $section?->querySelector( 'th > label' )?->getAttribute( 'for' ) ); + } + public function test_controls_carry_the_stored_value_or_else_the_default(): void { add_user_meta( $this->subscriber, "_{$this->prefix}_name", 'stored' ); add_user_meta( $this->subscriber, "_{$this->prefix}_empty", '' ); diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index ff33554..d6f3372 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -35,6 +35,7 @@ public function test_labels_and_help_point_at_ids_holding_entities(): void { $radio = self::render( 'radio', array( + 'title' => 'Mode', 'desc' => 'Help.', 'options' => array( 'a' => 'A' ), ), @@ -86,10 +87,19 @@ public function test_help_text_is_linked_from_the_control_by_aria_describedby(): $document = self::render( 'text', array( 'desc' => 'Shown below.' ) ); self::assertSame( 'acme.key.description', $document->getElementById( 'acme.key' )->getAttribute( 'aria-describedby' ) ); - self::assertTrue( $document->getElementById( 'acme.key.description' )->matches( 'p.description' ) ); + self::assertTrue( $document->getElementById( 'acme.key.description' )->matches( '.description' ) ); self::assertSame( 'Shown below.', self::text( $document->getElementById( 'acme.key.description' ) ) ); } + public function test_help_holding_paragraphs_and_lists_stays_inside_the_element_the_control_names(): void { + $document = self::render( 'text', array( 'desc' => '

First.

' ) ); + + $help = $document->getElementById( 'acme.key.description' ); + self::assertNotNull( $help?->querySelector( 'p' ) ); + self::assertNotNull( $help?->querySelector( 'ul > li' ) ); + self::assertSame( 'First.Second.', self::text( $help ) ); + } + public static function non_input_controls(): array { return array( 'textarea' => array( 'textarea', array(), 'textarea' ), @@ -129,7 +139,7 @@ public function test_a_desc_tip_of_true_is_ignored(): void { ); self::assertSame( array( 'Description.' ), self::descriptions( $document, $document->getElementById( 'acme.key' ) ) ); - self::assertCount( 1, $document->querySelectorAll( 'p.description' ) ); + self::assertCount( 1, $document->querySelectorAll( '.description' ) ); } public function test_a_control_without_help_has_no_aria_describedby(): void { @@ -302,6 +312,7 @@ public function test_a_group_links_its_help_from_its_fieldset(): void { $document = self::render( 'checkboxes', array( + 'title' => 'Picks', 'desc' => 'Pick any.', 'options' => array( 'a' => 'A' ), ) @@ -311,6 +322,34 @@ public function test_a_group_links_its_help_from_its_fieldset(): void { self::assertFalse( $document->getElementById( 'acme.key.0' )->hasAttribute( 'aria-describedby' ) ); } + public static function group_fields(): array { + return array( + 'radio' => array( 'radio' ), + 'checkboxes' => array( 'checkboxes' ), + ); + } + + // A host that names the group in its own fieldset passes no title. + #[DataProvider( 'group_fields' )] + public function test_a_group_without_a_title_prints_no_fieldset_and_each_choice_links_its_help( string $type ): void { + $document = self::render( + $type, + array( + 'desc' => 'Pick.', + 'options' => array( + 'a' => 'A', + 'b' => 'B', + ), + ) + ); + + self::assertNull( $document->querySelector( 'fieldset' ) ); + self::assertCount( 2, self::choices( $document ) ); + foreach ( self::choices( $document ) as $choice ) { + self::assertSame( array( 'Pick.' ), self::descriptions( $document, $choice ) ); + } + } + public static function disabled_fields(): array { return array( 'checkbox-top-level' => array( 'checkbox', array( 'disabled' => true ) ), @@ -512,7 +551,7 @@ protected static function descriptions( \Dom\HTMLDocument $document, \Dom\Elemen } protected static function choices( \Dom\HTMLDocument $document ): array { - return \iterator_to_array( $document->querySelectorAll( 'fieldset input:not([type="hidden"])' ) ); + return \iterator_to_array( $document->querySelectorAll( 'input:not([type="hidden"])' ) ); } protected static function post_pair( \Dom\Element $input ): array { diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index 87f6f7e..86eaebb 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -251,7 +251,46 @@ public function test_a_checkboxes_group_renders_in_a_fieldset_named_by_its_label self::assertCount( 1, $fieldset?->querySelectorAll( 'legend' ) ?? array() ); self::assertSame( '', $fieldset?->querySelector( "input[type=hidden][name=\"_{$this->prefix}_colors\"]" )?->getAttribute( 'value' ) ); self::assertSame( array( 'blue' ), $this->checked( $fieldset, "_{$this->prefix}_colors[]" ) ); - self::assertSame( 'Pick any.', \trim( (string) $fieldset?->querySelector( 'p.description' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Pick any.', \trim( (string) $fieldset?->querySelector( '.description' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertCount( 0, $fieldset?->querySelectorAll( 'fieldset' ) ?? array( 'missing' ) ); + $this->assert_each_choice_is_described( $fieldset, 'checkbox', "_{$this->prefix}_colors.description" ); + } + + public function test_a_radio_group_renders_in_one_fieldset_named_by_its_label(): void { + $product = $this->make_product( array( "_{$this->prefix}_tone" => 'blue' ) ); + $spec = $this->spec( + array( + $this->group( + array( + $this->field( + 'radio', + 'tone', + array( + 'options' => self::COLORS, + 'description' => 'Pick one.', + ) + ), + ) + ), + ) + ); + + $fieldset = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] )->querySelector( "fieldset.form-field._{$this->prefix}_tone_field" ); + + self::assertSame( 'Tone', \trim( (string) $fieldset?->querySelector( 'legend' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertCount( 1, $fieldset?->querySelectorAll( 'legend' ) ?? array() ); + self::assertCount( 0, $fieldset?->querySelectorAll( 'fieldset' ) ?? array( 'missing' ) ); + self::assertSame( 'blue', $fieldset?->querySelector( 'input[type=radio][checked]' )?->getAttribute( 'value' ) ); + $this->assert_each_choice_is_described( $fieldset, 'radio', "_{$this->prefix}_tone.description" ); + } + + protected function assert_each_choice_is_described( ?\Dom\Element $fieldset, string $type, string $help_id ): void { + $choices = $fieldset?->querySelectorAll( "input[type=$type]" ) ?? array(); + self::assertNotCount( 0, $choices ); + foreach ( $choices as $choice ) { + self::assertSame( $help_id, $choice->getAttribute( 'aria-describedby' ) ); + } + self::assertSame( $help_id, $fieldset?->querySelector( '.description' )?->getAttribute( 'id' ) ); } public function test_a_multiselect_posts_an_empty_value_before_its_choices(): void { From 2ed07c36a9231b37369604d19f5b36ac5e8679b5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:23:59 +0100 Subject: [PATCH 03/47] Add rows for the reshaped component, requirement and notice APIs The port audit asked for API shapes and messages that differ from the code: a component that implements only ConditionalComponentInterface is refused, though the interface describes one; WooCommerceRequirement is a one-shot instance where the style asks for a static family; AdminNotices' script callbacks are named for where one runs and what the other does; the woocommerce manifest names no WooCommerce floor, which configs' release gate reads; and the user-scope, revision and aggregate uninstall messages leave the user unquoted, use the retired "schema revision" and can end without a period. These rows fail on the current code. A conditional-only class boots as a component, and a plain class still throws before anything registers. The requirement rows call WooCommerceRequirement::check() with the plugin file. The notice rows call enqueue_delivery_script() and enqueue_dismissal_script() and end the owner messages with the quoted id. The manifest row reads requires-wc. consumer-a's uninstall keeps its data and logs once when its scoped engine is missing, and its failed-query message ends with a period while a cleanup's own ends with one only. Assisted-by: Claude Code:claude-opus-5-5 --- .../ConsumerA/ConsumerAUninstallTest.php | 2 ++ .../AdminNoticesBlockEditorTest.php | 6 ++-- .../Core/AdminNotice/AdminNoticesTest.php | 13 ++++---- .../Core/Migration/MigrationRunnerTest.php | 2 +- .../Core/Uninstall/UninstallerTest.php | 2 +- .../WooCommerceRequirementTest.php | 33 ++++++++----------- ...merceRequirementWithoutWooCommerceTest.php | 3 +- tests/Unit/Core/PluginKernelTest.php | 19 ++++++++--- tests/Unit/PackageManifestsTest.php | 3 +- 9 files changed, 46 insertions(+), 37 deletions(-) diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 50f89cd..9cde8a4 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -28,6 +28,7 @@ public static function engines_that_cannot_run(): array { 'autoloader' => array( array( 'vendor/autoload.php' ), array() ), 'scoped-autoloader' => array( array( 'vendor-prefixed/scoper-autoload.php' ), array() ), 'composition-root' => array( array( 'src/Plugin.php' ), array() ), + 'scoped-engine' => array( array( 'vendor-prefixed/ahegyes/wp-framework-core/src/Uninstall/Uninstaller.php' ), array() ), 'footprint' => array( array( 'includes/footprint.php' ), array() ), 'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ), ); @@ -59,6 +60,7 @@ public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_ke self::assertSame( 500, $request['died']['status'] ?? null ); self::assertStringStartsWith( 'Uninstall could not remove everything: option acme_ca_revision: ', $request['died']['message'] ?? '' ); + self::assertStringEndsWith( ' exist.', $request['died']['message'] ?? '' ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); self::assertSame( '1', get_option( 'acme_ca_revision' ) ); diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index 63e21bf..af99c81 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -108,7 +108,7 @@ public function test_no_other_screen_loads_the_script_or_acknowledges_a_notice( $notices->add( $this->notice( 'saved' ), $this->admin ); $this->view_as( $this->admin, $screen, $block_editor ); - $notices->enqueue_editor_script(); + $notices->enqueue_delivery_script(); self::assertFalse( wp_script_is( $this->script_handle(), 'registered' ) ); self::assertCount( 1, $notices->get_notices( NoticeScope::User, $this->admin ) ); @@ -133,7 +133,7 @@ public function test_registered_hooks_load_the_script_on_the_editors_assets_and_ $notices = $this->notices(); $notices->register_hooks(); - self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $notices, 'enqueue_editor_script' ) ) ); + self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $notices, 'enqueue_delivery_script' ) ) ); self::assertArrayHasKey( '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries', rest_get_server()->get_routes() ); } @@ -191,7 +191,7 @@ protected function delivered( string $scope, string $id, string $status, string protected function load_editor( int $user_id ): array { $this->remove_the_script(); $this->view_as( $user_id, 'post', true ); - $this->notices()->enqueue_editor_script(); + $this->notices()->enqueue_delivery_script(); self::assertSame( 1, \preg_match( '/\}\s*\)\(\s*(\{.*\})\s*\);/s', \implode( "\n", wp_scripts()->get_data( $this->script_handle(), 'after' ) ), $matches ) ); return \json_decode( $matches[1], true ); diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php index 14f177a..8e876b5 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php @@ -123,15 +123,16 @@ public function test_a_user_notice_can_be_added_for_another_user(): void { public static function misaddressed_notices(): array { return array( - 'a-user-for-a-site-notice' => array( NoticeScope::Site, 5, InvalidArgumentException::class ), - 'no-user-logged-in' => array( NoticeScope::User, 0, LogicException::class ), - 'a-negative-user' => array( NoticeScope::User, -5, LogicException::class ), + 'a-user-for-a-site-notice' => array( NoticeScope::Site, 5, InvalidArgumentException::class, "but user '5' was given." ), + 'no-user-logged-in' => array( NoticeScope::User, 0, LogicException::class, "but it has user '0'." ), + 'a-negative-user' => array( NoticeScope::User, -5, LogicException::class, "but it has user '-5'." ), ); } #[DataProvider( 'misaddressed_notices' )] - public function test_a_notice_addressed_to_no_user_or_to_a_user_outside_the_user_scope_is_rejected( NoticeScope $scope, int $user_id, string $exception ): void { + public function test_a_notice_addressed_to_no_user_or_to_a_user_outside_the_user_scope_is_rejected( NoticeScope $scope, int $user_id, string $exception, string $ending ): void { $this->expectException( $exception ); + $this->expectExceptionMessageMatches( '/' . \preg_quote( $ending, '/' ) . '$/' ); $this->notices()->add( $this->notice( 'saved', $scope ), $user_id ); } @@ -377,12 +378,12 @@ public function test_the_dismiss_script_loads_on_wp_api_fetch_only_after_a_persi $notices = $this->notices(); $notices->add( $this->notice( 'once', NoticeScope::Request ) ); $this->render( $this->admin, $notices ); - $notices->enqueue_dismiss_script(); + $notices->enqueue_dismissal_script(); self::assertFalse( wp_script_is( $this->script_handle(), 'registered' ) ); $notices->add( $this->notice( 'kept', NoticeScope::Request, persistent: true ) ); $this->render( $this->admin, $notices ); - $notices->enqueue_dismiss_script(); + $notices->enqueue_dismissal_script(); $script = \implode( "\n", wp_scripts()->get_data( $this->script_handle(), 'after' ) ); self::assertTrue( wp_script_is( $this->script_handle() ) ); diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php index 22de6b7..1f4b897 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php @@ -383,7 +383,7 @@ public function test_an_unconfirmed_checkpoint_stops_the_run_with_an_error(): vo self::assertSame( array( array( 1, 0 ) ), $this->ran ); $error = get_option( "{$this->option}_error" ); self::assertIsArray( $error ); - self::assertSame( "Could not record schema revision '1'.", $error['message'] ); + self::assertSame( "Could not record data revision '1'.", $error['message'] ); } public function test_a_resumable_step_rerun_after_an_interruption_creates_no_duplicate(): void { diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index db14305..b946c75 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -427,7 +427,7 @@ public function test_a_failing_cleanup_keeps_that_sites_footprint_and_consent_ro } self::assertInstanceOf( RuntimeException::class, $failure ); - self::assertStringContainsString( 'cleanup: The cleanup failed.', $failure->getMessage() ); + self::assertStringEndsWith( 'cleanup: The cleanup failed.', $failure->getMessage() ); self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron" ) ); self::assertTrue( $this->option_exists( "{$this->prefix}_settings" ) ); self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) ); diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php index 0c0a0d3..1a66888 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php @@ -24,24 +24,21 @@ public static function met_floors(): array { #[DataProvider( 'met_floors' )] public function test_woocommerce_meeting_the_floor_passes_and_nobody_sees_a_notice( ?string $floor ): void { - $headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor ); - $requirement = new WooCommerceRequirement( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ); + $headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor ); - self::assertTrue( $requirement->check() ); + self::assertTrue( WooCommerceRequirement::check( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ) ); self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_activate_plugins_are_told_to_update_it(): void { - $requirement = new WooCommerceRequirement( - $this->write_plugin( - array( - 'WC requires at least' => '99.1', - 'Requires Plugins' => 'woocommerce', - ) + $plugin_file = $this->write_plugin( + array( + 'WC requires at least' => '99.1', + 'Requires Plugins' => 'woocommerce', ) ); - self::assertFalse( $requirement->check() ); + self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); self::assertNotNull( $notice ); @@ -50,15 +47,13 @@ public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_ac } public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): void { - $requirement = new WooCommerceRequirement( - $this->write_plugin( - array( - 'Plugin Name' => 'Acme Shop', - 'WC requires at least' => '99.1', - ) + $plugin_file = $this->write_plugin( + array( + 'Plugin Name' => 'Acme Shop', + 'WC requires at least' => '99.1', ) ); - $requirement->check(); + WooCommerceRequirement::check( $plugin_file ); $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); @@ -69,7 +64,7 @@ public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): voi } public function test_a_failed_check_reads_translations_only_when_the_notice_renders(): void { - $requirement = new WooCommerceRequirement( $this->write_plugin( array( 'WC requires at least' => '99.1' ) ) ); + $plugin_file = $this->write_plugin( array( 'WC requires at least' => '99.1' ) ); $translations = 0; add_filter( 'gettext_wp-framework', @@ -80,7 +75,7 @@ static function ( string $translation ) use ( &$translations ): string { } ); - $requirement->check(); + WooCommerceRequirement::check( $plugin_file ); self::assertSame( 0, $translations ); $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php index 6de9b57..478a97e 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php @@ -22,9 +22,8 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate 'Requires Plugins' => 'woocommerce', ) ); - $requirement = new WooCommerceRequirement( $plugin_file ); - self::assertFalse( $requirement->check() ); + self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); // On a network, only a super admin can activate plugins unless the network enables the plugins menu. $super_admin = $this->make_user( 'acme_requirement_super_admin', 'administrator' ); diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php index cf8fad6..d96ccfc 100644 --- a/tests/Unit/Core/PluginKernelTest.php +++ b/tests/Unit/Core/PluginKernelTest.php @@ -89,9 +89,17 @@ public static function should_load(): bool { final class UngatedL3 extends RecordingComponent {} final class ConditionalOnly implements ConditionalComponentInterface { + public function __construct( + protected Journal $journal + ) {} + #[\Override] public static function should_load(): bool { - throw new \RuntimeException( 'should_load() ran.' ); + return true; + } + + public function register_hooks(): void { + $this->journal->entries[] = 'register ConditionalOnly'; } } @@ -125,6 +133,7 @@ public static function invalid_trees(): array { 'repeated-child' => array( array( CompositeA::class, ComponentB::class ) ), 'gated-out-repeat' => array( array( GatedOutL2::class, GatedOutL2::class ) ), 'cycle' => array( array( CycleStart::class ) ), + 'plain-class' => array( array( ComponentD::class, Journal::class ) ), ); } @@ -195,10 +204,12 @@ public function test_a_gated_out_child_is_skipped_while_its_siblings_load(): voi self::assertSame( array( GatedOutL2::class ), $kernel->skipped ); } - public function test_a_class_that_is_only_conditional_throws_before_its_gate_runs(): void { - $thrown = self::boot_and_catch( new PluginKernel( new FakeContainer( new Journal() ) ), array( ConditionalOnly::class ) ); + public function test_a_class_that_implements_only_the_conditional_interface_boots_as_a_component(): void { + $journal = new Journal(); - self::assertInstanceOf( LogicException::class, $thrown ); + ( new PluginKernel( new FakeContainer( $journal ) ) )->boot( array( ConditionalOnly::class ) ); + + self::assertSame( array( 'register ConditionalOnly' ), $journal->entries ); } #[DataProvider( 'invalid_trees' )] diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index dde984f..2aa0b38 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -63,6 +63,7 @@ final class PackageManifestsTest extends TestCase { 'woocommerce' => array( 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), 'requires-plugins' => array( 'woocommerce' ), + 'requires-wc' => '11.2', 'requires-wp' => '7.1', ), ); @@ -108,7 +109,7 @@ public function test_a_package_requires_exactly_its_mapped_edges( string $packag #[DataProvider( 'package_names' )] public function test_a_packages_extra_declares_exactly_its_floors_and_branch_alias( string $package ): void { - $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wp' ) ) ); + $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wc', 'requires-wp' ) ) ); \ksort( $extra ); self::assertSame( self::EXTRAS[ $package ], $extra ); From e024acc3ddf4a9a4cafe8de1b6a2bb26e676eb55 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:25:37 +0100 Subject: [PATCH 04/47] Speak block-editor notices as plain text A one-shot delivered to the block editor went to the notices store as markup, which the store reads aloud through wp.a11y with its tags turned into spaces and its entities spelled out, so a screen reader announced "&" for an ampersand. The delivery now carries a plain-text copy of the message, its tags stripped and its entities decoded, and the script creates the notice with speak: false and speaks that copy through wp.a11y itself, assertively for an error and politely otherwise, adding wp-a11y to its dependencies. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/src/AdminNotice/AdminNotices.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 387d959..09a6b7f 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -399,7 +399,7 @@ public function enqueue_editor_script(): void { ) ); - wp_register_script( $handle, false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. + wp_register_script( $handle, false, array( 'wp-a11y', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. wp_add_inline_script( $handle, <<<'JS' @@ -410,8 +410,11 @@ function deliver() { wp.data.dispatch( 'core/notices' ).createNotice( notice.status, notice.content, { id: notice.id, isDismissible: notice.isDismissible, + speak: false, __unstableHTML: true } ); + // The notices store would read the markup aloud with its entities spelled out. + wp.a11y.speak( notice.spoken, 'error' === notice.status ? 'assertive' : 'polite' ); } ); } ).catch( function ( error ) { console.error( 'The notice delivery failed.', error ); @@ -549,20 +552,22 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP } /** - * Returns the one-shots the block editor shows the current user, as its notices store takes them, and acknowledges them. + * Returns the one-shots the block editor shows the current user, as its notices store takes them with a plain-text copy to speak, and acknowledges them. * * @since 2.0.0 * @version 2.0.0 * - * @return list + * @return list */ public function rest_deliver(): array { $delivered = array(); foreach ( $this->select( true ) as $notice ) { + $content = wp_kses_post( $this->get_message( $notice ) ); $delivered[] = array( 'id' => $this->get_dom_id( $notice ), 'status' => $notice->type->value, - 'content' => wp_kses_post( $this->get_message( $notice ) ), + 'content' => $content, + 'spoken' => \html_entity_decode( wp_strip_all_tags( $content ), \ENT_QUOTES | \ENT_HTML5, 'UTF-8' ), 'isDismissible' => $notice->dismissible, ); $this->acknowledge( $notice ); From 356837907e29dff17faf43d24fb2087ecdad1f84 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:26:41 +0100 Subject: [PATCH 05/47] Point every row label at a control whose id holds an entity Forms prints a control's id through escape_attribute(), which encodes an ampersand first, while the hosts printed the row label's for through esc_attr(), which keeps an entity as it is. For a field id holding an entity such as &, the two decoded to different strings, and the label named nothing, on post and order meta boxes, profile and term forms and options pages. FormTable and PostMetaBox print the label through escape_attribute(), and OptionsPage hands WordPress a label_for already escaped by it, which core's esc_attr() then leaves as it is. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/AdminPage/OptionsPage.php | 3 +++ packages/settings/src/EditScreen/FormTable.php | 4 +++- packages/settings/src/EditScreen/Hosts/PostMetaBox.php | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 12ac803..a341266 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -190,6 +190,9 @@ public function render_page(): void { $option = $tab['option']; $stored = get_option( $option ); foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) { + if ( isset( $row['label_for'] ) ) { // Core prints the label through esc_attr(), which keeps an entity the control's id encodes. + $row['label_for'] = Forms::escape_attribute( $row['label_for'] ); + } add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } diff --git a/packages/settings/src/EditScreen/FormTable.php b/packages/settings/src/EditScreen/FormTable.php index 342a34b..44e27b2 100644 --- a/packages/settings/src/EditScreen/FormTable.php +++ b/packages/settings/src/EditScreen/FormTable.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Settings\EditScreen; +use DeepWebSolutions\Framework\Settings\Forms; + \defined( 'ABSPATH' ) || exit; /** @@ -43,7 +45,7 @@ protected function __construct() {} public static function render_rows( string $title, array $rows ): void { echo '

' . $title . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes its title. foreach ( $rows as $row ) { - $heading = \is_null( $row['label_for'] ) ? $row['title'] : ''; + $heading = \is_null( $row['label_for'] ) ? $row['title'] : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls. } echo ''; diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php index 7faa178..c1e59ff 100644 --- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php +++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php @@ -6,6 +6,7 @@ use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormHostInterface; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface; use DeepWebSolutions\Framework\Settings\EditScreen\Stores\MetadataStore; +use DeepWebSolutions\Framework\Settings\Forms; \defined( 'ABSPATH' ) || exit; @@ -111,7 +112,7 @@ public function get_default_store(): ObjectStoreInterface { #[\Override] public function render_rows( string $title, array $rows ): void { foreach ( $rows as $row ) { - $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : ''; + $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : ''; echo '

' . $heading . '

' . $row['control'] . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls. } } From 39351b3fb3a8de87fce2153fb472b8fc0005e2cd Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:27:57 +0100 Subject: [PATCH 06/47] Keep formatted help inside the element its control names Forms printed each help text inside a paragraph. A desc or desc_tip holding paragraphs or lists made the browser close that paragraph before them, so the element the control's aria-describedby names was left empty and the help read as unrelated text. Each help text now sits in a div that keeps the description class and the id, so a control's description holds the whole help. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/Forms.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 08ad0c9..076a1eb 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -514,7 +514,9 @@ protected static function render_legend( array $field ): string { } /** - * Returns the markup of the help paragraphs. + * Returns the markup of the help texts. + * + * Each text sits in a div, because help holding paragraphs or lists would end a paragraph before them and leave the element the control names empty. * * @since 2.0.0 * @version 2.0.0 @@ -526,12 +528,12 @@ protected static function render_legend( array $field ): string { protected static function render_help( array $help ): string { $html = ''; foreach ( $help as $help_id => $text ) { - $html .= ' 'description', 'id' => $help_id, ) - ) . '>' . wp_kses_post( $text ) . '

'; + ) . '>' . wp_kses_post( $text ) . ''; } return $html; From 14b3f5df24326043640016823a2c44a50fe4cc23 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:29:32 +0100 Subject: [PATCH 07/47] Print the product panel's choice groups in one fieldset The product data panel names a radio or checkboxes group in a fieldset of its own, with WooCommerce's visible legend, and passes Forms the field without a title. Forms still wrapped the choices in a fieldset of their own, now without a legend, so assistive technology met an unnamed group inside the named one. render_choices() leaves the fieldset to the host when the field has no title, and each choice then names the help through aria-describedby, which the outer fieldset does not carry. A titled group renders as it did. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/Forms.php | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 076a1eb..6bb82d6 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -69,7 +69,7 @@ protected function __construct() {} /** * Returns the escaped markup of the field's control, without its row label. * - * A radio, a checkboxes group and a checkbox with a desc render in a fieldset named by the title, so their host prints the row label as plain text, while every other control carries the id the row label points at. An enabled checkbox or multi-value control posts a hidden input first, so a field absent from a submission was not rendered or is disabled. A field with a render callable renders through it, and the callable escapes, names and describes its control, a fieldset named by the title for a group, and, unless the field is disabled, prints a hidden empty input before it. + * A radio, a checkboxes group and a checkbox with a desc render in a fieldset named by the title, so their host prints the row label as plain text, while every other control carries the id the row label points at. A radio or checkboxes group without a title leaves the fieldset to its host, and each of its choices names the help. An enabled checkbox or multi-value control posts a hidden input first, so a field absent from a submission was not rendered or is disabled. A field with a render callable renders through it, and the callable escapes, names and describes its control, a fieldset named by the title for a group, and, unless the field is disabled, prints a hidden empty input before it. * * @since 2.0.0 * @version 2.0.0 @@ -478,24 +478,30 @@ protected static function render_choices( array $field, mixed $value, string $na $disabled = self::is_disabled( $field ); $chosen = self::get_chosen( $value, $multiple ); $custom = self::get_custom_attributes( $field ); + $untitled = '' === ( $field['title'] ?? '' ); $choices = $multiple && ! $disabled ? self::render_hidden_input( $name, '' ) : ''; $index = 0; foreach ( $field['options'] ?? array() as $key => $label ) { $attributes = array( - 'type' => $multiple ? 'checkbox' : 'radio', - 'id' => "$id.$index", - 'name' => $multiple ? $name . '[]' : $name, - 'value' => (string) $key, - 'class' => $field['class'] ?? null, - 'style' => $field['css'] ?? null, - 'checked' => \in_array( (string) $key, $chosen, true ), - 'disabled' => $disabled || self::is_disabled_choice( $field, $key ), + 'type' => $multiple ? 'checkbox' : 'radio', + 'id' => "$id.$index", + 'name' => $multiple ? $name . '[]' : $name, + 'value' => (string) $key, + 'class' => $field['class'] ?? null, + 'style' => $field['css'] ?? null, + 'checked' => \in_array( (string) $key, $chosen, true ), + 'disabled' => $disabled || self::is_disabled_choice( $field, $key ), + 'aria-describedby' => $untitled ? self::get_described_by( $help ) : null, ); $choices .= ' "$id.$index" ) ) . '> ' . esc_html( $label ) . '
'; ++$index; } + if ( $untitled ) { // A host that names the group in a fieldset of its own passes no title. + return $choices . self::render_help( $help ); + } + return ' self::get_described_by( $help ) ) ) . '>' . self::render_legend( $field ) . $choices . self::render_help( $help ) . ''; } From 5ab8897a346423a692993a6547dac9b67026b239 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:29:48 +0100 Subject: [PATCH 08/47] Make every conditional component a component ConditionalComponentInterface stood alone while its sibling CompositeComponentInterface extends ComponentInterface, so a class that implemented only the conditional interface passed the type system as a gated component and then failed the kernel's check at boot. The interface extends ComponentInterface, as the composite one does. Classes that list both interfaces stay valid, and the kernel keeps its check, which a plain class still fails before anything registers. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/src/ConditionalComponentInterface.php | 2 +- tests/Unit/Core/PluginKernelTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ConditionalComponentInterface.php b/packages/core/src/ConditionalComponentInterface.php index 8807427..a3aeff8 100644 --- a/packages/core/src/ConditionalComponentInterface.php +++ b/packages/core/src/ConditionalComponentInterface.php @@ -10,7 +10,7 @@ * @since 2.0.0 * @version 2.0.0 */ -interface ConditionalComponentInterface { +interface ConditionalComponentInterface extends ComponentInterface { /** * Returns whether the component and its children load. * diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php index d96ccfc..0a092fc 100644 --- a/tests/Unit/Core/PluginKernelTest.php +++ b/tests/Unit/Core/PluginKernelTest.php @@ -98,6 +98,7 @@ public static function should_load(): bool { return true; } + #[\Override] public function register_hooks(): void { $this->journal->entries[] = 'register ConditionalOnly'; } From 591780ef24a951c55398400d96e479191ef986db Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:30:49 +0100 Subject: [PATCH 09/47] Check the WooCommerce floor through a static entry point WooCommerceRequirement was an instance built only to call check() once, which the framework's style writes as a stateless family: a final class with public static entry points and a protected constructor, as bootstrap's Requirements is. It becomes WooCommerceRequirement::check( string $plugin_file ): bool, whose notice closure captures the plugin file directly. consumer-a, the woocommerce persona and the README excerpt call it so. Assisted-by: Claude Code:claude-opus-5-5 --- packages/woocommerce/README.md | 2 +- packages/woocommerce/src/WooCommerceRequirement.php | 13 +++++-------- tests/Fixtures/consumer-a/src/Plugin.php | 2 +- tests/Fixtures/personas/woocommerce/functions.php | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index fc5b3dd..7569b74 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -106,7 +106,7 @@ add_action( 'plugins_loaded', 'acme_pw_register_hooks', 15 ); // ... function acme_pw_register_hooks(): void { - if ( ! new WooCommerceRequirement( ACME_PW_FILE )->check() ) { + if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) { return; } diff --git a/packages/woocommerce/src/WooCommerceRequirement.php b/packages/woocommerce/src/WooCommerceRequirement.php index 2f5cecb..2480070 100644 --- a/packages/woocommerce/src/WooCommerceRequirement.php +++ b/packages/woocommerce/src/WooCommerceRequirement.php @@ -20,12 +20,8 @@ final class WooCommerceRequirement { * * @since 2.0.0 * @version 2.0.0 - * - * @param string $plugin_file The path to the plugin's entry file. */ - public function __construct( - protected string $plugin_file - ) {} + protected function __construct() {} // endregion @@ -39,15 +35,16 @@ public function __construct( * @since 2.0.0 * @version 2.0.0 * + * @param string $plugin_file The path to the plugin's entry file. + * * @return bool */ - public function check(): bool { - $floor = get_file_data( $this->plugin_file, array( 'floor' => 'WC requires at least' ) )['floor']; + public static function check( string $plugin_file ): bool { + $floor = get_file_data( $plugin_file, array( 'floor' => 'WC requires at least' ) )['floor']; if ( \defined( 'WC_VERSION' ) && ( '' === $floor || \version_compare( WC_VERSION, $floor, '>=' ) ) ) { return true; } - $plugin_file = $this->plugin_file; add_action( 'all_admin_notices', static function () use ( $plugin_file, $floor ): void { diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index b116a5b..9c02f02 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -157,7 +157,7 @@ public function boot(): void { */ protected function compose(): bool { $this->scheduler->register_hooks(); // Before the WooCommerce check, so the plugin's recurring WP-Cron events keep their schedules while it is blocked. - if ( ! ( new WooCommerceRequirement( ACME_CA_FILE ) )->check() ) { + if ( ! WooCommerceRequirement::check( ACME_CA_FILE ) ) { return false; } diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php index 5d8960c..9259eec 100644 --- a/tests/Fixtures/personas/woocommerce/functions.php +++ b/tests/Fixtures/personas/woocommerce/functions.php @@ -27,7 +27,7 @@ * @return void */ function acme_pw_register_hooks(): void { - if ( ! new WooCommerceRequirement( ACME_PW_FILE )->check() ) { + if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) { return; } From 7ea812a4da9c74991ef9b1cff6e3d9febaf0611f Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:31:11 +0100 Subject: [PATCH 10/47] Name the notice scripts' callbacks after their handles AdminNotices' two script callbacks were named one for where it runs, enqueue_editor_script(), and one for what it does, enqueue_dismiss_script(), while their handles and REST routes say delivery and dismissal. As public hook callbacks they and their hooks join the 2.x contract, so their names settle before the first tag. They become enqueue_delivery_script() and enqueue_dismissal_script(), on the same hooks and priorities, and the flag that holds the dismissal script back becomes $needs_dismissal_script. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/src/AdminNotice/AdminNotices.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 09a6b7f..332a4b6 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -69,7 +69,7 @@ final class AdminNotices implements ComponentInterface { * * @var bool */ - protected bool $needs_dismiss_script = false; + protected bool $needs_dismissal_script = false; // endregion @@ -110,9 +110,9 @@ public function __construct( #[\Override] public function register_hooks(): void { add_action( 'all_admin_notices', array( $this, 'render_notices' ) ); - add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_script' ) ); + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_delivery_script' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); - add_action( 'admin_footer', array( $this, 'enqueue_dismiss_script' ) ); + add_action( 'admin_footer', array( $this, 'enqueue_dismissal_script' ) ); } // endregion @@ -313,7 +313,7 @@ public function render_notices(): void { ); if ( $notice->persistent && $notice->dismissible ) { $attributes['data-notice-nonce'] = wp_create_nonce( $this->get_nonce_action( $notice->scope, $notice->id ) ); - $this->needs_dismiss_script = true; + $this->needs_dismissal_script = true; } wp_admin_notice( @@ -383,7 +383,7 @@ public function register_rest_routes(): void { * * @return void */ - public function enqueue_editor_script(): void { + public function enqueue_delivery_script(): void { // The other block editors, such as the site editor while it browses, show no notices, so a one-shot delivered there would be lost. $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( true !== $screen?->is_block_editor() || 'post' !== $screen->base ) { @@ -445,8 +445,8 @@ function deliver() { * * @return void */ - public function enqueue_dismiss_script(): void { - if ( ! $this->needs_dismiss_script ) { + public function enqueue_dismissal_script(): void { + if ( ! $this->needs_dismissal_script ) { return; } From 588c2aef507e47cc745e1284cc4cda402f57b14d Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:31:31 +0100 Subject: [PATCH 11/47] Declare the woocommerce package's WooCommerce floor The woocommerce package named WooCommerce only under extra.requires-plugins, with no version, though its settings section API and the framework's tests need WooCommerce 11.2. configs' release gate reads extra.requires-wc from every package a plugin ships and holds the plugin's WC requires at least header to it, so no release checked a WooCommerce floor at all. The manifest declares requires-wc at 11.2, the newest stable WooCommerce, beside requires-wp, and the README's requirements name the key. WooCommerceRequirement keeps reading the plugin's own header at runtime. Assisted-by: Claude Code:claude-opus-5-5 --- packages/woocommerce/README.md | 2 +- packages/woocommerce/composer.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index 7569b74..c3f4ea2 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -4,7 +4,7 @@ The framework's settings, edit-screen and logging helpers carried onto WooCommer ## Requirements -It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least` and `Requires Plugins` headers meet. The plugin's own `WC requires at least` header is the WooCommerce floor that `WooCommerceRequirement` checks. +It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp`, its WooCommerce floor under `extra.requires-wc` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least`, `WC requires at least` and `Requires Plugins` headers meet. `WooCommerceRequirement` checks the plugin's own `WC requires at least` header at runtime. ## Installation diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json index 52112da..5b40f0a 100644 --- a/packages/woocommerce/composer.json +++ b/packages/woocommerce/composer.json @@ -18,6 +18,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", + "requires-wc": "11.2", "requires-plugins": [ "woocommerce" ], From d27451cefaf1df95ae4d5dcb728393beda0a0bfd Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:32:27 +0100 Subject: [PATCH 12/47] Quote the user, name the data revision and end the uninstall message Three messages broke the framework's own message rules. The user-scope exceptions left the offending user id unquoted, unlike every other exception's value. The runner's checkpoint failure said "schema revision", a term the framework retired for "data revision". And the aggregate uninstall failure ended with whatever its last failure ended with, so a database error left it without a period. The two notice exceptions quote the user id, the checkpoint failure and limitation 1 say data revision, and the uninstall message trims the last failure's own period before it adds one, so it ends with exactly one. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/src/AdminNotice/AdminNotices.php | 4 ++-- packages/core/src/Migration/MigrationRunner.php | 2 +- packages/core/src/Uninstall/Uninstaller.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68b7fca..c5038e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships ## Limitations -1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. +1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. 2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. 3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 332a4b6..148fc54 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -597,7 +597,7 @@ public function rest_deliver(): array { protected function get_owner( NoticeScope $scope, int $user_id ): int { if ( NoticeScope::User !== $scope ) { if ( 0 !== $user_id ) { - throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user $user_id was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user '$user_id' was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } return match ( $scope ) { @@ -609,7 +609,7 @@ protected function get_owner( NoticeScope $scope, int $user_id ): int { $user_id = $user_id ?: get_current_user_id(); if ( $user_id < 1 ) { - throw new LogicException( "A user notice needs a user, but it has user $user_id." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new LogicException( "A user notice needs a user, but it has user '$user_id'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } return $user_id; diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index b00daf6..219341e 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -498,7 +498,7 @@ protected function schedule_continuation(): void { */ protected function checkpoint( int $revision ): void { if ( ! update_option( $this->option, $revision, true ) ) { - throw new RuntimeException( "Could not record schema revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new RuntimeException( "Could not record data revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } if ( $revision > self::get_revision( "{$this->option}_high_water" ) ) { diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index ffd6bd8..474c5fb 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -172,7 +172,7 @@ public static function run( string $plugin_file, array $footprint, array $args = } if ( array() !== $uninstaller->failures ) { - throw new RuntimeException( 'Uninstall could not remove everything: ' . \implode( '; ', $uninstaller->failures ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new RuntimeException( 'Uninstall could not remove everything: ' . \rtrim( \implode( '; ', $uninstaller->failures ), '.' ) . '.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } } From 89887bcb82f2761c6c67ada7c02a47750b972101 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:32:27 +0100 Subject: [PATCH 13/47] Check consumer-a's uninstall engine inside its build boundary consumer-a's uninstall.php resolved its consent option outside the try that turns a missing build into a logged, kept uninstall, and never checked that the scoped engine loads, as the template's does. A build missing Uninstaller.php fatally failed the uninstall request instead of logging and keeping the data. The consent option name resolves inside the try, and a missing Uninstaller class throws there, so the build-error path logs it once and keeps the data, as the template's entry does. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/uninstall.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index 6721108..58a0d6f 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -30,7 +30,11 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/includes/footprint.php'; $acme_ca_footprint = acme_ca_get_footprint(); + $acme_ca_consent = Plugin::PREFIX . '_settings'; $acme_ca_meta_types = MetaTypes::DEFINITIONS; + if ( ! class_exists( Uninstaller::class ) ) { + throw new RuntimeException( 'its uninstall engine is missing.' ); + } } catch ( Throwable $error ) { // WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data. if ( defined( 'WP_CLI' ) && true === WP_CLI ) { @@ -47,7 +51,7 @@ $acme_ca_footprint, array( 'consent' => array( - 'option' => Plugin::PREFIX . '_settings', + 'option' => $acme_ca_consent, 'key' => 'delete_data', ), 'meta_types' => $acme_ca_meta_types, From c1431b0318d7c52417c85305112155199e7e4fd4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:34:09 +0100 Subject: [PATCH 14/47] Call the fixtures' boot boundary by its role The template's audit found that its composition root exposed both a boot property and a boot() method, one the boundary and one the action, and that its build-error @throws read "Thrown in" where the house form is "Thrown when". consumer-a, consumer-b and the core persona share both shapes, and core's README quotes the persona. The property becomes $boundary in the three fixtures, the README excerpt and the request harness that reads its status, and consumer-a's entry says it throws when the site runs in a local or development environment. The template and the ports change in their own branches. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/README.md | 4 ++-- tests/Fixtures/consumer-a/acme-consumer-a.php | 2 +- tests/Fixtures/consumer-a/src/Plugin.php | 6 +++--- tests/Fixtures/consumer-b/src/Plugin.php | 8 ++++---- tests/Fixtures/personas/core/src/Plugin.php | 6 +++--- tests/Integration/Support/consumer-request.php | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index efcc392..1191de4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -138,14 +138,14 @@ final class Plugin { public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); } // ... public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // ... diff --git a/tests/Fixtures/consumer-a/acme-consumer-a.php b/tests/Fixtures/consumer-a/acme-consumer-a.php index 0db9c20..3662245 100644 --- a/tests/Fixtures/consumer-a/acme-consumer-a.php +++ b/tests/Fixtures/consumer-a/acme-consumer-a.php @@ -56,7 +56,7 @@ * * @param Throwable $error The error that stopped the build. * - * @throws Throwable Thrown in local and development environments. + * @throws Throwable Thrown when the site runs in a local or development environment. * * @return void */ diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index 9c02f02..3cce1e7 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -69,7 +69,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The scheduler, which registers its WP-Cron schedules even while the plugin is blocked. @@ -124,7 +124,7 @@ final class Plugin { public function __construct() { $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null ); $this->scheduler = new Scheduler( self::PREFIX ); - $this->boot = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger ); } // endregion @@ -140,7 +140,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php index 2e19aaf..1347108 100644 --- a/tests/Fixtures/consumer-b/src/Plugin.php +++ b/tests/Fixtures/consumer-b/src/Plugin.php @@ -61,7 +61,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The kernel, once the composition built it, which tells which components loaded. @@ -94,8 +94,8 @@ final class Plugin { * @version 1.0.0 */ public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger ); + $this->logger = new ErrorLogLogger( self::SLUG ); + $this->boundary = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger ); } // endregion @@ -111,7 +111,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index 2951c65..87c60aa 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -63,7 +63,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The kernel, once the composition built it, which tells which components loaded. @@ -117,7 +117,7 @@ final class Plugin { */ public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); } @@ -134,7 +134,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index 6aa941a..0f8730f 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -60,7 +60,7 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $plugin = null; } $GLOBALS['acme_outcome']['records'][ $key ] = array( - 'status' => $plugin?->boot->status, + 'status' => $plugin?->boundary->status, 'skipped' => $plugin?->kernel?->skipped, 'boot_succeeded' => did_action( "{$prefix}/boot/succeeded" ), 'functions_loaded' => function_exists( "{$prefix}_plugin" ), From 05091248f29498032214d786bd3df18900e08a6b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:35:43 +0100 Subject: [PATCH 15/47] Drop unneeded interpolation braces and compare null through is_null() Two style rules had drifted in the packages. Fourteen interpolations braced a property followed by a slash, a hyphen or the string's end, where simple syntax already ends the name, against the rule that braces appear only where the expression needs them. Nineteen null checks compared with === or !== against the rule that null checks use \is_null(). Those braces go, and braces stay where a name character, [ or -> would otherwise extend the expression. Every null comparison in the packages reads \is_null( $x ) or ! \is_null( $x ), the bootstrap package's by-path classes included, since PHP 7.4 parses both. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/src/Updater.php | 18 +++++++------- .../core/src/AdminNotice/AdminNotices.php | 24 +++++++++---------- .../core/src/Migration/MigrationRunner.php | 2 +- packages/core/src/PluginBoot.php | 6 ++--- packages/core/src/Uninstall/Uninstaller.php | 4 ++-- .../settings/src/AdminPage/OptionsPage.php | 2 +- .../settings/src/EditScreen/ObjectForm.php | 2 +- .../src/ProductData/ProductDataTab.php | 2 +- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 355f855..85cae18 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -50,8 +50,8 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename ) } $config = self::get_updater_config( $plugin_file ); - $release = null === $config ? null : self::get_release( $config ); - if ( null === $config || null === $release || ! is_wp_version_compatible( $release['requires'] ) ) { + $release = \is_null( $config ) ? null : self::get_release( $config ); + if ( \is_null( $config ) || \is_null( $release ) || ! is_wp_version_compatible( $release['requires'] ) ) { return false; } @@ -77,7 +77,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { } $config = self::get_updater_config( $plugin_file ); - if ( null === $config || ( \get_object_vars( $args )['slug'] ?? null ) !== $config['slug'] ) { + if ( \is_null( $config ) || ( \get_object_vars( $args )['slug'] ?? null ) !== $config['slug'] ) { return $result; } @@ -92,7 +92,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { ); $release = self::get_release( $config ); - if ( null !== $release ) { + if ( ! \is_null( $release ) ) { $information = array( 'version' => $release['version'], 'download_link' => $release['package'], @@ -198,12 +198,12 @@ protected static function get_release( array $config ): ?array { } $release = self::read_release( $cached ); - if ( null !== $release ) { + if ( ! \is_null( $release ) ) { return $release; } $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) ); - $releases = null === $body ? null : \json_decode( $body, true ); + $releases = \is_null( $body ) ? null : \json_decode( $body, true ); $newest = array(); $tag = ''; @@ -237,8 +237,8 @@ protected static function get_release( array $config ): ?array { } } - $entry_file = null === $package ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors. - if ( null !== $entry_file ) { + $entry_file = \is_null( $package ) ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors. + if ( ! \is_null( $entry_file ) ) { // WordPress's get_file_data() reads only paths, so the fetched entry file is parsed with the same pattern. $entry_file = \str_replace( "\r", "\n", \substr( $entry_file, 0, 8 * KB_IN_BYTES ) ); $headers = array( @@ -266,7 +266,7 @@ protected static function get_release( array $config ): ?array { } } - set_transient( $transient, $release ?? array(), null === $release ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS ); + set_transient( $transient, $release ?? array(), \is_null( $release ) ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS ); return $release; } diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 148fc54..8980258 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -227,7 +227,7 @@ protected function select( bool $block_editor ): array { continue; } - if ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) { + if ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) { continue; } @@ -246,7 +246,7 @@ protected function select( bool $block_editor ): array { * @param string $id The notice's id. * @param string $scope The notice's scope. */ - if ( true === apply_filters( "{$this->prefix}/admin_notice/should_show", true, $notice->id, $scope->value ) ) { + if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) { $selected[] = $notice; } } @@ -390,7 +390,7 @@ public function enqueue_delivery_script(): void { return; } - $handle = "{$this->prefix}-notice-delivery"; + $handle = "$this->prefix-notice-delivery"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -450,7 +450,7 @@ public function enqueue_dismissal_script(): void { return; } - $handle = "{$this->prefix}-notice-dismissal"; + $handle = "$this->prefix-notice-dismissal"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -505,7 +505,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP $nonce = $params['nonce'] ?? null; $user_id = get_current_user_id(); - if ( null === $scope || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) { + if ( \is_null( $scope ) || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) { return new \WP_Error( 'rest_invalid_param', 'The notice scope, id or nonce is invalid.', array( 'status' => 400 ) ); } @@ -518,16 +518,16 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP $notice = null; if ( NoticeScope::Request !== $scope ) { $notice = $this->read( $scope, $owner )[ $id ] ?? null; - if ( null === $notice ) { + if ( \is_null( $notice ) ) { return new \WP_Error( 'rest_notice_invalid_id', 'No stored notice has that id.', array( 'status' => 404 ) ); } - if ( ! $notice->persistent || ! $notice->dismissible || ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) ) { + if ( ! $notice->persistent || ! $notice->dismissible || ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) ) { return new \WP_Error( 'rest_forbidden', 'This user cannot dismiss that notice.', array( 'status' => 403 ) ); } } - $changed = null !== $notice && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' ); + $changed = ! \is_null( $notice ) && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' ); } catch ( \Throwable $exception ) { $this->log( 'Could not dismiss a {scope} notice: {message}', $scope, $exception ); return new \WP_Error( 'rest_notice_store_error', 'The notice store failed.', array( 'status' => 500 ) ); @@ -545,7 +545,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP * @param int $user_id The user who dismissed it. * @param int $owner The notice's owner: 0 for a request notice, otherwise the user, blog or network id. */ - do_action( "{$this->prefix}/admin_notice/dismissed", $id, $scope->value, $user_id, $owner ); + do_action( "$this->prefix/admin_notice/dismissed", $id, $scope->value, $user_id, $owner ); } return new \WP_REST_Response( array( 'changed' => $changed ) ); @@ -639,7 +639,7 @@ protected function read( NoticeScope $scope, int $owner ): array { $notices = array(); foreach ( \is_array( $rows ) ? $rows : array() as $row ) { $notice = \is_array( $row ) ? Notice::from_array( $row ) : null; - if ( null !== $notice && $scope === $notice->scope && ( null === $notice->expires_at || $notice->expires_at > \time() ) ) { + if ( ! \is_null( $notice ) && $scope === $notice->scope && ( \is_null( $notice->expires_at ) || $notice->expires_at > \time() ) ) { $notices[ $notice->id ] = $notice; } } @@ -792,7 +792,7 @@ protected function get_message( Notice $notice ): string { * @param string $id The notice's id. * @param string $scope The notice's scope. */ - $message = apply_filters( "{$this->prefix}/admin_notice/message", $notice->message, $notice->id, $notice->scope->value ); + $message = apply_filters( "$this->prefix/admin_notice/message", $notice->message, $notice->id, $notice->scope->value ); return \is_string( $message ) ? $message : $notice->message; } @@ -830,7 +830,7 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti * @return string */ protected function get_dom_id( Notice $notice ): string { - return "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}"; + return "$this->prefix-{$notice->scope->value}-notice-$notice->id"; } /** diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index 219341e..7cb88fe 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -584,7 +584,7 @@ protected function get_time(): float { * @return non-empty-string */ protected function get_hook(): string { - return "{$this->option}/migration/run"; + return "$this->option/migration/run"; } // endregion diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php index 05260f1..cd66d8a 100644 --- a/packages/core/src/PluginBoot.php +++ b/packages/core/src/PluginBoot.php @@ -108,7 +108,7 @@ static function () use ( $plugin_file ): void { * * @param \Throwable $exception The failure that stopped the composition. */ - do_action( "{$this->prefix}/boot/failed", $exception ); + do_action( "$this->prefix/boot/failed", $exception ); if ( self::is_development_environment() ) { throw $exception; } @@ -125,7 +125,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "{$this->prefix}/boot/blocked" ); + do_action( "$this->prefix/boot/blocked" ); return; } @@ -137,7 +137,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "{$this->prefix}/boot/succeeded" ); + do_action( "$this->prefix/boot/succeeded" ); } /** diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 474c5fb..70dd9ed 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -428,7 +428,7 @@ protected function delete_meta_keys( string $meta_type, array $keys ): void { protected function run_cleanup( string $scope ): bool { global $wpdb; - if ( null === $this->args['cleanup'] ) { + if ( \is_null( $this->args['cleanup'] ) ) { return true; } @@ -560,7 +560,7 @@ protected function get_matching_ids( string $table, string $id_column, string $c * @return \WP_Post|true|\WP_Error */ protected static function confirm_deleted( mixed $deleted ): mixed { - return null === $deleted || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted; + return \is_null( $deleted ) || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted; } /** diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index a341266..703023a 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -243,7 +243,7 @@ protected function get_page(): array { * * @param list $tabs The page's tabs. */ - $page['tabs'] = apply_filters( "{$this->id}/options_page/tabs", $page['tabs'] ); + $page['tabs'] = apply_filters( "$this->id/options_page/tabs", $page['tabs'] ); $this->page = $page; } diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index f4c8161..9344be5 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -262,7 +262,7 @@ protected function get_form( int $object_id ): array { * @param list $fields The form's fields for the object. * @param int $object_id The id of the object being edited or saved. */ - $form['fields'] = apply_filters( "{$this->id}/object_form/fields", $form['fields'], $object_id ); + $form['fields'] = apply_filters( "$this->id/object_form/fields", $form['fields'], $object_id ); $this->forms[ $object_id ] = $form; } diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index b332903..619a41e 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -218,7 +218,7 @@ protected function get_groups( array $tab, \WC_Product $product ): array { * @param list $groups The tab's groups. * @param \WC_Product $product The product being edited or saved. */ - return apply_filters( "{$this->id}/product_data_tab/groups", $tab['groups'], $product ); + return apply_filters( "$this->id/product_data_tab/groups", $tab['groups'], $product ); } /** From d960a2dc06d0a25c8f2be8bc8959c8c5fb675c16 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:38:28 +0100 Subject: [PATCH 16/47] Unpack the expressions the audit found hard to read The port audit marked local spots that read worse than the code around them: the updater passed a null configuration along through ternaries and reused $release for the cached and the fetched record; ObjectCache wrote its two batch checks two ways and tested the salt twice; get_severity() packed its check into a ?? throw; unschedule_hook() reassigned the hook to itself only to validate it and check_ready() called its result $error; the grants fingerprint nested five calls deep; two uninstall loops built their lists in the loop header and uninstall_site() took a flag that is always ! is_multisite(); PostMetaBox minted nonces in a loop to learn one field name; Forms filtered attributes through a closure inside its loop; and WooCommerceLogger's error-log condition ran 234 columns. Each is rewritten in place with the same behavior: early returns and named variables in the updater, one array_all() form for both batch checks and one salt test, guards in get_severity() and the hook check, $ready, $present, $prefix_tables and $site_ids, is_multisite() inside uninstall_site(), array_key_first() for the nonce field, a skip in the attribute loop and a named $dropped condition. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/src/Updater.php | 28 +++++++++++-------- packages/core/src/Caching/ObjectCache.php | 19 ++++++------- packages/core/src/Logging/AbstractLogger.php | 8 ++++-- .../core/src/Permission/CapabilityGrants.php | 3 +- packages/core/src/Scheduling/Scheduler.php | 10 +++---- packages/core/src/Uninstall/Uninstaller.php | 20 ++++++------- .../src/EditScreen/Hosts/PostMetaBox.php | 7 +++-- packages/settings/src/Forms.php | 6 +++- .../src/Logging/WooCommerceLogger.php | 3 +- 9 files changed, 60 insertions(+), 44 deletions(-) diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 85cae18..c4f8f72 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -49,9 +49,13 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename ) return $update; } - $config = self::get_updater_config( $plugin_file ); - $release = \is_null( $config ) ? null : self::get_release( $config ); - if ( \is_null( $config ) || \is_null( $release ) || ! is_wp_version_compatible( $release['requires'] ) ) { + $config = self::get_updater_config( $plugin_file ); + if ( \is_null( $config ) ) { + return false; + } + + $release = self::get_release( $config ); + if ( \is_null( $release ) || ! is_wp_version_compatible( $release['requires'] ) ) { return false; } @@ -77,7 +81,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { } $config = self::get_updater_config( $plugin_file ); - if ( \is_null( $config ) || ( \get_object_vars( $args )['slug'] ?? null ) !== $config['slug'] ) { + if ( \is_null( $config ) || ( $args->slug ?? null ) !== $config['slug'] ) { return $result; } @@ -197,18 +201,19 @@ protected static function get_release( array $config ): ?array { return null; } - $release = self::read_release( $cached ); - if ( ! \is_null( $release ) ) { - return $release; + $cached_release = self::read_release( $cached ); + if ( ! \is_null( $cached_release ) ) { + return $cached_release; } $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) ); $releases = \is_null( $body ) ? null : \json_decode( $body, true ); - $newest = array(); - $tag = ''; - $version = ''; - foreach ( ( \is_array( $releases ) && 'prerelease' === $config['channel'] ) ? $releases : array( $releases ) as $candidate ) { + $candidates = \is_array( $releases ) && 'prerelease' === $config['channel'] ? $releases : array( $releases ); + $newest = array(); + $tag = ''; + $version = ''; + foreach ( $candidates as $candidate ) { if ( ! \is_array( $candidate ) || true === ( $candidate['draft'] ?? false ) || ! \is_string( $candidate['tag_name'] ?? null ) ) { continue; } @@ -237,6 +242,7 @@ protected static function get_release( array $config ): ?array { } } + $release = null; $entry_file = \is_null( $package ) ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors. if ( ! \is_null( $entry_file ) ) { // WordPress's get_file_data() reads only paths, so the fetched entry file is parsed with the same pattern. diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php index fe01f04..43169d6 100644 --- a/packages/core/src/Caching/ObjectCache.php +++ b/packages/core/src/Caching/ObjectCache.php @@ -114,13 +114,17 @@ public function getMultiple( iterable $keys, mixed $default = null ): iterable { return array(); } - $salt = $this->find_salt(); + $salt = $this->find_salt(); + if ( \is_null( $salt ) ) { + return \array_fill_keys( $keys, $default ); + } + $names = \array_map( 'md5', $keys ); - $entries = \is_null( $salt ) ? array() : wp_cache_get_multiple( $names, $this->group ); + $entries = wp_cache_get_multiple( $names, $this->group ); $values = array(); foreach ( $keys as $index => $key ) { - $entry = \is_null( $salt ) ? null : CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt ); + $entry = CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt ); $values[ $key ] = \is_null( $entry ) ? $default : $entry[0]; } @@ -162,7 +166,7 @@ public function setMultiple( iterable $values, null|int|\DateInterval $ttl = nul } $stored = wp_cache_set_multiple( $entries, $this->group, $lifetime ?? 0 ); - return \array_all( \array_keys( $entries ), fn( $name ) => true === ( $stored[ $name ] ?? false ) ); + return \array_all( \array_keys( $entries ), static fn ( string $name ): bool => true === ( $stored[ $name ] ?? false ) ); } /** @@ -260,12 +264,7 @@ protected function delete_names( array $names ): bool { } $deleted = wp_cache_delete_multiple( $names, $this->group ); - $gone = true; - foreach ( $names as $name ) { - $gone = ( true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) ) && $gone; - } - - return $gone; + return \array_all( $names, fn ( string $name ): bool => true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) ); } /** diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php index 415e2c2..27a78f0 100644 --- a/packages/core/src/Logging/AbstractLogger.php +++ b/packages/core/src/Logging/AbstractLogger.php @@ -55,8 +55,12 @@ abstract class AbstractLogger extends PsrAbstractLogger { * @return int */ protected static function get_severity( mixed $level ): int { - $severity = \is_string( $level ) ? ( self::SEVERITIES[ $level ] ?? null ) : null; - return $severity ?? throw new PsrInvalidArgumentException( "Log level '" . ( \is_string( $level ) ? $level : \get_debug_type( $level ) ) . "' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + if ( ! \is_string( $level ) || ! \array_key_exists( $level, self::SEVERITIES ) ) { + $shown = \is_string( $level ) ? $level : \get_debug_type( $level ); + throw new PsrInvalidArgumentException( "Log level '$shown' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + + return self::SEVERITIES[ $level ]; } /** diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php index e619bbc..0200c73 100644 --- a/packages/core/src/Permission/CapabilityGrants.php +++ b/packages/core/src/Permission/CapabilityGrants.php @@ -82,7 +82,8 @@ public function register_hooks(): void { */ public function run(): void { $roles = wp_roles(); - $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, \array_values( \array_filter( \array_keys( $this->grants ), $roles->is_role( ... ) ) ) ) ) ); + $present = \array_keys( \array_intersect_key( $this->grants, $roles->roles ) ); + $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, $present ) ) ); $row = $this->get_row(); if ( $row['fingerprint'] === $fingerprint ) { return; diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php index 2818795..6436269 100644 --- a/packages/core/src/Scheduling/Scheduler.php +++ b/packages/core/src/Scheduling/Scheduler.php @@ -222,7 +222,7 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error { */ #[\Override] public function unschedule_hook( string $hook ): true|\WP_Error { - $hook = ( new ScheduledHook( $hook ) )->hook; // Given no hook name, Action Scheduler would cancel the pending actions of every hook. + new ScheduledHook( $hook ); // Given no hook name, Action Scheduler would cancel the pending actions of every hook. $ready = $this->check_ready( __METHOD__ ); if ( true !== $ready ) { return $ready; @@ -259,7 +259,7 @@ public function unschedule_hook( string $hook ): true|\WP_Error { * @return true|\WP_Error */ protected function check_ready( string $method ): true|\WP_Error { - $error = match ( true ) { + $ready = match ( true ) { 0 === did_action( 'init' ) || \array_any( $this->backends, static fn ( BackendInterface $backend ): bool => BackendState::Initializing === $backend->get_state() ) => new \WP_Error( 'scheduler_not_ready', 'Scheduling waits until init has fired and Action Scheduler has initialized.' ), // Readiness is measured for the site the scheduler was built on, while another site may run other plugins or lack Action Scheduler's tables. @@ -268,11 +268,11 @@ protected function check_ready( string $method ): true|\WP_Error { default => true, }; - if ( true !== $error ) { - _doing_it_wrong( esc_html( $method ), esc_html( $error->get_error_message() ), '2.0.0' ); + if ( true !== $ready ) { + _doing_it_wrong( esc_html( $method ), esc_html( $ready->get_error_message() ), '2.0.0' ); } - return $error; + return $ready; } /** diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 70dd9ed..198bee1 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -137,21 +137,22 @@ public static function run( string $plugin_file, array $footprint, array $args = $args ); if ( ! is_multisite() ) { - $uninstaller->uninstall_site( true ); + $uninstaller->uninstall_site(); } else { if ( wp_is_large_network() && ! self::is_constant_true( 'WP_CLI' ) ) { // A web request could not finish sweeping a large network within the server's time limit, which WP-CLI does not have. throw new RuntimeException( 'Uninstall does not sweep a large network in a web request; run `wp plugin uninstall` instead.' ); } - foreach ( get_sites( + $site_ids = get_sites( array( 'fields' => 'ids', 'number' => 0, ) - ) as $site_id ) { + ); + foreach ( $site_ids as $site_id ) { switch_to_blog( $site_id ); try { - $uninstaller->uninstall_site( false ); + $uninstaller->uninstall_site(); } finally { restore_current_blog(); if ( wp_cache_supports( 'flush_runtime' ) ) { // WordPress keeps every visited site's options in its runtime cache, so the sweep's memory would grow with each site. @@ -188,11 +189,9 @@ public static function run( string $plugin_file, array $footprint, array $args = * * @global \wpdb $wpdb The WordPress database connection. * - * @param bool $network_footprint Whether the pass also deletes the network options and network-wide user meta, which a single site keeps as its own. - * * @return void */ - protected function uninstall_site( bool $network_footprint ): void { + protected function uninstall_site(): void { global $wpdb; $failed = \count( $this->failures ); @@ -293,9 +292,10 @@ protected function uninstall_site( bool $network_footprint ): void { } } - $keys = $meta_keys[ $name ] ?? array(); + $keys = $meta_keys[ $name ] ?? array(); + $prefix_tables = '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ); foreach ( $meta_prefixes[ $name ] ?? array() as $prefix ) { - foreach ( '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ) as $table ) { + foreach ( $prefix_tables as $table ) { $keys = \array_merge( $keys, $this->get_names_by_prefix( $table, 'meta_key', $prefix ) ); } } @@ -338,7 +338,7 @@ protected function uninstall_site( bool $network_footprint ): void { $this->record_failure( "table $table", $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A plugin's own table, which no WordPress API drops. } - if ( $network_footprint ) { + if ( ! is_multisite() ) { $this->delete_network_footprint(); } diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php index c1e59ff..2418462 100644 --- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php +++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php @@ -156,9 +156,10 @@ public function render_meta_box( \WP_Post $post ): void { // The block editor's heartbeat script sets only core's nonces, by name, while the classic editor's sets every one the heartbeat returns. if ( true === get_current_screen()?->is_block_editor() ) { - foreach ( \array_keys( $this->form->get_refreshed_nonces( $post->ID ) ) as $field ) { - $field = esc_js( $field ); - wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$field'] ) { jQuery( '#$field' ).val( nonces.replace['$field'] ); } } );" ); + $nonce_field = \array_key_first( $this->form->get_refreshed_nonces( $post->ID ) ); + if ( ! \is_null( $nonce_field ) ) { + $nonce_field = esc_js( $nonce_field ); + wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$nonce_field'] ) { jQuery( '#$nonce_field' ).val( nonces.replace['$nonce_field'] ); } } );" ); } } } diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 6bb82d6..daaae25 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -558,7 +558,11 @@ protected static function render_help( array $help ): string { */ protected static function render_attributes( array $own, array $custom = array() ): string { $html = ''; - foreach ( \array_filter( $own + $custom, static fn ( string|bool|null $value ): bool => ! \is_null( $value ) && false !== $value ) as $name => $value ) { + foreach ( $own + $custom as $name => $value ) { + if ( \is_null( $value ) || false === $value ) { + continue; + } + $html .= true === $value ? " $name" : " $name=\"" . self::escape_attribute( $value ) . '"'; } diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index 6248880..f18d2b3 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -103,7 +103,8 @@ public function log( mixed $level, string|\Stringable $message, array $context = $this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null; $this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded ); - if ( $severity >= self::SEVERITIES[ PsrLogLevel::ERROR ] && ( ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity ) ) { + $dropped = ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity; + if ( $severity >= self::SEVERITIES[ PsrLogLevel::ERROR ] && $dropped ) { \error_log( self::format_record( $this->source, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them. } } From 9cb8fd3acb8f1b6db02299fb93fdeda1fffb5168 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:39:10 +0100 Subject: [PATCH 17/47] Name helpers as their siblings do and check the cache key first The audit found names and orders out of step with their siblings. MigrationRunner's holds_lock() reads as a predicate but refreshes the lock and logs, beside an unlock() that Lock calls release(). ObjectCache named its nullable salt read find_salt() and its read-or-start get_salt(), the opposite sense of TransientCache's get_generation() and start_generation(). ObjectCache::set() checked the TTL before the key, while TransientCache checks the key first, as the guard rule's most-egregious-first order asks. And WooCommerceLogger called its threshold severity $floor, the word the package uses for WooCommerce's version floor. The runner's helpers become refresh_lock() and release_lock(); the cache's become get_salt() for the nullable read and start_salt() for the write, its two writers reading get_salt() ?? start_salt(); both caches' set() check the key before the TTL; and the logger's property becomes $threshold. A bad key throws from either set() before and after, so behavior is unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/src/Caching/ObjectCache.php | 18 +++++++++--------- .../core/src/Migration/MigrationRunner.php | 16 ++++++++-------- .../src/Logging/WooCommerceLogger.php | 8 ++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php index 43169d6..21fc13c 100644 --- a/packages/core/src/Caching/ObjectCache.php +++ b/packages/core/src/Caching/ObjectCache.php @@ -61,13 +61,13 @@ public function get( string $key, mixed $default = null ): mixed { // phpcs:igno */ #[\Override] public function set( string $key, mixed $value, null|int|\DateInterval $ttl = null ): bool { + $name = \md5( CacheEntries::check_key( $key ) ); $lifetime = CacheEntries::get_lifetime( $ttl ); if ( ! \is_null( $lifetime ) && $lifetime <= 0 ) { return $this->delete( $key ); } - $name = \md5( CacheEntries::check_key( $key ) ); - return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ), $this->group, $lifetime ?? 0 ); + return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ?? $this->start_salt() ), $this->group, $lifetime ?? 0 ); } /** @@ -114,7 +114,7 @@ public function getMultiple( iterable $keys, mixed $default = null ): iterable { return array(); } - $salt = $this->find_salt(); + $salt = $this->get_salt(); if ( \is_null( $salt ) ) { return \array_fill_keys( $keys, $default ); } @@ -160,7 +160,7 @@ public function setMultiple( iterable $values, null|int|\DateInterval $ttl = nul return true; } - $salt = $this->get_salt(); + $salt = $this->get_salt() ?? $this->start_salt(); foreach ( $entries as $name => $value ) { $entries[ $name ] = CacheEntries::wrap( $value, $salt ); } @@ -215,7 +215,7 @@ public function has( string $key ): bool { */ protected function read( string $key ): ?array { $name = \md5( CacheEntries::check_key( $key ) ); - $salt = $this->find_salt(); + $salt = $this->get_salt(); return \is_null( $salt ) ? null : CacheEntries::unwrap( wp_cache_get( $name, $this->group ), $salt ); } @@ -229,13 +229,13 @@ protected function read( string $key ): ?array { * * @return string|null */ - protected function find_salt(): ?string { + protected function get_salt(): ?string { $salt = wp_cache_get( 'last_changed', $this->group ); return \is_string( $salt ) && '' !== $salt ? $salt : null; } /** - * Returns the group's salt, after starting one when it has none. + * Starts a salt for the group and returns it. * * Started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts. * @@ -244,8 +244,8 @@ protected function find_salt(): ?string { * * @return string */ - protected function get_salt(): string { - return $this->find_salt() ?? wp_cache_set_last_changed( $this->group ); + protected function start_salt(): string { + return wp_cache_set_last_changed( $this->group ); } /** diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index 7cb88fe..8229642 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -257,7 +257,7 @@ public function run(): void { } while ( $revision < $target ) { - if ( ! $this->holds_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check. + if ( ! $this->refresh_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check. return; } @@ -265,7 +265,7 @@ public function run(): void { $memory_limit = wp_convert_hr_to_bytes( (string) \ini_get( 'memory_limit' ) ); $spent = ! \is_null( $this->deadline ) && ( $this->get_time() >= $this->deadline || ( 0 < $memory_limit && \memory_get_usage( true ) > 0.9 * $memory_limit ) ); if ( $spent || ( $step instanceof BatchedMigrationStepInterface && ! doing_action( $this->get_hook() ) ) ) { - $this->unlock(); // The lock goes first, so that the continuation can take it. + $this->release_lock(); // The lock goes first, so that the continuation can take it. $this->schedule_continuation(); return; } @@ -282,7 +282,7 @@ public function run(): void { $step(); } - if ( ! $this->holds_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now. + if ( ! $this->refresh_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now. return; } @@ -296,7 +296,7 @@ public function run(): void { } } } catch ( \Throwable $exception ) { - if ( $this->holds_lock( $revision ?? 0 ) ) { + if ( $this->refresh_lock( $revision ?? 0 ) ) { $this->record_error( $exception->getMessage(), $revision ); } @@ -310,14 +310,14 @@ public function run(): void { // In production the lock stays until its hold expires, when a failing step runs again; elsewhere the fixed step runs on the next request. if ( PluginBoot::is_development_environment() ) { - $this->unlock(); + $this->release_lock(); throw $exception; } return; } - $this->unlock(); + $this->release_lock(); } // endregion @@ -417,7 +417,7 @@ public function run_now(): void { * * @return bool */ - protected function holds_lock( int $revision ): bool { + protected function refresh_lock( int $revision ): bool { try { if ( $this->lock->refresh( $this->owner ) ) { return true; @@ -446,7 +446,7 @@ protected function holds_lock( int $revision ): bool { * * @return void */ - protected function unlock(): void { + protected function release_lock(): void { try { $this->lock->release( $this->owner ); } catch ( RuntimeException $exception ) { diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index f18d2b3..8bf26a1 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -19,14 +19,14 @@ final class WooCommerceLogger extends AbstractLogger { // region FIELDS AND CONSTANTS /** - * The severity of the lowest level the threshold logs, or null for the WP_DEBUG default. + * The severity of the lowest level logged, or null for the WP_DEBUG default. * * @since 2.0.0 * @version 2.0.0 * * @var int|null */ - protected ?int $floor = null; + protected ?int $threshold = null; // endregion @@ -50,7 +50,7 @@ public function __construct( protected ?\WC_Logger_Interface $logger = null ) { if ( ! \is_null( $threshold ) ) { - $this->floor = self::get_severity( $threshold ); + $this->threshold = self::get_severity( $threshold ); } } @@ -77,7 +77,7 @@ public function __construct( #[\Override] public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { $severity = self::get_severity( $level ); - if ( $severity < ( $this->floor ?? self::get_default_threshold() ) ) { + if ( $severity < ( $this->threshold ?? self::get_default_threshold() ) ) { return; } From 53bc6cdc04733e8a332ead344bb603e4bf151ca0 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:39:49 +0100 Subject: [PATCH 18/47] Place hook callbacks, helpers and getters in their regions The region rule puts hook callbacks under HOOKS, protected helpers under HELPERS and getters under GETTERS, but MigrationRunner and CapabilityGrants kept run(), which wp_loaded calls, under METHODS, and AdminNotices shelved its select() and acknowledge() helpers and its get_notices() getter there. run() moves to the top of HOOKS in both classes, whose METHODS regions then hold nothing and go; select() and acknowledge() open AdminNotices' HELPERS; and get_notices() sits in a GETTERS region before INHERITED METHODS. Only the order of members changes. Assisted-by: Claude Code:claude-opus-5-5 --- .../core/src/AdminNotice/AdminNotices.php | 238 +++++++++--------- .../core/src/Migration/MigrationRunner.php | 6 +- .../core/src/Permission/CapabilityGrants.php | 6 +- 3 files changed, 123 insertions(+), 127 deletions(-) diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 8980258..38d6fa0 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -99,6 +99,28 @@ public function __construct( // endregion + // region GETTERS + + /** + * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param NoticeScope $scope The scope. + * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default 0. + * + * @throws InvalidArgumentException Thrown when a user is given for another scope. + * @throws LogicException Thrown when user notices have no user and nobody is logged in. + * + * @return list + */ + public function get_notices( NoticeScope $scope, int $user_id = 0 ): array { + return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) ); + } + + // endregion + // region INHERITED METHODS /** @@ -169,123 +191,6 @@ public function remove( string $id, NoticeScope $scope, int $user_id = 0 ): void $this->delete( $scope, $this->get_owner( $scope, $user_id ), $id ); } - /** - * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param NoticeScope $scope The scope. - * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default 0. - * - * @throws InvalidArgumentException Thrown when a user is given for another scope. - * @throws LogicException Thrown when user notices have no user and nobody is logged in. - * - * @return list - */ - public function get_notices( NoticeScope $scope, int $user_id = 0 ): array { - return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) ); - } - - /** - * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens. - * - * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose storage throws is logged and left out. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param bool $block_editor Whether to select the block editor's notices. - * - * @return list - */ - protected function select( bool $block_editor ): array { - $user_id = get_current_user_id(); - if ( 0 === $user_id ) { - return array(); - } - - $selected = array(); - foreach ( self::ORDER as $scope ) { - $shows_here = match ( true ) { - $block_editor => NoticeScope::Request !== $scope, - is_network_admin() => NoticeScope::Site !== $scope, - is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope, - default => is_blog_admin(), - }; - if ( ! $shows_here ) { - continue; - } - - try { - foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) { - if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) { - continue; - } - - if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) { - continue; - } - - if ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) { - continue; - } - - $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null ); - if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) { - continue; - } - - /** - * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param bool $show Whether the notice shows. Only true shows it. - * @param string $id The notice's id. - * @param string $scope The notice's scope. - */ - if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) { - $selected[] = $notice; - } - } - } catch ( \Throwable $exception ) { - $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception ); - } - } - - return $selected; - } - - /** - * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it. - * - * A throw from storage is logged, so the notice may show again. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param Notice $notice The notice as it was selected. - * - * @return void - */ - protected function acknowledge( Notice $notice ): void { - if ( $notice->persistent ) { - return; - } - - try { - if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) { - $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id ); - } else { - $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' ); - } - } catch ( \Throwable $exception ) { - $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception ); - } - } - // endregion // region HOOKS @@ -580,6 +485,105 @@ public function rest_deliver(): array { // region HELPERS + /** + * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens. + * + * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose storage throws is logged and left out. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param bool $block_editor Whether to select the block editor's notices. + * + * @return list + */ + protected function select( bool $block_editor ): array { + $user_id = get_current_user_id(); + if ( 0 === $user_id ) { + return array(); + } + + $selected = array(); + foreach ( self::ORDER as $scope ) { + $shows_here = match ( true ) { + $block_editor => NoticeScope::Request !== $scope, + is_network_admin() => NoticeScope::Site !== $scope, + is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope, + default => is_blog_admin(), + }; + if ( ! $shows_here ) { + continue; + } + + try { + foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) { + if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) { + continue; + } + + if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) { + continue; + } + + if ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) { + continue; + } + + $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null ); + if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) { + continue; + } + + /** + * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param bool $show Whether the notice shows. Only true shows it. + * @param string $id The notice's id. + * @param string $scope The notice's scope. + */ + if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) { + $selected[] = $notice; + } + } + } catch ( \Throwable $exception ) { + $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception ); + } + } + + return $selected; + } + + /** + * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it. + * + * A throw from storage is logged, so the notice may show again. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param Notice $notice The notice as it was selected. + * + * @return void + */ + protected function acknowledge( Notice $notice ): void { + if ( $notice->persistent ) { + return; + } + + try { + if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) { + $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id ); + } else { + $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' ); + } + } catch ( \Throwable $exception ) { + $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception ); + } + } + /** * Returns the owner of the scope's notices: 0 for the request, otherwise the user, or the current blog or network, so code inside switch_to_blog() addresses the switched site. * diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index 8229642..11aa015 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -166,7 +166,7 @@ public function register_hooks(): void { // endregion - // region METHODS + // region HOOKS /** * Runs the pending steps under the lock, pausing at a batched step outside the runner's hook or once the request's budget is spent. @@ -320,10 +320,6 @@ public function run(): void { $this->release_lock(); } - // endregion - - // region HOOKS - /** * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background, with a link that runs a pending batched step now. * diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php index 0200c73..3e5165a 100644 --- a/packages/core/src/Permission/CapabilityGrants.php +++ b/packages/core/src/Permission/CapabilityGrants.php @@ -68,7 +68,7 @@ public function register_hooks(): void { // endregion - // region METHODS + // region HOOKS /** * Revokes each ledgered pair no longer declared and grants each declared pair that its role lacks and the ledger does not list, once the declaration or the declared roles have changed since the last complete run. @@ -179,10 +179,6 @@ public function run(): void { $this->logger->warning( 'The roles row did not keep the capability changes {changes}, so the next request makes them again.', array( 'changes' => \implode( ', ', $failed ) ) ); } - // endregion - - // region HOOKS - /** * Forgets the grants of each declared role that a write of the site's roles row deletes, so that the role is granted them again once it exists anew. * From 53c0874bf8233e7b60ffe5bfc935101e4b3b02e2 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:41:14 +0100 Subject: [PATCH 19/47] State the scheduler's and caches' behavior once, as they behave The scheduler and cache docblocks misstated or repeated behavior. The Action Scheduler backend called its unschedule best effort with no failure worse than pending actions, though a failing database write loops the bulk cancel until the request's time limit. ensure_recurring() packed its replacement rules into its summary, joined two clauses with a semicolon, never said a chain that exists keeps its priority, and repeated the running-occurrence reason clear_scheduled_hook() carries; Scheduler::clear() repeated it again. TransientCache's docblock ran two reasons into one sentence and counted rows where it stores transients, ObjectCache's whys had no subject, the caches' @throws restated their validation rules, and Scheduler::unschedule_hook() left out the throw its interface documents. Each docblock now states the role, then each why once on the member it affects: the backend's comment names the worst case, ensure_recurring() keeps its role in the summary and its rules in two plain sentences, the caches' @throws read "Thrown when ... is invalid.", and limitation 7 names the looping cancel. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/src/Caching/ObjectCache.php | 6 +++--- packages/core/src/Caching/TransientCache.php | 4 ++-- .../core/src/Scheduling/Backends/ActionSchedulerBackend.php | 2 +- packages/core/src/Scheduling/Scheduler.php | 4 ++-- packages/core/src/Scheduling/SchedulerInterface.php | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5038e1..b65e0c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. -7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. +7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. 8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted, and the current user sees a grant or revocation from the next request. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked. 9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key. 10. The framework's queries name tables through `%i`, so a `wpdb` replacement whose `prepare()` predates WordPress 6.2 breaks them. A `Lock` lives in the current site's options table, and a hold is judged by the TTL its holder wrote. A holder learns of a takeover only at its next `refresh()`, so data the lock protects is written with a compare-and-swap, such as `OptionRows::replace()`, where a stale write matters. diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php index 21fc13c..e2dbaae 100644 --- a/packages/core/src/Caching/ObjectCache.php +++ b/packages/core/src/Caching/ObjectCache.php @@ -27,7 +27,7 @@ final class ObjectCache implements PsrCacheInterface { * * @param string $group The object cache group, under the plugin's own prefix. * - * @throws InvalidArgumentException Thrown when the group is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens. + * @throws InvalidArgumentException Thrown when the group is invalid. */ public function __construct( protected string $group @@ -222,7 +222,7 @@ protected function read( string $key ): ?array { /** * Returns the group's salt, or null when it has none. * - * Read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one. + * The salt is read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one. * * @since 2.0.0 * @version 2.0.0 @@ -237,7 +237,7 @@ protected function get_salt(): ?string { /** * Starts a salt for the group and returns it. * - * Started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts. + * The salt is started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Caching/TransientCache.php b/packages/core/src/Caching/TransientCache.php index f13c176..4e1ae3a 100644 --- a/packages/core/src/Caching/TransientCache.php +++ b/packages/core/src/Caching/TransientCache.php @@ -12,7 +12,7 @@ /** * Caches values as transients of the current site under a prefix, and clears them all at once by starting a new generation. * - * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters, and holds the generation it was stored under, so that clear() leaves one row per key rather than one per generation. + * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters. Each also holds the generation it was stored under, so that clear() leaves one transient per key rather than one per generation. * * @since 2.0.0 * @version 2.0.0 @@ -63,7 +63,7 @@ final class TransientCache implements PsrCacheInterface { * @param string $prefix The prefix of every transient name, under the plugin's own prefix. * @param string|null $generation_option Optional. The option that keeps the generation, which caches that clear together share. Default is the prefix followed by cache_generation. * - * @throws InvalidArgumentException Thrown when the prefix is empty, surrounded by whitespace or longer than 140 characters, or the option name is empty, surrounded by whitespace or longer than 191 characters. + * @throws InvalidArgumentException Thrown when the prefix or the generation option name is invalid. */ public function __construct( protected string $prefix, diff --git a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php index d1d6f8b..3959637 100644 --- a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php +++ b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php @@ -142,7 +142,7 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error { */ #[\Override] public function unschedule_hook( string $hook ): true|\WP_Error { - // Best effort, since Action Scheduler reports no failure: at worst the hook's pending actions stay and run, or fail without a callback, until a later call succeeds. + // At worst a failing database write keeps Action Scheduler's bulk cancel looping until the request's time limit, and the hook's pending actions stay until a later call succeeds. as_unschedule_all_actions( $hook ); return true; } diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php index 6436269..eab2dec 100644 --- a/packages/core/src/Scheduling/Scheduler.php +++ b/packages/core/src/Scheduling/Scheduler.php @@ -219,6 +219,8 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error { * * @since 2.0.0 * @version 2.0.0 + * + * @throws InvalidArgumentException Thrown when the hook name is invalid. */ #[\Override] public function unschedule_hook( string $hook ): true|\WP_Error { @@ -306,8 +308,6 @@ protected static function get_failure( \Throwable $exception ): \WP_Error { /** * Cancels every pending invocation of the hook on every readable backend, then checks that none is left. * - * Action Scheduler reports no failed cancellation, so only the check catches one. - * * @since 2.0.0 * @version 2.0.0 * diff --git a/packages/core/src/Scheduling/SchedulerInterface.php b/packages/core/src/Scheduling/SchedulerInterface.php index eaca9e1..1699696 100644 --- a/packages/core/src/Scheduling/SchedulerInterface.php +++ b/packages/core/src/Scheduling/SchedulerInterface.php @@ -49,9 +49,9 @@ public function enqueue( ScheduledHook $hook, int $priority = 10 ): true|\WP_Err public function schedule_single( ScheduledHook $hook, int $timestamp, int $priority = 10 ): true|\WP_Error; /** - * Ensures that the hook recurs at the given interval, creating its chain when none exists and replacing duplicate chains, or a chain at another interval, with one chain, which also cancels a pending one-off invocation of the identity. + * Ensures that the hook recurs at the given interval, as one chain. * - * On Action Scheduler a running occurrence defers any replacement to a later call, because it queues its successor only after it completes; WP-Cron shows no running state, so it is called outside the hook's own callback. + * A missing chain is created at the priority, which a chain that exists keeps, and duplicate chains or a chain at another interval are replaced with one, which also cancels a pending one-off invocation of the identity. On Action Scheduler a running occurrence defers any replacement to a later call. On WP-Cron, call it outside the hook's own callback, because WP-Cron shows no running state. * * @since 2.0.0 * @version 2.0.0 From c47de3818034b48080ab2a22de1d57120f1a760e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:41:41 +0100 Subject: [PATCH 20/47] Describe the bootstrap classes by their roles The bootstrap docblocks drifted from what the code does and how the house writes it. Requirements' class docblock named half of its role, and print_admin_notice() justified itself by its callers in higher packages and put its @phpstan-param inside the @param group. The updater's register() repeated the 5.8 reason its configuration comment gives, get_release() promised a fallback to an older usable release it never makes and listed cache durations its code states, and two whys trailed long lines. Requirements says it also prints the notices that report what it finds, and print_admin_notice() keeps only the reason it needs nothing beyond WordPress, with its PHPStan tag in its own group. register() drops the repeated sentence, get_release() says it returns the channel's newest release only when its asset and requirements are usable, and the two whys sit above the checks they explain. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/src/Requirements.php | 7 ++++--- packages/bootstrap/src/Updater.php | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php index 97b6223..9b22377 100644 --- a/packages/bootstrap/src/Requirements.php +++ b/packages/bootstrap/src/Requirements.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Keeps a plugin from loading code its PHP or WordPress cannot run. + * Keeps a plugin from loading code its PHP or WordPress cannot run, and prints the notices that report it. * * @since 2.0.0 * @version 2.0.0 @@ -71,14 +71,15 @@ static function () use ( $plugin_file ): void { /** * Prints an admin notice about the plugin to users who can activate plugins, as wp_admin_notice() prints it. * - * The framework's boot checks report through it, because they run before the plugin's own admin notices exist or without them. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments. + * It needs nothing beyond WordPress itself, so it reports problems found before a plugin has booted. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments. * * @since 2.0.0 * @version 2.0.0 * + * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args + * * @param string|\WP_Error $message The message, as HTML, or the error whose message to print. * @param array $args Optional. The notice's arguments, as wp_admin_notice() takes them. Default is an error notice. - * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args * * @return void */ diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index c4f8f72..102c460 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -31,7 +31,7 @@ protected function __construct() {} /** * Offers the plugin's GitHub releases as updates, and answers its "View details" request, while its Update URI points at github.com. * - * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement. WordPress asks for Update URI offers from version 5.8, so below it no update is offered. + * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement. * * @since 2.0.0 * @version 2.0.0 @@ -183,9 +183,9 @@ protected static function get_updater_config( string $plugin_file ): ?array { } /** - * Returns the newest usable release on the configured channel, from the plugin's cache while it holds one, and otherwise from GitHub with the requirements of its tagged entry file. + * Returns the channel's newest release when its asset and the requirements of its tagged entry file are usable, from the plugin's cache while it holds one and otherwise from GitHub, or null. * - * A usable release is cached for an hour, and a failed fetch or an unusable release as empty for five minutes, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included. + * A failed fetch or an unusable release is cached as empty, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included. * * @since 2.0.0 * @version 2.0.0 @@ -219,11 +219,13 @@ protected static function get_release( array $config ): ?array { } $candidate_version = \ltrim( $candidate['tag_name'], 'v' ); - if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'. + // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'. + if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { continue; } - if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release. + // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release. + if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { continue; } From 396154bc59ffa89b5fb17a56c32dd198bb3584ed Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:42:36 +0100 Subject: [PATCH 21/47] State the notices' and storage's reasons once and in the house form The notice and storage docblocks misnamed or repeated their reasons. Notice spoke of "Stores", which no longer exist; AdminNotices called every caught failure a storage failure, though filters and capability checks throw there too; its constructor pointed the prefix at a protected constant no consumer can read; the delivery script's docblock carried three whys that belong beside the script lines they explain; optional parameters read "Default 0." and Notice's id joined two sentences with a semicolon. Lock repeated OptionRows' reasons for compare-and-swap, and OptionRows::get() opened a comment with a fragment. The notice docblocks name AdminNotices, read, write or filter failures and the prefix rule in words; the delivery script's whys are comments beside the load check, the save hook's priority, the catch and the HTML option; defaults read "Default is ..."; Lock points at OptionRows, whose class docblock now holds the add_option(), update_option() and delete_option() reasons; and the get() comment is a sentence. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/src/Updater.php | 2 +- .../core/src/AdminNotice/AdminNotices.php | 25 +++++++++++-------- packages/core/src/AdminNotice/Notice.php | 10 ++++---- packages/core/src/Storage/Lock.php | 2 +- packages/core/src/Storage/OptionRows.php | 4 +-- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 102c460..7e557bb 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -311,7 +311,7 @@ protected static function read_release( $value ): ?array { * @version 2.0.0 * * @param string $url The URL. - * @param array $headers Optional. The request's headers. Default none. + * @param array $headers Optional. The request's headers. Default is none. * * @return string|null */ diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 38d6fa0..30cb171 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -81,8 +81,8 @@ final class AdminNotices implements ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $prefix The plugin's prefix, matching PREFIX_PATTERN without a reserved segment. - * @param PsrLoggerInterface $logger The logger that records storage failures. + * @param string $prefix The plugin's prefix: a lowercase letter followed by up to 31 lowercase letters, digits and underscores, with no network or hidden segment. + * @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices. * * @throws InvalidArgumentException Thrown when the prefix is invalid. */ @@ -108,7 +108,7 @@ public function __construct( * @version 2.0.0 * * @param NoticeScope $scope The scope. - * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default 0. + * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default is 0. * * @throws InvalidArgumentException Thrown when a user is given for another scope. * @throws LogicException Thrown when user notices have no user and nobody is logged in. @@ -150,7 +150,7 @@ public function register_hooks(): void { * @version 2.0.0 * * @param Notice $notice The notice. - * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0. + * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0. * * @throws InvalidArgumentException Thrown when a user is given for a notice of another scope. * @throws LogicException Thrown when a user notice has no user and nobody is logged in. @@ -180,7 +180,7 @@ public function add( Notice $notice, int $user_id = 0 ): void { * * @param string $id The notice's id. * @param NoticeScope $scope The notice's scope. - * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0. + * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0. * * @throws InvalidArgumentException Thrown when a user is given for another scope. * @throws LogicException Thrown when a user notice has no user and nobody is logged in. @@ -279,9 +279,7 @@ public function register_rest_routes(): void { } /** - * Enqueues, on the post editor, the script that shows the current user's block-editor notices, fetched from the delivery endpoint as the editor loads with one-shots waiting and after each save that is not an autosave. - * - * The load only reports whether one-shots wait, because the meta boxes' save loads the edit screen in the background and discards it. The editor.savePost handler runs at priority 20, after the meta boxes save at 10, so it fetches the rejections that save reported, and it never throws, because a throw there reports the save as failed. Messages show as HTML, as on classic screens. + * Enqueues, on the post editor, the script that shows the current user's block-editor notices. * * @since 2.0.0 * @version 2.0.0 @@ -316,19 +314,24 @@ function deliver() { id: notice.id, isDismissible: notice.isDismissible, speak: false, + // Messages show as HTML, as on classic screens. __unstableHTML: true } ); // The notices store would read the markup aloud with its entities spelled out. wp.a11y.speak( notice.spoken, 'error' === notice.status ? 'assertive' : 'polite' ); } ); } ).catch( function ( error ) { + // A throw from an editor.savePost handler reports the save as failed. console.error( 'The notice delivery failed.', error ); } ); } + // The page only reports whether one-shots wait, because the meta boxes' save loads this screen in the background and discards it. if ( settings.waiting ) { deliver(); } + + // After the meta boxes save at priority 10, so the delivery brings the rejections that save reported. wp.hooks.addAction( 'editor.savePost', settings.prefix + '/notices', function ( post, options ) { if ( ! ( options && options.isAutosave ) ) { deliver(); @@ -488,7 +491,7 @@ public function rest_deliver(): array { /** * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens. * - * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose storage throws is logged and left out. + * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose read or filters throw is logged and left out. * * @since 2.0.0 * @version 2.0.0 @@ -801,13 +804,13 @@ protected function get_message( Notice $notice ): string { } /** - * Logs a storage failure as an error. + * Logs a failure to read, write or filter a scope's notices as an error. * * @since 2.0.0 * @version 2.0.0 * * @param string $message The message, with {scope} and {message} placeholders. - * @param NoticeScope $scope The scope whose storage failed. + * @param NoticeScope $scope The scope whose notices failed. * @param \Throwable $exception The failure. * * @return void diff --git a/packages/core/src/AdminNotice/Notice.php b/packages/core/src/AdminNotice/Notice.php index 905456d..37e23bd 100644 --- a/packages/core/src/AdminNotice/Notice.php +++ b/packages/core/src/AdminNotice/Notice.php @@ -10,7 +10,7 @@ /** * An admin notice: what it says, to whom, on which screens and for how long. * - * Stores keep the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace. + * AdminNotices keeps the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace. * * @since 2.0.0 * @version 2.0.0 @@ -40,16 +40,16 @@ * @since 2.0.0 * @version 2.0.0 * - * @param string $id The id, unique among its scope's notices and matching ID_PATTERN; a viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id. + * @param string $id The id, unique among its scope's notices and matching ID_PATTERN. A viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id. * @param string $message The message, as HTML in the locale of the request that creates the notice. * @param NoticeType $type Optional. The kind of notice. Default is an error. * @param NoticeScope $scope Optional. Where the notice belongs. Default is a user. - * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default false. - * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default true. + * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default is false. + * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default is true. * @param string|null $capability Optional. The capability a viewer needs, which site and network notices require. Default is none. * @param list $screens Optional. The ids of the screens the notice shows on. Default is every screen of its scope's surfaces. * @param int|null $expires_at Optional. The Unix time at which the notice expires. Default is never. - * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default false. + * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default is false. * * @throws InvalidArgumentException Thrown when the id, capability, screens or expiry is invalid. */ diff --git a/packages/core/src/Storage/Lock.php b/packages/core/src/Storage/Lock.php index c5313cb..c900974 100644 --- a/packages/core/src/Storage/Lock.php +++ b/packages/core/src/Storage/Lock.php @@ -12,7 +12,7 @@ /** * Leases a named lock to one owner at a time, which refreshes its hold while it works, and lets another owner take the lock over from a hold left past its TTL. * - * The lock is one row of the current site's options table, written only by compare-and-swap, because add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds. + * The lock is one row of the current site's options table, written only through OptionRows' compare-and-swap. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php index 9997802..2eb5487 100644 --- a/packages/core/src/Storage/OptionRows.php +++ b/packages/core/src/Storage/OptionRows.php @@ -10,7 +10,7 @@ /** * Reads and writes options as rows of the current site's options table, with writes that are atomic or conditional on the exact row read. * - * The options API decides each write from a possibly cached read, so it cannot write conditionally. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading. + * The options API decides each write from a possibly cached read, so add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading. * * @since 2.0.0 * @version 2.0.0 @@ -64,7 +64,7 @@ public static function get( string $name ): ?OptionRow { self::check_name( $name ); - // A row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row. + // The query reads a row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row. $wpdb->flush(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $name ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A cached value may predate another request's write. if ( '' !== $wpdb->last_error ) { // wpdb::get_row() returns null for a failed query as for a missing row, so the error left after the flush tells them apart. From 63c4f3ac9bd7bd3388513d15e6ec3e49664baa63 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:43:18 +0100 Subject: [PATCH 22/47] Describe the uninstall engine's failures as they happen The uninstall docblocks and comments misstated what fails and how. run() narrated each pass and repeated the reasons its inline comments give, its @throws left out failed reads, and its default read "Default none". The cancel comment said Action Scheduler throws when its store fails, though only its hybrid store, which runs while it migrates its data, throws, and its default store repeats a cancel it cannot write until the request's time limit. The cache comment promised entries expire, though WooCommerce's meta groups never do. The constructor typed the footprint's strings as any string, though get_strings() keeps none empty. Two comments in the runner and the kernel read badly cold, one with a semicolon. run() states the one fact a caller needs, that failures are thrown once every site was visited and a failed site keeps its consent; its tags name reads and "Default is none"; the comments name the hybrid store and entries that stay until invalidated or evicted; the strings are non-empty in every shape; the two comments are plain sentences; and limitation 3 names the default store's repeating cancel. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- .../core/src/Migration/MigrationRunner.php | 2 +- packages/core/src/PluginKernel.php | 2 +- packages/core/src/Uninstall/Uninstaller.php | 20 +++++++++---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b65e0c6..01af8ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. 2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. -3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. +3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a cancel Action Scheduler's default store cannot write repeats until the request's time limit; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index 11aa015..5e91865 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -185,7 +185,7 @@ public function run(): void { return; } - // Outside the hook a pending batched step only schedules the hook, so no lock is taken; a stale revision only leaves plain steps to the hook, which runs them too. + // Outside the hook a pending batched step only schedules the hook, so no lock is taken. A stale cached revision at worst leaves plain steps to the hook, which runs them too. if ( ! doing_action( $this->get_hook() ) && ( $this->steps[ $cached ] ?? null ) instanceof BatchedMigrationStepInterface ) { $this->schedule_continuation(); return; diff --git a/packages/core/src/PluginKernel.php b/packages/core/src/PluginKernel.php index ced7a9d..22f7922 100644 --- a/packages/core/src/PluginKernel.php +++ b/packages/core/src/PluginKernel.php @@ -118,7 +118,7 @@ protected function resolve( array $classes ): array { $components[] = $component; if ( \is_a( $class, CompositeComponentInterface::class, true ) ) { - \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // The declared class names the children, so no runtime state can add an unvisited node. + \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // Children come from a static method, so the visited set covers every node the walk can reach. } } diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 198bee1..f649c5d 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -48,9 +48,9 @@ final class Uninstaller { * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param array> $footprint - * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint - * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args + * @phpstan-param array> $footprint + * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint + * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args * * @param string $plugin_file The path to the plugin's entry file. * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. @@ -71,7 +71,7 @@ protected function __construct( /** * Removes the plugin's footprint from every site that consents, and unschedules its jobs on every site. * - * On each site the cleanup runs first, and a failed cleanup keeps that site's footprint. A failed delete leaves the others to run, while a throw from another plugin's hook ends that site's pass. Either keeps the site's consent row, and the failures are thrown once every site was visited, so WordPress keeps the plugin's files and the next uninstall retries. A job that cannot be unscheduled is logged instead, since it only ever fires into a missing callback. + * Failures are thrown once every site was visited, and a site that failed keeps its consent row, so WordPress keeps the plugin's files and the next uninstall completes the pass. * * @since 2.0.0 * @version 2.0.0 @@ -81,9 +81,9 @@ protected function __construct( * * @param string $plugin_file The path to the plugin's entry file. * @param array $footprint The plugin's stored data by kind. - * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default none, which keeps the footprint. + * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint. * - * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a delete, a site's pass or the cleanup failed, once every site was visited. + * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a read or delete, a site's pass or the cleanup failed, once every site was visited. * * @return void */ @@ -218,7 +218,7 @@ protected function uninstall_site(): void { foreach ( $jobs as $subject => $arguments ) { $wpdb->flush(); try { - as_unschedule_all_actions( ...$arguments ); // Action Scheduler throws when its store fails to cancel an action. + as_unschedule_all_actions( ...$arguments ); // Action Scheduler's hybrid store, which it runs while it migrates its data, throws when it fails to cancel an action. $result = null; } catch ( \Throwable $throwable ) { $result = $throwable; @@ -312,7 +312,7 @@ protected function uninstall_site(): void { } } - if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, the entries expire on their own. + if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, cached entries stay until they are invalidated or evicted. foreach ( $meta_type['cache_groups'] as $group ) { wp_cache_flush_group( $group ); } @@ -587,7 +587,7 @@ protected static function is_constant_true( string $name ): bool { * * @param mixed $value The value. * - * @return list + * @return list */ protected static function get_strings( mixed $value ): array { return \array_values( \array_filter( \is_array( $value ) ? $value : array(), static fn ( mixed $item ): bool => \is_string( $item ) && '' !== $item && '0' !== $item ) ); @@ -602,7 +602,7 @@ protected static function get_strings( mixed $value ): array { * @param mixed $value The meta kind's entries, by meta type name. * @param array $meta_types The run's meta types, by name. * - * @return array> + * @return array> */ protected static function get_meta_strings( mixed $value, array $meta_types ): array { return \array_map( self::get_strings( ... ), \array_intersect_key( \is_array( $value ) ? $value : array(), $meta_types ) ); From 70803bef78c1448992e59ba61faf6296e539b027 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:43:55 +0100 Subject: [PATCH 23/47] Align class-level PHPStan tags and let Version serve any project Style guide 2.1 aligns PHPStan tags within their group, as the method docblocks do, but seven class docblocks wrote groups of mixed tags with one space each, so their values started in different columns. And Version's docblock and convention 4 spoke of versions "the plugin" controls, in the package chartered for any PHP project. The seven groups pad each tag to the group's longest, and Version and convention 4 speak of versions the project controls. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/settings/src/AdminPage/OptionsPage.php | 4 ++-- packages/settings/src/EditScreen/ObjectForm.php | 2 +- packages/shared/src/Result/AbstractResult.php | 2 +- packages/shared/src/Result/Failure.php | 2 +- packages/shared/src/Result/Success.php | 2 +- packages/shared/src/Versioning/Version.php | 2 +- packages/woocommerce/src/Setting/SettingsSection.php | 2 +- packages/woocommerce/src/Setting/SettingsTab.php | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01af8ab..0609731 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ actionlint && zizmor .github/ 1. **Gates and toggles.** `should_load()` is static, pure and total: no hooks, writes, notices, translation, user reads or construction. Read options with an explicit default. Detect companions by include-time constants, classes or functions, aware of network activation. Gates read no request-type facts, because `REST_REQUEST` is defined only on `parse_request` and `is_admin()` holds in admin-ajax requests; callbacks check the request type. A feature's toggle and settings UI live outside its gate; a gate mixing an environment check with a setting exposes the environment half as a static method. 2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site. 3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation. -4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the plugin controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type. +4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type. 5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{prefix}/boot/succeeded`, on an explicit `false`. 6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator. 7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant. diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 703023a..3b66877 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -17,8 +17,8 @@ * * @phpstan-import-type Field from Forms * @phpstan-import-type FieldRow from Forms - * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list} - * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} + * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list} + * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} */ final class OptionsPage { // region FIELDS AND CONSTANTS diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index 9344be5..8a6dc12 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -19,7 +19,7 @@ * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Form array{title: string, capability: string, fields: list} + * @phpstan-type Form array{title: string, capability: string, fields: list} */ final class ObjectForm { // region FIELDS AND CONSTANTS diff --git a/packages/shared/src/Result/AbstractResult.php b/packages/shared/src/Result/AbstractResult.php index 2df40e7..33aeb9b 100644 --- a/packages/shared/src/Result/AbstractResult.php +++ b/packages/shared/src/Result/AbstractResult.php @@ -12,7 +12,7 @@ * * @template-covariant TValue * @template-covariant TError of ErrorInterface - * @phpstan-sealed Success|Failure + * @phpstan-sealed Success|Failure */ abstract readonly class AbstractResult { // region METHODS diff --git a/packages/shared/src/Result/Failure.php b/packages/shared/src/Result/Failure.php index f3a883a..c9af445 100644 --- a/packages/shared/src/Result/Failure.php +++ b/packages/shared/src/Result/Failure.php @@ -11,7 +11,7 @@ * @version 2.0.0 * * @template-covariant TError of ErrorInterface - * @extends AbstractResult + * @extends AbstractResult */ final readonly class Failure extends AbstractResult { // region MAGIC METHODS diff --git a/packages/shared/src/Result/Success.php b/packages/shared/src/Result/Success.php index 1ae1919..3719569 100644 --- a/packages/shared/src/Result/Success.php +++ b/packages/shared/src/Result/Success.php @@ -9,7 +9,7 @@ * @version 2.0.0 * * @template-covariant TValue - * @extends AbstractResult + * @extends AbstractResult */ final readonly class Success extends AbstractResult { // region MAGIC METHODS diff --git a/packages/shared/src/Versioning/Version.php b/packages/shared/src/Versioning/Version.php index d02c50b..ec7270c 100644 --- a/packages/shared/src/Versioning/Version.php +++ b/packages/shared/src/Versioning/Version.php @@ -6,7 +6,7 @@ use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; /** - * A version the plugin controls, ordered by precedence. + * A version the project controls, ordered by precedence. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php index 7e997a5..2ea4efa 100644 --- a/packages/woocommerce/src/Setting/SettingsSection.php +++ b/packages/woocommerce/src/Setting/SettingsSection.php @@ -17,7 +17,7 @@ * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Section array{tab: string, label: string} + * @phpstan-type Section array{tab: string, label: string} */ final class SettingsSection extends WooCommerceSettingsSection { // region FIELDS AND CONSTANTS diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php index a5c2d20..d71fb3e 100644 --- a/packages/woocommerce/src/Setting/SettingsTab.php +++ b/packages/woocommerce/src/Setting/SettingsTab.php @@ -16,7 +16,7 @@ * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Tab array{label: string, sections: array} + * @phpstan-type Tab array{label: string, sections: array} */ final class SettingsTab extends \WC_Settings_Page { // region FIELDS AND CONSTANTS From df84b975b567df4b4d9711a9664700eea4b08d95 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:46:23 +0100 Subject: [PATCH 24/47] Keep each settings docblock to its role and the why it owns The settings and WooCommerce docblocks repeated their members' reasons and narrated code. ObjectForm's class docblock restated its host, store, capability, nonce and notice rules, and save() ran four reasons into one paragraph. Forms' render_control() enumerated is_group()'s cases, sanitize_fields() taught a merge recipe, get_rows() listed its row shape beside the FieldRow type, and check_helper_id()'s @throws spelled its pattern in words. Both definition @params repeated the timing their interfaces state. Fields::sanitize_option() and ProductDataTab::render_field() described the code under them, MetaTypes restated its own values, and is_field() put its assertion after @param. SettingsSection claimed the registry refuses an id the tab already holds, though it accepts it and WooCommerce never asks that section, two class docblocks used backticks, OrderMetaBox joined two sentences with a semicolon, and PostMetaBox's priority 11 had no reason. Each keeps its role sentence and the one why it owns: ObjectForm the id its controls post under, save() the nonce and the once-per-request guard, render_control() the group and render-callable contract, sanitize_fields() which ids stay out; the interfaces keep the timing. SettingsSection names both outcomes, and PostMetaBox's hook says it runs after core's refreshes at 10. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/AdminPage/OptionsPage.php | 2 +- packages/settings/src/EditScreen/Hosts/PostMetaBox.php | 2 +- packages/settings/src/EditScreen/ObjectForm.php | 6 +++--- packages/settings/src/Forms.php | 8 ++++---- .../woocommerce/src/EditScreen/Hosts/OrderMetaBox.php | 2 +- packages/woocommerce/src/ProductData/ProductDataTab.php | 2 +- packages/woocommerce/src/Setting/Fields.php | 6 +++--- packages/woocommerce/src/Setting/SettingsSection.php | 2 +- packages/woocommerce/src/Setting/SettingsTab.php | 2 +- packages/woocommerce/src/Uninstall/MetaTypes.php | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 3b66877..a9e364b 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -44,7 +44,7 @@ final class OptionsPage { * @version 2.0.0 * * @param string $id The page's menu slug, which also prefixes its tabs filter. - * @param OptionsPageDefinitionInterface $definition The definition, which the page asks for once, on first use after init. + * @param OptionsPageDefinitionInterface $definition The page's definition. * * @throws InvalidArgumentException Thrown when the id is not a valid key. */ diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php index 2418462..d065f37 100644 --- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php +++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php @@ -73,7 +73,7 @@ public function register_hooks( ObjectForm $form ): void { if ( \in_array( 'attachment', $this->post_types, true ) ) { add_action( 'edit_attachment', array( $this, 'save_attachment' ) ); // WordPress saves an attachment without firing save_post. } - add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); + add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); // After core's own refreshes at 10, which create the replace maps this adds to. } /** diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index 8a6dc12..00558c4 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -13,7 +13,7 @@ /** * Adds a form of field arrays to the screen that edits an object, and stores the accepted fields as the object's data. * - * Its host decides where the form shows and when it saves, and its store keeps the values, by default the host's own. The form shows only to a user who may edit the object and holds the definition's capability, carries a nonce bound to the object, and reports each rejection of a save as a one-shot notice for the user, which replaces the notice of the field's earlier rejection. Controls post under the form's id, so they never meet the screen's own inputs, and each field stores under its id. + * Controls post under the form's id, so they never meet the screen's own inputs. * * @since 2.0.0 * @version 2.0.0 @@ -65,7 +65,7 @@ final class ObjectForm { * @version 2.0.0 * * @param string $id The form's id, which starts with the plugin's prefix and names the form's controls, nonce and fields filter. - * @param ObjectFormDefinitionInterface $definition The definition, which the form asks for once per object, on first use after init. + * @param ObjectFormDefinitionInterface $definition The form's definition. * @param ObjectFormHostInterface $host The host that shows and saves the form. * @param AdminNotices $notices The plugin's admin notices, which report the rejections. * @param ObjectStoreInterface|null $store Optional. The store of the form's values. Default is null, for the host's store. @@ -188,7 +188,7 @@ public function render( int $object_id ): void { /** * Stores the accepted fields of the request's submission for the object, and reports each rejection as a one-shot error notice for the current user. * - * Nothing changes unless the request carries the form's nonce for the object, which binds it to the object as WordPress core binds its edit forms, so a save that fires for a revision or another object in the same request is refused, and the current user may edit the object and holds the definition's capability. A field the form did not post, because it was not rendered or is disabled, keeps its value, as a rejected one does. The form saves once per object and request, because a save that updates the object again fires the host's save hook again with the same submission. + * The form's nonce binds the save to the object, so a save that fires for a revision or another object in the same request changes nothing. The form saves once per object and request, because a save that updates the object again fires the host's save hook again. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index daaae25..59e8702 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -69,7 +69,7 @@ protected function __construct() {} /** * Returns the escaped markup of the field's control, without its row label. * - * A radio, a checkboxes group and a checkbox with a desc render in a fieldset named by the title, so their host prints the row label as plain text, while every other control carries the id the row label points at. A radio or checkboxes group without a title leaves the fieldset to its host, and each of its choices names the help. An enabled checkbox or multi-value control posts a hidden input first, so a field absent from a submission was not rendered or is disabled. A field with a render callable renders through it, and the callable escapes, names and describes its control, a fieldset named by the title for a group, and, unless the field is disabled, prints a hidden empty input before it. + * A group, as is_group() tells, renders in a fieldset its title names, or in its host's fieldset when it has no title. A field with a render callable renders through it, and the callable escapes, names and describes its control and wraps a group the same way. Unless the field is disabled, the callable also prints a hidden empty input before its control, so that a field absent from a submission reads as unrendered or disabled. * * @since 2.0.0 * @version 2.0.0 @@ -99,7 +99,7 @@ public static function render_control( array $field, mixed $value, string $name, /** * Returns the accepted values and the rejections of a submission, keyed by field id, for the defined, present and enabled fields only. * - * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes. + * Absent, rejected and undefined ids stay out of the values. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error, and no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices. Every WP_Error message is plain text, which the host escapes. * * @since 2.0.0 * @version 2.0.0 @@ -231,7 +231,7 @@ public static function get_rejection_message( string $title, \WP_Error $error ): * * @param string $id The id. * - * @throws InvalidArgumentException Thrown when the id is not a lowercase letter, after an optional underscore, followed by lowercase letters, digits, underscores and hyphens. + * @throws InvalidArgumentException Thrown when the id is not a valid helper id. * * @return void */ @@ -279,7 +279,7 @@ public static function escape_attribute( string $value ): string { } /** - * Returns a row for each field with an id: the field named by its title, its stored value or else its default, the name it posts under, its HTML id, and the id its row label points at unless it is a group. + * Returns the row each field with an id renders as. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php index a80db01..4a24c32 100644 --- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php +++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php @@ -147,7 +147,7 @@ public function render_meta_box( mixed $order ): void { /** * Saves the form when WooCommerce saves an order from its edit screen, before WooCommerce saves the order's data, so that a status change and its emails see the form's values. * - * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists; only the box's own screens print the form's nonce. + * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists. Only the box's own screens print the form's nonce. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index 619a41e..1190e19 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -222,7 +222,7 @@ protected function get_groups( array $tab, \WC_Product $product ): array { } /** - * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable, radios or checkboxes. + * Prints the field's row in WooCommerce's product data markup, with its stored value. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 5b0bebc..87333bc 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -121,7 +121,7 @@ public function claim( array $settings, string $section_id ): array { /** * Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged. * - * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value when it holds only scalars, null and arrays, and a rejection becomes one of WooCommerce's settings errors. + * A disabled field and a field the form did not post are never saved. * * @since 2.0.0 * @version 2.0.0 @@ -211,10 +211,10 @@ protected function get_owned( mixed $setting ): ?array { * @since 2.0.0 * @version 2.0.0 * - * @param mixed $setting The setting. - * * @phpstan-assert-if-true Field $setting * + * @param mixed $setting The setting. + * * @return bool */ protected static function is_field( mixed $setting ): bool { diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php index 2ea4efa..e07a435 100644 --- a/packages/woocommerce/src/Setting/SettingsSection.php +++ b/packages/woocommerce/src/Setting/SettingsSection.php @@ -11,7 +11,7 @@ /** * Adds a section of framework field arrays under an existing WooCommerce settings tab, saved through WooCommerce's settings API. * - * The section is constructed in a woocommerce_settings_sections_registration callback and registered with the registry it receives, which refuses an id the tab already holds. It owns its fields after WooCommerce's settings filter, including fields other plugins add there, once WooCommerce first asks it for them. A field's `render` callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. + * The section is constructed in a woocommerce_settings_sections_registration callback and registered with the registry it receives, which refuses an id another registered section holds. A section whose id is one of the tab's own sections registers but never shows. It owns its fields after WooCommerce's settings filter, including fields other plugins add there, once WooCommerce first asks it for them. A field's render callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php index d71fb3e..8e691bf 100644 --- a/packages/woocommerce/src/Setting/SettingsTab.php +++ b/packages/woocommerce/src/Setting/SettingsTab.php @@ -10,7 +10,7 @@ /** * Adds a WooCommerce settings tab whose sections hold framework field arrays, saved through WooCommerce's settings API. * - * WooCommerce loads its settings page class only for the woocommerce_get_settings_pages filter, so the tab is constructed in that filter's callback, which registers it. The tab owns every field of its sections after WooCommerce's settings filter, including fields other plugins add there. A field's `render` callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. + * WooCommerce loads its settings page class only for the woocommerce_get_settings_pages filter, so the tab is constructed in that filter's callback, which registers it. The tab owns every field of its sections after WooCommerce's settings filter, including fields other plugins add there. A field's render callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/Uninstall/MetaTypes.php b/packages/woocommerce/src/Uninstall/MetaTypes.php index 41b66c0..1cdfa52 100644 --- a/packages/woocommerce/src/Uninstall/MetaTypes.php +++ b/packages/woocommerce/src/Uninstall/MetaTypes.php @@ -16,7 +16,7 @@ final class MetaTypes { /** * The meta types of WooCommerce's orders and order items, keyed by the names a footprint's meta kinds use. * - * Order meta lives in post meta and, where WooCommerce created it, in the order meta table. Order item meta lives only in WooCommerce's own table, whose WordPress meta type exists only while WooCommerce is loaded, and plugins are often deleted after WooCommerce is deactivated. + * Order item meta names no WordPress meta type, which exists only while WooCommerce is loaded. * * @since 2.0.0 * @version 2.0.0 From 3b218a6d97bbb949918a480ee143d6b4285bc778 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:48:00 +0100 Subject: [PATCH 25/47] Align the repository docs with CI and the sibling repositories The repository docs had drifted from CI and from the template and plugins. AGENTS.md said the split runs on every push to trunk, though it runs only when packages/ changes, marked method-level @internal for Forms but not for PluginBoot::is_development_environment(), and said Plugin Check's file checks run only at release, though like its sniffs they skip a plugin's vendor-prefixed/ at every run. CONTRIBUTING and the pull request template never asked for the changelog fragment CI validates, nor said how a release or a WooCommerce floor raise is cut, while the template's CONTRIBUTING does. The README listed three setup commands where npm run setup runs them, SECURITY.md spoke in a voice the template's no longer uses, and the changelog's header named neither its format nor its fragments. AGENTS.md states the split's path filter, marks the internal method and ends convention 10 at the vendor-prefixed/ skip. CONTRIBUTING gains "Changelog entries", "Releases" and "WooCommerce floor" sections, the last listing what one raise moves and the MetaTypes cache groups it re-checks, and the template asks for a fragment in place of a README note. The README runs npm run setup, SECURITY.md takes the template's voice, and the changelog takes the plugins' header without their readme.txt markers, since no package ships one. Assisted-by: Claude Code:claude-opus-5-5 --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- AGENTS.md | 6 +++--- CHANGELOG.md | 4 +++- CONTRIBUTING.md | 24 ++++++++++++++++++++++++ README.md | 4 +--- SECURITY.md | 4 +--- 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9750646..04b8144 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,7 +14,7 @@ - [ ] Tests added or updated (Unit and/or Integration as appropriate) - [ ] `composer quality-check` passes locally (PHPCS, PHPStan and the tests) -- [ ] Public API changes are documented in the affected package's `README.md` +- [ ] Changelog fragment added (`composer changelog:add`) - [ ] Breaking changes are explicitly justified below ## Breaking changes / migration notes diff --git a/AGENTS.md b/AGENTS.md index 0609731..a43483e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # wordpress-framework -A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages//` to its read-only `wp-framework-` mirror on every push to `trunk`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses. +A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages//` to its read-only `wp-framework-` mirror on every push to `trunk` that changes `packages/`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses. ## Layout @@ -14,7 +14,7 @@ wordpress-framework/ ├── packages/shared/ ahegyes/wp-framework-shared `require.php` · no package require · WordPress-free │ └── src/ psr-4 DeepWebSolutions\Framework\Shared\ · Error/, Exception/, Result/, ValueObject/, Versioning/ ├── packages/core/ ahegyes/wp-framework-core `require.php`, ext-filter · bootstrap ^2.0, shared ^2.0, psr/clock ^1, psr/container ^2.0.2, psr/log ^3.0.2, psr/simple-cache ^3 -│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot, ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface +│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface │ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope} │ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException} │ ├── Clock/SystemClock · Logging/{AbstractLogger, ErrorLogLogger, GuardedLogger (@internal)} @@ -84,7 +84,7 @@ actionlint && zizmor .github/ 7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant. 8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`. 9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects. -10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/` (its file checks run only at release). +10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/`. ## Contract diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa1157..2fcb401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ # Changelog -Notable changes to the five framework packages, which release together under one version. +All notable changes to the five framework packages, which release together under one version, are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea51e03..449de7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,8 +21,32 @@ actionlint zizmor .github/ ``` +## Changelog entries + +Each user-visible change adds one fragment, so pull requests never conflict on `CHANGELOG.md`: + +```sh +composer changelog:add +``` + +The command asks for the change's significance, its type and its entry, and writes the fragment to `changelog/`, which the pull request commits. CI validates the fragments with `composer changelog:validate`. + ## Filing changes - Keep one concern per pull request. - Fill in the template's breaking-changes section. - Follow the code, documentation and commit conventions in [AGENTS.md](AGENTS.md). + +## Releases + +The five packages release together under one version. A release writes the fragments into a new version block of `CHANGELOG.md`, whose version the fragments' significance sets: + +```sh +composer changelog:write +``` + +Pushing the tag `v` on that commit splits it to every mirror, where consumers' `vcs` repositories find the release. + +## WooCommerce floor + +The woocommerce package tracks the newest stable WooCommerce release until a tag. Raising the floor moves, in one commit, the manifest's `extra.requires-wc`, the root's and the fixtures' `wp-plugin/woocommerce` and `php-stubs/woocommerce-stubs` constraints, the fixtures' `WC requires at least` and `WC tested up to` headers, and the locks. The same commit re-checks that WooCommerce still uses each cache group `MetaTypes::DEFINITIONS` names, because the uninstall engine flushes them. diff --git a/README.md b/README.md index 0835671..e025a98 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ The packages form a stack: core builds on bootstrap and shared, settings on core ## Local development ```sh -composer packages-install # PHP deps (wraps composer install, ignoring only the PHP upper bound) -npm install # Node deps (wp-env) -npm run wp-env:start # developer environment at http://localhost:8800, as local, with WooCommerce +npm run setup # PHP and Node deps, then the developer environment at http://localhost:8800, as local, with WooCommerce composer quality-check # Validation, PHPCS, PHPStan, Unit and Integration tests (starts the tests environment) ``` diff --git a/SECURITY.md b/SECURITY.md index 761fac8..cdec1ee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,7 @@ ## Reporting a vulnerability -Please report security vulnerabilities privately via [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new). Do not open a public issue. - -You can expect an initial response within 7 days. Once the report is triaged, you'll receive updates as fixes land. Once a fix is released, credit is given in the advisory unless you request anonymity. +Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new), never in a public issue. The first response comes within 7 days, and the advisory credits the reporter once a fix is released, unless they ask to stay anonymous. ## Supported versions From f59faee7ab90220e8318e1286e184cbcb6fcbc15 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:58:32 +0100 Subject: [PATCH 26/47] Give every fixture entry and scoper config its description and versions Style guide 1 gives a procedural file's docblock a description, @since and @version before @package, but the fixture plugins' entry files held only their plugin headers and @package, and every scoper.inc.php and the shared persona's script lacked @since and @version. consumer-a, consumer-b and the personas are the anatomy the template and the ports copy, so the gap spread to every plugin. Each entry's docblock opens with one sentence on what the file loads, then the plugin headers, then @since and @version in their own group before @package, and each scoper.inc.php and run.php gains the same group. WordPress reads its headers from any line of the first 8 KB, so the description line changes nothing it parses. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/acme-consumer-a.php | 5 +++++ tests/Fixtures/consumer-a/scoper.inc.php | 3 +++ tests/Fixtures/consumer-b/acme-consumer-b.php | 5 +++++ tests/Fixtures/consumer-b/scoper.inc.php | 3 +++ tests/Fixtures/personas/bootstrap/acme-persona-bootstrap.php | 5 +++++ tests/Fixtures/personas/bootstrap/scoper.inc.php | 3 +++ tests/Fixtures/personas/core/acme-persona-core.php | 5 +++++ tests/Fixtures/personas/core/scoper.inc.php | 3 +++ tests/Fixtures/personas/settings/acme-persona-settings.php | 5 +++++ tests/Fixtures/personas/settings/scoper.inc.php | 3 +++ tests/Fixtures/personas/shared/run.php | 3 +++ tests/Fixtures/personas/shared/scoper.inc.php | 3 +++ .../personas/woocommerce/acme-persona-woocommerce.php | 5 +++++ tests/Fixtures/personas/woocommerce/scoper.inc.php | 3 +++ 14 files changed, 54 insertions(+) diff --git a/tests/Fixtures/consumer-a/acme-consumer-a.php b/tests/Fixtures/consumer-a/acme-consumer-a.php index 3662245..7e56273 100644 --- a/tests/Fixtures/consumer-a/acme-consumer-a.php +++ b/tests/Fixtures/consumer-a/acme-consumer-a.php @@ -1,5 +1,7 @@ Date: Tue, 10 Nov 2026 11:58:46 +0100 Subject: [PATCH 27/47] Teach each package's helpers through the persona its README quotes The package READMEs, whose examples PackageReadmesTest copies from the personas, left public API untaught and the personas drifted from the template. No persona ran the uninstall engine or MetaTypes, the settings persona showed no object form, its number field had no default, so its untouched first save was rejected, and the shared persona showed neither the exception mirrors nor a narrowed result. The core persona composed unlike the template, with the scheduler outside COMPONENTS and the container, and built its settings key inline in three places. The READMEs also omitted core's filter extension, the updater's config keys and default channel, print_admin_notice(), and the woocommerce package's section, order form, product meta reader and folder layout, and the bootstrap README said version_compare() ranks other suffixes above their release, which does not hold for 'Beta'. The core persona composes as the template does and keeps its key in includes/options.php; it and the woocommerce persona gain an uninstall.php, the latter passing MetaTypes::DEFINITIONS with a consent checkbox; the settings persona adds a post form on a PostMetaBox and a defaulted, bounded number field; the shared persona returns a result under #[\NoDiscard], narrows it and catches a mirror by ExceptionInterface. Each README quotes the new code and names what it left out. Persona rows run both uninstalls, the post form's save and the new shared outputs. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/README.md | 10 ++- packages/core/README.md | 43 ++++++++++-- packages/settings/README.md | 53 +++++++++++++-- packages/shared/README.md | 37 ++++++++++- packages/woocommerce/README.md | 25 ++++++- tests/Fixtures/personas/core/functions.php | 3 +- .../personas/core/includes/footprint.php | 32 +++++++++ .../personas/core/includes/migrations.php | 4 +- .../personas/core/includes/options.php | 23 +++++++ .../Fixtures/personas/core/phpstan.dist.neon | 1 + tests/Fixtures/personas/core/src/Greeting.php | 2 +- tests/Fixtures/personas/core/src/Plugin.php | 36 +++++----- tests/Fixtures/personas/core/uninstall.php | 55 ++++++++++++++++ .../Fixtures/personas/settings/functions.php | 17 +++-- .../personas/settings/src/Details.php | 40 +++++++++++ .../personas/settings/src/Settings.php | 13 ++-- tests/Fixtures/personas/shared/run.php | 14 +++- .../Fixtures/personas/shared/src/Catalog.php | 66 +++++++++++++++++++ .../personas/woocommerce/functions.php | 4 +- .../woocommerce/includes/footprint.php | 28 ++++++++ .../personas/woocommerce/includes/index.php | 1 + .../personas/woocommerce/includes/options.php | 33 ++++++++++ .../personas/woocommerce/phpstan.dist.neon | 2 + .../personas/woocommerce/src/Packing.php | 8 ++- .../personas/woocommerce/uninstall.php | 55 ++++++++++++++++ .../Integration/Personas/CorePersonaTest.php | 17 +++++ .../Personas/SettingsPersonaTest.php | 17 +++++ .../Personas/SharedPersonaTest.php | 4 +- .../Personas/WooCommercePersonaTest.php | 19 ++++++ 29 files changed, 614 insertions(+), 48 deletions(-) create mode 100644 tests/Fixtures/personas/core/includes/footprint.php create mode 100644 tests/Fixtures/personas/core/includes/options.php create mode 100644 tests/Fixtures/personas/core/uninstall.php create mode 100644 tests/Fixtures/personas/settings/src/Details.php create mode 100644 tests/Fixtures/personas/shared/src/Catalog.php create mode 100644 tests/Fixtures/personas/woocommerce/includes/footprint.php create mode 100644 tests/Fixtures/personas/woocommerce/includes/index.php create mode 100644 tests/Fixtures/personas/woocommerce/includes/options.php create mode 100644 tests/Fixtures/personas/woocommerce/uninstall.php diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md index d43eadc..21b7ff8 100644 --- a/packages/bootstrap/README.md +++ b/packages/bootstrap/README.md @@ -79,7 +79,15 @@ if ( ! Requirements::check_floors( __FILE__ ) ) { require_once __DIR__ . '/functions.php'; ``` -The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release. +The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`. The `dws_github_updater/config/{text-domain}` filter receives and returns `enabled`, `repository` as `owner/name`, `asset` and `channel`: an installed version with a suffix follows the `prerelease` channel, the highest of the ten newest releases, and any other version the `stable` channel, the latest release. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` misorders other suffixes, such as `-pre`, `-patch` or `-Beta`. + +`Requirements::print_admin_notice()` prints a notice to users who can activate plugins, as `wp_admin_notice()` prints it, so a plugin can report a problem before it boots: + +```php +function acme_pb_render_notice(): void { + Requirements::print_admin_notice( esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ), array( 'type' => 'info' ) ); +} +``` ## Contract and versioning diff --git a/packages/core/README.md b/packages/core/README.md index 1191de4..ee0ad4e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ The plugin kernel. A plugin is composed of small components that load only where ## Requirements -It builds on wp-framework-bootstrap and wp-framework-shared, and on the PSR container, log, clock and simple-cache interfaces. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses. +It builds on wp-framework-bootstrap and wp-framework-shared, on the PSR container, log, clock and simple-cache interfaces, and on PHP's filter extension. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses. ## Installation @@ -121,6 +121,7 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationR use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; +use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; use Acme\PersonaCore\Scoped\DI\ContainerBuilder; use Acme\PersonaCore\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -130,6 +131,7 @@ final class Plugin { // ... public const array COMPONENTS = array( + Scheduler::class, AdminNotices::class, Greeting::class, ); @@ -138,8 +140,8 @@ final class Plugin { public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); } // ... @@ -155,7 +157,7 @@ final class Plugin { $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -163,9 +165,12 @@ final class Plugin { // ... - protected function get_definitions( PsrLoggerInterface $logger ): array { + protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( - AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger, + Scheduler::class => static fn (): Scheduler => $scheduler, + SchedulerInterface::class => static fn (): Scheduler => $scheduler, + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } @@ -188,6 +193,34 @@ final class Greeting implements ComponentInterface { } ``` +The plugin's `uninstall.php` hands its footprint to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data: + +```php +function acme_pc_get_footprint(): array { + return array( + 'option_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ), + 'network_options' => array( Plugin::PREFIX . '_network_notices' ), + 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); +} +``` + +```php +Uninstaller::run( + $acme_pc_file, + $acme_pc_footprint, + array( + 'consent' => array( + 'option' => $acme_pc_consent, + 'key' => 'delete_data', + ), + ) +); +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/packages/settings/README.md b/packages/settings/README.md index bdd5e92..bc92082 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -86,20 +86,29 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/functions.php'; ``` -The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page: +The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page, and a form on the post edit screen whose rejections core's `AdminNotices` reports: ```php +use Acme\PersonaSettings\Details; use Acme\PersonaSettings\Settings; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; defined( 'ABSPATH' ) || exit; -add_action( 'plugins_loaded', 'acme_ps_register_settings_page', 15 ); +add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 ); // ... -function acme_ps_register_settings_page(): void { +function acme_ps_register_hooks(): void { + $notices = new AdminNotices( Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) ); + $notices->register_hooks(); + new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); } ``` @@ -123,10 +132,15 @@ final class Settings implements OptionsPageDefinitionInterface { 'option' => self::PREFIX . '_settings', 'fields' => array( array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'validate' => array( $this, 'validate_limit' ), + 'type' => 'number', + 'id' => 'limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => array( $this, 'validate_limit' ), ), ), ), @@ -146,6 +160,31 @@ final class Settings implements OptionsPageDefinitionInterface { } ``` +A form's definition object declares its fields the same way. `PostMetaBox` shows the form on the edit screens of the post types it names, `UserProfile` on the profile screens and `TermForm` on the term screens of the taxonomies it names. Each host stores a field under its id as the object's meta through `MetadataStore`, unless the form takes another store: + +```php +final class Details implements ObjectFormDefinitionInterface { + // ... + + #[\Override] + public function get_object_form( string $form_id, int $object_id ): array { + return array( + 'title' => __( 'Details', 'acme-persona-settings' ), + 'capability' => 'edit_posts', + 'fields' => array( + array( + 'type' => 'text', + 'id' => '_' . Settings::PREFIX . '_subtitle', + 'title' => __( 'Subtitle', 'acme-persona-settings' ), + ), + ), + ); + } + + // ... +} +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/packages/shared/README.md b/packages/shared/README.md index 5f778fb..4de1040 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -61,32 +61,65 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage -A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. A script loads the project's autoloader and imports the package under the project's prefix: +A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. The package throws its mirrors of PHP's `InvalidArgumentException`, `LogicException` and `RuntimeException`, which extend them and implement `ExceptionInterface`, so a caller catches the framework's failures by that interface. A script loads the project's autoloader and imports the package under the project's prefix: ```php +use Acme\PersonaShared\Catalog; use Acme\PersonaShared\Point; use Acme\PersonaShared\UnknownKey; +use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Exception\ExceptionInterface; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Failure; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Success; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Versioning\Version; require __DIR__ . '/vendor/autoload.php'; +try { + $acme_rejected = (string) Version::from_string( 'next' ); +} catch ( ExceptionInterface $acme_exception ) { + $acme_rejected = $acme_exception::class; +} + +$acme_catalog = new Catalog( array( 'acme' => 'box' ) ); + echo \json_encode( array( 'wordpress' => \defined( 'ABSPATH' ), 'version' => array( (string) Version::from_string( '2.1.0-rc.1' ), Version::from_string( '2.1.0' )->compare( Version::from_string( '2.1.0-rc.1' ) ) ), + 'rejected' => $acme_rejected, 'results' => array( Success::from( 'acme' )->match( static fn ( string $value ): string => "found $value", static fn (): string => 'failed' ), Failure::from( new UnknownKey( 'acme' ) )->match( static fn (): string => 'found', static fn ( UnknownKey $error ): string => "unknown $error->key" ), ), + 'narrowed' => array( $acme_catalog->describe( 'acme' ), $acme_catalog->describe( 'other' ) ), 'equality' => array( new Point( 1, 2 )->equals( new Point( 1, 2 ) ), new Point( 1, 2 )->equals( new Point( 2, 1 ) ) ), ), \JSON_THROW_ON_ERROR ); ``` -A failure carries an error object, and a value object declares its values as promoted properties: +An operation with failures its caller branches on returns `AbstractResult` under `#[\NoDiscard]`, and `is_success()` or `is_failure()` narrows the result to its `value` or its `error`. A failure carries an error object, and a value object declares its values as promoted properties: + +```php +final readonly class Catalog { + // ... + + #[\NoDiscard] + public function find( string $key ): AbstractResult { + return isset( $this->values[ $key ] ) ? Success::from( $this->values[ $key ] ) : Failure::from( new UnknownKey( $key ) ); + } + + // ... + + public function describe( string $key ): string { + $found = $this->find( $key ); + + return $found->is_success() ? "found $found->value" : "unknown {$found->error->key}"; + } + + // ... +} +``` ```php final readonly class UnknownKey implements ErrorInterface { diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index c3f4ea2..8433001 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -67,6 +67,8 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage +`Logging/` extends core's logging and `EditScreen/` settings' object forms, while `ProductData/` and `Setting/` are WooCommerce screens built on settings' `Forms`. A `SettingsTab` adds a WooCommerce settings tab, and a `SettingsSection`, constructed in a `woocommerce_settings_sections_registration` callback, adds a section to an existing one. An `OrderMetaBox` hosts an object form on the order edit screens and stores through `OrderMetaStore`, and `ProductMeta::get_value()` reads a product field's stored value, or a fallback only where the meta is absent. + The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: ```php @@ -101,6 +103,8 @@ use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\WooCom defined( 'ABSPATH' ) || exit; +require_once __DIR__ . '/includes/options.php'; + add_action( 'plugins_loaded', 'acme_pw_register_hooks', 15 ); // ... @@ -151,6 +155,12 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD 'id' => self::PREFIX . '_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), + array( + 'type' => 'checkbox', + 'id' => ACME_PW_OPTION_DELETE_DATA, + 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), + 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), + ), ); } @@ -165,7 +175,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD 'fields' => array( array( 'type' => 'text', - 'id' => '_' . self::PREFIX . '_box', + 'id' => ACME_PW_META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), @@ -178,6 +188,19 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD } ``` +The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, and passes `MetaTypes::DEFINITIONS`, so that a footprint can name the `order` and `order_item` meta kinds, whose tables WordPress knows only while WooCommerce is loaded: + +```php +Uninstaller::run( + $acme_pw_file, + $acme_pw_footprint, + array( + 'consent' => array( 'option' => $acme_pw_consent ), + 'meta_types' => $acme_pw_meta_types, + ) +); +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/tests/Fixtures/personas/core/functions.php b/tests/Fixtures/personas/core/functions.php index 4fdad8e..a9bb42f 100644 --- a/tests/Fixtures/personas/core/functions.php +++ b/tests/Fixtures/personas/core/functions.php @@ -1,6 +1,6 @@ , user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + */ +function acme_pc_get_footprint(): array { + return array( + 'option_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ), + 'network_options' => array( Plugin::PREFIX . '_network_notices' ), + 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); +} diff --git a/tests/Fixtures/personas/core/includes/migrations.php b/tests/Fixtures/personas/core/includes/migrations.php index eb7aa7c..f70b891 100644 --- a/tests/Fixtures/personas/core/includes/migrations.php +++ b/tests/Fixtures/personas/core/includes/migrations.php @@ -8,8 +8,6 @@ * @package Acme\PersonaCore */ -use Acme\PersonaCore\Plugin; - defined( 'ABSPATH' ) || exit; /** @@ -37,7 +35,7 @@ function acme_pc_get_migration_steps(): array { * @return void */ function acme_pc_add_settings(): void { - if ( false === get_option( Plugin::PREFIX . '_settings' ) && ! add_option( Plugin::PREFIX . '_settings', array( 'greeting' => 'Hello' ) ) ) { + if ( false === get_option( ACME_PC_OPTION_SETTINGS ) && ! add_option( ACME_PC_OPTION_SETTINGS, array( 'greeting' => 'Hello' ) ) ) { throw new RuntimeException( 'The settings could not be stored.' ); } } diff --git a/tests/Fixtures/personas/core/includes/options.php b/tests/Fixtures/personas/core/includes/options.php new file mode 100644 index 0000000..3bb77a3 --- /dev/null +++ b/tests/Fixtures/personas/core/includes/options.php @@ -0,0 +1,23 @@ + */ public const array COMPONENTS = array( + Scheduler::class, AdminNotices::class, Greeting::class, ); @@ -66,14 +68,14 @@ final class Plugin { public readonly PluginBoot $boundary; /** - * The kernel, once the composition built it, which tells which components loaded. + * The scheduler of the plugin's background work, which the migration runner continues through. * * @since 1.0.0 * @version 1.0.0 * - * @var PluginKernel|null + * @var Scheduler */ - protected(set) ?PluginKernel $kernel = null; + public readonly Scheduler $scheduler; /** * The migration runner, once the composition built it. @@ -86,24 +88,24 @@ final class Plugin { protected(set) ?MigrationRunner $migrations = null; /** - * The logger every part of the plugin writes to. + * The kernel, once the composition built it, which tells which components loaded. * * @since 1.0.0 * @version 1.0.0 * - * @var PsrLoggerInterface + * @var PluginKernel|null */ - protected readonly PsrLoggerInterface $logger; + protected(set) ?PluginKernel $kernel = null; /** - * The scheduler of the plugin's background work, which the migration runner continues through. + * The logger every part of the plugin writes to. * * @since 1.0.0 * @version 1.0.0 * - * @var Scheduler + * @var PsrLoggerInterface */ - protected readonly Scheduler $scheduler; + protected readonly PsrLoggerInterface $logger; // endregion @@ -117,8 +119,8 @@ final class Plugin { */ public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); } // endregion @@ -154,25 +156,29 @@ protected function compose(): bool { $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; } /** - * Returns the container's definitions of the services the root builds. + * Returns the container's definitions of the services the root builds, which the kernel's components share. * * @since 1.0.0 * @version 1.0.0 * - * @param PsrLoggerInterface $logger The plugin's logger. + * @param PsrLoggerInterface $logger The plugin's logger. + * @param Scheduler $scheduler The plugin's scheduler. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger ): array { + protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( - AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger, + Scheduler::class => static fn (): Scheduler => $scheduler, + SchedulerInterface::class => static fn (): Scheduler => $scheduler, + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php new file mode 100644 index 0000000..6dd4e7e --- /dev/null +++ b/tests/Fixtures/personas/core/uninstall.php @@ -0,0 +1,55 @@ +getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The uninstall request has no other channel. + return; +} + +Uninstaller::run( + $acme_pc_file, + $acme_pc_footprint, + array( + 'consent' => array( + 'option' => $acme_pc_consent, + 'key' => 'delete_data', + ), + ) +); diff --git a/tests/Fixtures/personas/settings/functions.php b/tests/Fixtures/personas/settings/functions.php index 57f8a04..ee7ff9e 100644 --- a/tests/Fixtures/personas/settings/functions.php +++ b/tests/Fixtures/personas/settings/functions.php @@ -1,6 +1,6 @@ register_hooks(); + new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); } diff --git a/tests/Fixtures/personas/settings/src/Details.php b/tests/Fixtures/personas/settings/src/Details.php new file mode 100644 index 0000000..32112e9 --- /dev/null +++ b/tests/Fixtures/personas/settings/src/Details.php @@ -0,0 +1,40 @@ + __( 'Details', 'acme-persona-settings' ), + 'capability' => 'edit_posts', + 'fields' => array( + array( + 'type' => 'text', + 'id' => '_' . Settings::PREFIX . '_subtitle', + 'title' => __( 'Subtitle', 'acme-persona-settings' ), + ), + ), + ); + } + + // endregion +} diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php index b1ede0c..0c47af5 100644 --- a/tests/Fixtures/personas/settings/src/Settings.php +++ b/tests/Fixtures/personas/settings/src/Settings.php @@ -49,10 +49,15 @@ public function get_options_page( string $page_id ): array { 'option' => self::PREFIX . '_settings', 'fields' => array( array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'validate' => array( $this, 'validate_limit' ), + 'type' => 'number', + 'id' => 'limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => array( $this, 'validate_limit' ), ), ), ), diff --git a/tests/Fixtures/personas/shared/run.php b/tests/Fixtures/personas/shared/run.php index 8ae69d8..beb6b4e 100644 --- a/tests/Fixtures/personas/shared/run.php +++ b/tests/Fixtures/personas/shared/run.php @@ -1,6 +1,6 @@ 'box' ) ); + echo \json_encode( array( 'wordpress' => \defined( 'ABSPATH' ), 'version' => array( (string) Version::from_string( '2.1.0-rc.1' ), Version::from_string( '2.1.0' )->compare( Version::from_string( '2.1.0-rc.1' ) ) ), + 'rejected' => $acme_rejected, 'results' => array( Success::from( 'acme' )->match( static fn ( string $value ): string => "found $value", static fn (): string => 'failed' ), Failure::from( new UnknownKey( 'acme' ) )->match( static fn (): string => 'found', static fn ( UnknownKey $error ): string => "unknown $error->key" ), ), + 'narrowed' => array( $acme_catalog->describe( 'acme' ), $acme_catalog->describe( 'other' ) ), 'equality' => array( new Point( 1, 2 )->equals( new Point( 1, 2 ) ), new Point( 1, 2 )->equals( new Point( 2, 1 ) ) ), ), \JSON_THROW_ON_ERROR diff --git a/tests/Fixtures/personas/shared/src/Catalog.php b/tests/Fixtures/personas/shared/src/Catalog.php new file mode 100644 index 0000000..6bcf3d5 --- /dev/null +++ b/tests/Fixtures/personas/shared/src/Catalog.php @@ -0,0 +1,66 @@ + $values The values, keyed by their keys. + */ + public function __construct( + public array $values + ) {} + + // endregion + + // region METHODS + + /** + * Returns the value the key holds, or the failure that names the key. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The key. + * + * @return AbstractResult + */ + #[\NoDiscard] + public function find( string $key ): AbstractResult { + return isset( $this->values[ $key ] ) ? Success::from( $this->values[ $key ] ) : Failure::from( new UnknownKey( $key ) ); + } + + /** + * Returns what a lookup of the key found, in words. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The key. + * + * @return string + */ + public function describe( string $key ): string { + $found = $this->find( $key ); + + return $found->is_success() ? "found $found->value" : "unknown {$found->error->key}"; + } + + // endregion +} diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php index 9259eec..bc7c8aa 100644 --- a/tests/Fixtures/personas/woocommerce/functions.php +++ b/tests/Fixtures/personas/woocommerce/functions.php @@ -1,6 +1,6 @@ , meta: array{post: list}} + */ +function acme_pw_get_footprint(): array { + return array( + 'option_prefixes' => array( Packing::PREFIX . '_' ), + 'meta' => array( 'post' => array( ACME_PW_META_BOX ) ), + ); +} diff --git a/tests/Fixtures/personas/woocommerce/includes/index.php b/tests/Fixtures/personas/woocommerce/includes/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/tests/Fixtures/personas/woocommerce/includes/index.php @@ -0,0 +1 @@ + self::PREFIX . '_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), + array( + 'type' => 'checkbox', + 'id' => ACME_PW_OPTION_DELETE_DATA, + 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), + 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), + ), ); } @@ -62,7 +68,7 @@ public function get_product_data_tab( string $tab_id, \WC_Product $product ): ar 'fields' => array( array( 'type' => 'text', - 'id' => '_' . self::PREFIX . '_box', + 'id' => ACME_PW_META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), diff --git a/tests/Fixtures/personas/woocommerce/uninstall.php b/tests/Fixtures/personas/woocommerce/uninstall.php new file mode 100644 index 0000000..f6f0abf --- /dev/null +++ b/tests/Fixtures/personas/woocommerce/uninstall.php @@ -0,0 +1,55 @@ +getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The uninstall request has no other channel. + return; +} + +Uninstaller::run( + $acme_pw_file, + $acme_pw_footprint, + array( + 'consent' => array( 'option' => $acme_pw_consent ), + 'meta_types' => $acme_pw_meta_types, + ) +); diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php index f9cb284..612805f 100644 --- a/tests/Integration/Personas/CorePersonaTest.php +++ b/tests/Integration/Personas/CorePersonaTest.php @@ -26,4 +26,21 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with self::assertTrue( $request['records']['greeting_shortcode'] ); self::assertSame( array( 'greeting' => 'Hello' ), get_option( 'acme_pc_settings' ) ); } + + public function test_a_consenting_uninstall_removes_the_plugin_s_options(): void { + update_option( + 'acme_pc_settings', + array( + 'greeting' => 'Hello', + 'delete_data' => 'yes', + ) + ); + update_option( 'acme_pc_revision', 1 ); + $plugin = $this->install_consumer( 'personas/core', 'acme-persona-core' ); + $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_pc_admin', 'administrator' ), array( 'uninstall' => $plugin ) ); + + self::assertTrue( $request['records']['deleted'] ); + self::assertFalse( get_option( 'acme_pc_settings' ) ); + self::assertFalse( get_option( 'acme_pc_revision' ) ); + } } diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php index 7ad0aa8..3124df5 100644 --- a/tests/Integration/Personas/SettingsPersonaTest.php +++ b/tests/Integration/Personas/SettingsPersonaTest.php @@ -39,6 +39,23 @@ public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of at least 1.', $page['body'] ); } + public function test_the_post_form_saves_its_field_on_the_post_through_the_block_editor_s_meta_box_save(): void { + $user = $this->make_user( 'acme_ps_admin', 'administrator' ); + $spec = array( 'activate' => $this->install_consumer( 'personas/settings', 'acme-persona-settings' ) ); + $post_id = wp_insert_post( array( 'post_title' => 'Acme post' ) ); + + try { + $body = $this->consumer_request( 'GET', "post.php?post=$post_id&action=edit", $user, $spec )['body']; + $data = \array_replace_recursive( self::serialize_form( $body, 'form.metabox-base-form' ), self::serialize_form( $body, 'form.metabox-location-advanced', array( 'acme_ps_details[_acme_ps_subtitle]' => 'Hello' ) ) ); + self::assertSame( 1, \preg_match( '/var _wpMetaBoxUrl = ("[^"]+");/', $body, $matches ) ); + $this->consumer_request( 'POST', \explode( '/wp-admin/', (string) \json_decode( $matches[1] ), 2 )[1], $user, $spec, $data ); + + self::assertSame( 'Hello', get_post_meta( $post_id, '_acme_ps_subtitle', true ) ); + } finally { + wp_delete_post( $post_id, true ); + } + } + // Posts the settings tab as the browser submits it after the user typed the limit. protected function save_limit( string $limit ): array { $user = $this->make_user( 'acme_ps_admin', 'administrator' ); diff --git a/tests/Integration/Personas/SharedPersonaTest.php b/tests/Integration/Personas/SharedPersonaTest.php index 86dbe23..b0f82d5 100644 --- a/tests/Integration/Personas/SharedPersonaTest.php +++ b/tests/Integration/Personas/SharedPersonaTest.php @@ -10,7 +10,7 @@ final class SharedPersonaTest extends TestCase { protected const string BUILD = __DIR__ . '/../../Fixtures/personas/shared'; // The script runs in its own PHP process, so WordPress never loads beside the scoped shared package. - public function test_the_scoped_package_parses_versions_matches_results_and_compares_value_objects_in_plain_php(): void { + public function test_the_scoped_package_parses_versions_throws_its_mirrors_matches_and_narrows_results_and_compares_value_objects_in_plain_php(): void { self::assertFileExists( self::BUILD . '/vendor-prefixed/scoper-autoload.php', 'Build personas/shared first with `composer fixtures:build`.' ); $descriptors = array( @@ -29,7 +29,9 @@ public function test_the_scoped_package_parses_versions_matches_results_and_comp array( 'wordpress' => false, 'version' => array( '2.1.0-rc.1', 1 ), + 'rejected' => 'Acme\\PersonaShared\\Scoped\\DeepWebSolutions\\Framework\\Shared\\Exception\\InvalidArgumentException', 'results' => array( 'found acme', 'unknown acme' ), + 'narrowed' => array( 'found box', 'unknown other' ), 'equality' => array( true, false ), ), \json_decode( $output, true, flags: \JSON_THROW_ON_ERROR ) diff --git a/tests/Integration/Personas/WooCommercePersonaTest.php b/tests/Integration/Personas/WooCommercePersonaTest.php index 9832113..0d36035 100644 --- a/tests/Integration/Personas/WooCommercePersonaTest.php +++ b/tests/Integration/Personas/WooCommercePersonaTest.php @@ -47,4 +47,23 @@ public function test_the_product_data_tab_saves_its_field_on_the_product_through $product->delete( true ); } } + + public function test_a_consenting_uninstall_removes_the_plugin_s_options_and_product_meta(): void { + update_option( 'acme_pw_default_box', 'Large' ); + update_option( 'acme_pw_delete_data', 'yes' ); + $product = new \WC_Product_Simple(); + $product->update_meta_data( '_acme_pw_box', 'Small' ); + $product->save(); + + try { + $request = $this->consumer_request( 'GET', 'index.php', $this->admin, array( 'uninstall' => $this->spec['activate'] ) ); + + self::assertTrue( $request['records']['deleted'] ); + self::assertFalse( get_option( 'acme_pw_default_box' ) ); + self::assertFalse( get_option( 'acme_pw_delete_data' ) ); + self::assertSame( '', get_post_meta( $product->get_id(), '_acme_pw_box', true ) ); + } finally { + $product->delete( true ); + } + } } From dde288aa3fb86ed76cb9df5e4cb4d1c8c21da479 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:11 +0100 Subject: [PATCH 28/47] Give the core tests' copied doubles and helpers one home Six core test files defined the same recording logger under different names and four the same throwing logger, two copied the admin-notice render helper, and the scheduler and migration tests kept diverged mirrors of Action Scheduler's queue run and wp-cron.php. The doing_it_wrong recorder, the switched-site restore loop and the prefix sweep were copied the same way, so a fix to one copy left the others behind, and the consumer cleanup left their user options in the shared database. Each now has one home in Integration/Support: RecordingLogger, ThrowingLogger, RecordsDoingItWrongTrait, DispatchesScheduledHooksTrait, render_all_admin_notices(), an #[After] restore_switched_sites() and delete_prefixed_options(), which the consumer cleanup also calls. The migration tests share their option lifecycle and get_lock() through MigrationFixturesTrait, the switched-site matrix reuses SchedulerTest::writes(), and the PluginBoot tests take IsolatesStorageTrait's prefix. The scheduler kind reads 'action-scheduler', two enqueue() calls drop an argument the method does not take, the boot test is named for success, anonymous-class methods carry #[\Override], and interpolation and null checks follow the style guide. Assisted-by: Claude Code:claude-opus-5-5 --- .../Core/Logging/ErrorLogLoggerTest.php | 1 + .../Core/Logging/GuardedLoggerTest.php | 23 +-- .../Core/Migration/MigrationFixturesTrait.php | 27 +++ .../MigrationRunnerInLocalEnvironmentTest.php | 21 +-- .../Core/Migration/MigrationRunnerTest.php | 174 ++++++------------ .../Core/Permission/CapabilityGrantsTest.php | 28 +-- .../Core/PluginBootInLocalEnvironmentTest.php | 20 +- tests/Integration/Core/PluginBootTest.php | 97 +++------- .../SchedulerOnSwitchedSiteTest.php | 33 +--- .../Core/Scheduling/SchedulerTest.php | 156 ++++++---------- .../Core/Storage/OptionRowsTest.php | 7 - .../Integration/Support/CreatesSitesTrait.php | 9 + .../Support/DispatchesScheduledHooksTrait.php | 37 ++++ .../Support/InstallsConsumersTrait.php | 7 +- .../Support/IsolatesStorageTrait.php | 13 +- .../Support/ProvidesLogMessagesTrait.php | 3 + tests/Integration/Support/RecordingLogger.php | 14 ++ .../Support/RecordsDoingItWrongTrait.php | 18 ++ .../Support/RendersAdminScreensTrait.php | 8 + tests/Integration/Support/ThrowingLogger.php | 16 ++ tests/Integration/Support/admin-request.php | 2 +- .../Integration/Support/consumer-request.php | 5 +- 22 files changed, 320 insertions(+), 399 deletions(-) create mode 100644 tests/Integration/Core/Migration/MigrationFixturesTrait.php create mode 100644 tests/Integration/Support/DispatchesScheduledHooksTrait.php create mode 100644 tests/Integration/Support/RecordingLogger.php create mode 100644 tests/Integration/Support/RecordsDoingItWrongTrait.php create mode 100644 tests/Integration/Support/ThrowingLogger.php diff --git a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php index 6cf67ef..13c192e 100644 --- a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php @@ -82,6 +82,7 @@ public function test_a_record_casts_only_the_context_values_its_message_uses(): $unused = new class() implements \Stringable { public int $casts = 0; + #[\Override] public function __toString(): string { ++$this->casts; diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php index 2aa66ea..7965dba 100644 --- a/tests/Integration/Core/Logging/GuardedLoggerTest.php +++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php @@ -3,32 +3,13 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel as PsrLogLevel; -final class RecordingLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, $message, $context ); - } -} - -final class ThrowingLogger extends PsrAbstractLogger { - public function __construct( - protected \Throwable $failure - ) {} - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw $this->failure; - } -} - #[CoversClass( GuardedLogger::class )] final class GuardedLoggerTest extends TestCase { protected string $log_file; diff --git a/tests/Integration/Core/Migration/MigrationFixturesTrait.php b/tests/Integration/Core/Migration/MigrationFixturesTrait.php new file mode 100644 index 0000000..30caa3b --- /dev/null +++ b/tests/Integration/Core/Migration/MigrationFixturesTrait.php @@ -0,0 +1,27 @@ +option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); + } + + #[After] + protected function delete_runner_options(): void { + foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { + delete_option( $this->option . $suffix ); + } + } + + protected function get_lock(): mixed { + return OptionRows::get( "{$this->option}_lock" )?->value; + } +} diff --git a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php index 6d76f33..7a12703 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php @@ -4,7 +4,6 @@ use DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; -use DeepWebSolutions\Framework\Core\Storage\OptionRows; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; @@ -14,9 +13,9 @@ #[CoversClass( MigrationRunner::class )] #[RunTestsInSeparateProcesses] final class MigrationRunnerInLocalEnvironmentTest extends TestCase { - protected static string|false $environment_type = false; + use MigrationFixturesTrait; - protected string $option; + protected static string|false $environment_type = false; #[\Override] public static function setUpBeforeClass(): void { @@ -29,18 +28,6 @@ public static function tearDownAfterClass(): void { \putenv( false === self::$environment_type ? 'WP_ENVIRONMENT_TYPE' : 'WP_ENVIRONMENT_TYPE=' . self::$environment_type ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv -- Restores the outer value. } - #[\Override] - protected function setUp(): void { - $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); - } - - #[\Override] - protected function tearDown(): void { - foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { - delete_option( $this->option . $suffix ); - } - } - public function test_a_failing_step_releases_the_lock_and_is_rethrown_so_the_fixed_step_runs_next(): void { self::assertSame( 'local', wp_get_environment_type() ); $failure = new \RuntimeException( 'Step failed locally.' ); @@ -98,8 +85,4 @@ function (): void { protected function make_runner( array $steps ): MigrationRunner { return new MigrationRunner( __DIR__ . '/../../../Fixtures/Core/acme-boot.php', $this->option, $steps, new PsrNullLogger(), new Scheduler( 'acme' ) ); } - - protected function get_lock(): mixed { - return OptionRows::get( "{$this->option}_lock" )?->value; - } } diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php index 1f4b897..c166745 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php @@ -6,36 +6,22 @@ use DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; -use DeepWebSolutions\Framework\Core\Storage\OptionRows; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\RuntimeException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\DispatchesScheduledHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ReportsActionSchedulerVersionsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\SettableClock; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface as PsrClockInterface; -use Psr\Log\AbstractLogger as PsrAbstractLogger; use Psr\Log\LoggerInterface as PsrLoggerInterface; -final class MigrationLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class FailingMigrationLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} - final class RecordedBatches implements BatchedMigrationStepInterface { public int $processed = 0; @@ -58,7 +44,10 @@ public function process_batch(): bool { #[CoversClass( MigrationRunner::class )] final class MigrationRunnerTest extends TestCase { use CreatesUsersTrait; + use DispatchesScheduledHooksTrait; use IsolatesHooksTrait; + use MigrationFixturesTrait; + use RendersAdminScreensTrait; use ReportsActionSchedulerVersionsTrait; protected const string PLUGIN_FILE = __DIR__ . '/../../../Fixtures/Core/acme-boot.php'; @@ -67,9 +56,7 @@ final class MigrationRunnerTest extends TestCase { protected const string PROGRESS_NOTICE = 'Acme Boot is updating its data in the background.'; - protected string $option; - - protected MigrationLogger $logger; + protected RecordingLogger $logger; protected array $ran = array(); @@ -79,8 +66,7 @@ final class MigrationRunnerTest extends TestCase { #[\Override] protected function setUp(): void { - $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); - $this->logger = new MigrationLogger(); + $this->logger = new RecordingLogger(); // The runner pauses near memory_limit, so step counts must not depend on what the test process already holds. $this->memory_limit = (string) \ini_get( 'memory_limit' ); @@ -92,13 +78,9 @@ protected function tearDown(): void { global $wpdb; \ini_set( 'memory_limit', $this->memory_limit ); - foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { - delete_option( $this->option . $suffix ); - } - wp_unschedule_hook( "{$this->option}/migration/run" ); - wp_set_current_user( 0 ); + wp_unschedule_hook( "$this->option/migration/run" ); unset( $_REQUEST['_wpnonce'], $_SERVER['HTTP_REFERER'] ); - $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "{$this->option}/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created. + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "$this->option/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created. } public function test_pending_steps_run_once_each_in_order_with_a_checkpoint_after_each(): void { @@ -274,7 +256,7 @@ public function test_a_failed_revision_read_stops_the_run_with_an_error(): void add_filter( 'query', function ( string $query ): string { - return \str_starts_with( \trim( $query ), 'SELECT option_value' ) && \str_contains( $query, "'{$this->option}'" ) ? 'SELECT option_value FROM acme_missing_table' : $query; + return \str_starts_with( \trim( $query ), 'SELECT option_value' ) && \str_contains( $query, "'$this->option'" ) ? 'SELECT option_value FROM acme_missing_table' : $query; } ); $suppressed = $wpdb->suppress_errors(); @@ -376,7 +358,7 @@ public function test_an_array_stored_as_the_revision_reads_as_0_and_stops_the_ru } public function test_an_unconfirmed_checkpoint_stops_the_run_with_an_error(): void { - add_filter( "pre_update_option_{$this->option}", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); + add_filter( "pre_update_option_$this->option", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); $this->make_runner( array( $this->step( 1 ), $this->step( 2 ) ) )->run(); @@ -559,7 +541,7 @@ public function test_a_cached_revision_above_the_code_is_checked_against_the_dat public function test_a_lock_taken_over_during_a_checkpoint_stops_the_run_before_its_next_step(): void { add_action( - "update_option_{$this->option}", + "update_option_$this->option", function (): void { update_option( "{$this->option}_lock", self::hold( 'acme-other', \time() ), false ); // Stands in for another run taking the lock over. } @@ -646,7 +628,7 @@ static function (): void { }, ), null, - new FailingMigrationLogger() + new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) ); try { @@ -657,7 +639,7 @@ static function (): void { wp_delete_file( $log_file ); } - self::assertStringContainsString( "[{$this->option}.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log ); + self::assertStringContainsString( "[$this->option.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); self::assertIsArray( get_option( "{$this->option}_error" ) ); } @@ -676,7 +658,7 @@ function ( string $query ): string { $previous = \ini_set( 'error_log', $log_file ); try { - $this->make_runner( array( $this->step( 1 ) ), null, new FailingMigrationLogger() )->run(); + $this->make_runner( array( $this->step( 1 ) ), null, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) )->run(); $log = (string) \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); @@ -684,7 +666,7 @@ function ( string $query ): string { $wpdb->suppress_errors( $suppressed ); } - self::assertStringContainsString( "[{$this->option}.warning] Could not take the data migration lock: {error} {\"error\":\"", $log ); + self::assertStringContainsString( "[$this->option.warning] Could not take the data migration lock: {error} {\"error\":\"", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); self::assertSame( array(), $this->ran ); } @@ -712,10 +694,10 @@ public function test_a_request_outside_the_runner_hook_runs_plain_steps_but_no_b } public function test_a_request_outside_the_runner_hook_adds_nothing_while_the_hook_runs_elsewhere(): void { - as_enqueue_async_action( "{$this->option}/migration/run", array(), 'acme' ); + as_enqueue_async_action( "$this->option/migration/run", array(), 'acme' ); $running = as_get_scheduled_actions( array( - 'hook' => "{$this->option}/migration/run", + 'hook' => "$this->option/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, ), 'ids' @@ -731,7 +713,7 @@ public function test_the_runner_hook_runs_batches_until_the_step_finishes(): voi $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches, $this->step( 2 ) ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 3, $batches->processed ); self::assertSame( array( array( 2, 1 ) ), $this->ran ); @@ -754,7 +736,7 @@ function ( mixed $event ) use ( &$locks ): mixed { } ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( array( null ), $locks ); @@ -769,9 +751,9 @@ public function test_a_batched_step_spans_requests_while_its_revision_stays_behi $revisions = array(); for ( $request = 1; $request <= 3; ++$request ) { - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->make_runner( array( $batches ), $clock )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $revisions[] = array( $batches->processed, MigrationRunner::get_revision( $this->option ) ); } @@ -792,7 +774,7 @@ static function () use ( $clock ): void { ); $this->load_wordpress( $runner ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); @@ -811,7 +793,7 @@ static function (): void { $batches = new RecordedBatches( 3, static fn ( int $processed ) => 1 === $processed && $takeover->run() ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( $later->now->getTimestamp(), $this->get_lock()['time'] ?? null ); @@ -851,7 +833,7 @@ public function test_the_runtime_cache_is_flushed_after_every_batch(): void { }; $this->make_runner( array( new RecordedBatches( 2, $batch ), new RecordedBatches( 1, $batch ) ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( array( false, false, false ), $cached ); self::assertFalse( wp_cache_get( 'acme_batch', 'acme' ) ); @@ -909,7 +891,7 @@ public function test_memory_past_90_percent_of_the_limit_stops_the_loop( bool $t \ini_set( 'memory_limit', $tight ? (string) (int) ( \memory_get_usage( true ) / 0.93 ) : '-1' ); try { - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); } finally { \ini_set( 'memory_limit', $previous ); } @@ -922,7 +904,7 @@ public function test_the_runner_hook_returns_without_enqueueing_while_another_ru $this->insert_lock( \time() - 5 ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 0, $batches->processed ); self::assertSame( 0, $this->count_pending_runs() ); @@ -940,7 +922,7 @@ static function ( int $processed ): void { $this->make_runner( array( $this->step( 1 ), $batches ) )->register_hooks(); update_option( $this->option, 1 ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $error = get_option( "{$this->option}_error" ); self::assertIsArray( $error ); @@ -966,7 +948,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure() false ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertFalse( get_option( "{$this->option}_error" ) ); @@ -974,7 +956,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure() public static function dispatchers(): array { return array( - 'action-scheduler' => array( 'production' ), + 'action-scheduler' => array( 'action-scheduler' ), 'wp-cron' => array( 'wp-cron' ), ); } @@ -983,14 +965,14 @@ public static function dispatchers(): array { public function test_a_batched_step_finishes_through_real_scheduler_dispatch( string $kind ): void { $clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) ); $batches = new RecordedBatches( 5, static fn () => $clock->advance( 10 ) ); - $scheduler = 'production' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); + $scheduler = 'action-scheduler' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); $requests = 0; while ( 1 > MigrationRunner::get_revision( $this->option ) && $requests < 5 ) { // Each request's wp_loaded pass enqueues the runner, then its queue run dispatches what is due. - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( "$this->option/migration/run" ) : self::run_due_cron_events( "$this->option/migration/run" ); ++$requests; } @@ -1005,16 +987,16 @@ public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_prog $batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - $this->run_due_actions(); + self::run_due_actions( "$this->option/migration/run" ); // A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does. - $this->run_due_actions( \time() + MINUTE_IN_SECONDS ); + self::run_due_actions( "$this->option/migration/run", \time() + MINUTE_IN_SECONDS ); self::assertSame( 1, $batches->processed ); self::assertSame( 0, $this->count_pending_runs() ); - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - $this->run_due_actions(); + self::run_due_actions( "$this->option/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( 0, MigrationRunner::get_revision( $this->option ) ); @@ -1041,31 +1023,27 @@ static function (): void { ); $runner = $this->make_runner( array( $batches ) ); $runner->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::ERROR_NOTICE . 'The <batch> failed.', $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); - wp_set_current_user( $this->make_user( 'acme-migrations-editor', 'editor' ) ); - self::assertStringNotContainsString( self::ERROR_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::ERROR_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) ); } public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_link(): void { $this->make_runner( array( $this->step( 1 ), new RecordedBatches( 3 ) ) )->register_hooks(); update_option( $this->option, 1 ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::PROGRESS_NOTICE, $notices ); self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) ); \parse_str( (string) wp_parse_url( \html_entity_decode( $link[1] ), \PHP_URL_QUERY ), $query ); - self::assertSame( "{$this->option}/migration/run_now", $query['action'] ); - self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "{$this->option}/migration/run_now" ) ); + self::assertSame( "$this->option/migration/run_now", $query['action'] ); + self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "$this->option/migration/run_now" ) ); - wp_set_current_user( $this->make_user( 'acme-migrations-editor', 'editor' ) ); - self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) ); } public function test_a_failed_batch_shows_its_error_with_a_run_now_link_that_completes_the_repaired_step(): void { @@ -1079,12 +1057,11 @@ static function () use ( &$broken ): void { } ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $this->age_lock(); $broken = false; - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::ERROR_NOTICE . 'The batch failed.', $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) ); @@ -1126,8 +1103,7 @@ public function test_an_invalid_error_row_shows_no_error_notice( mixed $row ): v $this->make_runner( array( new RecordedBatches( 3 ) ) )->register_hooks(); update_option( "{$this->option}_error", $row, false ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringNotContainsString( self::ERROR_NOTICE, $notices ); self::assertStringContainsString( self::PROGRESS_NOTICE, $notices ); @@ -1136,9 +1112,7 @@ public function test_an_invalid_error_row_shows_no_error_notice( mixed $row ): v public function test_a_pending_plain_step_shows_no_progress_notice(): void { $this->make_runner( array( $this->step( 1 ) ) )->register_hooks(); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - - self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ) ); } public function test_no_notice_shows_once_every_step_has_run(): void { @@ -1156,8 +1130,7 @@ public function test_no_notice_shows_once_every_step_has_run(): void { false ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringNotContainsString( self::ERROR_NOTICE, $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); @@ -1175,7 +1148,7 @@ public function test_run_now_runs_the_runner_hook_and_returns_to_a_local_referri $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches ) )->register_hooks(); wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $_REQUEST['_wpnonce'] = wp_create_nonce( "{$this->option}/migration/run_now" ); + $_REQUEST['_wpnonce'] = wp_create_nonce( "$this->option/migration/run_now" ); $_SERVER['HTTP_REFERER'] = \str_starts_with( $referer, 'https://' ) ? $referer : admin_url( $referer ); self::assertSame( admin_url( $destination ), $this->post_run_now() ); @@ -1195,7 +1168,7 @@ public function test_run_now_refuses_an_invalid_nonce_and_users_who_cannot_activ $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches ) )->register_hooks(); wp_set_current_user( $this->make_user( "acme-migrations-$role", $role ) ); - $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "{$this->option}/migration/run_now" ) : 'invalid'; + $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "$this->option/migration/run_now" ) : 'invalid'; self::assertSame( 'died', $this->post_run_now() ); self::assertSame( 0, $batches->processed ); @@ -1253,13 +1226,6 @@ protected static function hold( string $owner, int $time ): array { ); } - protected function render_admin_notices(): string { - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); - } - // The handler exits after redirecting, so the redirect and wp_die() throw to return here first. protected function post_run_now(): string { add_filter( @@ -1276,7 +1242,7 @@ static function ( string $location ): never { ); try { - do_action( "admin_post_{$this->option}/migration/run_now" ); + do_action( "admin_post_$this->option/migration/run_now" ); } catch ( \OutOfBoundsException | \OverflowException $stop ) { return $stop->getMessage(); } @@ -1287,42 +1253,14 @@ static function ( string $location ): never { protected function count_pending_runs(): int { $cron = 0; foreach ( _get_cron_array() as $hooks ) { - $cron += \count( $hooks[ "{$this->option}/migration/run" ] ?? array() ); + $cron += \count( $hooks[ "$this->option/migration/run" ] ?? array() ); } $query = array( - 'hook' => "{$this->option}/migration/run", + 'hook' => "$this->option/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, ); return $cron + \count( as_get_scheduled_actions( $query, 'ids' ) ); } - - // Mirrors one Action Scheduler queue-runner batch, which claims the actions due by the given time or now. - protected function run_due_actions( ?int $at = null ): void { - $store = \ActionScheduler::store(); - $claim = $store->stake_claim( 10, \is_null( $at ) ? null : new \DateTime( "@$at" ), array( "{$this->option}/migration/run" ) ); - foreach ( $claim->get_actions() as $action_id ) { - \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); - } - $store->release_claim( $claim ); - } - - // Mirrors one wp-cron.php run, which runs the events due by the given time or now. - protected function run_due_cron_events( ?int $at = null ): void { - foreach ( _get_cron_array() as $timestamp => $hooks ) { - if ( $timestamp > ( $at ?? \time() ) ) { - break; - } - - foreach ( $hooks[ "{$this->option}/migration/run" ] ?? array() as $event ) { - wp_unschedule_event( $timestamp, "{$this->option}/migration/run", $event['args'], true ); - do_action_ref_array( "{$this->option}/migration/run", $event['args'] ); - } - } - } - - protected function get_lock(): mixed { - return OptionRows::get( "{$this->option}_lock" )?->value; - } } diff --git a/tests/Integration/Core/Permission/CapabilityGrantsTest.php b/tests/Integration/Core/Permission/CapabilityGrantsTest.php index 2cffd07..8fd841d 100644 --- a/tests/Integration/Core/Permission/CapabilityGrantsTest.php +++ b/tests/Integration/Core/Permission/CapabilityGrantsTest.php @@ -9,27 +9,13 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\InterceptsQueriesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class GrantsLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class FailingGrantsLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} +use Psr\Log\LoggerInterface as PsrLoggerInterface; #[CoversClass( CapabilityGrants::class )] final class CapabilityGrantsTest extends TestCase { @@ -39,11 +25,11 @@ final class CapabilityGrantsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; - protected GrantsLogger $logger; + protected RecordingLogger $logger; #[\Override] protected function setUp(): void { - $this->logger = new GrantsLogger(); + $this->logger = new RecordingLogger(); } #[\Override] @@ -352,7 +338,7 @@ public function test_a_warning_whose_logger_throws_reaches_the_error_log_without $this->keep_roles_row(); try { - $this->grants( array( $editor => array( $this->cap( 'a' ) ) ), new FailingGrantsLogger() )->run(); + $this->grants( array( $editor => array( $this->cap( 'a' ) ) ), new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) )->run(); $log = (string) \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); @@ -529,7 +515,7 @@ public function test_a_run_and_a_role_deletion_change_only_their_own_sites_roles self::assertFalse( get_option( "{$this->prefix}_grants" ) ); } - protected function grants( array $grants, ?PsrAbstractLogger $logger = null ): CapabilityGrants { + protected function grants( array $grants, ?PsrLoggerInterface $logger = null ): CapabilityGrants { return new CapabilityGrants( "{$this->prefix}_grants", $grants, $logger ?? $this->logger ); } diff --git a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php index 9293916..ff941da 100644 --- a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php +++ b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core; use DeepWebSolutions\Framework\Core\PluginBoot; +use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; @@ -12,6 +13,8 @@ #[CoversClass( PluginBoot::class )] #[RunTestsInSeparateProcesses] final class PluginBootInLocalEnvironmentTest extends TestCase { + use IsolatesStorageTrait; + protected static string|false $environment_type = false; protected array $fired = array(); @@ -32,31 +35,26 @@ public static function tearDownAfterClass(): void { #[\Override] protected function setUp(): void { add_action( - 'acme/boot/succeeded', + "$this->prefix/boot/succeeded", function (): void { - $this->fired[] = 'acme/boot/succeeded'; + $this->fired[] = 'succeeded'; }, 10, 0 ); add_action( - 'acme/boot/failed', + "$this->prefix/boot/failed", function ( \Throwable $failure ): void { - $this->fired[] = 'acme/boot/failed'; + $this->fired[] = 'failed'; $this->failures[] = $failure; } ); } - #[\Override] - protected function tearDown(): void { - delete_transient( 'acme_boot_failure_' . \md5( 'composition failed' ) ); - } - public function test_a_failing_composition_is_rethrown_after_the_failure_action(): void { self::assertSame( 'local', wp_get_environment_type() ); $failure = new \RuntimeException( 'composition failed' ); - $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', 'acme', new PsrNullLogger() ); + $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', $this->prefix, new PsrNullLogger() ); $caught = null; try { @@ -67,7 +65,7 @@ public function test_a_failing_composition_is_rethrown_after_the_failure_action( self::assertSame( $failure, $caught, 'The failure was not rethrown.' ); self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); } } diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php index 9b60b0b..914dfdc 100644 --- a/tests/Integration/Core/PluginBootTest.php +++ b/tests/Integration/Core/PluginBootTest.php @@ -5,35 +5,20 @@ use DeepWebSolutions\Framework\Core\PluginBoot; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class RecordingLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class ThrowingLogger extends PsrAbstractLogger { - public function __construct( - protected \Throwable $failure - ) {} - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw $this->failure; - } -} #[CoversClass( PluginBoot::class )] final class PluginBootTest extends TestCase { use CreatesUsersTrait; use IsolatesHooksTrait; + use IsolatesStorageTrait; + use RendersAdminScreensTrait; protected const string PLUGIN_FILE = __DIR__ . '/../../Fixtures/Core/acme-boot.php'; @@ -47,46 +32,39 @@ final class PluginBootTest extends TestCase { #[\Override] protected function setUp(): void { - $this->delete_failure_transients(); $this->logger = new RecordingLogger(); add_action( - 'acme/boot/succeeded', + "$this->prefix/boot/succeeded", function (): void { - $this->fired[] = 'acme/boot/succeeded'; + $this->fired[] = 'succeeded'; }, 10, 0 ); add_action( - 'acme/boot/blocked', + "$this->prefix/boot/blocked", function (): void { - $this->fired[] = 'acme/boot/blocked'; + $this->fired[] = 'blocked'; }, 10, 0 ); add_action( - 'acme/boot/failed', + "$this->prefix/boot/failed", function ( \Throwable $failure ): void { - $this->fired[] = 'acme/boot/failed'; + $this->fired[] = 'failed'; $this->failures[] = $failure; } ); } - #[\Override] - protected function tearDown(): void { - $this->delete_failure_transients(); - wp_set_current_user( 0 ); - } - - public function test_a_composition_that_completes_boots_and_announces_initialization_once(): void { + public function test_a_composition_that_completes_boots_and_announces_success_once(): void { $boot = $this->make_boot(); $boot->run( static fn (): bool => true ); self::assertSame( 'booted', $boot->status ); - self::assertSame( array( 'acme/boot/succeeded' ), $this->fired ); + self::assertSame( array( 'succeeded' ), $this->fired ); } public function test_a_composition_named_as_a_callable_boots(): void { @@ -95,16 +73,17 @@ public function test_a_composition_named_as_a_callable_boots(): void { $boot->run( '__return_true' ); self::assertSame( 'booted', $boot->status ); - self::assertSame( array( 'acme/boot/succeeded' ), $this->fired ); + self::assertSame( array( 'succeeded' ), $this->fired ); } public function test_a_composition_that_reports_an_unmet_requirement_is_blocked(): void { // The all hook receives an action's own arguments, before do_action() pads an argumentless call with an empty string. $arguments = array(); + $blocked = "$this->prefix/boot/blocked"; add_action( 'all', - static function ( string $hook_name, mixed ...$args ) use ( &$arguments ): void { - if ( 'acme/boot/blocked' === $hook_name ) { + static function ( string $hook_name, mixed ...$args ) use ( &$arguments, $blocked ): void { + if ( $blocked === $hook_name ) { $arguments[] = $args; } } @@ -114,7 +93,7 @@ static function ( string $hook_name, mixed ...$args ) use ( &$arguments ): void $boot->run( static fn (): bool => false ); self::assertSame( 'blocked', $boot->status ); - self::assertSame( array( 'acme/boot/blocked' ), $this->fired ); + self::assertSame( array( 'blocked' ), $this->fired ); self::assertSame( array( array() ), $arguments ); } @@ -125,14 +104,10 @@ public function test_a_failing_composition_fails_and_tells_only_plugin_activator $boot->run( static fn (): bool => throw $failure ); self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); - - wp_set_current_user( $this->make_user( 'acme-boot-admin', 'administrator' ) ); - self::assertStringContainsString( self::NOTICE, $this->render_admin_notices() ); - - wp_set_current_user( $this->make_user( 'acme-boot-editor', 'editor' ) ); - self::assertStringNotContainsString( self::NOTICE, $this->render_admin_notices() ); + self::assertStringContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-admin', 'administrator' ) ) ); + self::assertStringNotContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-editor', 'editor' ) ) ); } public function test_the_same_failure_is_logged_once_an_hour(): void { @@ -144,7 +119,7 @@ public function test_the_same_failure_is_logged_once_an_hour(): void { self::assertSame( 'error', $this->logger->records[0][0] ); self::assertSame( 'Boot failed; the plugin did not finish starting: {message}', $this->logger->records[0][1] ); self::assertSame( 'first failure', $this->logger->records[0][2]['message'] ); - self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( '_transient_timeout_acme_boot_failure_' . \md5( 'first failure' ) ), 5 ); + self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( "_transient_timeout_{$this->prefix}_boot_failure_" . \md5( 'first failure' ) ), 5 ); } public function test_distinct_failures_are_each_logged_once(): void { @@ -165,7 +140,7 @@ public static function logging_failures(): array { #[DataProvider( 'logging_failures' )] public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_and_is_announced( \Throwable $logging_failure ): void { $failure = new \RuntimeException( 'composition failed' ); - $boot = new PluginBoot( self::PLUGIN_FILE, 'acme', new ThrowingLogger( $logging_failure ) ); + $boot = new PluginBoot( self::PLUGIN_FILE, $this->prefix, new ThrowingLogger( $logging_failure ) ); $log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' ); $previous = \ini_set( 'error_log', $log_file ); @@ -178,31 +153,15 @@ public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_a } self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); - self::assertStringContainsString( '[acme.error] Boot failed; the plugin did not finish starting: {message} {"message":"composition failed"}', $log ); + self::assertStringContainsString( "[$this->prefix.error] Boot failed; the plugin did not finish starting: {message} {\"message\":\"composition failed\"}", $log ); self::assertStringContainsString( 'RuntimeException: composition failed in ' . __FILE__ . ':' . $failure->getLine(), $log ); self::assertStringContainsString( $logging_failure::class . ': ' . $logging_failure->getMessage(), $log ); - - wp_set_current_user( $this->make_user( 'acme-boot-admin', 'administrator' ) ); - self::assertStringContainsString( self::NOTICE, $this->render_admin_notices() ); - } - - // An interrupted run leaves its throttle transients behind, which silence the next run's logging for up to an hour. - protected function delete_failure_transients(): void { - foreach ( array( 'composition failed', 'first failure', 'second failure' ) as $message ) { - delete_transient( 'acme_boot_failure_' . \md5( $message ) ); - } + self::assertStringContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-admin', 'administrator' ) ) ); } protected function make_boot(): PluginBoot { - return new PluginBoot( self::PLUGIN_FILE, 'acme', $this->logger ); - } - - protected function render_admin_notices(): string { - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return new PluginBoot( self::PLUGIN_FILE, $this->prefix, $this->logger ); } } diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php index 4aa88d3..6f327b0 100644 --- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php @@ -2,13 +2,13 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling; -use DeepWebSolutions\Framework\Core\Scheduling\Recurrence; use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -17,26 +17,19 @@ final class SchedulerOnSwitchedSiteTest extends TestCase { use CreatesSitesTrait; use IsolatesHooksTrait; + use RecordsDoingItWrongTrait; protected string $hook; protected int $other_site; - protected array $doing_it_wrong = array(); - protected array $writes = array(); #[\Override] protected function setUp(): void { $this->hook = 'acme_scheduling_' . \bin2hex( \random_bytes( 4 ) ); $this->other_site = $this->make_site( 'acme-scheduling' ); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - function ( string $function_name ): void { - $this->doing_it_wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); add_filter( 'pre_update_option_cron', function ( mixed $value, mixed $old_value ): mixed { @@ -53,27 +46,15 @@ function ( mixed $value, mixed $old_value ): mixed { #[\Override] protected function tearDown(): void { - while ( ms_is_switched() ) { - restore_current_blog(); - } - + // The cleanup starts on the main site, and the trait restores the sites only after tearDown(). + $this->restore_switched_sites(); wp_unschedule_hook( $this->hook ); switch_to_blog( $this->other_site ); wp_unschedule_hook( $this->hook ); restore_current_blog(); } - public static function writes(): array { - return array( - 'enqueue' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->enqueue( $hook ) ), - 'schedule-single' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->schedule_single( $hook, \time() + 60 ) ), - 'ensure-recurring' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ), - 'clear-scheduled-hook' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->clear_scheduled_hook( $hook ) ), - 'unschedule-hook' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->unschedule_hook( $hook->hook ) ), - ); - } - - #[DataProvider( 'writes' )] + #[DataProviderExternal( SchedulerTest::class, 'writes' )] public function test_a_write_on_a_switched_site_is_an_error_that_writes_nothing( \Closure $write ): void { $scheduler = $this->make_scheduler(); switch_to_blog( $this->other_site ); diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php index 73b3e1f..7bd59c1 100644 --- a/tests/Integration/Core/Scheduling/SchedulerTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerTest.php @@ -8,8 +8,10 @@ use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; +use DeepWebSolutions\Framework\Tests\Integration\Support\DispatchesScheduledHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InterceptsQueriesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ReportsActionSchedulerVersionsTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -19,16 +21,16 @@ #[CoversClass( ActionSchedulerBackend::class )] #[CoversClass( WpCronBackend::class )] final class SchedulerTest extends TestCase { + use DispatchesScheduledHooksTrait; use InterceptsQueriesTrait; use IsolatesHooksTrait; + use RecordsDoingItWrongTrait; use ReportsActionSchedulerVersionsTrait; protected const string GROUP = 'acme'; protected string $hook; - protected array $doing_it_wrong = array(); - protected array $writes = array(); protected array $calls = array(); @@ -36,13 +38,7 @@ final class SchedulerTest extends TestCase { #[\Override] protected function setUp(): void { $this->hook = 'acme_scheduling_' . \bin2hex( \random_bytes( 4 ) ); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - function ( string $function_name ): void { - $this->doing_it_wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); add_action( 'action_scheduler_stored_action', function ( int $action_id ): void { @@ -118,7 +114,7 @@ public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { public static function schedulers(): array { return array( - 'action-scheduler' => array( 'production' ), + 'action-scheduler' => array( 'action-scheduler' ), 'wp-cron' => array( 'wp-cron' ), ); } @@ -131,8 +127,8 @@ public function test_enqueueing_twice_leaves_one_pending_invocation( string $kin self::assertTrue( $scheduler->enqueue( $hook ) ); self::assertTrue( $scheduler->enqueue( $hook ) ); - self::assertSame( 'production' === $kind ? 1 : 0, \count( $this->pending_action_ids() ) ); - self::assertSame( 'production' === $kind ? 0 : 1, \count( $this->cron_events() ) ); + self::assertSame( 'action-scheduler' === $kind ? 1 : 0, \count( $this->pending_action_ids() ) ); + self::assertSame( 'action-scheduler' === $kind ? 0 : 1, \count( $this->cron_events() ) ); $ran = array(); add_action( $this->hook, @@ -140,7 +136,7 @@ static function ( int $arg ) use ( &$ran ): void { $ran[] = $arg; } ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( array( 7 ), $ran ); } @@ -148,14 +144,14 @@ public function test_enqueueing_with_a_pending_wp_cron_invocation_adds_nothing_t $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->enqueue( $hook ); - self::assertTrue( $this->make_scheduler( 'production' )->enqueue( $hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->enqueue( $hook ) ); self::assertSame( array(), $this->pending_action_ids() ); self::assertCount( 1, $this->cron_events() ); } public function test_enqueueing_inside_its_own_callback_counts_the_running_invocation(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -167,14 +163,14 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertSame( array( true, array() ), $this->calls ); self::assertSame( array(), $this->pending_action_ids() ); } public function test_a_callback_continues_itself_with_a_single_invocation_due_now(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); $running = $this->pending_action_ids(); @@ -196,7 +192,7 @@ function () use ( $scheduler, $hook ): void { } ); - self::assertSame( 1, $this->run_due_actions() ); + self::assertSame( 1, self::run_due_actions( $this->hook ) ); self::assertSame( array( true ), $this->calls ); self::assertSame( array( false ), $unique, 'Action Scheduler data stores differ on whether a running action refuses a unique insert.' ); @@ -206,7 +202,7 @@ function () use ( $scheduler, $hook ): void { } public function test_a_failed_continuation_insert_inside_its_own_callback_is_an_error(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -217,7 +213,7 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertCount( 1, $this->calls ); self::assert_wp_error( $this->calls[0], 'scheduler_failed' ); @@ -225,7 +221,7 @@ function () use ( $scheduler, $hook ): void { } public function test_an_action_scheduler_duplicate_of_a_running_invocation_is_success_without_a_write(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -247,7 +243,7 @@ public function test_an_enqueue_that_races_another_request_adds_no_duplicate_on_ }; add_filter( 'pre_as_enqueue_async_action', $race ); - self::assertTrue( $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ) ); self::assertCount( 1, $this->pending_action_ids() ); } @@ -255,7 +251,7 @@ public function test_an_enqueue_that_races_another_request_adds_no_duplicate_on_ public function test_an_action_scheduler_store_failure_is_an_error(): void { add_filter( 'pre_as_enqueue_async_action', '__return_zero' ); - $result = $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ); + $result = $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ); self::assert_wp_error( $result, 'scheduler_failed' ); } @@ -268,7 +264,7 @@ static function (): never { } ); - $result = $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ); + $result = $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ); self::assert_wp_error( $result, 'scheduler_failed' ); self::assertSame( 'Store unavailable.', $result->get_error_message() ); @@ -286,7 +282,7 @@ public static function writes(): array { #[DataProvider( 'writes' )] public function test_a_write_before_action_scheduler_initializes_is_an_error_that_writes_nothing( \Closure $write ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); $GLOBALS['wp_current_filter'][] = 'init'; $this->writes = array(); @@ -321,7 +317,7 @@ public function test_ensuring_a_recurring_hook_creates_one_chain_and_then_writes self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( 3600, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() ); @@ -346,7 +342,7 @@ public function test_ensuring_counts_only_recurring_occurrences_as_chains( strin self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $recurring = \array_filter( $this->pending_action_ids(), static fn ( int $action_id ): bool => \ActionScheduler::store()->fetch_action( $action_id )->get_schedule()->is_recurring() ); self::assertCount( 2, $this->pending_action_ids() ); self::assertCount( 1, $recurring ); @@ -360,7 +356,7 @@ public function test_ensuring_leaves_a_sole_wp_cron_chain_where_it_is(): void { $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); $this->writes = array(); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); self::assertCount( 1, $this->cron_events() ); @@ -375,7 +371,7 @@ public function test_ensuring_moves_an_existing_chain_to_the_requested_interval( self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( 7200, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() ); @@ -388,7 +384,7 @@ public function test_ensuring_moves_a_sole_wp_cron_chain_at_another_interval_ont $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); self::assertSame( array(), $this->cron_events() ); $pending = $this->pending_action_ids(); @@ -401,7 +397,7 @@ public function test_ensuring_with_a_chain_on_each_scheduler_leaves_one_on_actio $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); as_schedule_recurring_action( \time(), 3600, $this->hook, array(), self::GROUP ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->cron_events() ); self::assertCount( 1, $this->pending_action_ids() ); @@ -410,7 +406,7 @@ public function test_ensuring_with_a_chain_on_each_scheduler_leaves_one_on_actio public function test_ensuring_while_a_recurring_occurrence_runs_creates_and_clears_nothing(): void { $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); as_schedule_recurring_action( \time(), 3600, $this->hook, array(), self::GROUP ); as_schedule_recurring_action( \time() + 3600, 3600, $this->hook, array(), self::GROUP ); add_action( @@ -422,7 +418,7 @@ function () use ( $scheduler, $hook ): void { } ); - self::assertSame( 1, $this->run_due_actions() ); + self::assertSame( 1, self::run_due_actions( $this->hook ) ); self::assertSame( array( true, array() ), $this->calls ); self::assertCount( 1, $this->cron_events() ); @@ -449,7 +445,7 @@ static function () use ( &$ran ): void { } ); - $rescheduled = $this->run_due_cron_events(); + $rescheduled = self::run_due_cron_events( $this->hook ); self::assertSame( 1, $ran ); self::assertSame( array( true ), $rescheduled ); @@ -469,7 +465,7 @@ public function test_clearing_a_scheduled_hook_removes_every_pending_occurrence_ wp_schedule_single_event( \time() + 7200, $this->hook, array( 1 ) ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 2 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->clear_scheduled_hook( $hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->clear_scheduled_hook( $hook ) ); self::assertCount( 1, $this->pending_action_ids( array( 1 ) ), 'An occurrence outside the group survives.' ); self::assertCount( 1, $this->pending_action_ids( array( 2 ) ) ); @@ -486,7 +482,7 @@ public function test_an_invocation_that_survives_clearing_is_an_error(): void { } public function test_a_failed_clear_is_returned_by_clearing_a_scheduled_hook(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); wp_schedule_single_event( \time() + 3600, $this->hook ); add_filter( 'pre_clear_scheduled_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); @@ -501,7 +497,7 @@ public function test_a_failed_clear_stops_collapsing_duplicate_chains(): void { add_filter( 'pre_clear_scheduled_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); $this->writes = array(); - $result = $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); + $result = $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); self::assert_wp_error( $result, 'acme_clear_failed' ); self::assertSame( array(), \array_filter( $this->writes, static fn ( string $write ): bool => \str_starts_with( $write, 'stored' ) ) ); @@ -514,7 +510,7 @@ public function test_a_clear_that_leaves_a_chain_stops_collapsing_duplicate_chai add_filter( 'pre_clear_scheduled_hook', '__return_zero' ); $this->writes = array(); - $result = $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); + $result = $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); self::assert_wp_error( $result, 'scheduler_failed' ); self::assertSame( array(), \array_filter( $this->writes, static fn ( string $write ): bool => \str_starts_with( $write, 'stored' ) ) ); @@ -523,14 +519,14 @@ public function test_a_clear_that_leaves_a_chain_stops_collapsing_duplicate_chai public function test_a_failed_clear_is_returned_by_unscheduling_a_hook(): void { add_filter( 'pre_unschedule_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); - self::assert_wp_error( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ), 'acme_clear_failed' ); + self::assert_wp_error( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ), 'acme_clear_failed' ); } public function test_unscheduling_a_hook_on_action_scheduler_is_best_effort(): void { as_enqueue_async_action( $this->hook ); $this->fail_queries( "a.hook='$this->hook'" ); - self::assertTrue( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ) ); remove_all_filters( 'query' ); self::assertCount( 1, $this->pending_action_ids() ); @@ -540,14 +536,14 @@ public function test_a_wp_cron_duplicate_event_is_success(): void { $backend = new WpCronBackend( self::GROUP ); $hook = new ScheduledHook( $this->hook ); - self::assertTrue( $backend->enqueue( $hook, 10, true ) ); - self::assertTrue( $backend->enqueue( $hook, 10, true ) ); + self::assertTrue( $backend->enqueue( $hook, 10 ) ); + self::assertTrue( $backend->enqueue( $hook, 10 ) ); self::assertCount( 1, $this->cron_events() ); } public function test_clearing_a_scheduled_hook_while_a_recurring_occurrence_runs_is_an_error(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); add_action( @@ -557,14 +553,14 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertCount( 1, $this->calls ); self::assert_wp_error( $this->calls[0], 'scheduler_running' ); } public function test_clearing_a_one_off_inside_its_own_callback_succeeds(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -574,7 +570,7 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertSame( array( true ), $this->calls ); } @@ -587,7 +583,7 @@ public function test_unscheduling_a_hook_removes_every_argument_variant_in_every wp_schedule_single_event( \time() + 3600, $this->hook ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 1 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ) ); self::assertSame( array(), $this->pending_action_ids() ); self::assertSame( array(), $this->cron_events() ); @@ -599,7 +595,7 @@ public function test_unscheduling_a_hook_removes_every_argument_variant_in_every } public function test_is_scheduled_sees_a_pending_invocation_on_either_scheduler(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $on_cron = new ScheduledHook( $this->hook, array( 'cron' ) ); $on_queue = new ScheduledHook( $this->hook, array( 'queue' ) ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 'cron' ) ); @@ -611,7 +607,7 @@ public function test_is_scheduled_sees_a_pending_invocation_on_either_scheduler( } public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler_initializes(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); wp_schedule_single_event( \time() + 3600, $this->hook ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); $GLOBALS['wp_current_filter'][] = 'init'; @@ -621,7 +617,7 @@ public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler } public function test_is_scheduled_sees_a_running_action_scheduler_invocation(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -638,7 +634,7 @@ public function test_scheduling_a_single_invocation_twice_keeps_the_first( strin self::assertTrue( $scheduler->schedule_single( $hook, $first ) ); self::assertTrue( $scheduler->schedule_single( $hook, $first + 86400 ) ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( $first, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_date()->getTimestamp() ); @@ -650,7 +646,7 @@ public function test_scheduling_a_single_invocation_twice_keeps_the_first( strin } public function test_scheduling_a_single_invocation_while_the_identity_runs_adds_a_pending_one(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -663,7 +659,7 @@ public function test_scheduling_a_single_invocation_while_the_identity_runs_adds public function test_a_timestamp_below_1_is_rejected(): void { $this->expectException( InvalidArgumentException::class ); - $this->make_scheduler( 'production' )->schedule_single( new ScheduledHook( $this->hook ), 0 ); + $this->make_scheduler( 'action-scheduler' )->schedule_single( new ScheduledHook( $this->hook ), 0 ); } #[DataProvider( 'schedulers' )] @@ -677,7 +673,7 @@ static function () use ( &$ran ): void { ); self::assertTrue( $this->make_scheduler( $kind )->schedule_single( new ScheduledHook( $this->hook ), \time() - 60 ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( 1, $ran ); } @@ -692,14 +688,14 @@ public static function prioritized_writes(): array { #[DataProvider( 'prioritized_writes' )] public function test_a_priority_is_stored_on_the_action_scheduler_row( \Closure $write ): void { - self::assertTrue( $write( $this->make_scheduler( 'production' ), new ScheduledHook( $this->hook ), 7 ) ); + self::assertTrue( $write( $this->make_scheduler( 'action-scheduler' ), new ScheduledHook( $this->hook ), 7 ) ); self::assertSame( 7, \ActionScheduler::store()->fetch_action( $this->pending_action_ids()[0] )->get_priority() ); } #[DataProvider( 'prioritized_writes' )] public function test_action_scheduler_clamps_a_priority_outside_0_to_255( \Closure $write ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); self::assertTrue( $write( $scheduler, new ScheduledHook( $this->hook, array( -1 ) ), -1 ) ); self::assertTrue( $write( $scheduler, new ScheduledHook( $this->hook, array( 256 ) ), 256 ) ); @@ -728,7 +724,7 @@ public static function versions_below_the_floor(): array { #[DataProvider( 'versions_below_the_floor' )] public function test_action_scheduler_below_the_floor_takes_no_writes( array $versions ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( $versions ); self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) ); @@ -740,7 +736,7 @@ public function test_action_scheduler_below_the_floor_takes_no_writes( array $ve public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_below_the_floor(): void { as_enqueue_async_action( $this->hook, array(), self::GROUP ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( array( '4.0.0' ) ); self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) ); @@ -749,7 +745,7 @@ public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_ } public function test_an_action_scheduler_that_never_initialized_by_the_end_of_init_is_unsupported(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( array( '3.5.4' ) ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); @@ -760,7 +756,7 @@ public function test_an_action_scheduler_that_never_initialized_by_the_end_of_in } public function test_action_scheduler_below_the_floor_is_still_read_and_cleared(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); as_schedule_single_action( \time() + 3600, $this->hook, array(), self::GROUP ); $this->report_action_scheduler_versions( array( '4.0.0' ) ); @@ -772,7 +768,7 @@ public function test_action_scheduler_below_the_floor_is_still_read_and_cleared( } public function test_one_scheduler_follows_action_scheduler_readiness_from_call_to_call(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $ready = $GLOBALS['wp_actions']['action_scheduler_init']; unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); @@ -787,7 +783,7 @@ public function test_one_scheduler_follows_action_scheduler_readiness_from_call_ public static function arguments(): array { $cases = array(); - foreach ( array( 'production', 'wp-cron' ) as $kind ) { + foreach ( array( 'action-scheduler', 'wp-cron' ) as $kind ) { $cases[ "$kind-every-scalar-kind" ] = array( $kind, array( true, false, null, 0, 'Acme' ) ); $cases[ "$kind-hashed-by-action-scheduler" ] = array( $kind, array( \str_repeat( 'a', 300 ) ) ); } @@ -812,7 +808,7 @@ static function ( mixed ...$passed ) use ( &$received ): void { $scheduler->enqueue( $hook ); self::assertTrue( $scheduler->is_scheduled( $hook ) ); self::assertFalse( $scheduler->is_scheduled( new ScheduledHook( $this->hook, array( 'other' ) ) ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( array( $args ), $received ); } @@ -820,7 +816,7 @@ static function ( mixed ...$passed ) use ( &$received ): void { public function test_ensuring_replaces_a_cron_expression_chain_with_one_interval_chain(): void { as_schedule_cron_action( \time(), '0 * * * *', $this->hook, array(), self::GROUP ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) ); $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); @@ -865,36 +861,4 @@ protected function cron_events(): array { return $events; } - - // Mirrors one Action Scheduler queue-runner batch. - protected function run_due_actions(): int { - $store = \ActionScheduler::store(); - $claim = $store->stake_claim( 10, null, array( $this->hook ) ); - foreach ( $claim->get_actions() as $action_id ) { - \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); - } - $store->release_claim( $claim ); - - return \count( $claim->get_actions() ); - } - - // Mirrors one wp-cron.php run. - protected function run_due_cron_events(): array { - $rescheduled = array(); - foreach ( _get_cron_array() as $timestamp => $hooks ) { - if ( $timestamp > \time() ) { - break; - } - - foreach ( $hooks[ $this->hook ] ?? array() as $event ) { - if ( false !== $event['schedule'] ) { - $rescheduled[] = wp_reschedule_event( $timestamp, $event['schedule'], $this->hook, $event['args'], true ); - } - wp_unschedule_event( $timestamp, $this->hook, $event['args'], true ); - do_action_ref_array( $this->hook, $event['args'] ); - } - } - - return $rescheduled; - } } diff --git a/tests/Integration/Core/Storage/OptionRowsTest.php b/tests/Integration/Core/Storage/OptionRowsTest.php index 4f7fe8b..248f9aa 100644 --- a/tests/Integration/Core/Storage/OptionRowsTest.php +++ b/tests/Integration/Core/Storage/OptionRowsTest.php @@ -23,13 +23,6 @@ final class OptionRowsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; - #[\Override] - protected function tearDown(): void { - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } - } - public function test_an_inserted_row_reads_back_unchanged_as_one_row_that_is_not_autoloaded(): void { $written = self::insert( "{$this->prefix}_one", array( 'message' => "Can't \\ \"save\"." ) ); diff --git a/tests/Integration/Support/CreatesSitesTrait.php b/tests/Integration/Support/CreatesSitesTrait.php index a186937..83861a0 100644 --- a/tests/Integration/Support/CreatesSitesTrait.php +++ b/tests/Integration/Support/CreatesSitesTrait.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use PHPUnit\Framework\Attributes\After; + trait CreatesSitesTrait { protected function make_site( string $slug ): int { $existing = get_sites( @@ -24,4 +26,11 @@ protected function make_site( string $slug ): int { return $site_id; } + + #[After] + protected function restore_switched_sites(): void { + while ( is_multisite() && ms_is_switched() ) { + restore_current_blog(); + } + } } diff --git a/tests/Integration/Support/DispatchesScheduledHooksTrait.php b/tests/Integration/Support/DispatchesScheduledHooksTrait.php new file mode 100644 index 0000000..9a5792c --- /dev/null +++ b/tests/Integration/Support/DispatchesScheduledHooksTrait.php @@ -0,0 +1,37 @@ +stake_claim( 10, \is_null( $at ) ? null : new \DateTime( "@$at" ), array( $hook ) ); + foreach ( $claim->get_actions() as $action_id ) { + \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); + } + $store->release_claim( $claim ); + + return \count( $claim->get_actions() ); + } + + // Mirrors one wp-cron.php run, which reschedules and runs the hook's events due by the given time or now, and returns each reschedule's result. + protected static function run_due_cron_events( string $hook, ?int $at = null ): array { + $rescheduled = array(); + foreach ( _get_cron_array() as $timestamp => $hooks ) { + if ( $timestamp > ( $at ?? \time() ) ) { + break; + } + + foreach ( $hooks[ $hook ] ?? array() as $event ) { + if ( false !== $event['schedule'] ) { + $rescheduled[] = wp_reschedule_event( $timestamp, $event['schedule'], $hook, $event['args'], true ); + } + wp_unschedule_event( $timestamp, $hook, $event['args'], true ); + do_action_ref_array( $hook, $event['args'] ); + } + } + + return $rescheduled; + } +} diff --git a/tests/Integration/Support/InstallsConsumersTrait.php b/tests/Integration/Support/InstallsConsumersTrait.php index 470b707..c79ccec 100644 --- a/tests/Integration/Support/InstallsConsumersTrait.php +++ b/tests/Integration/Support/InstallsConsumersTrait.php @@ -7,6 +7,7 @@ trait InstallsConsumersTrait { use CopiesFixturesTrait; + use IsolatesStorageTrait; use RunsAdminRequestsTrait; protected array $consumer_copies = array(); @@ -49,10 +50,8 @@ protected function remove_consumers(): void { } $this->plugin_options = array(); - foreach ( is_multisite() ? get_sites( array( 'fields' => 'ids' ) ) : array( 1 ) as $site_id ) { - foreach ( array( 'acme\_ca\_', 'acme\_cb\_', 'acme\_pc\_', 'acme\_ps\_', 'acme\_pw\_' ) as $prefix ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $prefix . '%', '\_transient\_' . $prefix . '%', '\_transient\_timeout\_' . $prefix . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the consumers stored. - } + foreach ( array( 'acme_ca_', 'acme_cb_', 'acme_pc_', 'acme_ps_', 'acme_pw_' ) as $prefix ) { + self::delete_prefixed_options( $prefix ); } $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s OR meta_key LIKE %s', $wpdb->postmeta, '\_acme\_ca\_%', '\_acme\_pw\_%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows consumer-a and the woocommerce persona stored. wp_clean_plugins_cache(); diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php index d9adc2c..f38c71b 100644 --- a/tests/Integration/Support/IsolatesStorageTrait.php +++ b/tests/Integration/Support/IsolatesStorageTrait.php @@ -15,15 +15,20 @@ protected function use_a_fresh_prefix(): void { #[After] protected function delete_stored_rows(): void { + self::delete_prefixed_options( $this->prefix ); + wp_cache_flush(); + } + + // Deletes every site's options and transients, the user options and the network options whose names hold the prefix. + protected static function delete_prefixed_options( string $prefix ): void { global $wpdb; foreach ( is_multisite() ? get_sites( array( 'fields' => 'ids' ) ) : array( 1 ) as $site_id ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $wpdb->esc_like( $this->prefix ) . '%', $wpdb->esc_like( "_transient_$this->prefix" ) . '%', $wpdb->esc_like( "_transient_timeout_$this->prefix" ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $wpdb->esc_like( $prefix ) . '%', $wpdb->esc_like( "_transient_$prefix" ) . '%', $wpdb->esc_like( "_transient_timeout_$prefix" ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->usermeta, '%' . $wpdb->esc_like( $this->prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->usermeta, '%' . $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. if ( is_multisite() ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->sitemeta, $wpdb->esc_like( $this->prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->sitemeta, $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } - wp_cache_flush(); } } diff --git a/tests/Integration/Support/ProvidesLogMessagesTrait.php b/tests/Integration/Support/ProvidesLogMessagesTrait.php index 10681d9..d7fd611 100644 --- a/tests/Integration/Support/ProvidesLogMessagesTrait.php +++ b/tests/Integration/Support/ProvidesLogMessagesTrait.php @@ -5,16 +5,19 @@ trait ProvidesLogMessagesTrait { public static function log_messages(): array { $stringable = new class() implements \Stringable { + #[\Override] public function __toString(): string { return 'stringable'; } }; $unprintable = new class() implements \Stringable { + #[\Override] public function __toString(): string { throw new \LogicException( 'Not printable.' ); } }; $erroring = new class() implements \Stringable { + #[\Override] public function __toString(): string { throw new \TypeError( 'Not a string.' ); } diff --git a/tests/Integration/Support/RecordingLogger.php b/tests/Integration/Support/RecordingLogger.php new file mode 100644 index 0000000..077e26d --- /dev/null +++ b/tests/Integration/Support/RecordingLogger.php @@ -0,0 +1,14 @@ +records[] = array( $level, (string) $message, $context ); + } +} diff --git a/tests/Integration/Support/RecordsDoingItWrongTrait.php b/tests/Integration/Support/RecordsDoingItWrongTrait.php new file mode 100644 index 0000000..ab057ba --- /dev/null +++ b/tests/Integration/Support/RecordsDoingItWrongTrait.php @@ -0,0 +1,18 @@ +doing_it_wrong[] = $function_name; + } + ); + } +} diff --git a/tests/Integration/Support/RendersAdminScreensTrait.php b/tests/Integration/Support/RendersAdminScreensTrait.php index 8a183a2..ec02f4b 100644 --- a/tests/Integration/Support/RendersAdminScreensTrait.php +++ b/tests/Integration/Support/RendersAdminScreensTrait.php @@ -14,6 +14,14 @@ protected function view_as( int $user_id, string $screen_id = 'dashboard', bool get_current_screen()?->is_block_editor( $block_editor ); } + protected function render_all_admin_notices( int $user_id ): string { + wp_set_current_user( $user_id ); + \ob_start(); + do_action( 'all_admin_notices' ); + + return (string) \ob_get_clean(); + } + protected static function notices_in( string $html ): array { $notices = array(); $tags = new \WP_HTML_Tag_Processor( $html ); diff --git a/tests/Integration/Support/ThrowingLogger.php b/tests/Integration/Support/ThrowingLogger.php new file mode 100644 index 0000000..0104f6a --- /dev/null +++ b/tests/Integration/Support/ThrowingLogger.php @@ -0,0 +1,16 @@ +failure; + } +} diff --git a/tests/Integration/Support/admin-request.php b/tests/Integration/Support/admin-request.php index 09c2c27..cca5592 100644 --- a/tests/Integration/Support/admin-request.php +++ b/tests/Integration/Support/admin-request.php @@ -18,7 +18,7 @@ register_shutdown_function( static function () use ( &$acme_outcome, $acme_request ): void { $fatal = error_get_last(); - if ( null !== $fatal && 0 !== ( $fatal['type'] & ( E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR ) ) ) { + if ( ! is_null( $fatal ) && 0 !== ( $fatal['type'] & ( E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR ) ) ) { $acme_outcome['errors'][] = "{$fatal['message']} in {$fatal['file']}:{$fatal['line']}"; } file_put_contents( $acme_request['result'], serialize( $acme_outcome ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- WordPress may not have loaded. diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index 0f8730f..dcba6a5 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -62,9 +62,9 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $GLOBALS['acme_outcome']['records'][ $key ] = array( 'status' => $plugin?->boundary->status, 'skipped' => $plugin?->kernel?->skipped, - 'boot_succeeded' => did_action( "{$prefix}/boot/succeeded" ), + 'boot_succeeded' => did_action( "$prefix/boot/succeeded" ), 'functions_loaded' => function_exists( "{$prefix}_plugin" ), - 'migrations' => \is_object( $plugin ) && property_exists( $plugin, 'migrations' ) && \is_object( $plugin->migrations ) ? \get_class( $plugin->migrations ) : null, + 'migrations' => is_object( $plugin ) && property_exists( $plugin, 'migrations' ) && is_object( $plugin->migrations ) ? get_class( $plugin->migrations ) : null, ); } }; @@ -76,6 +76,7 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $handlers, array( new class() implements WC_Log_Handler_Interface { + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $GLOBALS['acme_outcome']['records']['logs'][] = array( $level, $message, $context['source'] ?? null ); From 4b70a9a81e1e5cc6cc24980f13067ebb26f7169e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:11 +0100 Subject: [PATCH 29/47] Build the admin-notice tests' notices through one fixture trait Four admin-notice test files kept their own notice() builders, which had drifted in default message, capability and persistence, and copied ids() and dom_id(); the dismissal file made every notice persistent behind its callers' backs. They also carried their own loggers and switched-site loops, and the cookie-authentication row left its session token in the shared database. AdminNoticeFixturesTrait holds one notice(), ids() and dom_id(), the dismissal rows pass persistent: true explicitly, the files use the shared loggers, recorder and site restore, and the cookie row takes its session from RunsAdminRequestsTrait, whose cleanup destroys it. Assisted-by: Claude Code:claude-opus-5-5 --- .../AdminNotice/AdminNoticeFixturesTrait.php | 23 +++++ .../AdminNoticesBlockEditorTest.php | 24 +---- .../AdminNotice/AdminNoticesDismissalTest.php | 96 +++++++------------ .../AdminNotice/AdminNoticesStorageTest.php | 15 +-- .../Core/AdminNotice/AdminNoticesTest.php | 60 +++--------- 5 files changed, 74 insertions(+), 144 deletions(-) create mode 100644 tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php diff --git a/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php b/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php new file mode 100644 index 0000000..bc94f94 --- /dev/null +++ b/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php @@ -0,0 +1,23 @@ +prefix-$scope-notice-$id"; + } + + protected static function ids( array $notices ): array { + return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); + } +} diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index af99c81..44f35a9 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; @@ -20,6 +19,7 @@ #[CoversClass( AdminNotices::class )] final class AdminNoticesBlockEditorTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; @@ -44,9 +44,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { $GLOBALS['wp_rest_server'] = null; - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_the_post_editor_loads_knowing_whether_one_shots_wait_and_acknowledges_none(): void { @@ -79,7 +76,7 @@ public function test_the_delivery_endpoint_answers_only_a_logged_in_user(): void } public function test_a_delivered_message_is_filtered_and_keeps_only_post_markup(): void { - add_filter( "{$this->prefix}/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 ); + add_filter( "$this->prefix/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 ); $this->notices()->add( $this->notice( 'saved', message: 'Read the guide.' ), $this->admin ); wp_set_current_user( $this->admin ); @@ -145,7 +142,7 @@ public function test_a_network_one_shot_reaches_the_post_editor_of_any_site(): v switch_to_blog( $site_id ); wp_set_current_user( $this->admin ); - self::assertSame( array( "{$this->prefix}-network-notice-x" ), \array_column( $this->deliver()->get_data(), 'id' ) ); + self::assertSame( array( "$this->prefix-network-notice-x" ), \array_column( $this->deliver()->get_data(), 'id' ) ); self::assertSame( array(), $this->notices()->get_notices( NoticeScope::Network ) ); } @@ -179,7 +176,7 @@ protected function assert_only_the_others_wait(): void { protected function delivered( string $scope, string $id, string $status, string $content, bool $dismissible = true, ?string $spoken = null ): array { return array( - 'id' => "{$this->prefix}-$scope-notice-$id", + 'id' => "$this->prefix-$scope-notice-$id", 'status' => $status, 'content' => $content, 'spoken' => $spoken ?? $content, @@ -205,15 +202,8 @@ protected function notices(): AdminNotices { return new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); } - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = '', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, '' === $message ? "Notice $id." : $message, ...$arguments ); - } - protected function script_handle(): string { - return "{$this->prefix}-notice-delivery"; + return "$this->prefix-notice-delivery"; } #[After] @@ -221,8 +211,4 @@ protected function remove_the_script(): void { wp_dequeue_script( $this->script_handle() ); wp_deregister_script( $this->script_handle() ); } - - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php index 04bf1e5..6640ec2 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php @@ -3,46 +3,33 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class DismissalLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class ThrowingDismissalLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} #[CoversClass( AdminNotices::class )] final class AdminNoticesDismissalTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use RendersAdminScreensTrait; + use RunsAdminRequestsTrait; protected int $admin; protected int $other_admin; - protected DismissalLogger $logger; + protected RecordingLogger $logger; protected AdminNotices $notices; protected array $dismissed = array(); protected array $saved_request = array(); @@ -51,10 +38,10 @@ final class AdminNoticesDismissalTest extends TestCase { protected function setUp(): void { $this->admin = $this->make_user( 'acme_notices_admin', 'administrator' ); $this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' ); - $this->logger = new DismissalLogger(); + $this->logger = new RecordingLogger(); $this->notices = new AdminNotices( $this->prefix, $this->logger ); add_action( - "{$this->prefix}/admin_notice/dismissed", + "$this->prefix/admin_notice/dismissed", function ( mixed ...$arguments ): void { $this->dismissed[] = $arguments; }, @@ -76,37 +63,34 @@ protected function tearDown(): void { list( $_SERVER, $_POST, $_COOKIE ) = $this->saved_request; $GLOBALS['wp_rest_server'] = null; $GLOBALS['wp_rest_auth_cookie'] = null; - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_a_dismissed_persistent_user_notice_stays_stored_and_hidden_when_added_again(): void { - $this->notices->add( $this->notice( 'kept', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'kept', NoticeScope::User, persistent: true ), $this->admin ); $response = $this->dismiss( $this->shown( $this->admin, 'user', 'kept' ) ); self::assertSame( array( 200, array( 'changed' => true ) ), array( $response->get_status(), $response->get_data() ) ); self::assertSame( array(), $this->render( $this->admin ) ); self::assertCount( 1, $this->notices->get_notices( NoticeScope::User, $this->admin ) ); - $this->notices->add( $this->notice( 'kept', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'kept', NoticeScope::User, persistent: true ), $this->admin ); self::assertSame( array(), $this->render( $this->admin ) ); } public function test_a_shared_site_notice_dismissed_by_one_capable_user_is_deleted_for_everyone(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); self::assertSame( array( 'changed' => true ), $this->dismiss( $this->shown( $this->admin, 'site', 'setup' ) )->get_data() ); self::assertSame( array(), $this->render( $this->other_admin ) ); self::assertSame( array(), $this->notices->get_notices( NoticeScope::Site ) ); self::assertFalse( get_user_option( "{$this->prefix}_hidden_notices", $this->admin ) ); - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); self::assertSame( array( $this->dom_id( 'site', 'setup' ) ), \array_keys( $this->render( $this->admin ) ) ); } public function test_a_per_viewer_site_notice_dismissed_by_one_user_still_shows_to_another(): void { - $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true ) ); + $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'site', 'news' ) )->get_status() ); @@ -116,8 +100,8 @@ public function test_a_per_viewer_site_notice_dismissed_by_one_user_still_shows_ } public function test_dismissing_a_request_notice_records_only_the_callers_state_and_leaves_a_site_notice_of_the_same_id(): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Request ) ); - $this->notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Request, persistent: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'request', 'x' ) )->get_status() ); @@ -126,9 +110,9 @@ public function test_dismissing_a_request_notice_records_only_the_callers_state_ } public function test_the_dismissal_action_fires_once_per_dismissal_that_takes_effect_with_the_full_identity(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); - $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true ) ); - $this->notices->add( $this->notice( 'mine', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); + $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true, persistent: true ) ); + $this->notices->add( $this->notice( 'mine', NoticeScope::User, persistent: true ), $this->admin ); $setup = $this->shown( $this->admin, 'site', 'setup' ); $news = $this->shown( $this->admin, 'site', 'news' ); $mine = $this->shown( $this->admin, 'user', 'mine' ); @@ -156,7 +140,7 @@ public static function refusals(): array { 'missing-nonce' => array( static fn ( self $test ): array => array( array( 'nonce' => null ) + $test->shown( $test->admin, 'site', 'x' ), 400 ) ), 'another-notice' => array( static function ( self $test ): array { - $test->notices->add( $test->notice( 'other', NoticeScope::Site ) ); + $test->notices->add( $test->notice( 'other', NoticeScope::Site, persistent: true ) ); return array( array( 'nonce' => $test->shown( $test->admin, 'site', 'other' )['nonce'] ) + $test->shown( $test->admin, 'site', 'x' ), 403 ); }, ), @@ -184,14 +168,14 @@ static function ( self $test ): array { 'lacking-capability' => array( static function ( self $test ): array { $shown = $test->shown( $test->admin, 'site', 'x' ); - $test->notices->add( $test->notice( 'x', NoticeScope::Site, capability: 'acme_never_granted' ) ); + $test->notices->add( $test->notice( 'x', NoticeScope::Site, capability: 'acme_never_granted', persistent: true ) ); return array( $shown, 403 ); }, ), 'not-dismissible' => array( static function ( self $test ): array { $shown = $test->shown( $test->admin, 'site', 'x' ); - $test->notices->add( $test->notice( 'x', NoticeScope::Site, dismissible: false ) ); + $test->notices->add( $test->notice( 'x', NoticeScope::Site, dismissible: false, persistent: true ) ); return array( $shown, 403 ); }, ), @@ -207,7 +191,7 @@ static function ( self $test ): array { #[DataProvider( 'refusals' )] public function test_a_dismissal_without_a_matching_nonce_audience_or_record_changes_nothing( \Closure $arrange ): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); wp_set_current_user( $this->admin ); list( $params, $status ) = $arrange( $this ); $stored = $this->notices->get_notices( NoticeScope::Site ); @@ -275,7 +259,7 @@ public static function failing_stores(): array { #[DataProvider( 'failing_stores' )] public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer, \Closure $fail ): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Site, per_viewer: $per_viewer ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, per_viewer: $per_viewer, persistent: true ) ); $shown = $this->shown( $this->admin, 'site', 'x' ); $fail( $this ); @@ -287,8 +271,8 @@ public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer } public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches_the_error_log(): void { - $notices = new AdminNotices( $this->prefix, new ThrowingDismissalLogger() ); - $notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $notices = new AdminNotices( $this->prefix, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) ); + $notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); $request = new \WP_REST_Request( 'POST' ); $request->set_body_params( $this->shown( $this->admin, 'site', 'x' ) ); add_filter( "pre_update_option_{$this->prefix}_notices", static fn (): never => throw new \RuntimeException( 'The options table is unavailable.' ) ); @@ -310,11 +294,9 @@ public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches } public function test_the_http_path_with_cookie_authentication_needs_the_rest_nonce(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); $_SERVER['REQUEST_METHOD'] = 'GET'; - $expiration = \time() + DAY_IN_SECONDS; - $token = \WP_Session_Tokens::get_instance( $this->admin )->create( $expiration ); - $_COOKIE[ LOGGED_IN_COOKIE ] = wp_generate_auth_cookie( $this->admin, $expiration, 'logged_in', $token ); + $_COOKIE[ LOGGED_IN_COOKIE ] = $this->get_session_cookies( $this->admin )[ LOGGED_IN_COOKIE ]; $params = $this->shown( $this->admin, 'site', 'setup' ); $rest_nonce = wp_create_nonce( 'wp_rest' ); @@ -329,7 +311,7 @@ public function test_the_http_path_with_cookie_authentication_needs_the_rest_non public function test_a_shared_network_notice_dismissed_on_one_site_is_deleted_for_the_network(): void { $site_id = $this->make_site( 'acme-notices' ); add_user_to_blog( $site_id, $this->admin, 'administrator' ); - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read' ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', persistent: true ) ); switch_to_blog( $site_id ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); @@ -344,7 +326,7 @@ public function test_a_shared_network_notice_dismissed_on_one_site_is_deleted_fo public function test_a_per_viewer_network_notice_dismissed_on_one_site_stays_hidden_on_every_site_and_in_the_network_admin(): void { $site_id = $this->make_site( 'acme-notices' ); add_user_to_blog( $site_id, $this->admin, 'administrator' ); - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); switch_to_blog( $site_id ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); @@ -358,7 +340,7 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_site_stays_hid #[Group( 'multisite' )] public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_shows_on_another(): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); // User meta is installation-wide, so a second network sees the same dismissals. @@ -371,7 +353,7 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_ ) ); try { - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); $shown = \array_keys( $this->render( $this->admin ) ); } finally { delete_network_option( 2, "{$this->prefix}_network_notices" ); @@ -382,14 +364,6 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_ self::assertSame( array( 'network:1:x' ), \array_keys( get_user_meta( $this->admin, "{$this->prefix}_network_hidden_notices", true ) ) ); } - protected function notice( string $id, NoticeScope $scope, mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['persistent'] ??= true; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, "Notice $id.", ...$arguments ); - } - // What the dismiss script posts for the notice as the user is shown it. protected function shown( int $user_id, string $scope, string $id ): array { $attributes = $this->render( $user_id )[ $this->dom_id( $scope, $id ) ]; @@ -411,7 +385,7 @@ protected function render( int $user_id, string $screen = 'dashboard' ): array { protected function dismiss( array $params ): \WP_REST_Response { $request = new \WP_REST_Request( 'POST', '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals' ); - $request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => null !== $value ) ); + $request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => ! \is_null( $value ) ) ); return rest_do_request( $request ); } @@ -424,7 +398,7 @@ protected function serve( array $params, ?string $rest_nonce ): array { wp_get_current_user(); $_SERVER['REQUEST_METHOD'] = 'POST'; unset( $_SERVER['HTTP_X_WP_NONCE'] ); - if ( null !== $rest_nonce ) { + if ( ! \is_null( $rest_nonce ) ) { $_SERVER['HTTP_X_WP_NONCE'] = $rest_nonce; } $_POST = $params; @@ -434,8 +408,4 @@ protected function serve( array $params, ?string $rest_nonce ): array { return \json_decode( (string) \ob_get_clean(), true ); } - - protected function dom_id( string $scope, string $id ): string { - return "{$this->prefix}-$scope-notice-$id"; - } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php index 65cec4f..174dad9 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php @@ -16,6 +16,7 @@ #[CoversClass( AdminNotices::class )] final class AdminNoticesStorageTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesStorageTrait; @@ -33,9 +34,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { wp_set_current_user( 0 ); - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_each_scope_keeps_its_notices_as_one_array_by_id_in_its_home(): void { @@ -179,13 +177,6 @@ public function test_network_notices_live_in_the_network_option_whichever_site_a self::assertSame( array( 'one' ), self::ids( $this->notices->get_notices( NoticeScope::Network ) ) ); } - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = 'Hi.', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::User === $scope ? null : 'manage_options'; - - return new Notice( $id, $message, ...$arguments ); - } - protected function view( string $screen ): void { require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; require_once ABSPATH . 'wp-admin/includes/screen.php'; @@ -215,10 +206,6 @@ protected function option_row( string $name ): array { return array( maybe_unserialize( $row['option_value'] ?? '' ), $row['autoload'] ?? null ); } - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } - protected static function messages( array $notices ): array { return \array_map( static fn ( Notice $notice ): string => $notice->message, $notices ); } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php index 8e876b5..0356e67 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -12,49 +11,36 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class NoticesLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} #[CoversClass( AdminNotices::class )] final class AdminNoticesTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; + use RecordsDoingItWrongTrait; use RendersAdminScreensTrait; protected int $admin; protected int $other_admin; protected int $editor; - protected NoticesLogger $logger; + protected RecordingLogger $logger; #[\Override] protected function setUp(): void { $this->admin = $this->make_user( 'acme_notices_admin', 'administrator' ); $this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' ); $this->editor = $this->make_user( 'acme_notices_editor', 'editor' ); - $this->logger = new NoticesLogger(); - } - - #[\Override] - protected function tearDown(): void { - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } + $this->logger = new RecordingLogger(); } public static function invalid_prefixes(): array { @@ -138,24 +124,17 @@ public function test_a_notice_addressed_to_no_user_or_to_a_user_outside_the_user } public function test_a_request_notice_added_after_the_notices_showed_is_reported(): void { - $reported = array(); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - static function ( string $function_name ) use ( &$reported ): void { - $reported[] = $function_name; - } - ); + $this->record_doing_it_wrong(); wp_set_current_user( $this->admin ); $notices = $this->notices(); $notices->add( $this->notice( 'before', NoticeScope::Request ) ); - self::assertSame( array(), $reported ); + self::assertSame( array(), $this->doing_it_wrong ); do_action( 'all_admin_notices' ); $notices->add( $this->notice( 'user' ) ); $notices->add( $this->notice( 'after', NoticeScope::Request ) ); - self::assertSame( array( AdminNotices::class . '::add' ), $reported ); + self::assertSame( array( AdminNotices::class . '::add' ), $this->doing_it_wrong ); self::assertSame( array( 'before', 'after' ), self::ids( $notices->get_notices( NoticeScope::Request ) ) ); } @@ -306,7 +285,7 @@ public function test_a_notice_that_has_not_expired_yet_is_listed_and_shows(): vo public function test_the_visibility_filter_hides_a_notice_without_consuming_it_unless_it_returns_true(): void { $calls = array(); add_filter( - "{$this->prefix}/admin_notice/should_show", + "$this->prefix/admin_notice/should_show", static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): mixed { $calls[] = array( $visible, $id, $scope ); return match ( $id ) { @@ -331,7 +310,7 @@ static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): m public function test_the_message_filter_replaces_the_printed_message_unless_it_returns_no_string(): void { $calls = array(); add_filter( - "{$this->prefix}/admin_notice/message", + "$this->prefix/admin_notice/message", static function ( string $message, string $id, string $scope ) use ( &$calls ): ?string { $calls[] = array( $message, $id, $scope ); return 'kept' === $id ? null : 'Translated.'; @@ -511,7 +490,7 @@ protected function hide( int $user_id, string $key, string $state ): void { } protected function script_handle(): string { - return "{$this->prefix}-notice-dismissal"; + return "$this->prefix-notice-dismissal"; } #[After] @@ -519,19 +498,4 @@ protected function remove_the_script(): void { wp_dequeue_script( $this->script_handle() ); wp_deregister_script( $this->script_handle() ); } - - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = '', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, '' === $message ? "Notice $id." : $message, ...$arguments ); - } - - protected function dom_id( string $scope, string $id ): string { - return "{$this->prefix}-$scope-notice-$id"; - } - - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } } From 1618fc6215aac4eb898a7b53d27b6d2da05a88a8 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:36 +0100 Subject: [PATCH 30/47] Share the object-form helpers and field behaviors across the tests The four object-form host tests copied the same DOM and request helpers, the four request fixtures each expanded field behaviors with their own closure and record shape, and their docblocks claimed a kernel-free plugin wires them as they do. A valid id was accepted four different ways, new expressions were dereferenced in two forms, and ProductFixturesTrait sat in one folder while other folders built products by hand. InspectsObjectFormsTrait holds the form helpers, with the screen's scope passed in, and RecordedDefinition::with_behaviors() is the one behavior superset with one record shape. A valid id is a get_id() round trip or a row that expects no assertions, the settings tests use the bare new form, ProductFixturesTrait moves to Support, anonymous-class methods carry #[\Override], the uninstall record key reads woocommerce_loaded, and interpolation and null checks follow the style guide. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/Settings/acme-object-forms.php | 45 ++------ tests/Fixtures/Settings/acme-options-page.php | 23 +--- .../WooCommerce/acme-product-data.php | 43 ++------ tests/Fixtures/WooCommerce/acme-settings.php | 44 ++------ tests/Fixtures/WooCommerce/acme-uninstall.php | 2 +- .../Settings/AdminPage/OptionsPageTest.php | 36 ++++--- .../EditScreen/Hosts/PostMetaBoxTest.php | 102 ++++-------------- .../EditScreen/Hosts/TermFormTest.php | 55 +++------- .../EditScreen/Hosts/UserProfileTest.php | 61 ++--------- .../Settings/EditScreen/ObjectFormTest.php | 8 +- .../EditScreen/Stores/MetadataStoreTest.php | 10 +- .../Settings/FormsCheckHelperIdTest.php | 4 +- .../Settings/FormsRenderControlTest.php | 2 +- .../Support/InspectsObjectFormsTrait.php | 72 +++++++++++++ .../ProductFixturesTrait.php | 2 +- .../Support/RecordedDefinition.php | 31 ++++++ .../EditScreen/Hosts/OrderMetaBoxTest.php | 84 ++------------- .../Logging/WooCommerceLoggerTest.php | 4 + .../ProductData/ProductDataTabTest.php | 8 +- .../ProductData/ProductMetaTest.php | 1 + .../WooCommerce/Setting/SettingsTest.php | 19 ++-- .../WooCommerce/Uninstall/MetaTypesTest.php | 2 +- 22 files changed, 230 insertions(+), 428 deletions(-) create mode 100644 tests/Integration/Support/InspectsObjectFormsTrait.php rename tests/Integration/{WooCommerce/ProductData => Support}/ProductFixturesTrait.php (90%) diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php index 49360af..6afdd5b 100644 --- a/tests/Fixtures/Settings/acme-object-forms.php +++ b/tests/Fixtures/Settings/acme-object-forms.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { - $fields = $acme_with_behaviors( $spec['form']['fields'] ); + static function () use ( $spec ): void { + $fields = RecordedDefinition::with_behaviors( $spec['form']['fields'] ); $definition = new RecordedDefinition( static function ( string $form_id, int $object_id ) use ( $spec, $fields ): array { $GLOBALS['acme_outcome']['records'][] = array( 'definition' ); @@ -77,12 +48,14 @@ public function __construct( protected string $option ) {} + #[\Override] public function get_values( int $object_id, array $keys ): array { $GLOBALS['acme_outcome']['records'][] = array( 'get_values', $object_id, $keys ); return array_intersect_key( get_option( $this->option, array() )[ $object_id ] ?? array(), array_flip( $keys ) ); } + #[\Override] public function update_values( int $object_id, array $values ): void { $GLOBALS['acme_outcome']['records'][] = array( 'update_values', $object_id, $values ); @@ -95,17 +68,17 @@ public function update_values( int $object_id, array $values ): void { $notices = new AdminNotices( $spec['id'], new ErrorLogLogger( 'acme' ) ); $notices->register_hooks(); - ( new ObjectForm( $spec['id'], $definition, $host, $notices, $store ) )->register_hooks(); + new ObjectForm( $spec['id'], $definition, $host, $notices, $store )->register_hooks(); } ); if ( isset( $spec['contributed'] ) ) { add_filter( "{$spec['id']}/object_form/fields", - static function ( array $fields, int $object_id ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $fields, int $object_id ) use ( $spec ): array { $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id ); - return array_merge( $fields, $acme_with_behaviors( $spec['contributed'] ) ); + return array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed'] ) ); }, 10, 2 diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php index 44baff6..1e0c603 100644 --- a/tests/Fixtures/Settings/acme-options-page.php +++ b/tests/Fixtures/Settings/acme-options-page.php @@ -1,8 +1,8 @@ $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } + $tab['fields'] = RecordedDefinition::with_behaviors( $tab['fields'] ); } return $tabs; diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php index 65d1179..13f00e7 100644 --- a/tests/Fixtures/WooCommerce/acme-product-data.php +++ b/tests/Fixtures/WooCommerce/acme-product-data.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { + static function () use ( $spec ): void { $definition = new RecordedDefinition( - static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_behaviors ): ?array { + static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array { if ( 'acme:simple' === ( $spec['supports'] ?? null ) && ! $product->is_type( 'simple' ) ) { return null; } @@ -55,7 +26,7 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_ if ( $group['per_product'] ?? false ) { $GLOBALS['acme_outcome']['records'][] = array( 'fields', $product->get_id() ); } - $groups[] = array( 'fields' => $acme_with_behaviors( $group['fields'] ) ) + array_intersect_key( $group, array( 'class' => true ) ); + $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $group['fields'] ) ) + array_intersect_key( $group, array( 'class' => true ) ); } return array( @@ -78,9 +49,9 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_ if ( isset( $spec['contributed'] ) ) { add_filter( "{$spec['id']}/product_data_tab/groups", - static function ( array $groups, WC_Product $product ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $groups, WC_Product $product ) use ( $spec ): array { $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id() ); - $groups[] = array( 'fields' => $acme_with_behaviors( $spec['contributed'] ) ); + $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $spec['contributed'] ) ); return $groups; }, diff --git a/tests/Fixtures/WooCommerce/acme-settings.php b/tests/Fixtures/WooCommerce/acme-settings.php index 2be3048..992ee70 100644 --- a/tests/Fixtures/WooCommerce/acme-settings.php +++ b/tests/Fixtures/WooCommerce/acme-settings.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:uppercase' => strtoupper( $raw ), - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - if ( isset( $spec['tab'] ) ) { add_filter( 'woocommerce_get_settings_pages', - static function ( array $pages ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $pages ) use ( $spec ): array { $pages[] = new SettingsTab( $spec['tab']['id'], array( 'label' => $spec['tab']['label'], 'sections' => array_map( static fn ( array $section ): string => $section['label'], $spec['tab']['sections'] ), ), - new RecordedDefinition( static fn ( string $tab_id, string $section_id ): array => $acme_with_behaviors( $spec['tab']['sections'][ $section_id ]['fields'] ) ) + new RecordedDefinition( static fn ( string $tab_id, string $section_id ): array => RecordedDefinition::with_behaviors( $spec['tab']['sections'][ $section_id ]['fields'] ) ) ); return $pages; @@ -64,7 +34,7 @@ static function ( array $pages ) use ( $spec, $acme_with_behaviors ): array { if ( isset( $spec['section'] ) ) { add_action( 'woocommerce_settings_sections_registration', - static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { + static function ( $registry ) use ( $spec ): void { $registry->register( new SettingsSection( $spec['section']['id'], @@ -72,7 +42,7 @@ static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { 'tab' => $spec['section']['tab'], 'label' => $spec['section']['label'], ), - new RecordedDefinition( static fn (): array => $acme_with_behaviors( $spec['section']['fields'] ) ) + new RecordedDefinition( static fn (): array => RecordedDefinition::with_behaviors( $spec['section']['fields'] ) ) ) ); } @@ -82,7 +52,7 @@ static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { if ( isset( $spec['contributed'] ) ) { add_filter( "woocommerce_get_settings_{$spec['contributed']['tab']}", - static fn ( array $settings, mixed $section_id ): array => $spec['contributed']['section'] === $section_id ? array_merge( $settings, $acme_with_behaviors( $spec['contributed']['fields'] ) ) : $settings, + static fn ( array $settings, mixed $section_id ): array => $spec['contributed']['section'] === $section_id ? array_merge( $settings, RecordedDefinition::with_behaviors( $spec['contributed']['fields'] ) ) : $settings, 10, 2 ); diff --git a/tests/Fixtures/WooCommerce/acme-uninstall.php b/tests/Fixtures/WooCommerce/acme-uninstall.php index e938219..2bc7f7d 100644 --- a/tests/Fixtures/WooCommerce/acme-uninstall.php +++ b/tests/Fixtures/WooCommerce/acme-uninstall.php @@ -15,7 +15,7 @@ add_action( 'admin_init', static function () use ( $spec ): void { - $GLOBALS['acme_outcome']['records']['woocommerce loaded'] = class_exists( 'WooCommerce', false ); + $GLOBALS['acme_outcome']['records']['woocommerce_loaded'] = class_exists( 'WooCommerce', false ); Uninstaller::run( $spec['plugin_file'], $spec['footprint'], diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index d3b8d37..53ed702 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -86,7 +86,9 @@ public function test_an_invalid_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_id( string $id ): void { - self::assertInstanceOf( OptionsPage::class, new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ) ); + $this->expectNotToPerformAssertions(); + + new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ); } public function test_the_definition_is_evaluated_neither_at_construction_nor_at_registration(): void { @@ -123,7 +125,7 @@ public function test_a_first_save_validates_once_and_stores_the_result_without_a $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( array( 'name' => 'v!' ), get_option( $this->option ) ); - self::assertSame( array( array( 'name', 'v' ) ), $saved['records'] ); + self::assertSame( array( array( 'validate', 'name', 'v' ) ), $saved['records'] ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } @@ -131,7 +133,7 @@ public function test_a_programmatic_update_outside_options_php_is_stored_as_give $value = array( 'name' => ' raw ' ); $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( $this->option => $value ) ) ); - $request = $this->admin_request( 'GET', "admin-post.php?option_page={$this->option}", $this->admin, self::FIXTURE, $spec ); + $request = $this->admin_request( 'GET', "admin-post.php?option_page=$this->option", $this->admin, self::FIXTURE, $spec ); self::assertSame( $value, get_option( $this->option ) ); self::assertSame( array(), $request['records'] ); @@ -204,7 +206,7 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ $page = $this->follow( $spec, $this->admin, $this->save( $spec, $this->admin, $data ) )['body']; self::assertSame( array( 'Email', 'site' ), self::row_titles( $form ) ); - self::assertSame( "{$this->option}.site", self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); + self::assertSame( "$this->option.site", self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); self::assertNull( self::document( $form )->querySelector( 'form em' ) ); self::assertSame( array( @@ -360,7 +362,7 @@ public function test_an_option_page_outside_the_post_body_or_padded_still_valida unset( $data['option_page'] ); $query = '?option_page=' . \rawurlencode( $this->option ); } else { - $data['option_page'] = " {$this->option} "; + $data['option_page'] = " $this->option "; } $saved = $this->save( $spec, $this->admin, $data, $query ); @@ -398,7 +400,7 @@ public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_ar $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( array( 'colors', array() ) ), $saved['records'] ); + self::assertSame( array( array( 'validate', 'colors', array() ) ), $saved['records'] ); self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); } @@ -443,7 +445,7 @@ public function test_a_label_points_at_its_control_when_the_field_id_holds_an_en $form = self::document( $this->open( $this->spec( array( self::field( 'text', 'r&d' ) ) ), $this->admin )['body'] )->querySelector( 'form[action="options.php"]' ); $id = (string) $form?->querySelector( 'td input[type=text]' )?->getAttribute( 'id' ); - self::assertSame( "{$this->option}.r&d", $id ); + self::assertSame( "$this->option.r&d", $id ); self::assertSame( $id, $form?->querySelector( 'th label' )?->getAttribute( 'for' ) ); } @@ -494,16 +496,16 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an } self::assertSame( array( - 'Name' => "{$this->option}.name", - 'Count' => "{$this->option}.count", - 'Email' => "{$this->option}.email", - 'Site' => "{$this->option}.site", - 'Notes' => "{$this->option}.notes", - 'On' => "{$this->option}.on", - 'Off' => "{$this->option}.off", - 'Size' => "{$this->option}.size", + 'Name' => "$this->option.name", + 'Count' => "$this->option.count", + 'Email' => "$this->option.email", + 'Site' => "$this->option.site", + 'Notes' => "$this->option.notes", + 'On' => "$this->option.on", + 'Off' => "$this->option.off", + 'Size' => "$this->option.size", 'Tone' => null, - 'Colors' => "{$this->option}.colors", + 'Colors' => "$this->option.colors", 'Days' => null, 'Agree' => null, ), @@ -634,7 +636,7 @@ protected function open( array $spec, int $user_id, string $tab = '' ): array { $parent_slug = $spec['page']['parent']; $file = \str_ends_with( $parent_slug, '.php' ) ? $parent_slug : 'admin.php'; - return $this->admin_request( 'GET', "$file?page={$this->id}" . ( '' === $tab ? '' : "&tab=$tab" ), $user_id, self::FIXTURE, $spec ); + return $this->admin_request( 'GET', "$file?page=$this->id" . ( '' === $tab ? '' : "&tab=$tab" ), $user_id, self::FIXTURE, $spec ); } protected function save( array $spec, int $user_id, array $data, string $query = '' ): array { diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php index 1861e6b..d1370e8 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php @@ -8,12 +8,11 @@ use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -23,19 +22,10 @@ #[CoversClass( PostMetaBox::class )] final class PostMetaBoxTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $editor; @@ -85,11 +75,9 @@ public function test_an_invalid_form_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_form_id( string $id ): void { - $host = new PostMetaBox( array( 'post' ) ); - $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); - $form->register_hooks(); + $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); - self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) ); + self::assertSame( $id, $form->get_id() ); } public function test_the_definition_is_evaluated_only_when_a_screen_needs_it(): void { @@ -115,9 +103,9 @@ public function test_the_box_shows_on_the_edit_screens_of_its_post_types_only(): $post = self::document( $this->edit_screen( $spec, $post_id )['body'] ); $page = self::document( $this->edit_screen( $spec, $page_id )['body'] ); - self::assertSame( 'Acme details', \trim( (string) $post->querySelector( "form#post div.postbox[id=\"{$this->id}\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Acme details', \trim( (string) $post->querySelector( "form#post div.postbox[id=\"$this->id\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNotNull( $this->box( $post )->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); - self::assertNull( $page->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $page->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_the_box_sits_in_its_context(): void { @@ -135,7 +123,7 @@ public function test_the_box_sits_in_its_context(): void { $screen = self::document( $this->edit_screen( $spec, $post_id )['body'] ); - self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"$this->id\"]" ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { @@ -145,7 +133,7 @@ public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { $screen = self::document( $this->edit_screen( $spec, $post_id, $this->editor )['body'] ); self::assertNotNull( $screen->querySelector( 'form#post' ) ); - self::assertNull( $screen->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $screen->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_a_single_control_is_labelled_by_its_row(): void { @@ -154,10 +142,10 @@ public function test_a_single_control_is_labelled_by_its_row(): void { $box = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) ); - $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertNotNull( $label ); self::assertSame( 'Name', \trim( $label->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); } public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { @@ -167,7 +155,7 @@ public function test_a_label_points_at_its_control_when_the_field_id_holds_an_en $box = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) ); $id = (string) $box->querySelector( 'input[type=text]' )?->getAttribute( 'id' ); - self::assertSame( "{$this->id}._{$this->prefix}_r&d", $id ); + self::assertSame( "$this->id._{$this->prefix}_r&d", $id ); self::assertSame( $id, $box->querySelector( 'label.post-attributes-label' )?->getAttribute( 'for' ) ); } @@ -183,7 +171,7 @@ public function test_a_group_is_one_fieldset_named_by_its_own_legend_under_its_r self::assertCount( 1, $box->querySelectorAll( 'fieldset' ) ); self::assertSame( 'Color', \trim( (string) $fieldset->querySelector( 'legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $fieldset->parentElement?->querySelector( 'p.post-attributes-label-wrapper > span.post-attributes-label' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_color\"]" ) ); + self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"$this->id._{$this->prefix}_color\"]" ) ); self::assertNotNull( $fieldset->querySelector( "input[name^=\"{$this->id}[_{$this->prefix}_color]\"]" ) ); } public function test_controls_carry_the_stored_value_or_else_the_default(): void { @@ -265,16 +253,16 @@ public function test_a_custom_control_renders_with_its_value_name_and_id(): void $request = $this->edit_screen( $spec, $post_id ); $box = $this->box( self::document( $request['body'] ) ); - self::assertContains( array( 'render', "_{$this->prefix}_widget", 'stored', "{$this->id}[_{$this->prefix}_widget]", "{$this->id}._{$this->prefix}_widget" ), $request['records'] ); - self::assertNotNull( $box->querySelector( "label[for=\"{$this->id}._{$this->prefix}_widget\"]" ) ); - self::assertNotNull( $box->querySelector( "input.acme-widget[id=\"{$this->id}._{$this->prefix}_widget\"]" ) ); + self::assertContains( array( 'render', "_{$this->prefix}_widget", 'stored', "{$this->id}[_{$this->prefix}_widget]", "$this->id._{$this->prefix}_widget" ), $request['records'] ); + self::assertNotNull( $box->querySelector( "label[for=\"$this->id._{$this->prefix}_widget\"]" ) ); + self::assertNotNull( $box->querySelector( "input.acme-widget[id=\"$this->id._{$this->prefix}_widget\"]" ) ); } public function test_titles_print_as_text(): void { $post_id = $this->make_post(); $spec = $this->spec( array( $this->field( 'text', 'note', array( 'title' => 'Customer\'s note' ) ) ) ); - $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"{$this->id}._{$this->prefix}_note\"]" ); + $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"$this->id._{$this->prefix}_note\"]" ); self::assertSame( 'Customer\'s note', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNull( $label?->querySelector( 'b' ) ); @@ -284,7 +272,7 @@ public function test_an_untitled_field_is_labelled_by_its_id(): void { $post_id = $this->make_post(); $spec = $this->spec( array( $this->field( 'text', 'name', array( 'title' => '' ) ) ) ); - $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertSame( "_{$this->prefix}_name", \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } @@ -688,7 +676,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th $saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) ); $redirected = $this->follow( $spec, $saved ); wp_set_current_user( $this->admin ); - $delivered = ( new AdminNotices( $this->id, new ErrorLogLogger( 'acme' ) ) )->rest_deliver(); + $delivered = new AdminNotices( $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver(); $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec ); self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) ); @@ -749,18 +737,6 @@ protected function spec( array $fields, array $form = array(), array $extra = ar ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The form posts as the Update button submits it, after the user set the named controls; tampering replaces posted keys and removes posted keys or a key of the form's array. protected function save( array $spec, int $post_id, array $changes = array(), array $tampered = array(), array $removed = array() ): array { $data = \array_replace_recursive( self::serialize_form( $this->edit_screen( $spec, $post_id )['body'], 'form#post', $changes ), $tampered ) + array( 'save' => 'Update' ); @@ -776,29 +752,10 @@ protected function save( array $spec, int $post_id, array $changes = array(), ar return $this->admin_request( 'POST', 'post.php', $this->admin, self::FIXTURE, $spec, $data ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $post_id, string $name ): array { return get_post_meta( $post_id, "_{$this->prefix}_$name", false ); } - protected function records( array $request, string $kind ): array { - return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); - } - - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - // The block editor posts its meta box forms with the base form to the meta box loader's URL, as its requestMetaBoxUpdates() does. protected function block_editor_save( array $spec, int $post_id, array $changes = array() ): array { $body = $this->edit_screen( $spec, $post_id )['body']; @@ -868,29 +825,6 @@ protected function edit_screen( array $spec, int $post_id, ?int $user_id = null return $this->admin_request( 'GET', "post.php?post=$post_id&action=edit", $user_id ?? $this->admin, self::FIXTURE, $spec ); } - protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { - $box = $screen->querySelector( "div.postbox[id=\"{$this->id}\"] div.inside" ); - self::assertNotNull( $box, "The screen printed no {$this->id} box." ); - - return $box; - } - - protected function control( \Dom\Element $box, string $name ): \Dom\Element { - $control = $box->querySelector( "[id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The box printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected function selected( \Dom\Element $box, string $name ): array { - $values = array(); - foreach ( $this->control( $box, $name )->querySelectorAll( 'option[selected]' ) as $option ) { - $values[] = (string) $option->getAttribute( 'value' ); - } - - return $values; - } - #[After] protected function delete_posts(): void { foreach ( $this->post_ids as $post_id ) { diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php index 9322e36..13aa3a7 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php @@ -7,11 +7,10 @@ use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\TermForm; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -20,18 +19,9 @@ #[CoversClass( TermForm::class )] final class TermFormTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $editor; @@ -47,7 +37,7 @@ protected function setUp(): void { public function test_the_host_registers_on_each_of_its_taxonomies(): void { $host = new TermForm( array( 'category', 'post_tag' ) ); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); foreach ( array( 'category', 'post_tag' ) as $taxonomy ) { self::assertSame( 10, has_action( "{$taxonomy}_edit_form", array( $host, 'render_section' ) ) ); @@ -66,7 +56,7 @@ public function test_the_form_shows_after_cores_fields_on_its_taxonomies_edit_sc $section = $edit->querySelector( "form#edittag > table.form-table ~ input[type=hidden][name=\"{$this->id}_nonce\"] + h2 + table.form-table" ); self::assertNotNull( $section ); self::assertSame( 'Acme term', \trim( (string) $section->previousElementSibling?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"{$this->id}._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"$this->id._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > td > fieldset > legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) fieldset' ) ); self::assertNotNull( $other->querySelector( 'form#edittag' ) ); @@ -80,8 +70,8 @@ public function test_controls_carry_the_stored_value_or_else_the_default(): void $edit = self::document( $this->edit_screen( $spec, 'category', $term_id )['body'] ); - self::assertSame( 'stored', $edit->querySelector( "[id=\"{$this->id}._{$this->prefix}_name\"]" )?->getAttribute( 'value' ) ); - self::assertSame( 'fresh', $edit->querySelector( "[id=\"{$this->id}._{$this->prefix}_absent\"]" )?->getAttribute( 'value' ) ); + self::assertSame( 'stored', $edit->querySelector( "[id=\"$this->id._{$this->prefix}_name\"]" )?->getAttribute( 'value' ) ); + self::assertSame( 'fresh', $edit->querySelector( "[id=\"$this->id._{$this->prefix}_absent\"]" )?->getAttribute( 'value' ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_section(): void { @@ -135,13 +125,13 @@ public function test_quick_edit_stores_nothing(): void { '_inline_edit' => (string) $list->querySelector( 'input#_inline_edit' )?->getAttribute( 'value' ), 'taxonomy' => 'category', 'tax_ID' => (string) $term_id, - 'name' => "{$this->prefix} quick", - 'slug' => "{$this->prefix}-quick", + 'name' => "$this->prefix quick", + 'slug' => "$this->prefix-quick", $this->id => array( "_{$this->prefix}_name" => 'quick' ), ) ); - self::assertSame( "{$this->prefix} quick", get_term( $term_id )->name ); + self::assertSame( "$this->prefix quick", get_term( $term_id )->name ); self::assertSame( array( 'kept' ), $this->meta( $term_id, 'name' ) ); } @@ -165,13 +155,13 @@ public function test_an_update_in_code_stores_nothing(): void { $_POST[ $this->id ] = array( "_{$this->prefix}_name" => 'programmatic' ); try { - wp_update_term( $term_id, 'category', array( 'name' => "{$this->prefix} in code" ) ); + wp_update_term( $term_id, 'category', array( 'name' => "$this->prefix in code" ) ); } finally { unset( $_POST[ $this->id ] ); wp_set_current_user( 0 ); } - self::assertSame( "{$this->prefix} in code", get_term( $term_id )->name ); + self::assertSame( "$this->prefix in code", get_term( $term_id )->name ); self::assertSame( array( 'kept' ), $this->meta( $term_id, 'name' ) ); } @@ -188,20 +178,8 @@ protected function spec( array $fields, array $form = array() ): array { ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - protected function make_term( string $taxonomy ): int { - $term = wp_insert_term( "{$this->prefix} " . \count( $this->terms ), $taxonomy ); + $term = wp_insert_term( "$this->prefix " . \count( $this->terms ), $taxonomy ); self::assertIsArray( $term ); $this->terms[] = array( (int) $term['term_id'], $taxonomy ); @@ -216,15 +194,6 @@ protected function meta( int $term_id, string $name ): array { return get_term_meta( $term_id, "_{$this->prefix}_$name", false ); } - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - #[After] protected function delete_terms(): void { foreach ( $this->terms as list( $term_id, $taxonomy ) ) { diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php index 6ffe6a4..6d699d6 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php @@ -7,11 +7,10 @@ use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\UserProfile; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -21,18 +20,9 @@ #[CoversClass( UserProfile::class )] final class UserProfileTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $subscriber; @@ -61,7 +51,7 @@ public static function multisite_profiles(): array { public function test_the_host_registers_on_both_profile_screens(): void { $host = new UserProfile(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 10, has_action( 'show_user_profile', array( $host, 'render_section' ) ) ); self::assertSame( 10, has_action( 'edit_user_profile', array( $host, 'render_section' ) ) ); @@ -78,8 +68,8 @@ public function test_the_form_shows_as_a_titled_section_of_labelled_rows( string $section = $form?->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"] ~ h2 + table.form-table" ); self::assertNotNull( $section ); self::assertSame( 'Acme profile', \trim( (string) $section->previousElementSibling?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"{$this->id}._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $section->querySelector( "tr > td > input[id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"$this->id._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertNotNull( $section->querySelector( "tr > td > input[id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > th[scope=row]' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > td > fieldset > legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) legend' ) ); @@ -92,7 +82,7 @@ public function test_a_label_points_at_its_control_when_the_field_id_holds_an_en $section = self::document( $this->open( $spec, 'own' )['body'] )->querySelector( "form#your-profile input[name=\"{$this->id}_nonce\"] ~ h2 + table.form-table" ); $id = (string) $section?->querySelector( 'td > input[type=text]' )?->getAttribute( 'id' ); - self::assertSame( "{$this->id}._{$this->prefix}_r&d", $id ); + self::assertSame( "$this->id._{$this->prefix}_r&d", $id ); self::assertSame( $id, $section?->querySelector( 'th > label' )?->getAttribute( 'for' ) ); } @@ -107,8 +97,9 @@ public function test_controls_carry_the_stored_value_or_else_the_default(): void ) ); - $form = self::document( $this->open( $spec, 'other' )['body'] ); + $form = self::document( $this->open( $spec, 'other' )['body'] )->querySelector( 'form#your-profile' ); + self::assertNotNull( $form ); self::assertSame( 'stored', $this->control( $form, 'name' )->getAttribute( 'value' ) ); self::assertSame( '', $this->control( $form, 'empty' )->getAttribute( 'value' ) ); self::assertSame( 'fresh', $this->control( $form, 'absent' )->getAttribute( 'value' ) ); @@ -257,50 +248,16 @@ protected function spec( array $fields, array $form = array() ): array { ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The administrator opens their own profile, or the subscriber's. protected function open( array $spec, string $screen ): array { - return $this->admin_request( 'GET', 'own' === $screen ? 'profile.php' : "user-edit.php?user_id={$this->subscriber}", $this->admin, self::FIXTURE, $spec ); + return $this->admin_request( 'GET', 'own' === $screen ? 'profile.php' : "user-edit.php?user_id=$this->subscriber", $this->admin, self::FIXTURE, $spec ); } protected function save( array $spec, string $screen, array $changes ): array { return $this->admin_request( 'POST', 'own' === $screen ? 'profile.php' : 'user-edit.php', $this->admin, self::FIXTURE, $spec, self::serialize_form( $this->open( $spec, $screen )['body'], 'form#your-profile', $changes ) ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $user_id, string $name ): array { return get_user_meta( $user_id, "_{$this->prefix}_$name", false ); } - - protected function control( \Dom\HTMLDocument $screen, string $name ): \Dom\Element { - $control = $screen->querySelector( "form#your-profile [id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The profile printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } } diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php index f9c914f..9a9e1c3 100644 --- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php +++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php @@ -210,21 +210,25 @@ public function test_a_host_serves_one_form(): void { $this->expectException( LogicException::class ); - ( new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); } public function test_a_host_the_consumer_writes_serves_one_form_too(): void { $host = new class() implements ObjectFormHostInterface { + #[\Override] public function register_hooks( ObjectForm $form ): void {} + #[\Override] public function get_object_capability(): string { return 'edit_post'; } + #[\Override] public function get_default_store(): ObjectStoreInterface { return new MetadataStore( 'post' ); } + #[\Override] public function render_rows( string $title, array $rows ): void {} }; $form = $this->form( $host ); @@ -233,7 +237,7 @@ public function render_rows( string $title, array $rows ): void {} $this->expectException( LogicException::class ); - ( new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); } protected function form( ObjectFormHostInterface $host, string $capability = 'edit_posts' ): ObjectForm { diff --git a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php index 95492de..807b1ce 100644 --- a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php +++ b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php @@ -34,7 +34,7 @@ public function test_only_the_keys_the_object_has_are_returned_with_their_stored add_metadata( $meta_type, $object_id, "_{$this->prefix}_empty", '' ); add_metadata( $meta_type, $object_id, "_{$this->prefix}_list", array() ); - $values = ( new MetadataStore( $meta_type ) )->get_values( $object_id, array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); + $values = new MetadataStore( $meta_type )->get_values( $object_id, array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); self::assertSame( array( @@ -55,7 +55,7 @@ public function test_a_null_row_reads_as_absent_with_or_without_a_registered_def update_metadata( $meta_type, $object_id, "_{$this->prefix}_registered", null ); update_metadata( $meta_type, $object_id, "_{$this->prefix}_plain", null ); - self::assertSame( array(), ( new MetadataStore( $meta_type ) )->get_values( $object_id, array( "_{$this->prefix}_registered", "_{$this->prefix}_plain" ) ) ); + self::assertSame( array(), new MetadataStore( $meta_type )->get_values( $object_id, array( "_{$this->prefix}_registered", "_{$this->prefix}_plain" ) ) ); } finally { unregister_meta_key( $meta_type, "_{$this->prefix}_registered" ); } @@ -66,7 +66,7 @@ public function test_values_are_stored_as_given_backslashes_included( string $me $object_id = $this->make_object( $meta_type ); add_metadata( $meta_type, $object_id, "_{$this->prefix}_text", 'old' ); - ( new MetadataStore( $meta_type ) )->update_values( + new MetadataStore( $meta_type )->update_values( $object_id, array( "_{$this->prefix}_text" => 'O\'Brien C:\\dir', @@ -90,7 +90,7 @@ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws $object_id = $this->make_object( 'post' ); try { - ( new MetadataStore( 'post' ) )->update_values( + new MetadataStore( 'post' )->update_values( $object_id, array( "_{$this->prefix}_first" => 'Written first.', @@ -137,7 +137,7 @@ protected function make_object( string $meta_type ): int { ) ), 'user' => $this->make_user( "{$this->prefix}_user", 'subscriber' ), - 'term' => $this->term_ids[] = (int) wp_insert_term( "{$this->prefix} term", 'category' )['term_id'], + 'term' => $this->term_ids[] = (int) wp_insert_term( "$this->prefix term", 'category' )['term_id'], }; } diff --git a/tests/Integration/Settings/FormsCheckHelperIdTest.php b/tests/Integration/Settings/FormsCheckHelperIdTest.php index e283fcc..e665bb8 100644 --- a/tests/Integration/Settings/FormsCheckHelperIdTest.php +++ b/tests/Integration/Settings/FormsCheckHelperIdTest.php @@ -23,8 +23,8 @@ public function test_an_invalid_id_is_rejected_by_name( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_id( string $id ): void { - Forms::check_helper_id( $id ); + $this->expectNotToPerformAssertions(); - $this->addToAssertionCount( 1 ); + Forms::check_helper_id( $id ); } } diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index d6f3372..6725010 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -490,7 +490,7 @@ public function test_a_placeholder_prints_only_on_text_like_controls( string $ty ) ); - self::assertSame( $printed, null !== $document->querySelector( '[placeholder]' ) ); + self::assertSame( $printed, ! \is_null( $document->querySelector( '[placeholder]' ) ) ); } public static function sizing_classes(): array { diff --git a/tests/Integration/Support/InspectsObjectFormsTrait.php b/tests/Integration/Support/InspectsObjectFormsTrait.php new file mode 100644 index 0000000..8e9f41d --- /dev/null +++ b/tests/Integration/Support/InspectsObjectFormsTrait.php @@ -0,0 +1,72 @@ +id to the form's id and $this->admin to the user its requests run as. +trait InspectsObjectFormsTrait { + use RunsAdminRequestsTrait; + use SerializesFormsTrait; + + protected const string FIXTURE = __DIR__ . '/../../Fixtures/Settings/acme-object-forms.php'; + + protected const array COLORS = array( + 'red' => 'Red', + 'blue' => 'Blue', + 'teal' => 'Teal', + ); + + protected function field( string $type, string $name, array $keys = array() ): array { + return $keys + array( + 'type' => $type, + 'id' => "_{$this->prefix}_$name", + 'title' => \ucfirst( $name ), + ); + } + + protected function control_name( string $name ): string { + return "{$this->id}[_{$this->prefix}_$name]"; + } + + protected function follow( array $spec, array $saved ): array { + self::assertIsString( $saved['location'] ); + + return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); + } + + protected function records( array $request, string $kind ): array { + return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); + } + + protected static function rejections( string $html ): array { + $messages = array(); + foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { + $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + } + + return $messages; + } + + protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { + $box = $screen->querySelector( "div.postbox[id=\"$this->id\"] div.inside" ); + self::assertNotNull( $box, "The screen printed no $this->id box." ); + + return $box; + } + + // The scope is the screen's element that holds the form, such as a meta box's body or the profile's form. + protected function control( \Dom\ParentNode $scope, string $name ): \Dom\Element { + $control = $scope->querySelector( "[id=\"$this->id._{$this->prefix}_$name\"]" ); + self::assertNotNull( $control, "The form printed no _{$this->prefix}_$name." ); + + return $control; + } + + protected function selected( \Dom\ParentNode $scope, string $name ): array { + $values = array(); + foreach ( $this->control( $scope, $name )->querySelectorAll( 'option[selected]' ) as $option ) { + $values[] = (string) $option->getAttribute( 'value' ); + } + + return $values; + } +} diff --git a/tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php b/tests/Integration/Support/ProductFixturesTrait.php similarity index 90% rename from tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php rename to tests/Integration/Support/ProductFixturesTrait.php index 7f506a1..39c0ed1 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php +++ b/tests/Integration/Support/ProductFixturesTrait.php @@ -1,6 +1,6 @@ record( $tab_id, $product ); } + // Replaces each field's acme: validate and render names with callables that record what they receive in the request's outcome. + public static function with_behaviors( array $fields ): array { + foreach ( $fields as &$field ) { + $field_id = $field['id'] ?? ''; + if ( 'acme:render' === ( $field['render'] ?? null ) ) { + $field['render'] = static function ( array $field, mixed $value, string $name, string $id ) use ( $field_id ): string { + $GLOBALS['acme_outcome']['records'][] = array( 'render', $field_id, $value, $name, $id ); + + return ''; + }; + } + + $behavior = $field['validate'] ?? null; + if ( \is_string( $behavior ) && \str_starts_with( $behavior, 'acme:' ) ) { + $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { + $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); + + return match ( $behavior ) { + 'acme:append' => $raw . '!', + 'acme:uppercase' => \strtoupper( $raw ), + 'acme:reject' => new \WP_Error( 'acme_rejected', 'Use at most four characters.' ), + 'acme:reject-silently' => new \WP_Error( 'acme_rejected' ), + 'acme:record' => $raw, + }; + }; + } + } + + return $fields; + } + protected function record( mixed ...$arguments ): mixed { $this->calls[] = $arguments; diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php index c729b7b..6311c16 100644 --- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php @@ -6,11 +6,10 @@ use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\OrderFixturesTrait; use DeepWebSolutions\Framework\WooCommerce\EditScreen\Hosts\OrderMetaBox; use PHPUnit\Framework\Attributes\CoversClass; @@ -20,19 +19,10 @@ #[CoversClass( OrderMetaBox::class )] final class OrderMetaBoxTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use OrderFixturesTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected string $id; @@ -89,10 +79,10 @@ public function test_the_box_shows_on_the_edit_screens_of_its_order_types_only() $screen = self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ); $elsewhere = self::document( $this->edit_screen( $other, $order->get_id() )['body'] ); - self::assertSame( 'Acme details', \trim( (string) $screen->querySelector( "form#order div.postbox[id=\"{$this->id}\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Acme details', \trim( (string) $screen->querySelector( "form#order div.postbox[id=\"$this->id\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNotNull( $this->box( $screen )->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); self::assertNotNull( $elsewhere->querySelector( 'form#order' ) ); - self::assertNull( $elsewhere->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $elsewhere->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_the_box_sits_in_its_context(): void { @@ -110,7 +100,7 @@ public function test_the_box_sits_in_its_context(): void { $screen = self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ); - self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"$this->id\"]" ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { @@ -121,7 +111,7 @@ public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { $screen = self::document( $this->edit_screen( $spec, $order->get_id(), $manager )['body'] ); self::assertNotNull( $screen->querySelector( 'form#order' ) ); - self::assertNull( $screen->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $screen->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_a_single_control_is_labelled_by_its_row(): void { @@ -130,10 +120,10 @@ public function test_a_single_control_is_labelled_by_its_row(): void { $box = $this->box( self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ) ); - $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertNotNull( $label ); self::assertSame( 'Name', \trim( $label->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); } #[DataProvider( 'group_fields' )] @@ -148,7 +138,7 @@ public function test_a_group_is_one_fieldset_named_by_its_own_legend_under_its_r self::assertCount( 1, $box->querySelectorAll( 'fieldset' ) ); self::assertSame( 'Color', \trim( (string) $fieldset->querySelector( 'legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $fieldset->parentElement?->querySelector( 'p.post-attributes-label-wrapper > span.post-attributes-label' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_color\"]" ) ); + self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"$this->id._{$this->prefix}_color\"]" ) ); self::assertNotNull( $fieldset->querySelector( "input[name^=\"{$this->id}[_{$this->prefix}_color]\"]" ) ); } public function test_controls_carry_the_stored_value_or_else_the_default(): void { @@ -374,7 +364,7 @@ public function test_on_the_posts_table_the_box_shows_the_stored_values(): void $screen = self::document( $this->posts_edit_screen( $spec, $order->get_id() )['body'] ); self::assertSame( 'shop_order', get_post_type( $order->get_id() ) ); - self::assertNotNull( $screen->querySelector( "form#post div.postbox[id=\"{$this->id}\"] input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); + self::assertNotNull( $screen->querySelector( "form#post div.postbox[id=\"$this->id\"] input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); self::assertSame( 'stored', $this->control( $this->box( $screen ), 'name' )->getAttribute( 'value' ) ); } @@ -420,18 +410,6 @@ protected function spec( array $fields, array $form = array(), array $extra = ar ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The form posts as WooCommerce's Update button submits it, after the user set the named controls; tampering replaces posted keys and removes posted keys or a key of the form's array. protected function save( array $spec, int $order_id, array $changes = array(), array $tampered = array(), array $removed = array() ): array { $data = \array_replace_recursive( self::serialize_form( $this->edit_screen( $spec, $order_id )['body'], 'form#order', $changes ), $tampered ); @@ -453,29 +431,10 @@ protected function posts_save( array $spec, int $order_id, array $changes ): arr return $this->admin_request( 'POST', 'post.php', $this->admin, self::FIXTURE, $spec, $data ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $order_id, string $name ): array { return $this->order_meta( $order_id, "_{$this->prefix}_$name" ); } - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - - protected function records( array $request, string $kind ): array { - return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); - } - protected function edit_screen( array $spec, int $order_id, ?int $user_id = null ): array { return $this->admin_request( 'GET', "admin.php?page=wc-orders&action=edit&id=$order_id", $user_id ?? $this->admin, self::FIXTURE, $spec ); } @@ -483,27 +442,4 @@ protected function edit_screen( array $spec, int $order_id, ?int $user_id = null protected function posts_edit_screen( array $spec, int $order_id ): array { return $this->admin_request( 'GET', "post.php?post=$order_id&action=edit", $this->admin, self::FIXTURE, $spec ); } - - protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { - $box = $screen->querySelector( "div.postbox[id=\"{$this->id}\"] div.inside" ); - self::assertNotNull( $box, "The screen printed no {$this->id} box." ); - - return $box; - } - - protected function control( \Dom\Element $box, string $name ): \Dom\Element { - $control = $box->querySelector( "[id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The box printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected function selected( \Dom\Element $box, string $name ): array { - $values = array(); - foreach ( $this->control( $box, $name )->querySelectorAll( 'option[selected]' ) as $option ) { - $values[] = (string) $option->getAttribute( 'value' ); - } - - return $values; - } } diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php index 754f08a..63d373b 100644 --- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php +++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php @@ -28,6 +28,7 @@ protected function setUp(): void { $this->handler = new class() implements \WC_Log_Handler_Interface { public array $records = array(); + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $this->records[] = array( $level, $message, $context ); @@ -126,6 +127,7 @@ public function test_a_record_casts_only_the_context_values_its_message_uses(): $unused = new class() implements \Stringable { public int $casts = 0; + #[\Override] public function __toString(): string { ++$this->casts; @@ -169,6 +171,7 @@ public function test_a_context_value_that_cannot_be_encoded_reaches_woocommerces $this->handler = new class() extends LogHandlerFileV2 { public array $entries = array(); + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $this->entries[] = self::format_entry( $timestamp, $level, $message, $context ); @@ -176,6 +179,7 @@ public function handle( $timestamp, $level, $message, $context ) { } }; $unencodable = new class() implements \JsonSerializable { + #[\Override] public function jsonSerialize(): mixed { throw new \LogicException( 'Not encodable.' ); } diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index 86eaebb..f97de03 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -6,6 +6,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; @@ -70,10 +71,9 @@ public function test_an_invalid_tab_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_id( string $id ): void { - $tab = new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); - $tab->register_hooks(); + $this->expectNotToPerformAssertions(); - self::assertSame( 10, has_filter( 'woocommerce_product_data_tabs', array( $tab, 'add_tab' ) ) ); + new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); } public function test_the_definition_is_evaluated_only_when_the_edit_screen_needs_it(): void { @@ -795,7 +795,7 @@ public function test_an_unsupported_product_never_saves_the_tabs_fields(): void } public function test_a_v1_meta_key_round_trips_through_the_panel(): void { - $key = "_{$this->prefix}-wrwc_warranty-type"; + $key = "_$this->prefix-wrwc_warranty-type"; $product = $this->make_product( array( $key => 'limited' ) ); $spec = $this->spec( array( diff --git a/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php b/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php index e9e86c7..6fccd70 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductMeta; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 6e62e77..5eaecb9 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use DeepWebSolutions\Framework\WooCommerce\Setting\Fields; @@ -26,6 +27,7 @@ final class SettingsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; + use RecordsDoingItWrongTrait; use RunsAdminRequestsTrait; use SerializesFormsTrait; @@ -178,8 +180,8 @@ public function test_saving_a_section_stores_its_values_and_leaves_the_other_sec public function test_a_tab_field_round_trips_through_woocommerces_rest_api(): void { $this->add_tab_in_process( array( '' => $this->in_process_section( array( $this->field( 'text', 'name' ) ) ) ) ); - $updated = $this->rest( 'PUT', "/wc/v3/settings/{$this->prefix}/{$this->prefix}_name", array( 'value' => 'via rest' ) ); - $listed = $this->rest( 'GET', "/wc/v3/settings/{$this->prefix}" ); + $updated = $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_name", array( 'value' => 'via rest' ) ); + $listed = $this->rest( 'GET', "/wc/v3/settings/$this->prefix" ); self::assertSame( 200, $updated->get_status() ); self::assertSame( 'via rest', get_option( "{$this->prefix}_name" ) ); @@ -218,19 +220,12 @@ public function test_a_registered_section_builds_its_fields_once_from_its_tab_an } public function test_a_section_whose_id_is_taken_is_rejected_by_the_registry(): void { - $wrong = array(); $registry = SettingsSectionRegistry::get_instance(); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - static function ( string $function_name ) use ( &$wrong ): void { - $wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); self::assertTrue( $registry->register( new SettingsSection( $this->prefix, $this->registered_section( 'products' ), $this->fields_definition() ) ) ); self::assertFalse( $registry->register( new SettingsSection( $this->prefix, $this->registered_section( 'products' ), $this->fields_definition() ) ) ); - self::assertSame( array( SettingsSectionRegistry::class . '::register' ), $wrong ); + self::assertSame( array( SettingsSectionRegistry::class . '::register' ), $this->doing_it_wrong ); } public function test_a_disabled_field_is_never_saved_even_when_its_value_is_posted(): void { @@ -388,7 +383,7 @@ public function test_over_rest_validate_receives_woocommerces_normalized_value() ) ); - $this->rest( 'PUT', "/wc/v3/settings/{$this->prefix}/{$this->prefix}_name", array( 'value' => ' bold ' ) ); + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_name", array( 'value' => ' bold ' ) ); $normalized = wp_kses_post( 'bold' ); diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php index bb4faac..da0d179 100644 --- a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php +++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php @@ -80,7 +80,7 @@ public function test_with_woocommerce_deactivated_a_consenting_uninstall_still_d ) ); - self::assertSame( array( 'woocommerce loaded' => false ), $request['records'] ); + self::assertSame( array( 'woocommerce_loaded' => false ), $request['records'] ); self::assertSame( $this->kept_order_meta_keys(), $this->meta_keys_in( 'postmeta' ) ); self::assertSame( $this->kept_order_meta_keys(), $this->meta_keys_in( 'wc_orders_meta' ) ); self::assertSame( $this->kept_order_item_meta_keys(), $this->meta_keys_in( 'woocommerce_order_itemmeta' ) ); From 8df1fdfd23ad59238890b2786e17b68aecb9be41 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:36 +0100 Subject: [PATCH 31/47] Write the requirement and updater tests' plugin files in one place The updater, requirements and WooCommerce requirement tests each wrote their plugin files with their own writer, cleaned them up three different ways and rendered admin notices with copied helpers, and UpdaterTest's providers passed closures that forced its staging helpers public. WritesPluginFilesTrait writes each plugin into its own folder and removes it with remove_tree(), the notices render through RendersAdminScreensTrait, and the unusable releases are data the test method stages, so the helpers are protected again. Assisted-by: Claude Code:claude-opus-5-5 --- .../Bootstrap/RequirementsTest.php | 43 ++++-------- tests/Integration/Bootstrap/UpdaterTest.php | 69 ++++++++----------- .../Support/WritesPluginFilesTrait.php | 39 +++++++++++ .../WooCommerce/RequirementFixturesTrait.php | 37 ++-------- .../WooCommerceRequirementTest.php | 10 +-- ...merceRequirementWithoutWooCommerceTest.php | 4 +- 6 files changed, 93 insertions(+), 109 deletions(-) create mode 100644 tests/Integration/Support/WritesPluginFilesTrait.php diff --git a/tests/Integration/Bootstrap/RequirementsTest.php b/tests/Integration/Bootstrap/RequirementsTest.php index dd9ea7c..93596dc 100644 --- a/tests/Integration/Bootstrap/RequirementsTest.php +++ b/tests/Integration/Bootstrap/RequirementsTest.php @@ -5,6 +5,8 @@ use DeepWebSolutions\Framework\Bootstrap\Requirements; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -13,11 +15,11 @@ final class RequirementsTest extends TestCase { use CreatesUsersTrait; use IsolatesHooksTrait; + use RendersAdminScreensTrait; + use WritesPluginFilesTrait; protected string $slug; - protected array $directories = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once \dirname( __DIR__, 3 ) . '/packages/bootstrap/load.php'; @@ -29,15 +31,6 @@ protected function setUp(): void { remove_all_actions( 'all_admin_notices' ); } - #[\Override] - protected function tearDown(): void { - wp_set_current_user( 0 ); - foreach ( $this->directories as $directory ) { - \array_map( 'unlink', \glob( "$directory/*" ) ); - \rmdir( $directory ); - } - } - public static function unmet_headers(): array { return array( 'php-header' => array( array( 'Requires PHP' => '99' ) ), @@ -167,28 +160,18 @@ public function test_a_message_that_is_neither_a_string_nor_an_error_prints_noth } protected function write_plugin( string $root, array $headers ): string { - $directory = "$root/$this->slug"; - \mkdir( $directory ); - $this->directories[] = $directory; - - $lines = array( 'Plugin Name: Acme Requirements', "Text Domain: $this->slug" ); - foreach ( $headers as $name => $value ) { - $lines[] = "$name: $value"; - } - - $plugin_file = "$directory/$this->slug.php"; - \file_put_contents( $plugin_file, "write_plugin_file( + $root, + $this->slug, + array( + 'Plugin Name' => 'Acme Requirements', + 'Text Domain' => $this->slug, + ) + $headers + ); } protected function render_notices_as( string $role ): string { - wp_set_current_user( $this->make_user( "acme_requirements_$role", $role ) ); - - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return $this->render_all_admin_notices( $this->make_user( "acme_requirements_$role", $role ) ); } protected function print_notice_as( string $role, mixed ...$arguments ): string { diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php index 2c40d4a..18cf506 100644 --- a/tests/Integration/Bootstrap/UpdaterTest.php +++ b/tests/Integration/Bootstrap/UpdaterTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Bootstrap\Updater; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,11 +12,10 @@ #[CoversClass( Updater::class )] final class UpdaterTest extends TestCase { use IsolatesHooksTrait; + use WritesPluginFilesTrait; protected string $slug; - protected array $plugin_files = array(); - protected array $responses = array(); protected array $requests = array(); @@ -40,34 +40,22 @@ protected function tearDown(): void { $slug = \basename( $plugin_file, '.php' ); delete_transient( "{$slug}_github_release_stable" ); delete_transient( "{$slug}_github_release_prerelease" ); - wp_delete_file( $plugin_file ); - \rmdir( \dirname( $plugin_file ) ); } delete_site_transient( 'update_plugins' ); wp_clean_plugins_cache( false ); } + // A latest-release status of 0 leaves the request unanswered, and a null asset count keeps every asset. public static function unusable_releases(): array { return array( - 'failed-fetch' => array( static fn ( self $test ) => $test->stage( $test->api_url( 'releases/latest' ), 500, '' ) ), - 'unreachable-api' => array( static fn ( self $test ) => null ), - 'release-without-assets' => array( static fn ( self $test ) => $test->stage_latest( array( 'assets' => array() ) + $test->release( 'v2.0.0' ) ) ), - 'release-without-the-zip' => array( - static function ( self $test ): void { - $release = $test->release( 'v2.0.0' ); - $release['assets'] = \array_slice( $release['assets'], 0, 1 ); - $test->stage_latest( $release ); - }, - ), - 'unreadable-requirements' => array( - static function ( self $test ): void { - $test->stage_latest( $test->release( 'v2.0.0' ) ); - $test->stage( $test->raw_url( 'v2.0.0' ), 404, '404: Not Found' ); - }, - ), - 'tag-not-a-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'preview' ) ) ), - 'unflagged-prerelease' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.1.0-beta.1' ) ) ), - 'requirement-not-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.0.0' ), '', array( 'Requires PHP' => '8.1' ) ) ), + 'failed-fetch' => array( 500 ), + 'unreachable-api' => array( 0 ), + 'release-without-assets' => array( 200, 'v2.0.0', 0 ), + 'release-without-the-zip' => array( 200, 'v2.0.0', 1 ), + 'unreadable-requirements' => array( 200, 'v2.0.0', null, 404 ), + 'tag-not-a-version' => array( 200, 'preview' ), + 'unflagged-prerelease' => array( 200, 'v2.1.0-beta.1' ), + 'requirement-not-version' => array( 200, 'v2.0.0', null, 200, array( 'Requires PHP' => '8.1' ) ), ); } @@ -174,9 +162,18 @@ public function test_an_up_to_date_install_is_listed_without_an_update_and_its_r } #[DataProvider( 'unusable_releases' )] - public function test_an_unusable_release_offers_nothing_and_is_fetched_again_after_five_minutes( \Closure $stage ): void { + public function test_an_unusable_release_offers_nothing_and_is_fetched_again_after_five_minutes( int $latest_status, string $tag = '', ?int $assets = null, int $entry_status = 200, array $requirements = array() ): void { Updater::register( $this->write_plugin( $this->slug ) ); - $stage( $this ); + if ( 200 === $latest_status ) { + $release = $this->release( $tag ); + $release['assets'] = \array_slice( $release['assets'], 0, $assets ); + $this->stage_latest( $release, '', $requirements ); + } elseif ( 0 !== $latest_status ) { + $this->stage( $this->api_url( 'releases/latest' ), $latest_status, '' ); + } + if ( 200 !== $entry_status ) { + $this->stage( $this->raw_url( $tag ), $entry_status, "$entry_status: Not Found" ); + } $updates = $this->check_for_updates(); $requests = $this->requests; @@ -527,7 +524,7 @@ public function answer_request( mixed $preempt, array $args, string $url ): mixe return $this->responses[ $url ] ?? new \WP_Error( 'http_request_failed', "Nothing answers $url." ); } - public function release( string $tag, string $slug = '' ): array { + protected function release( string $tag, string $slug = '' ): array { $slug = '' === $slug ? $this->slug : $slug; return array( @@ -548,20 +545,20 @@ public function release( string $tag, string $slug = '' ): array { ); } - public function stage( string $url, int $code, string $body ): void { + protected function stage( string $url, int $code, string $body ): void { $this->responses[ $url ] = self::response( $code, $body ); } - public function stage_latest( array $release, string $slug = '', array $requirements = array() ): void { + protected function stage_latest( array $release, string $slug = '', array $requirements = array() ): void { $this->stage( $this->api_url( 'releases/latest', $slug ), 200, (string) wp_json_encode( $release ) ); $this->stage_entry_file( $release['tag_name'], $slug, $requirements ); } - public function api_url( string $path, string $slug = '' ): string { + protected function api_url( string $path, string $slug = '' ): string { return 'https://api.github.com/repos/acme/' . ( '' === $slug ? $this->slug : $slug ) . "/$path"; } - public function raw_url( string $tag, string $slug = '' ): string { + protected function raw_url( string $tag, string $slug = '' ): string { $slug = '' === $slug ? $this->slug : $slug; return "https://raw.githubusercontent.com/acme/$slug/$tag/$slug.php"; @@ -607,17 +604,7 @@ protected function write_plugin( string $slug, array $headers = array() ): strin 'Update URI' => "https://github.com/acme/$slug", ); - $lines = array(); - foreach ( \array_filter( $headers ) as $name => $value ) { - $lines[] = "$name: $value"; - } - - $plugin_file = WP_PLUGIN_DIR . "/$slug/$slug.php"; - wp_mkdir_p( \dirname( $plugin_file ) ); - \file_put_contents( $plugin_file, "plugin_files, true ) ) { - $this->plugin_files[] = $plugin_file; - } + $plugin_file = $this->write_plugin_file( WP_PLUGIN_DIR, $slug, $headers ); wp_clean_plugins_cache( false ); return $plugin_file; diff --git a/tests/Integration/Support/WritesPluginFilesTrait.php b/tests/Integration/Support/WritesPluginFilesTrait.php new file mode 100644 index 0000000..d1b0f73 --- /dev/null +++ b/tests/Integration/Support/WritesPluginFilesTrait.php @@ -0,0 +1,39 @@ + $value ) { + if ( '' !== $value ) { + $lines[] = " * $name: $value"; + } + } + + $plugin_file = "$root/$slug/$slug.php"; + wp_mkdir_p( "$root/$slug" ); + \file_put_contents( $plugin_file, "plugin_files, true ) ) { + $this->plugin_files[] = $plugin_file; + } + + return $plugin_file; + } + + #[After] + protected function remove_plugin_files(): void { + foreach ( $this->plugin_files as $plugin_file ) { + self::remove_tree( \dirname( $plugin_file ) ); + } + $this->plugin_files = array(); + } +} diff --git a/tests/Integration/WooCommerce/RequirementFixturesTrait.php b/tests/Integration/WooCommerce/RequirementFixturesTrait.php index 2eba97c..49f8950 100644 --- a/tests/Integration/WooCommerce/RequirementFixturesTrait.php +++ b/tests/Integration/WooCommerce/RequirementFixturesTrait.php @@ -3,40 +3,23 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\Before; trait RequirementFixturesTrait { use IsolatesHooksTrait; - - protected string $directory = ''; + use RendersAdminScreensTrait; + use WritesPluginFilesTrait; #[Before] protected function clear_admin_notices(): void { remove_all_actions( 'all_admin_notices' ); } - #[After] - protected function remove_plugin(): void { - wp_set_current_user( 0 ); - if ( '' !== $this->directory ) { - \array_map( 'unlink', \glob( "$this->directory/*" ) ); - \rmdir( $this->directory ); - } - } - // WordPress core reads a plugin's headers and dependencies from the plugins directory, so the fixture lives there. protected function write_plugin( array $headers ): string { - $slug = 'acme-shop-' . \bin2hex( \random_bytes( 3 ) ); - $this->directory = WP_PLUGIN_DIR . "/$slug"; - \mkdir( $this->directory ); - - $lines = array( ' 'Acme Shop' ) as $name => $value ) { - $lines[] = " * $name: $value"; - } - $lines[] = ' */'; - \file_put_contents( "$this->directory/$slug.php", \implode( "\n", $lines ) . "\n" ); + $plugin_file = $this->write_plugin_file( WP_PLUGIN_DIR, 'acme-shop-' . \bin2hex( \random_bytes( 3 ) ), $headers + array( 'Plugin Name' => 'Acme Shop' ) ); // WordPress reads every plugin's headers and dependencies once per request. wp_clean_plugins_cache( false ); @@ -49,14 +32,6 @@ static function (): void { \WP_Plugin_Dependencies::class )(); - return "$this->directory/$slug.php"; - } - - protected function render_notices_as( int $user_id ): string { - wp_set_current_user( $user_id ); - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return $plugin_file; } } diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php index 1a66888..634e1d5 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php @@ -27,7 +27,7 @@ public function test_woocommerce_meeting_the_floor_passes_and_nobody_sees_a_noti $headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor ); self::assertTrue( WooCommerceRequirement::check( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ) ); - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_activate_plugins_are_told_to_update_it(): void { @@ -40,10 +40,10 @@ public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_ac self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); - $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); + $notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); self::assertNotNull( $notice ); self::assertSame( 'Acme Shop needs WooCommerce 99.1 or later, so its features stay off until WooCommerce is updated.', \trim( $notice->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_editor', 'editor' ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_editor', 'editor' ) ) ); } public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): void { @@ -55,7 +55,7 @@ public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): voi ); WooCommerceRequirement::check( $plugin_file ); - $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); + $notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); // WordPress core strips tags from the plugin's name, while the header's version reaches the notice as written. self::assertNotNull( $notice ); @@ -78,7 +78,7 @@ static function ( string $translation ) use ( &$translations ): string { WooCommerceRequirement::check( $plugin_file ); self::assertSame( 0, $translations ); - $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); + $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); self::assertGreaterThan( 0, $translations ); } } diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php index 478a97e..9c808b6 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php @@ -28,7 +28,7 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate // On a network, only a super admin can activate plugins unless the network enables the plugins menu. $super_admin = $this->make_user( 'acme_requirement_super_admin', 'administrator' ); grant_super_admin( $super_admin ); - $html = $this->render_notices_as( $super_admin ); + $html = $this->render_all_admin_notices( $super_admin ); $error = validate_plugin_requirements( plugin_basename( $plugin_file ) ); revoke_super_admin( $super_admin ); @@ -42,6 +42,6 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate ) ); self::assertSame( wp_kses_post( $expected ), $html ); - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } } From e0461bb6076ed4e31a442f11187e82ca7a62d052 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:36 +0100 Subject: [PATCH 32/47] Run the tests' child processes through one proc_open helper Four test helpers copied the same proc_open boilerplate, and two Unit tests shelled out through exec with a command string; ConsumerA's uninstall test also built its product by hand. RunsProcessesTrait::run_process() runs an argv array without a shell and returns the exit code, output and errors, piping stdin only when given input; the admin requests, the PHP 7.4 site, both Unit tests and WP-CLI's uninstall use it, the uninstall test builds its product through ProductFixturesTrait, and RunsPhp74SiteTrait loses a comment that narrated its code. Assisted-by: Claude Code:claude-opus-5-5 --- .../ConsumerA/ConsumerAUninstallTest.php | 25 ++++------------ .../Support/RunsAdminRequestsTrait.php | 18 +++++------ tests/Php74/Support/RunsPhp74SiteTrait.php | 16 +++------- tests/Support/RunsProcessesTrait.php | 30 +++++++++++++++++++ tests/Unit/AutoloadTest.php | 12 ++++---- tests/Unit/Shared/WordPressIsolationTest.php | 11 ++++--- 6 files changed, 57 insertions(+), 55 deletions(-) create mode 100644 tests/Support/RunsProcessesTrait.php diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 9cde8a4..9e79fa4 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InstallsConsumersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -13,6 +14,7 @@ final class ConsumerAUninstallTest extends TestCase { use CreatesUsersTrait; use InstallsConsumersTrait; + use ProductFixturesTrait; protected const string DIR = 'acme-consumer-a-uninstall'; @@ -84,29 +86,17 @@ public function test_a_wp_cli_uninstall_that_cannot_run_the_engine_fails_and_kee $this->seed_footprint(); $this->install_consumer( 'consumer-a', self::DIR, $remove, $headers ); - $descriptors = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), - ); - $process = \proc_open( array( 'wp', 'plugin', 'uninstall', self::DIR ), $descriptors, $pipes, ABSPATH ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs WP-CLI's own command. - self::assertIsResource( $process ); - $output = \stream_get_contents( $pipes[1] ) . \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - $exit = \proc_close( $process ); + list( $exit_code, $output, $errors ) = self::run_process( array( 'wp', 'plugin', 'uninstall', self::DIR ), ABSPATH ); wp_cache_flush(); - self::assertNotSame( 0, $exit, (string) $output ); + self::assertNotSame( 0, $exit_code, $output . $errors ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); } #[After] - protected function delete_seeded_rows(): void { - if ( 0 !== $this->product_id ) { - wp_delete_post( $this->product_id, true ); - } + protected function delete_request_log(): void { if ( '' !== $this->log && \is_file( $this->log ) ) { \unlink( $this->log ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the request's log. } @@ -122,10 +112,7 @@ protected function seed_footprint(): void { update_user_option( $this->user_id, 'acme_ca_hidden_notices', array( 'seeded' ) ); update_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', array( 'seeded' ) ); - $product = wc_get_product_object( 'simple' ); - $product->set_name( 'Acme product' ); - $product->update_meta_data( '_acme_ca_packing_note', 'kept' ); - $this->product_id = $product->save(); + $this->product_id = $this->make_product( array( '_acme_ca_packing_note' => 'kept' ) )->get_id(); } protected function uninstall_on_the_web( string $plugin, array $missing, string $failing_query = '' ): array { diff --git a/tests/Integration/Support/RunsAdminRequestsTrait.php b/tests/Integration/Support/RunsAdminRequestsTrait.php index e5e8476..64c9293 100644 --- a/tests/Integration/Support/RunsAdminRequestsTrait.php +++ b/tests/Integration/Support/RunsAdminRequestsTrait.php @@ -2,9 +2,12 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\After; trait RunsAdminRequestsTrait { + use RunsProcessesTrait; + protected array $session_cookies = array(); protected array $session_tokens = array(); @@ -26,11 +29,10 @@ protected function admin_request( string $method, string $path, int $user_id, st '-dopcache.record_warnings=1', __DIR__ . '/admin-request.php', ); - $process = \proc_open( $command, array( array( 'pipe', 'r' ), array( 'pipe', 'w' ), array( 'pipe', 'w' ) ), $pipes ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs the request in its own process. - self::assertIsResource( $process ); - \fwrite( - $pipes[0], - \serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- The request process reads it. + + list( , $body, $stderr ) = self::run_process( + $command, + input: \serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- The request process reads it. array( 'method' => $method, 'host' => (string) \parse_url( home_url(), \PHP_URL_HOST ) . ( \is_int( \parse_url( home_url(), \PHP_URL_PORT ) ) ? ':' . \parse_url( home_url(), \PHP_URL_PORT ) : '' ), @@ -45,12 +47,6 @@ protected function admin_request( string $method, string $path, int $user_id, st ) ) ); - \fclose( $pipes[0] ); - $body = (string) \stream_get_contents( $pipes[1] ); - $stderr = (string) \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - \proc_close( $process ); $outcome = \unserialize( (string) \file_get_contents( $result ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- The request process wrote it. \unlink( $result ); diff --git a/tests/Php74/Support/RunsPhp74SiteTrait.php b/tests/Php74/Support/RunsPhp74SiteTrait.php index 9643177..dce57eb 100644 --- a/tests/Php74/Support/RunsPhp74SiteTrait.php +++ b/tests/Php74/Support/RunsPhp74SiteTrait.php @@ -3,17 +3,18 @@ namespace DeepWebSolutions\Framework\Tests\Php74\Support; use DeepWebSolutions\Framework\Tests\Support\CopiesFixturesTrait; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\After; trait RunsPhp74SiteTrait { use CopiesFixturesTrait; + use RunsProcessesTrait; protected const string ROOT = __DIR__ . '/../../..'; // .wp-env.php74.json points WP_PLUGIN_DIR here, inside the repository the environment maps. protected const string PLUGINS = self::ROOT . '/tests/.cache/php74-plugins'; - // Copies a built fixture plugin into the PHP 7.4 site's plugins directory and returns its plugin file. protected function install_on_php74( string $fixture, string $dir, array $headers = array() ): string { return "$dir/" . self::copy_fixture( $fixture, self::PLUGINS . "/$dir", array(), $headers ); } @@ -28,17 +29,8 @@ protected function php74_request( array $plugins ): array { } protected function wp_cli( string ...$args ): string { - $descriptors = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), - ); - $process = \proc_open( array( self::ROOT . '/node_modules/.bin/wp-env', '--config', '.wp-env.php74.json', 'run', 'cli', 'wp', ...$args ), $descriptors, $pipes, self::ROOT, array( 'CI' => '1' ) + \getenv() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs WP-CLI in the PHP 7.4 environment. - self::assertIsResource( $process ); - $output = (string) \stream_get_contents( $pipes[1] ); - $errors = (string) \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - self::assertSame( 0, \proc_close( $process ), "Start the PHP 7.4 environment with `composer test:integration:php74`.\n$errors$output" ); + list( $exit_code, $output, $errors ) = self::run_process( array( self::ROOT . '/node_modules/.bin/wp-env', '--config', '.wp-env.php74.json', 'run', 'cli', 'wp', ...$args ), self::ROOT, array( 'CI' => '1' ) + \getenv() ); + self::assertSame( 0, $exit_code, "Start the PHP 7.4 environment with `composer test:integration:php74`.\n$errors$output" ); return $output; } diff --git a/tests/Support/RunsProcessesTrait.php b/tests/Support/RunsProcessesTrait.php new file mode 100644 index 0000000..50427d9 --- /dev/null +++ b/tests/Support/RunsProcessesTrait.php @@ -0,0 +1,30 @@ + array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + if ( ! \is_null( $input ) ) { + $descriptors[0] = array( 'pipe', 'r' ); + } + + $process = \proc_open( $command, $descriptors, $pipes, $cwd, $env ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs the command directly, without a shell. + self::assertIsResource( $process ); + if ( ! \is_null( $input ) ) { + \fwrite( $pipes[0], $input ); + \fclose( $pipes[0] ); + } + + $output = (string) \stream_get_contents( $pipes[1] ); + $errors = (string) \stream_get_contents( $pipes[2] ); + \fclose( $pipes[1] ); + \fclose( $pipes[2] ); + + return array( \proc_close( $process ), $output, $errors ); + } +} diff --git a/tests/Unit/AutoloadTest.php b/tests/Unit/AutoloadTest.php index 18c389f..589d971 100644 --- a/tests/Unit/AutoloadTest.php +++ b/tests/Unit/AutoloadTest.php @@ -2,19 +2,17 @@ namespace DeepWebSolutions\Framework\Tests\Unit; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] final class AutoloadTest extends TestCase { - public function test_every_class_follows_the_psr_4_mapping_of_its_file(): void { - $command = \implode( - ' ', - \array_map( 'escapeshellarg', array( 'composer', 'dump-autoload', '--optimize', '--strict-psr', '--dry-run', '--no-interaction', '--working-dir', \dirname( __DIR__, 2 ) ) ) - ); + use RunsProcessesTrait; - \exec( "$command 2>&1", $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The test runs the real Composer. + public function test_every_class_follows_the_psr_4_mapping_of_its_file(): void { + list( $exit_code, $output, $errors ) = self::run_process( array( 'composer', 'dump-autoload', '--optimize', '--strict-psr', '--dry-run', '--no-interaction', '--working-dir', \dirname( __DIR__, 2 ) ) ); - self::assertSame( 0, $exit_code, \implode( "\n", $output ) ); + self::assertSame( 0, $exit_code, $output . $errors ); } } diff --git a/tests/Unit/Shared/WordPressIsolationTest.php b/tests/Unit/Shared/WordPressIsolationTest.php index 31a9d56..7a308f3 100644 --- a/tests/Unit/Shared/WordPressIsolationTest.php +++ b/tests/Unit/Shared/WordPressIsolationTest.php @@ -2,21 +2,20 @@ namespace DeepWebSolutions\Framework\Tests\Unit\Shared; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] final class WordPressIsolationTest extends TestCase { + use RunsProcessesTrait; + public function test_a_wordpress_call_in_shared_code_fails_the_shared_phpstan_config(): void { $root = \dirname( __DIR__, 3 ); $fixture = "$root/tests/Fixtures/Shared/wordpress-call.php"; - $command = \implode( - ' ', - \array_map( 'escapeshellarg', array( \PHP_BINARY, "$root/vendor/bin/phpstan", 'analyse', '--configuration', "$root/phpstan.shared.neon", '--error-format', 'json', '--no-progress', $fixture ) ) - ); - \exec( "$command 2>/dev/null", $output ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The test runs the real PHPStan. - $report = \json_decode( \implode( "\n", $output ), true, flags: \JSON_THROW_ON_ERROR ); + list( , $output ) = self::run_process( array( \PHP_BINARY, "$root/vendor/bin/phpstan", 'analyse', '--configuration', "$root/phpstan.shared.neon", '--error-format', 'json', '--no-progress', $fixture ) ); + $report = \json_decode( $output, true, flags: \JSON_THROW_ON_ERROR ); self::assertSame( array( 'function.notFound' ), \array_column( $report['files'][ $fixture ]['messages'] ?? array(), 'identifier' ) ); } From 9fff4bbb1dbea49d2a9a23613dcf565a6e80b8a5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:36 +0100 Subject: [PATCH 33/47] Check every fixture build's scoping in one data-driven test The consumers' and personas' scoping checks lived in three classes with redeclared fixture paths, and each consumer's boot test repeated a production-install row of its own. One provider covers consumer-a, consumer-b and the five personas: installed.json, the top level, the framework closure, the PSR set and the bootstrap loader. PersonasScopingTest and the two boot rows go, and the test reads CopiesFixturesTrait::FIXTURES. Assisted-by: Claude Code:claude-opus-5-5 --- .../ConsumerA/ConsumerABootTest.php | 24 ++-------- .../ConsumerB/ConsumerBBootTest.php | 10 ---- .../Consumers/ConsumersScopingTest.php | 45 +++++++++++------ .../Personas/PersonasScopingTest.php | 48 ------------------- 4 files changed, 36 insertions(+), 91 deletions(-) delete mode 100644 tests/Integration/Personas/PersonasScopingTest.php diff --git a/tests/Integration/ConsumerA/ConsumerABootTest.php b/tests/Integration/ConsumerA/ConsumerABootTest.php index fcc3d4a..81ba4e5 100644 --- a/tests/Integration/ConsumerA/ConsumerABootTest.php +++ b/tests/Integration/ConsumerA/ConsumerABootTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InstallsConsumersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -12,6 +13,7 @@ final class ConsumerABootTest extends TestCase { use CreatesUsersTrait; use InstallsConsumersTrait; + use ProductFixturesTrait; protected const string SCOPED = 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\'; @@ -35,26 +37,10 @@ public function test_a_gated_subtree_is_skipped_and_its_children_never_load(): v self::assertFalse( $request['records']['reports_page_class'] ); } - public function test_the_build_is_a_production_install_that_keeps_the_bootstrap_loader_at_its_contract_path(): void { - $installed = \json_decode( (string) \file_get_contents( self::FIXTURES . '/consumer-a/vendor/composer/installed.json' ), true ); - - self::assertFileExists( self::FIXTURES . '/consumer-a/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php' ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - } - public function test_the_product_edit_screen_shows_the_plugins_tab(): void { - $product = wc_get_product_object( 'simple' ); - $product->set_name( 'Acme product' ); - $product->save(); - $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); - - try { - $request = $this->consumer_request( 'GET', "post.php?post={$product->get_id()}&action=edit", $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); - } finally { - wp_delete_post( $product->get_id(), true ); - } + $product = $this->make_product(); + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $request = $this->consumer_request( 'GET', "post.php?post={$product->get_id()}&action=edit", $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); self::assertNull( $request['died'] ); self::assertStringContainsString( 'id="acme_ca_packing_product_data"', $request['body'] ); diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php index 7705c61..2729189 100644 --- a/tests/Integration/ConsumerB/ConsumerBBootTest.php +++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php @@ -23,14 +23,4 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r self::assertSame( 'booted', $request['records']['consumer_b']['status'] ); self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] ); } - - public function test_the_build_is_a_production_install_that_keeps_the_bootstrap_loader_at_its_contract_path(): void { - self::assertFileExists( self::FIXTURES . '/consumer-b/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php' ); - self::assertFileExists( self::FIXTURES . '/consumer-b/vendor/composer/installed.json' ); - - $installed = \json_decode( (string) \file_get_contents( self::FIXTURES . '/consumer-b/vendor/composer/installed.json' ), true ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - } } diff --git a/tests/Integration/Consumers/ConsumersScopingTest.php b/tests/Integration/Consumers/ConsumersScopingTest.php index 8624ad3..3af2204 100644 --- a/tests/Integration/Consumers/ConsumersScopingTest.php +++ b/tests/Integration/Consumers/ConsumersScopingTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Consumers; +use DeepWebSolutions\Framework\Tests\Support\CopiesFixturesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -9,21 +10,38 @@ #[CoversNothing] final class ConsumersScopingTest extends TestCase { - protected const string FIXTURES = __DIR__ . '/../../Fixtures'; + use CopiesFixturesTrait; + + public static function builds(): array { + $psr = array( 'clock', 'container', 'log', 'simple-cache' ); - public static function closures(): array { return array( - 'consumer-a' => array( 'consumer-a', array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ) ), - 'consumer-b' => array( 'consumer-b', array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ) ), + 'consumer-a' => array( 'consumer-a', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), + 'consumer-b' => array( 'consumer-b', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), + 'persona-bootstrap' => array( 'personas/bootstrap', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap' ), array() ), + 'persona-shared' => array( 'personas/shared', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-shared' ), array() ), + 'persona-core' => array( 'personas/core', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-shared' ), $psr ), + 'persona-settings' => array( 'personas/settings', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), + 'persona-woocommerce' => array( 'personas/woocommerce', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), ); } - #[DataProvider( 'closures' )] - public function test_the_scoped_build_holds_exactly_its_closure_s_framework_packages_and_psr_interfaces( string $fixture, array $packages ): void { - $scoped = self::FIXTURES . "/$fixture/vendor-prefixed"; + // PHP-DI's own dependencies change between releases, so the top level of a build that ships it stays unlisted. + #[DataProvider( 'builds' )] + public function test_the_production_build_ships_its_closure_scoped_and_nothing_unscoped( string $fixture, ?array $top_level, array $packages, array $psr ): void { + $build = self::FIXTURES . "/$fixture"; + self::assertFileExists( "$build/vendor/composer/installed.json", "Build $fixture first with `composer fixtures:build`." ); - self::assertSame( $packages, self::list_directories( "$scoped/ahegyes" ) ); - self::assertSame( array( 'clock', 'container', 'log', 'simple-cache' ), self::list_directories( "$scoped/psr" ) ); + $installed = \json_decode( (string) \file_get_contents( "$build/vendor/composer/installed.json" ), true ); + self::assertIsArray( $installed ); + self::assertFalse( $installed['dev'] ); + self::assertSame( array(), $installed['packages'] ); + if ( ! \is_null( $top_level ) ) { + self::assertSame( $top_level, self::list_entries( "$build/vendor-prefixed" ) ); + } + self::assertSame( $packages, self::list_entries( "$build/vendor-prefixed/ahegyes" ) ); + self::assertSame( $psr, self::list_entries( "$build/vendor-prefixed/psr" ) ); + self::assertSame( \in_array( 'wp-framework-bootstrap', $packages, true ), \is_file( "$build/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php" ) ); } #[RunInSeparateProcess] @@ -59,11 +77,10 @@ public function test_the_scoped_woocommerce_settings_classes_extend_woocommerce_ self::assertSame( 'WC_Settings_Page', \get_parent_class( $scoped . 'SettingsTab' ) ); } - protected static function list_directories( string $path ): array { - self::assertDirectoryExists( $path ); - $directories = \array_map( 'basename', (array) \glob( "$path/*", \GLOB_ONLYDIR ) ); - \sort( $directories ); + protected static function list_entries( string $path ): array { + $entries = \is_dir( $path ) ? \array_values( \array_diff( (array) \scandir( $path ), array( '.', '..' ) ) ) : array(); + \sort( $entries ); - return $directories; + return $entries; } } diff --git a/tests/Integration/Personas/PersonasScopingTest.php b/tests/Integration/Personas/PersonasScopingTest.php deleted file mode 100644 index 188ef5f..0000000 --- a/tests/Integration/Personas/PersonasScopingTest.php +++ /dev/null @@ -1,48 +0,0 @@ - array( 'bootstrap', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap' ), array() ), - 'shared' => array( 'shared', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-shared' ), array() ), - 'core' => array( 'core', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-shared' ), $psr ), - 'settings' => array( 'settings', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), - 'woocommerce' => array( 'woocommerce', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), - ); - } - - // PHP-DI's own dependencies change between releases, so the core persona's top level stays unlisted. - #[DataProvider( 'trees' )] - public function test_the_production_build_ships_its_closure_scoped_and_nothing_unscoped( string $persona, ?array $top_level, array $packages, array $psr ): void { - $build = self::PERSONAS . "/$persona"; - self::assertFileExists( "$build/vendor/composer/installed.json", "Build personas/$persona first with `composer fixtures:build`." ); - - $installed = \json_decode( (string) \file_get_contents( "$build/vendor/composer/installed.json" ), true ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - if ( null !== $top_level ) { - self::assertSame( $top_level, self::list_entries( "$build/vendor-prefixed" ) ); - } - self::assertSame( $packages, self::list_entries( "$build/vendor-prefixed/ahegyes" ) ); - self::assertSame( $psr, self::list_entries( "$build/vendor-prefixed/psr" ) ); - } - - protected static function list_entries( string $path ): array { - $entries = \is_dir( $path ) ? \array_values( \array_diff( (array) \scandir( $path ), array( '.', '..' ) ) ) : array(); - \sort( $entries ); - - return $entries; - } -} From 9e3fb433e4f396a64381f6e7fdd937d24686bde7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:37 +0100 Subject: [PATCH 34/47] Assert uninstall rows through closures, not method-name strings The uninstall providers named their assertion helpers as strings, which forced a dispatch helper and a single-use post_type_has_posts(), the two files named the consent constant two ways and scattered their providers, and the network test copied the switched-site restore. Each row carries a closure, the helper is inlined, both files use the prefix-derived constant with the providers together, the network test relies on CreatesSitesTrait's restore, and null checks use is_null(). Assisted-by: Claude Code:claude-opus-5-5 --- .../Core/Uninstall/UninstallFixturesTrait.php | 6 +- .../Uninstall/UninstallerOnNetworkTest.php | 19 +++--- .../Core/Uninstall/UninstallerTest.php | 58 +++++++++---------- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php index a15e355..210bfba 100644 --- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php +++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php @@ -295,14 +295,10 @@ protected function get_posts_of_type( string $type ): array { return $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE BINARY post_type = %s', $wpdb->posts, $type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counts every status, trash and auto-draft included. } - protected function post_type_has_posts( string $type ): bool { - return array() !== $this->get_posts_of_type( $type ); - } - protected function table_exists( string $name ): bool { global $wpdb; - return null !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . $name ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Schema read. + return ! \is_null( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . $name ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Schema read. } protected function role_has( string $role, string $capability ): bool { diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php index cfadcbf..07ace5d 100644 --- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php @@ -39,9 +39,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { - while ( ms_is_switched() ) { - restore_current_blog(); - } foreach ( get_sites( array( 'fields' => 'ids', @@ -52,6 +49,13 @@ protected function tearDown(): void { } } + public static function constant_consents(): array { + return array( + 'with-the-constant' => array( true ), + 'without-the-constant' => array( false ), + ); + } + public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans_its_subsite_and_unschedules_everywhere(): void { $this->seed_site( $this->main_site, null ); $this->seed_site( $this->other_site, 'yes' ); @@ -129,13 +133,6 @@ function () use ( $p ): void { self::assertSame( array( false, false, false, false ), $other ); } - public static function constant_consents(): array { - return array( - 'with-the-constant' => array( true ), - 'without-the-constant' => array( false ), - ); - } - #[DataProvider( 'constant_consents' )] public function test_the_network_pass_runs_only_on_the_consent_constant( bool $constant ): void { $this->seed_site( $this->main_site, 'yes' ); @@ -314,7 +311,7 @@ protected function seed_site( int $site_id, ?string $consent ): void { function () use ( $consent ): void { global $wpdb; - if ( null !== $consent ) { + if ( ! \is_null( $consent ) ) { update_option( "{$this->prefix}_remove_data", $consent ); } update_option( "{$this->prefix}_settings", 'x' ); diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index b946c75..670bb4e 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -54,6 +54,30 @@ static function ( string $prefix ): array { ); } + public static function retaining_and_consenting(): array { + return array( + 'retaining' => array( false ), + 'consenting' => array( true ), + ); + } + + public static function failing_statements(): array { + return array( + 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_settings" ) ), + 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_cache_a" ) ), + 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', static fn ( self $test ): bool => array() !== $test->get_posts_of_type( "{$test->prefix}_type" ) ), + 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', static fn ( self $test ): bool => $test->network_option_exists( "{$test->prefix}_network_settings" ) ), + 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_settings" ), '_cache_remove_data' ), + ); + } + + public static function refused_deletes(): array { + return array( + 'a-post' => array( 'pre_delete_post', '_type' ), + 'a-meta-row' => array( 'delete_post_metadata_by_mid', '_flag' ), + ); + } + #[DataProvider( 'retaining_consents' )] public function test_a_run_without_consent_keeps_the_footprint_and_runs_no_cleanup( \Closure $consent ): void { $this->seed_footprint(); @@ -324,18 +348,8 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_ self::assertTrue( as_has_scheduled_action( "{$this->prefix}_grouped" ) ); } - public static function failing_statements(): array { - return array( - 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ), - 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ), - 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ), - 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ), - 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings', '_cache_remove_data' ), - ); - } - #[DataProvider( 'failing_statements' )] - public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row, string $consent = '_remove_data' ): void { + public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, \Closure $kept, string $consent = '_remove_data' ): void { global $wpdb; $this->seed_footprint(); @@ -352,7 +366,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t self::assertInstanceOf( RuntimeException::class, $failure ); self::assertStringContainsString( $this->prefix . $subject, $failure->getMessage() ); - self::assertTrue( $this->$kept( $this->prefix . $row ) ); + self::assertTrue( $kept( $this ) ); self::assertFalse( $this->table_exists( "{$this->prefix}_log" ) ); self::assertSame( array( 'cleanup' ), \array_column( $this->calls, 0 ) ); self::assertTrue( $this->option_exists( $this->prefix . $consent ) ); @@ -361,17 +375,10 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t $this->restore_query_errors(); Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); - self::assertFalse( $this->$kept( $this->prefix . $row ) ); + self::assertFalse( $kept( $this ) ); self::assertFalse( $this->option_exists( $this->prefix . $consent ) ); } - public static function refused_deletes(): array { - return array( - 'a-post' => array( 'pre_delete_post', '_type' ), - 'a-meta-row' => array( 'delete_post_metadata_by_mid', '_flag' ), - ); - } - #[DataProvider( 'refused_deletes' )] public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_retry_completes( string $filter, string $subject ): void { $this->seed_footprint(); @@ -491,13 +498,6 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } - public static function retaining_and_consenting(): array { - return array( - 'retaining' => array( false ), - 'consenting' => array( true ), - ); - } - protected static function stored_consent( mixed $value, string $key = '' ): \Closure { return static function ( string $prefix ) use ( $value, $key ): array { update_option( "{$prefix}_remove_data", $value ); @@ -508,8 +508,8 @@ protected static function stored_consent( mixed $value, string $key = '' ): \Clo protected static function defined_consent( mixed $value ): \Closure { return static function ( string $prefix ) use ( $value ): array { - $constant = \strtoupper( "{$prefix}_remove_all_data_" . \bin2hex( \random_bytes( 2 ) ) ); - \define( $constant, $value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- A unique name per run, since a constant cannot be undefined. + $constant = \strtoupper( "{$prefix}_remove_all_data" ); + \define( $constant, $value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- A unique name per test, since a constant cannot be undefined. return array( 'consent' => array( 'constant' => $constant ) ); }; From c7909f51ea44c667c5c9e1084dbd88fadd63d80a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:37 +0100 Subject: [PATCH 35/47] Derive the package tests' list and stack walk from one source PackageManifestsTest and PackageReadmesTest each derived the package list and walked the stack their own way, and the manifest fixtures sat in the one kebab-case folder under the PascalCase Fixtures tree. ProvidesPackagesTrait holds the list and one closure() walk that both tests use, the source directories and autoloaded packages derive from OWNERS, and the folder becomes PackageManifests, with the layout line following. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- .../relative-name.php | 0 tests/Unit/PackageManifestsTest.php | 85 +++++-------------- tests/Unit/PackageReadmesTest.php | 35 ++------ tests/Unit/Support/ProvidesPackagesTrait.php | 51 +++++++++++ 5 files changed, 81 insertions(+), 92 deletions(-) rename tests/Fixtures/{package-manifests => PackageManifests}/relative-name.php (100%) create mode 100644 tests/Unit/Support/ProvidesPackagesTrait.php diff --git a/AGENTS.md b/AGENTS.md index a43483e..0cb309a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ wordpress-framework/ │ ├── ProductData/{ProductDataTab, ProductDataTabDefinitionInterface, ProductMeta} │ ├── Setting/{SettingsTab, SettingsSection, SectionFieldsDefinitionInterface, Fields (@internal)} │ └── Uninstall/MetaTypes -├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, package-manifests, consumer-a, consumer-b, personas, end-to-end}} +├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, PackageManifests, consumer-a, consumer-b, personas, end-to-end}} │ consumer-a: a WooCommerce plugin on all five packages, scoped through configs by its own composer.json │ consumer-b: a plugin on bootstrap, shared, core and settings, without WooCommerce, scoped the same way │ personas/{bootstrap, shared, core, settings, woocommerce}: one plugin per stack, shared a plain PHP project, each requiring only its top package, scoped the same way; each lists its stack's mirrors as `vcs` repositories after the path repository, the set its package README copies diff --git a/tests/Fixtures/package-manifests/relative-name.php b/tests/Fixtures/PackageManifests/relative-name.php similarity index 100% rename from tests/Fixtures/package-manifests/relative-name.php rename to tests/Fixtures/PackageManifests/relative-name.php diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index 2aa0b38..b77ace1 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -4,6 +4,7 @@ use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; +use DeepWebSolutions\Framework\Tests\Unit\Support\ProvidesPackagesTrait; use PhpParser\Node; use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; @@ -21,28 +22,7 @@ #[CoversNothing] final class PackageManifestsTest extends TestCase { - protected const array REQUIRES = array( - 'bootstrap' => array( 'php' => '>=7.4' ), - 'shared' => array( 'php' => '>=8.5' ), - 'core' => array( - 'ahegyes/wp-framework-bootstrap' => '^2.0', - 'ahegyes/wp-framework-shared' => '^2.0', - 'ext-filter' => '*', - 'php' => '>=8.5', - 'psr/clock' => '^1', - 'psr/container' => '^2.0.2', - 'psr/log' => '^3.0.2', - 'psr/simple-cache' => '^3', - ), - 'settings' => array( - 'ahegyes/wp-framework-core' => '^2.0', - 'php' => '>=8.5', - ), - 'woocommerce' => array( - 'ahegyes/wp-framework-settings' => '^2.0', - 'php' => '>=8.5', - ), - ); + use ProvidesPackagesTrait; protected const array EXTRAS = array( 'bootstrap' => array( @@ -80,19 +60,6 @@ final class PackageManifestsTest extends TestCase { 'Psr\\SimpleCache\\' => 'psr/simple-cache', ); - public static function packages(): array { - $packages = array(); - foreach ( self::REQUIRES as $package => $requires ) { - $packages[ $package ] = array( $package, $requires ); - } - - return $packages; - } - - public static function package_names(): array { - return \array_map( static fn ( array $package ): array => array( $package[0] ), self::packages() ); - } - public function test_the_monorepo_holds_exactly_the_mapped_packages(): void { $directories = \array_map( 'basename', \glob( self::packages_directory() . '/*', \GLOB_ONLYDIR ) ); @@ -155,13 +122,20 @@ public function test_the_bootstrap_package_is_never_autoloaded(): void { self::assertArrayNotHasKey( 'autoload', self::read_manifest( 'bootstrap' ) ); } + public static function package_namespaces(): array { + $namespaces = array(); + foreach ( self::OWNERS as $prefix => $owner ) { + if ( \str_starts_with( $owner, 'ahegyes/wp-framework-' ) ) { + $package = \substr( $owner, \strlen( 'ahegyes/wp-framework-' ) ); + $namespaces[ $package ] = array( $package, $prefix ); + } + } + + return $namespaces; + } + public static function autoloaded_packages(): array { - return array( - 'shared' => array( 'shared', 'DeepWebSolutions\\Framework\\Shared\\' ), - 'core' => array( 'core', 'DeepWebSolutions\\Framework\\Core\\' ), - 'settings' => array( 'settings', 'DeepWebSolutions\\Framework\\Settings\\' ), - 'woocommerce' => array( 'woocommerce', 'DeepWebSolutions\\Framework\\WooCommerce\\' ), - ); + return \array_diff_key( self::package_namespaces(), array( 'bootstrap' => true ) ); } // Composer includes a files entry on every request. @@ -172,7 +146,7 @@ public function test_an_autoloaded_package_maps_only_its_namespace_to_src( strin public function test_every_file_outside_the_shared_package_exits_without_wordpress_right_after_its_use_block(): void { $unguarded = array(); - foreach ( array( 'bootstrap', 'core', 'settings', 'woocommerce' ) as $package ) { + foreach ( \array_diff( \array_keys( self::REQUIRES ), array( 'shared' ) ) as $package ) { $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) ); foreach ( new \RegexIterator( $files, '#(?getPathname() ); @@ -199,20 +173,10 @@ public function test_every_package_directory_carries_an_index_stub(): void { self::assertSame( array(), $missing ); } - public static function source_directories(): array { - return array( - 'bootstrap' => array( 'bootstrap/src', 'DeepWebSolutions\\Framework\\Bootstrap\\' ), - 'shared' => array( 'shared/src', 'DeepWebSolutions\\Framework\\Shared\\' ), - 'core' => array( 'core/src', 'DeepWebSolutions\\Framework\\Core\\' ), - 'settings' => array( 'settings/src', 'DeepWebSolutions\\Framework\\Settings\\' ), - 'woocommerce' => array( 'woocommerce/src', 'DeepWebSolutions\\Framework\\WooCommerce\\' ), - ); - } - // Composer drops PSR-4 violations under vendor/ without a word, so a misplaced package class would autoload in no consumer. - #[DataProvider( 'source_directories' )] - public function test_every_package_class_sits_at_the_path_its_name_implies( string $directory, string $prefix ): void { - self::assertSame( array(), self::misplaced_classes( self::packages_directory() . "/$directory", $prefix ) ); + #[DataProvider( 'package_namespaces' )] + public function test_every_package_class_sits_at_the_path_its_name_implies( string $package, string $prefix ): void { + self::assertSame( array(), self::misplaced_classes( self::packages_directory() . "/$package/src", $prefix ) ); } public function test_a_class_away_from_the_path_its_name_implies_is_misplaced(): void { @@ -235,20 +199,11 @@ public function test_a_class_away_from_the_path_its_name_implies_is_misplaced(): } public function test_a_namespace_relative_name_resolves_against_its_namespace(): void { - $file = \dirname( __DIR__ ) . '/Fixtures/package-manifests/relative-name.php'; + $file = \dirname( __DIR__ ) . '/Fixtures/PackageManifests/relative-name.php'; self::assertContains( 'DeepWebSolutions\\Framework\\Settings\\AdminPage\\OptionsPage', self::referenced_names( $file ) ); } - protected static function closure( string $package ): array { - $closure = array( "ahegyes/wp-framework-$package" ); - foreach ( \array_keys( self::REQUIRES[ $package ] ) as $name ) { - $closure = \array_merge( $closure, \str_starts_with( $name, 'ahegyes/wp-framework-' ) ? self::closure( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) : array( $name ) ); - } - - return \array_values( \array_unique( $closure ) ); - } - protected static function misplaced_classes( string $directory, string $prefix ): array { $misplaced = array(); $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ) ); diff --git a/tests/Unit/PackageReadmesTest.php b/tests/Unit/PackageReadmesTest.php index e84b11a..061fc42 100644 --- a/tests/Unit/PackageReadmesTest.php +++ b/tests/Unit/PackageReadmesTest.php @@ -2,25 +2,18 @@ namespace DeepWebSolutions\Framework\Tests\Unit; +use DeepWebSolutions\Framework\Tests\Unit\Support\ProvidesPackagesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class PackageReadmesTest extends TestCase { - protected const string ROOT = __DIR__ . '/../..'; + use ProvidesPackagesTrait; - public static function packages(): array { - return array( - 'bootstrap' => array( 'bootstrap' ), - 'shared' => array( 'shared' ), - 'core' => array( 'core' ), - 'settings' => array( 'settings' ), - 'woocommerce' => array( 'woocommerce' ), - ); - } + protected const string ROOT = __DIR__ . '/../..'; - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_every_php_example_is_copied_from_the_package_s_persona( string $package ): void { $examples = self::fences( $package, 'php' ); self::assertNotEmpty( $examples, "The $package README shows no PHP example." ); @@ -40,7 +33,7 @@ public function test_every_php_example_is_copied_from_the_package_s_persona( str } } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_every_json_example_is_part_of_the_package_s_persona_manifest( string $package ): void { $examples = self::fences( $package, 'json' ); self::assertNotEmpty( $examples, "The $package README shows no JSON example." ); @@ -51,7 +44,7 @@ public function test_every_json_example_is_part_of_the_package_s_persona_manifes } } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_the_readme_lists_one_mirror_for_each_package_of_its_stack( string $package ): void { $mirrors = array(); foreach ( self::fences( $package, 'json' ) as $example ) { @@ -62,10 +55,11 @@ public function test_the_readme_lists_one_mirror_for_each_package_of_its_stack( } } - self::assertSame( \array_map( static fn ( string $name ): string => "https://github.com/ahegyes/wp-framework-$name", self::stack( $package ) ), $mirrors ); + $stack = \array_filter( self::closure( $package ), static fn ( string $name ): bool => \str_starts_with( $name, 'ahegyes/wp-framework-' ) ); + self::assertSame( \array_map( static fn ( string $name ): string => "https://github.com/$name", \array_values( $stack ) ), $mirrors ); } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_the_readme_states_the_contract_and_its_versioning_as_agents_md_does( string $package ): void { \preg_match( '/^## Contract\n\n(.*?)\n\n## /ms', (string) \file_get_contents( self::ROOT . '/AGENTS.md' ), $section ); $paragraphs = \explode( "\n\n", $section[1] ?? '' ); @@ -139,17 +133,6 @@ protected static function is_part_of( mixed $part, mixed $whole ): bool { return true; } - protected static function stack( string $package ): array { - $stack = array(); - foreach ( \array_keys( self::read_json( "packages/$package/composer.json" )['require'] ) as $name ) { - if ( \str_starts_with( $name, 'ahegyes/wp-framework-' ) ) { - $stack = \array_merge( $stack, self::stack( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) ); - } - } - - return \array_values( \array_unique( \array_merge( $stack, array( $package ) ) ) ); - } - protected static function read_json( string $path ): array { return \json_decode( (string) \file_get_contents( self::ROOT . "/$path" ), true, flags: \JSON_THROW_ON_ERROR ); } diff --git a/tests/Unit/Support/ProvidesPackagesTrait.php b/tests/Unit/Support/ProvidesPackagesTrait.php new file mode 100644 index 0000000..09d2924 --- /dev/null +++ b/tests/Unit/Support/ProvidesPackagesTrait.php @@ -0,0 +1,51 @@ + array( 'php' => '>=7.4' ), + 'shared' => array( 'php' => '>=8.5' ), + 'core' => array( + 'ahegyes/wp-framework-bootstrap' => '^2.0', + 'ahegyes/wp-framework-shared' => '^2.0', + 'ext-filter' => '*', + 'php' => '>=8.5', + 'psr/clock' => '^1', + 'psr/container' => '^2.0.2', + 'psr/log' => '^3.0.2', + 'psr/simple-cache' => '^3', + ), + 'settings' => array( + 'ahegyes/wp-framework-core' => '^2.0', + 'php' => '>=8.5', + ), + 'woocommerce' => array( + 'ahegyes/wp-framework-settings' => '^2.0', + 'php' => '>=8.5', + ), + ); + + public static function packages(): array { + $packages = array(); + foreach ( self::REQUIRES as $package => $requires ) { + $packages[ $package ] = array( $package, $requires ); + } + + return $packages; + } + + public static function package_names(): array { + return \array_map( static fn ( array $package ): array => array( $package[0] ), self::packages() ); + } + + // Lists every package below the given one before it, as the packages' READMEs list their mirrors. + protected static function closure( string $package ): array { + $closure = array(); + foreach ( \array_keys( self::REQUIRES[ $package ] ) as $name ) { + $closure = \array_merge( $closure, \str_starts_with( $name, 'ahegyes/wp-framework-' ) ? self::closure( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) : array( $name ) ); + } + + return \array_values( \array_unique( \array_merge( $closure, array( "ahegyes/wp-framework-$package" ) ) ) ); + } +} From 0f117d72ebb382cab190cabec1d9e22d969cb9d1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:01:37 +0100 Subject: [PATCH 36/47] Give ResultTest's predicate row its own two-column provider The predicate row took a payload argument it never read, only because it shared the three-column outcomes() provider. It reads a two-column results() provider sliced from outcomes(), since PHPUnit warns when a data set has more columns than the method takes. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Unit/Shared/Result/ResultTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Shared/Result/ResultTest.php b/tests/Unit/Shared/Result/ResultTest.php index b7402c1..1362af7 100644 --- a/tests/Unit/Shared/Result/ResultTest.php +++ b/tests/Unit/Shared/Result/ResultTest.php @@ -44,8 +44,12 @@ static function ( mixed $error ) use ( &$calls ): string { self::assertSame( array( array( $outcome, $payload ) ), $calls ); } - #[DataProvider( 'outcomes' )] - public function test_the_predicates_agree_with_the_outcome( AbstractResult $result, string $outcome, mixed $payload ): void { + public static function results(): array { + return \array_map( static fn ( array $outcome ): array => \array_slice( $outcome, 0, 2 ), self::outcomes() ); + } + + #[DataProvider( 'results' )] + public function test_the_predicates_agree_with_the_outcome( AbstractResult $result, string $outcome ): void { self::assertSame( 'success' === $outcome, $result->is_success() ); self::assertSame( 'failure' === $outcome, $result->is_failure() ); } From 5a8477c0cbe10a65381fa132616c5a8765278d46 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:02:06 +0100 Subject: [PATCH 37/47] Load the bootstrap classes from a global file The bootstrap package's load.php declared the package's namespace, though it defines nothing and only requires the two by-path classes, so the declaration served no symbol and made the loader read as a class file. load.php is a global procedural file with the global guard, and the guard row expects the unqualified form in a file without a namespace. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/load.php | 4 +--- tests/Unit/PackageManifestsTest.php | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/bootstrap/load.php b/packages/bootstrap/load.php index 1aae420..358e497 100644 --- a/packages/bootstrap/load.php +++ b/packages/bootstrap/load.php @@ -8,9 +8,7 @@ * @package DeepWebSolutions\Framework\Bootstrap */ -namespace DeepWebSolutions\Framework\Bootstrap; - -\defined( 'ABSPATH' ) || exit; +defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/src/Requirements.php'; require_once __DIR__ . '/src/Updater.php'; diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index b77ace1..c2ba0cd 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -153,7 +153,8 @@ public function test_every_file_outside_the_shared_package_exits_without_wordpre $nodes = ( new ParserFactory() )->createForHostVersion()->parse( $code ); $namespace = ( new NodeFinder() )->findFirstInstanceOf( $nodes, Namespace_::class ); $statement = \current( \array_filter( $namespace?->stmts ?? $nodes, static fn ( Node $node ): bool => ! $node instanceof Declare_ && ! $node instanceof Use_ && ! $node instanceof GroupUse ) ); - if ( false === $statement || "\\defined( 'ABSPATH' ) || exit;" !== \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) ) { + $guard = \is_null( $namespace ) ? "defined( 'ABSPATH' ) || exit;" : "\\defined( 'ABSPATH' ) || exit;"; + if ( false === $statement || \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) !== $guard ) { $unguarded[] = $file->getPathname(); } } From 359fbef0e484fb88ccbaefa448cae3014352091b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:03:39 +0100 Subject: [PATCH 38/47] Call methods on new objects in PHP 8.4's bare form everywhere The settings tests took PHP 8.4's bare new Foo()->bar() form under X ruling 255, but OrderMetaBox and 42 sites across the core, WooCommerce and Unit tests still wrapped the new expression in parentheses, so the codebase dereferenced new objects two ways. Every such site outside the PHP 7.4 files drops the parentheses; the style guide names the form. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/EditScreen/Hosts/OrderMetaBox.php | 2 +- tests/Fixtures/WooCommerce/acme-product-data.php | 2 +- .../Core/Caching/TransientCacheTest.php | 2 +- .../Core/Logging/ErrorLogLoggerTest.php | 16 ++++++++-------- .../Core/Logging/GuardedLoggerTest.php | 6 +++--- .../Core/Permission/CapabilityGrantsTest.php | 2 +- .../Core/Scheduling/SchedulerTest.php | 2 +- tests/Integration/Core/Storage/LockTest.php | 6 +++--- .../ReportsActionSchedulerVersionsTrait.php | 2 +- .../EditScreen/Hosts/OrderMetaBoxTest.php | 4 ++-- .../EditScreen/Stores/OrderMetaStoreTest.php | 10 +++++----- .../WooCommerce/Setting/SettingsTest.php | 6 +++--- tests/Unit/Core/AdminNotice/NoticeTest.php | 2 +- tests/Unit/Core/Clock/SystemClockTest.php | 2 +- tests/Unit/Core/Logging/ErrorLogLoggerTest.php | 2 +- tests/Unit/Core/PluginKernelTest.php | 4 ++-- .../Backends/ActionSchedulerBackendTest.php | 2 +- tests/Unit/PackageManifestsTest.php | 14 +++++++------- 18 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php index 4a24c32..bd2fb33 100644 --- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php +++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php @@ -101,7 +101,7 @@ public function get_default_store(): ObjectStoreInterface { */ #[\Override] public function render_rows( string $title, array $rows ): void { - ( new PostMetaBox( array() ) )->render_rows( $title, $rows ); + new PostMetaBox( array() )->render_rows( $title, $rows ); } // endregion diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php index 13f00e7..0f701db 100644 --- a/tests/Fixtures/WooCommerce/acme-product-data.php +++ b/tests/Fixtures/WooCommerce/acme-product-data.php @@ -42,7 +42,7 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array { } ); - ( new ProductDataTab( $spec['id'], $definition ) )->register_hooks(); + new ProductDataTab( $spec['id'], $definition )->register_hooks(); } ); diff --git a/tests/Integration/Core/Caching/TransientCacheTest.php b/tests/Integration/Core/Caching/TransientCacheTest.php index 31fa120..ab90311 100644 --- a/tests/Integration/Core/Caching/TransientCacheTest.php +++ b/tests/Integration/Core/Caching/TransientCacheTest.php @@ -130,7 +130,7 @@ public function test_an_entry_expires_after_its_lifetime( null|int|\DateInterval $this->cache()->set( 'key', 'Value.', $ttl ); $after = \time(); - $seconds ??= ( new \DateTimeImmutable( "@$before" ) )->add( $ttl )->getTimestamp() - $before; + $seconds ??= new \DateTimeImmutable( "@$before" )->add( $ttl )->getTimestamp() - $before; self::assertGreaterThanOrEqual( $before + $seconds, $this->timeout( 'key' ) ); self::assertLessThanOrEqual( $after + $seconds, $this->timeout( 'key' ) ); } diff --git a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php index 13c192e..44a18df 100644 --- a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php @@ -49,7 +49,7 @@ public static function levels(): array { public function test_every_level_reaches_the_error_log_under_wp_debug( string $level ): void { self::assertTrue( WP_DEBUG ); - ( new ErrorLogLogger( 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertStringContainsString( "[acme.$level] Cache acme is cold.", (string) \file_get_contents( $this->log_file ) ); } @@ -66,14 +66,14 @@ static function ( mixed ...$args ) use ( &$received ): void { 2 ); - ( new ErrorLogLogger( 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertSame( array( array( 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $received ); } #[DataProvider( 'log_messages' )] public function test_a_record_interpolates_only_printable_context_values( string $message, array $context, string $expected ): void { - ( new ErrorLogLogger( 'acme' ) )->error( $message, $context ); + new ErrorLogLogger( 'acme' )->error( $message, $context ); self::assertStringContainsString( "[acme.error] $expected" . \PHP_EOL, (string) \file_get_contents( $this->log_file ) ); } @@ -90,7 +90,7 @@ public function __toString(): string { } }; - ( new ErrorLogLogger( 'acme' ) )->error( + new ErrorLogLogger( 'acme' )->error( 'Hello {name}.', array( 'name' => 'world', @@ -105,7 +105,7 @@ public function __toString(): string { public function test_an_exception_in_the_context_is_logged_with_its_class_location_and_trace(): void { $exception = new \RuntimeException( 'Cache is cold.' ); - ( new ErrorLogLogger( 'acme' ) )->error( + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed: {message}', array( 'message' => $exception->getMessage(), @@ -122,13 +122,13 @@ public function test_an_exception_in_the_context_is_logged_with_its_class_locati public function test_a_previous_exception_is_logged_with_the_exception(): void { $exception = new \RuntimeException( 'Warm-up failed.', 0, new \LogicException( 'Cache is cold.' ) ); - ( new ErrorLogLogger( 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => $exception ) ); + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => $exception ) ); self::assertStringContainsString( 'LogicException: Cache is cold. in ' . __FILE__, (string) \file_get_contents( $this->log_file ) ); } public function test_an_exception_context_value_that_is_not_a_throwable_is_ignored(): void { - ( new ErrorLogLogger( 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) ); + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) ); self::assertStringNotContainsString( 'Cache is cold.', (string) \file_get_contents( $this->log_file ) ); } @@ -137,6 +137,6 @@ public function test_a_level_psr_3_does_not_define_is_rejected(): void { $this->expectException( PsrInvalidArgumentException::class ); $this->expectExceptionMessage( "Log level 'verbose' is not a PSR-3 level." ); - ( new ErrorLogLogger( 'acme' ) )->log( 'verbose', 'Cache is cold.' ); + new ErrorLogLogger( 'acme' )->log( 'verbose', 'Cache is cold.' ); } } diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php index 7965dba..7b5ee4d 100644 --- a/tests/Integration/Core/Logging/GuardedLoggerTest.php +++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php @@ -31,7 +31,7 @@ protected function tearDown(): void { public function test_a_record_passes_to_the_wrapped_logger_unchanged_and_skips_the_error_log(): void { $inner = new RecordingLogger(); - ( new GuardedLogger( $inner, 'acme' ) )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new GuardedLogger( $inner, 'acme' )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertSame( array( array( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $inner->records ); self::assertSame( '', \file_get_contents( $this->log_file ) ); @@ -41,7 +41,7 @@ public function test_a_record_whose_logger_throws_reaches_the_error_log_raw_with $exception = new \RuntimeException( 'Cache is cold.' ); $failure = new \UnexpectedValueException( 'The log is not writable.' ); - ( new GuardedLogger( new ThrowingLogger( $failure ), 'acme' ) )->error( + new GuardedLogger( new ThrowingLogger( $failure ), 'acme' )->error( 'Warm-up of {name} failed.', array( 'name' => 'acme', @@ -58,7 +58,7 @@ public function test_a_level_psr_3_does_not_define_throws_before_the_wrapped_log $inner = new RecordingLogger(); try { - ( new GuardedLogger( $inner, 'acme' ) )->log( 'verbose', 'Cache is cold.' ); + new GuardedLogger( $inner, 'acme' )->log( 'verbose', 'Cache is cold.' ); self::fail( 'The level was accepted.' ); } catch ( PsrInvalidArgumentException $exception ) { self::assertSame( "Log level 'verbose' is not a PSR-3 level.", $exception->getMessage() ); diff --git a/tests/Integration/Core/Permission/CapabilityGrantsTest.php b/tests/Integration/Core/Permission/CapabilityGrantsTest.php index 8fd841d..e878461 100644 --- a/tests/Integration/Core/Permission/CapabilityGrantsTest.php +++ b/tests/Integration/Core/Permission/CapabilityGrantsTest.php @@ -109,7 +109,7 @@ public function test_the_first_run_grants_each_declared_pair_to_its_role_and_led $this->stored_caps( $editor ) ); self::assertSame( array( $this->cap( 'a' ) => true ), $this->stored_caps( $author ) ); - self::assertTrue( ( new \WP_User( $user ) )->has_cap( $this->cap( 'a' ) ) ); + self::assertTrue( new \WP_User( $user )->has_cap( $this->cap( 'a' ) ) ); self::assertEquals( $declared, $this->ledger() ); } diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php index 7bd59c1..7caf58a 100644 --- a/tests/Integration/Core/Scheduling/SchedulerTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerTest.php @@ -109,7 +109,7 @@ public function test_an_invalid_group_is_rejected( string $group ): void { public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { $this->expectException( InvalidArgumentException::class ); - ( new Scheduler( 'acme' ) )->unschedule_hook( '' ); + new Scheduler( 'acme' )->unschedule_hook( '' ); } public static function schedulers(): array { diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php index 94bdefb..d5ca1ff 100644 --- a/tests/Integration/Core/Storage/LockTest.php +++ b/tests/Integration/Core/Storage/LockTest.php @@ -53,7 +53,7 @@ public function test_a_hold_past_its_ttl_is_taken_over(): void { } public function test_expiry_is_judged_by_the_ttl_the_row_holds(): void { - ( new Lock( "{$this->prefix}_lock", 600, $this->clock ) )->acquire( 'first' ); + new Lock( "{$this->prefix}_lock", 600, $this->clock )->acquire( 'first' ); $this->clock->advance( 61 ); self::assertSame( LockClaim::Busy, $this->make_lock()->acquire( 'second' ) ); @@ -219,7 +219,7 @@ public function test_a_release_that_races_a_takeover_leaves_the_new_hold(): void public function test_without_a_clock_the_lock_reads_the_system_time(): void { $before = \time(); - ( new Lock( "{$this->prefix}_lock", 60 ) )->acquire( 'first' ); + new Lock( "{$this->prefix}_lock", 60 )->acquire( 'first' ); $time = $this->stored()['time'] ?? 0; self::assertGreaterThanOrEqual( $before, $time ); @@ -231,7 +231,7 @@ public static function invalid_calls(): array { 'zero-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', 0 ) ), 'negative-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', -60 ) ), 'empty-name' => array( static fn (): mixed => new Lock( '', 60 ) ), - 'empty-owner' => array( static fn (): mixed => ( new Lock( 'acme_lock', 60 ) )->acquire( '' ) ), + 'empty-owner' => array( static fn (): mixed => new Lock( 'acme_lock', 60 )->acquire( '' ) ), ); } diff --git a/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php b/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php index 7d63157..79305af 100644 --- a/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php +++ b/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php @@ -17,7 +17,7 @@ protected function report_action_scheduler_versions( array $versions ): void { #[After] protected function restore_action_scheduler_versions(): void { if ( ! \is_null( $this->registered_versions ) ) { - ( new \ReflectionProperty( \ActionScheduler_Versions::class, 'versions' ) )->setValue( \ActionScheduler_Versions::instance(), $this->registered_versions ); + new \ReflectionProperty( \ActionScheduler_Versions::class, 'versions' )->setValue( \ActionScheduler_Versions::instance(), $this->registered_versions ); $this->registered_versions = null; } } diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php index 6311c16..af32e73 100644 --- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php @@ -55,7 +55,7 @@ public static function silent_and_worded_rejections(): array { public function test_the_box_is_added_on_add_meta_boxes(): void { $host = new OrderMetaBox(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) ); } @@ -202,7 +202,7 @@ public function test_per_object_fields_receive_the_order_and_contributed_fields_ public function test_the_box_saves_before_woocommerces_order_data(): void { $host = new OrderMetaBox(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 35, has_action( 'woocommerce_process_shop_order_meta', array( $host, 'save_meta_box' ) ) ); } diff --git a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php index a7a332d..97fc7ed 100644 --- a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php @@ -28,7 +28,7 @@ public function test_only_the_keys_the_order_has_are_returned_with_their_stored_ ) ); - $values = ( new OrderMetaStore() )->get_values( $order->get_id(), array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); + $values = new OrderMetaStore()->get_values( $order->get_id(), array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); self::assertSame( array( @@ -44,7 +44,7 @@ public function test_the_stored_value_is_read_past_woocommerces_view_filter(): v $order = $this->make_order( array( "_{$this->prefix}_text" => 'stored' ) ); add_filter( "woocommerce_order_get__{$this->prefix}_text", static fn (): string => 'filtered' ); - self::assertSame( array( "_{$this->prefix}_text" => 'stored' ), ( new OrderMetaStore() )->get_values( $order->get_id(), array( "_{$this->prefix}_text" ) ) ); + self::assertSame( array( "_{$this->prefix}_text" => 'stored' ), new OrderMetaStore()->get_values( $order->get_id(), array( "_{$this->prefix}_text" ) ) ); } #[DataProvider( 'order_storages' )] @@ -52,7 +52,7 @@ public function test_values_are_stored_as_given_text_backslashes_included( strin $this->use_order_storage( $storage ); $order = $this->make_order( array( "_{$this->prefix}_text" => 'old' ) ); - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_text" => 'O\'Brien C:\\dir', @@ -81,7 +81,7 @@ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws $order = $this->make_order(); try { - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_first" => 'Written first.', @@ -158,7 +158,7 @@ function ( int $order_id ) use ( &$seen ): void { } ); - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_a" => 'one', diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 5eaecb9..7b1f1af 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -109,8 +109,8 @@ public function test_an_id_woocommerce_would_rewrite_is_rejected_as_a_tab_id_a_s #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_and_section_id( string $id ): void { - self::assertSame( $id, ( new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ) )->get_id() ); - self::assertSame( $id, ( new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() ) )->get_id() ); + self::assertSame( $id, new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() )->get_id() ); + self::assertSame( $id, new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() )->get_id() ); } public function test_a_section_builds_its_fields_only_when_woocommerce_asks_for_them(): void { @@ -620,7 +620,7 @@ protected function rest( string $method, string $route, array $params = array() add_action( 'rest_api_init', static function (): void { - ( new \WC_REST_Setting_Options_Controller() )->register_routes(); + new \WC_REST_Setting_Options_Controller()->register_routes(); } ); $GLOBALS['wp_rest_server'] = null; diff --git a/tests/Unit/Core/AdminNotice/NoticeTest.php b/tests/Unit/Core/AdminNotice/NoticeTest.php index 03e004e..bf5621b 100644 --- a/tests/Unit/Core/AdminNotice/NoticeTest.php +++ b/tests/Unit/Core/AdminNotice/NoticeTest.php @@ -98,7 +98,7 @@ public function test_absent_optional_fields_take_their_defaults_and_unknown_keys } public static function rejected_rows(): array { - $row = ( new Notice( 'a', 'Hi', scope: NoticeScope::Site, capability: 'manage_options' ) )->to_array(); + $row = new Notice( 'a', 'Hi', scope: NoticeScope::Site, capability: 'manage_options' )->to_array(); return array( 'version-2' => array( array( 'v' => 2 ) + $row ), diff --git a/tests/Unit/Core/Clock/SystemClockTest.php b/tests/Unit/Core/Clock/SystemClockTest.php index ac54149..ff1fbf5 100644 --- a/tests/Unit/Core/Clock/SystemClockTest.php +++ b/tests/Unit/Core/Clock/SystemClockTest.php @@ -10,7 +10,7 @@ final class SystemClockTest extends TestCase { public function test_now_is_the_current_time(): void { $before = \microtime( true ); - $now = ( new SystemClock() )->now(); + $now = new SystemClock()->now(); $after = \microtime( true ); self::assertGreaterThanOrEqual( $before, (float) $now->format( 'U.u' ) ); diff --git a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php index a5aa451..d91f99a 100644 --- a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php @@ -14,7 +14,7 @@ public function test_without_wp_debug_a_record_below_warning_is_dropped(): void $previous = \ini_set( 'error_log', $log_file ); try { - ( new ErrorLogLogger( 'acme' ) )->info( 'Cache is cold.' ); + new ErrorLogLogger( 'acme' )->info( 'Cache is cold.' ); $log = \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php index 0a092fc..929430c 100644 --- a/tests/Unit/Core/PluginKernelTest.php +++ b/tests/Unit/Core/PluginKernelTest.php @@ -48,7 +48,7 @@ public function register_hooks(): void { } protected function record( string $event ): void { - $this->journal->entries[] = "$event " . ( new \ReflectionClass( $this ) )->getShortName(); + $this->journal->entries[] = "$event " . new \ReflectionClass( $this )->getShortName(); } } @@ -208,7 +208,7 @@ public function test_a_gated_out_child_is_skipped_while_its_siblings_load(): voi public function test_a_class_that_implements_only_the_conditional_interface_boots_as_a_component(): void { $journal = new Journal(); - ( new PluginKernel( new FakeContainer( $journal ) ) )->boot( array( ConditionalOnly::class ) ); + new PluginKernel( new FakeContainer( $journal ) )->boot( array( ConditionalOnly::class ) ); self::assertSame( array( 'register ConditionalOnly' ), $journal->entries ); } diff --git a/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php b/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php index 26da0f0..6823fa6 100644 --- a/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php +++ b/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php @@ -10,6 +10,6 @@ #[CoversClass( ActionSchedulerBackend::class )] final class ActionSchedulerBackendTest extends TestCase { public function test_action_scheduler_is_absent_when_its_functions_are_not_loaded(): void { - self::assertSame( BackendState::Absent, ( new ActionSchedulerBackend( 'acme' ) )->get_state() ); + self::assertSame( BackendState::Absent, new ActionSchedulerBackend( 'acme' )->get_state() ); } } diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index c2ba0cd..b1a27a5 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -150,8 +150,8 @@ public function test_every_file_outside_the_shared_package_exits_without_wordpre $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) ); foreach ( new \RegexIterator( $files, '#(?getPathname() ); - $nodes = ( new ParserFactory() )->createForHostVersion()->parse( $code ); - $namespace = ( new NodeFinder() )->findFirstInstanceOf( $nodes, Namespace_::class ); + $nodes = new ParserFactory()->createForHostVersion()->parse( $code ); + $namespace = new NodeFinder()->findFirstInstanceOf( $nodes, Namespace_::class ); $statement = \current( \array_filter( $namespace?->stmts ?? $nodes, static fn ( Node $node ): bool => ! $node instanceof Declare_ && ! $node instanceof Use_ && ! $node instanceof GroupUse ) ); $guard = \is_null( $namespace ) ? "defined( 'ABSPATH' ) || exit;" : "\\defined( 'ABSPATH' ) || exit;"; if ( false === $statement || \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) !== $guard ) { @@ -211,11 +211,11 @@ protected static function misplaced_classes( string $directory, string $prefix ) foreach ( new \RegexIterator( $files, '/\.php$/' ) as $file ) { $path = \substr( $file->getPathname(), \strlen( $directory ) + 1 ); $expected = $prefix . \str_replace( '/', '\\', \substr( $path, 0, -4 ) ); - $nodes = ( new ParserFactory() )->createForHostVersion()->parse( \file_get_contents( $file->getPathname() ) ); + $nodes = new ParserFactory()->createForHostVersion()->parse( \file_get_contents( $file->getPathname() ) ); // A file without a namespace declaration holds its classes in the global namespace. - $namespaces = ( new NodeFinder() )->findInstanceOf( $nodes, Namespace_::class ) ?: array( new Namespace_( null, $nodes ) ); + $namespaces = new NodeFinder()->findInstanceOf( $nodes, Namespace_::class ) ?: array( new Namespace_( null, $nodes ) ); foreach ( $namespaces as $namespace ) { - foreach ( ( new NodeFinder() )->findInstanceOf( $namespace->stmts, ClassLike::class ) as $class ) { + foreach ( new NodeFinder()->findInstanceOf( $namespace->stmts, ClassLike::class ) as $class ) { $name = \ltrim( $namespace->name?->toString() . '\\' . $class->name?->toString(), '\\' ); if ( ! \is_null( $class->name ) && $expected !== $name ) { $misplaced[] = "$path: $name"; @@ -242,9 +242,9 @@ protected static function read_manifest( string $package ): array { protected static function referenced_names( string $file ): array { // Names built from strings at runtime and types named only in docblocks are not seen. - $nodes = ( new NodeTraverser( new NameResolver() ) )->traverse( ( new ParserFactory() )->createForHostVersion()->parse( \file_get_contents( $file ) ) ); + $nodes = new NodeTraverser( new NameResolver() )->traverse( new ParserFactory()->createForHostVersion()->parse( \file_get_contents( $file ) ) ); - return \array_map( static fn ( Name $name ): string => $name->toString(), ( new NodeFinder() )->findInstanceOf( $nodes, Name::class ) ); + return \array_map( static fn ( Name $name ): string => $name->toString(), new NodeFinder()->findInstanceOf( $nodes, Name::class ) ); } protected static function is_foreign( string $name, array $allowed ): bool { From 2e1737dcb482e973f22185f1ae82a37f0ba12666 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:09:02 +0100 Subject: [PATCH 39/47] Separate each closing brace from the statement after it The owner's grouping rule puts a blank line after each closing brace before the next statement at its depth, but eleven spots this branch touched ran a statement straight on after a loop or conditional: two package lines whose neighbors changed and nine in the moved and new test helpers. Each gains the blank line; only whitespace changes. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/AdminPage/OptionsPage.php | 1 + packages/settings/src/EditScreen/Hosts/PostMetaBox.php | 1 + tests/Fixtures/WooCommerce/acme-product-data.php | 1 + tests/Integration/Bootstrap/UpdaterTest.php | 1 + tests/Integration/Consumers/ConsumersScopingTest.php | 1 + tests/Integration/Support/DispatchesScheduledHooksTrait.php | 2 ++ tests/Integration/Support/IsolatesStorageTrait.php | 1 + tests/Integration/Support/ProductFixturesTrait.php | 1 + tests/Integration/Support/WritesPluginFilesTrait.php | 1 + .../Integration/WooCommerce/ProductData/ProductDataTabTest.php | 1 + 10 files changed, 11 insertions(+) diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index a9e364b..3a55504 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -193,6 +193,7 @@ public function render_page(): void { if ( isset( $row['label_for'] ) ) { // Core prints the label through esc_attr(), which keeps an entity the control's id encodes. $row['label_for'] = Forms::escape_attribute( $row['label_for'] ); } + add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php index d065f37..a7a90f5 100644 --- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php +++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php @@ -73,6 +73,7 @@ public function register_hooks( ObjectForm $form ): void { if ( \in_array( 'attachment', $this->post_types, true ) ) { add_action( 'edit_attachment', array( $this, 'save_attachment' ) ); // WordPress saves an attachment without firing save_post. } + add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); // After core's own refreshes at 10, which create the replace maps this adds to. } diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php index 0f701db..1cfbdab 100644 --- a/tests/Fixtures/WooCommerce/acme-product-data.php +++ b/tests/Fixtures/WooCommerce/acme-product-data.php @@ -26,6 +26,7 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array { if ( $group['per_product'] ?? false ) { $GLOBALS['acme_outcome']['records'][] = array( 'fields', $product->get_id() ); } + $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $group['fields'] ) ) + array_intersect_key( $group, array( 'class' => true ) ); } diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php index 18cf506..b1128fa 100644 --- a/tests/Integration/Bootstrap/UpdaterTest.php +++ b/tests/Integration/Bootstrap/UpdaterTest.php @@ -171,6 +171,7 @@ public function test_an_unusable_release_offers_nothing_and_is_fetched_again_aft } elseif ( 0 !== $latest_status ) { $this->stage( $this->api_url( 'releases/latest' ), $latest_status, '' ); } + if ( 200 !== $entry_status ) { $this->stage( $this->raw_url( $tag ), $entry_status, "$entry_status: Not Found" ); } diff --git a/tests/Integration/Consumers/ConsumersScopingTest.php b/tests/Integration/Consumers/ConsumersScopingTest.php index 3af2204..17f7a75 100644 --- a/tests/Integration/Consumers/ConsumersScopingTest.php +++ b/tests/Integration/Consumers/ConsumersScopingTest.php @@ -39,6 +39,7 @@ public function test_the_production_build_ships_its_closure_scoped_and_nothing_u if ( ! \is_null( $top_level ) ) { self::assertSame( $top_level, self::list_entries( "$build/vendor-prefixed" ) ); } + self::assertSame( $packages, self::list_entries( "$build/vendor-prefixed/ahegyes" ) ); self::assertSame( $psr, self::list_entries( "$build/vendor-prefixed/psr" ) ); self::assertSame( \in_array( 'wp-framework-bootstrap', $packages, true ), \is_file( "$build/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php" ) ); diff --git a/tests/Integration/Support/DispatchesScheduledHooksTrait.php b/tests/Integration/Support/DispatchesScheduledHooksTrait.php index 9a5792c..b3e8139 100644 --- a/tests/Integration/Support/DispatchesScheduledHooksTrait.php +++ b/tests/Integration/Support/DispatchesScheduledHooksTrait.php @@ -10,6 +10,7 @@ protected static function run_due_actions( string $hook, ?int $at = null ): int foreach ( $claim->get_actions() as $action_id ) { \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); } + $store->release_claim( $claim ); return \count( $claim->get_actions() ); @@ -27,6 +28,7 @@ protected static function run_due_cron_events( string $hook, ?int $at = null ): if ( false !== $event['schedule'] ) { $rescheduled[] = wp_reschedule_event( $timestamp, $event['schedule'], $hook, $event['args'], true ); } + wp_unschedule_event( $timestamp, $hook, $event['args'], true ); do_action_ref_array( $hook, $event['args'] ); } diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php index f38c71b..7721952 100644 --- a/tests/Integration/Support/IsolatesStorageTrait.php +++ b/tests/Integration/Support/IsolatesStorageTrait.php @@ -26,6 +26,7 @@ protected static function delete_prefixed_options( string $prefix ): void { foreach ( is_multisite() ? get_sites( array( 'fields' => 'ids' ) ) : array( 1 ) as $site_id ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $wpdb->esc_like( $prefix ) . '%', $wpdb->esc_like( "_transient_$prefix" ) . '%', $wpdb->esc_like( "_transient_timeout_$prefix" ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->usermeta, '%' . $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. if ( is_multisite() ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->sitemeta, $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. diff --git a/tests/Integration/Support/ProductFixturesTrait.php b/tests/Integration/Support/ProductFixturesTrait.php index 39c0ed1..c991c99 100644 --- a/tests/Integration/Support/ProductFixturesTrait.php +++ b/tests/Integration/Support/ProductFixturesTrait.php @@ -13,6 +13,7 @@ protected function make_product( array $meta = array(), string $type = 'simple' foreach ( $meta as $key => $value ) { $product->update_meta_data( $key, $value ); } + $product->save(); $this->product_ids[] = $product->get_id(); diff --git a/tests/Integration/Support/WritesPluginFilesTrait.php b/tests/Integration/Support/WritesPluginFilesTrait.php index d1b0f73..e8c15e0 100644 --- a/tests/Integration/Support/WritesPluginFilesTrait.php +++ b/tests/Integration/Support/WritesPluginFilesTrait.php @@ -34,6 +34,7 @@ protected function remove_plugin_files(): void { foreach ( $this->plugin_files as $plugin_file ) { self::remove_tree( \dirname( $plugin_file ) ); } + $this->plugin_files = array(); } } diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index f97de03..c284af9 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -290,6 +290,7 @@ protected function assert_each_choice_is_described( ?\Dom\Element $fieldset, str foreach ( $choices as $choice ) { self::assertSame( $help_id, $choice->getAttribute( 'aria-describedby' ) ); } + self::assertSame( $help_id, $fieldset?->querySelector( '.description' )?->getAttribute( 'id' ) ); } From e64190fe9e916b24948b702550dc985dbc0c90df Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:19:05 +0100 Subject: [PATCH 40/47] Point the WooCommerce row labels at controls whose id holds an entity X172's A41 and X340 print every settings host's row label through Forms::escape_attribute(), as Forms prints a control's id, but the two WooCommerce hosts that print their own rows, a settings tab's custom row and the product panel's render-callable row, still printed the label's for through esc_attr(). esc_attr() keeps an entity a field id holds, so the label named an id no control carried, and clicking it or a screen reader found nothing. Both rows print the label through Forms::escape_attribute(), and render_control()'s docblock says a render callable prints its id the same way, as the request fixtures' callable does. A row on each screen asserts the label's for equals the control's id for an entity id. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/Forms.php | 2 +- .../src/ProductData/ProductDataTab.php | 2 +- packages/woocommerce/src/Setting/Fields.php | 2 +- .../Support/RecordedDefinition.php | 3 ++- .../ProductData/ProductDataTabTest.php | 10 +++++++++ .../WooCommerce/Setting/SettingsTest.php | 21 +++++++++++++++++++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 59e8702..32b6c63 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -69,7 +69,7 @@ protected function __construct() {} /** * Returns the escaped markup of the field's control, without its row label. * - * A group, as is_group() tells, renders in a fieldset its title names, or in its host's fieldset when it has no title. A field with a render callable renders through it, and the callable escapes, names and describes its control and wraps a group the same way. Unless the field is disabled, the callable also prints a hidden empty input before its control, so that a field absent from a submission reads as unrendered or disabled. + * A group, as is_group() tells, renders in a fieldset its title names, or in its host's fieldset when it has no title. A field with a render callable renders through it, and the callable escapes, names and describes its control, prints its id through escape_attribute() as the row label does, and wraps a group the same way. Unless the field is disabled, the callable also prints a hidden empty input before its control, so that a field absent from a submission reads as unrendered or disabled. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index 1190e19..147c816 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -252,7 +252,7 @@ protected static function render_field( array $field, \WC_Product $product ): vo 'desc' => $field['description'] ?? '', ) + $field; $group = Forms::is_group( $field ); - echo $group ? '
' . wp_kses_post( $field['label'] ) . '' : '

'; + echo $group ? '

' . wp_kses_post( $field['label'] ) . '' : '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Forms::escape_attribute() escapes the id as the control's id is. echo Forms::render_control( $control, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control, and a render callable its own. echo $group ? '

' : '

'; return; diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 87333bc..56cd946 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -181,7 +181,7 @@ public function render_row( array $resolved ): void { echo ''; // A group's legend names it, so its row heading is plain text. - echo Forms::is_group( $field ) ? esc_html( $field['title'] ) : ''; + echo Forms::is_group( $field ) ? esc_html( $field['title'] ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Forms::escape_attribute() escapes the id as the control's id is. echo ''; echo Forms::render_control( $field, $resolved['value'] ?? '', $name, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control. echo ''; diff --git a/tests/Integration/Support/RecordedDefinition.php b/tests/Integration/Support/RecordedDefinition.php index 4f657fe..e1726d5 100644 --- a/tests/Integration/Support/RecordedDefinition.php +++ b/tests/Integration/Support/RecordedDefinition.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Settings\AdminPage\OptionsPageDefinitionInterface; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormDefinitionInterface; +use DeepWebSolutions\Framework\Settings\Forms; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTabDefinitionInterface; use DeepWebSolutions\Framework\WooCommerce\Setting\SectionFieldsDefinitionInterface; @@ -42,7 +43,7 @@ public static function with_behaviors( array $fields ): array { $field['render'] = static function ( array $field, mixed $value, string $name, string $id ) use ( $field_id ): string { $GLOBALS['acme_outcome']['records'][] = array( 'render', $field_id, $value, $name, $id ); - return ''; + return ''; }; } diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index c284af9..76f73d5 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -206,6 +206,16 @@ public function test_a_custom_type_field_renders_its_control_in_woocommerces_row self::assertSame( array( array( 'render', "_{$this->prefix}_widget", 'stored', "_{$this->prefix}_widget", "_{$this->prefix}_widget" ) ), $request['records'] ); } + public function test_a_custom_type_field_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $product = $this->make_product(); + $spec = $this->spec( array( $this->group( array( $this->field( 'acme_widget', 'r&d', array( 'render' => 'acme:render' ) ) ) ) ) ); + + $row = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] )->querySelector( '#woocommerce-product-data input.acme-widget' )?->closest( 'p' ); + + self::assertSame( "_{$this->prefix}_r&d", $row?->querySelector( 'input.acme-widget' )?->getAttribute( 'id' ) ); + self::assertSame( "_{$this->prefix}_r&d", $row?->querySelector( 'label' )?->getAttribute( 'for' ) ); + } + public static function group_types(): array { return array( 'radio' => array( 'radio' ), diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 7b1f1af..9a80110 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -461,6 +461,27 @@ public function test_a_custom_type_field_renders_in_woocommerces_row_through_its self::assertSame( array( array( 'render', "{$this->prefix}_widget", 'stored', "{$this->prefix}_widget", "{$this->prefix}_widget" ) ), $opened['records'] ); } + public function test_a_custom_type_field_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $fields = array( + array( + 'type' => 'title', + 'id' => "{$this->prefix}_options", + 'title' => 'Options', + ), + $this->field( 'widget', 'r&d', array( 'render' => 'acme:render' ) ), + array( + 'type' => 'sectionend', + 'id' => "{$this->prefix}_options", + ), + ); + $spec = $this->tab_spec( array( '' => self::section( 'General', $fields ) ) ); + + $row = self::document( $this->open( $spec )['body'] )->querySelector( 'form#mainform input.acme-widget' )?->closest( 'tr' ); + + self::assertSame( "{$this->prefix}_r&d", $row?->querySelector( 'input.acme-widget' )?->getAttribute( 'id' ) ); + self::assertSame( "{$this->prefix}_r&d", $row?->querySelector( 'th.titledesc label' )?->getAttribute( 'for' ) ); + } + public function test_a_custom_type_field_saves_through_its_validate(): void { $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->custom_field( array( 'validate' => 'acme:append' ) ) ) ) ) ); From 389a77bd9267431e8ae9d1eb1be3e273ef8fc89e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:21:14 +0100 Subject: [PATCH 41/47] Correct the docblocks and docs the reviews found inaccurate The review of this branch found statements that misdescribe the code. Fields::sanitize_option() said a field the form did not post is never saved, though WooCommerce posts nothing for a cleared checkbox or multiselect, which is stored cleared. AdminNotices::select() said a scope whose read or filters throw is left out, though the notices it selected before the throw stay. Uninstaller::run() promised the next uninstall completes the pass, which a repeated failure does not. The woocommerce README said WordPress knows the order tables only while WooCommerce is loaded, though MetaTypes names them so the uninstall needs no WooCommerce. AGENTS.md named only the namespaced ABSPATH guard, which load.php, a global file, does not use. And the runner's comment ran two sentences, and a fixture docblock used a semicolon. Each states what the code does, AGENTS.md names the global form, and the comment and docblock read as single plain sentences. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/src/AdminNotice/AdminNotices.php | 2 +- packages/core/src/Migration/MigrationRunner.php | 2 +- packages/core/src/Uninstall/Uninstaller.php | 2 +- packages/woocommerce/README.md | 2 +- packages/woocommerce/src/Setting/Fields.php | 2 +- tests/Fixtures/Settings/acme-object-forms.php | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cb309a..6bcf4e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships ## Style -- Every PHP file opens with `get_hook() ) && ( $this->steps[ $cached ] ?? null ) instanceof BatchedMigrationStepInterface ) { $this->schedule_continuation(); return; diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index f649c5d..b2c8341 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -71,7 +71,7 @@ protected function __construct( /** * Removes the plugin's footprint from every site that consents, and unschedules its jobs on every site. * - * Failures are thrown once every site was visited, and a site that failed keeps its consent row, so WordPress keeps the plugin's files and the next uninstall completes the pass. + * Failures are thrown once every site was visited, and a site that failed keeps its consent row, so WordPress keeps the plugin's files and the next uninstall retries the pass. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index 8433001..34883bc 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -188,7 +188,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD } ``` -The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, and passes `MetaTypes::DEFINITIONS`, so that a footprint can name the `order` and `order_item` meta kinds, whose tables WordPress knows only while WooCommerce is loaded: +The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, and passes `MetaTypes::DEFINITIONS`, so that a footprint can name the `order` and `order_item` meta kinds, whose WooCommerce tables the definitions name, so the uninstall finds them while WooCommerce is inactive: ```php Uninstaller::run( diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 56cd946..4380498 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -121,7 +121,7 @@ public function claim( array $settings, string $section_id ): array { /** * Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged. * - * A disabled field and a field the form did not post are never saved. + * A disabled field is never saved, and a field the form did not post keeps its value, but for a checkbox or multiselect, which WooCommerce's form posts nothing for when cleared, so it is stored cleared. * * @since 2.0.0 * @version 2.0.0 diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php index 6afdd5b..781a428 100644 --- a/tests/Fixtures/Settings/acme-object-forms.php +++ b/tests/Fixtures/Settings/acme-object-forms.php @@ -2,7 +2,7 @@ /** * Adds the request spec's object form on plugins_loaded. * - * The form's id also prefixes the admin notices that report its rejections. The spec names the form's host (post, user, term or order) with its arguments, and the form's title, capability and fields; the definition records each evaluation. A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. With per_object, the definition also records the object id it receives. The spec may also contribute fields through the form's fields filter, keep posts in the classic editor, keep orders in the posts table, record what an order status change sees under a meta key, give the form a recording store and update the saved post again inside save_post. + * The form's id also prefixes the admin notices that report its rejections. The spec names the form's host (post, user, term or order) with its arguments, and the form's title, capability and fields, and the definition records each evaluation. A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. With per_object, the definition also records the object id it receives. The spec may also contribute fields through the form's fields filter, keep posts in the classic editor, keep orders in the posts table, record what an order status change sees under a meta key, give the form a recording store and update the saved post again inside save_post. * * @package Acme */ From ff1d2efada15d530f4ac77654d525eeb11e87fa8 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:21:14 +0100 Subject: [PATCH 42/47] Speak a delivered notice's paragraphs and list items apart X172's A40 sends the block editor a plain-text copy of each notice for wp.a11y.speak(), but wp_strip_all_tags() joins block elements with nothing between them, so a message of two paragraphs or a list was read aloud as 'Saved.Check:BoxWeight', its words run together. A space goes before each block element's tag before the tags are stripped, and the copy's whitespace collapses, so the reader pauses between paragraphs and items while inline markup reads as before. A row delivers paragraphs and a list. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/src/AdminNotice/AdminNotices.php | 7 +++++-- .../Core/AdminNotice/AdminNoticesBlockEditorTest.php | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 04b3896..fb1f7f8 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -470,12 +470,15 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP public function rest_deliver(): array { $delivered = array(); foreach ( $this->select( true ) as $notice ) { - $content = wp_kses_post( $this->get_message( $notice ) ); + $content = wp_kses_post( $this->get_message( $notice ) ); + + // A space before each block element keeps a screen reader from running paragraphs and list items together. + $spaced = (string) \preg_replace( '#<(?=/?(?:p|br|div|li|ul|ol|h[1-6]|blockquote|pre|t[rdh]|d[ldt]|hr)\b)#i', ' <', $content ); $delivered[] = array( 'id' => $this->get_dom_id( $notice ), 'status' => $notice->type->value, 'content' => $content, - 'spoken' => \html_entity_decode( wp_strip_all_tags( $content ), \ENT_QUOTES | \ENT_HTML5, 'UTF-8' ), + 'spoken' => \html_entity_decode( wp_strip_all_tags( $spaced, true ), \ENT_QUOTES | \ENT_HTML5, 'UTF-8' ), 'isDismissible' => $notice->dismissible, ); $this->acknowledge( $notice ); diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index 44f35a9..821c4df 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -90,6 +90,13 @@ public function test_a_delivered_message_carries_its_plain_text_for_screen_reade self::assertSame( 'Saved & filed. Read the “guide”.', $this->deliver()->get_data()[0]['spoken'] ); } + public function test_a_delivered_message_s_paragraphs_and_list_items_are_spoken_apart(): void { + $this->notices()->add( $this->notice( 'saved', message: '

Saved.

Check:

  • Box
  • Weight
' ), $this->admin ); + wp_set_current_user( $this->admin ); + + self::assertSame( 'Saved. Check: Box Weight', $this->deliver()->get_data()[0]['spoken'] ); + } + public static function other_screens(): array { return array( 'site-editor' => array( 'site-editor', true ), From a80ba0eff1ee8ad55f0f9a7965d3c74783fb2d33 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:21:14 +0100 Subject: [PATCH 43/47] Ask WooCommerce's logging settings only about errors X177's A102 named WooCommerceLogger's error-log condition $dropped but computed it before the severity test, so every info and debug record that passed the threshold asked WooCommerce's logging settings three questions whose answer only an error uses. The method returns after WooCommerce's log for anything below error, then names the condition, so only errors read the settings again. Assisted-by: Claude Code:claude-opus-5-5 --- packages/woocommerce/src/Logging/WooCommerceLogger.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index 8bf26a1..0637843 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -103,8 +103,12 @@ public function log( mixed $level, string|\Stringable $message, array $context = $this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null; $this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded ); + if ( $severity < self::SEVERITIES[ PsrLogLevel::ERROR ] ) { + return; + } + $dropped = ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity; - if ( $severity >= self::SEVERITIES[ PsrLogLevel::ERROR ] && $dropped ) { + if ( $dropped ) { \error_log( self::format_record( $this->source, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them. } } From 91e9222e7db7a05fb0cef6985bcc2a91c2897e2a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:21:15 +0100 Subject: [PATCH 44/47] Register the core persona's scheduler once and declare one interface The core persona, which core's README quotes, lists Scheduler::class first among its components, so the kernel registers the scheduler's hooks, and its composition also called register_hooks() itself. The template and consumer-a make that early call before their WooCommerce check, so a blocked plugin keeps its WP-Cron schedules, but the persona has no such check, so the README taught a redundant second registration. consumer-a's FaultInjector also implemented ComponentInterface beside ConditionalComponentInterface, which extends it under X178's A103. The persona's composition registers only the migration runner before the kernel, with the README excerpt and its sentence following, and FaultInjector implements ConditionalComponentInterface alone. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/README.md | 3 +-- tests/Fixtures/consumer-a/src/FaultInjector.php | 3 +-- tests/Fixtures/personas/core/src/Plugin.php | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index ee0ad4e..ceb47b3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -112,7 +112,7 @@ function acme_pc_boot(): void { } ``` -One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments; the composition registers the scheduler and the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container and registers its hooks: +One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks: ```php use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; @@ -153,7 +153,6 @@ final class Plugin { // ... protected function compose(): bool { - $this->scheduler->register_hooks(); $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); diff --git a/tests/Fixtures/consumer-a/src/FaultInjector.php b/tests/Fixtures/consumer-a/src/FaultInjector.php index 92b17fc..6eb58e1 100644 --- a/tests/Fixtures/consumer-a/src/FaultInjector.php +++ b/tests/Fixtures/consumer-a/src/FaultInjector.php @@ -2,7 +2,6 @@ namespace Acme\ConsumerA; -use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ConditionalComponentInterface; \defined( 'ABSPATH' ) || exit; @@ -13,7 +12,7 @@ * @since 1.0.0 * @version 1.0.0 */ -final class FaultInjector implements ComponentInterface, ConditionalComponentInterface { +final class FaultInjector implements ConditionalComponentInterface { // region MAGIC METHODS /** diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index 1471d51..a2cf80e 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -144,7 +144,7 @@ public function boot(): void { // region HELPERS /** - * Registers the scheduler and the migrations, then builds the kernel and boots the plugin's components. + * Registers the migrations, then builds the kernel and boots the plugin's components, the scheduler first. * * @since 1.0.0 * @version 1.0.0 @@ -152,7 +152,6 @@ public function boot(): void { * @return bool */ protected function compose(): bool { - $this->scheduler->register_hooks(); $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); From 8bba4c8dd8f5fb80bb935433c1cbab4c5b7266c6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:26:51 +0100 Subject: [PATCH 45/47] Print plain help as a paragraph WordPress styles as a description X172's A42 put every help text in a div, so that help holding paragraphs or lists stays inside the element the control names. But WordPress's admin CSS styles only p.description and span.description, so every Forms-rendered help text, on options pages, object forms and WooCommerce's custom rows, lost its grey, its 13px size and its spacing below the control. As X344 rules, a help text prints as

unless it holds a block element, and only then as

, each keeping its id. The help rows assert the paragraph for plain and inline-marked help and the div for block help. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/Forms.php | 8 +++++--- tests/Integration/Settings/FormsRenderControlTest.php | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 32b6c63..66dee1d 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -522,7 +522,7 @@ protected static function render_legend( array $field ): string { /** * Returns the markup of the help texts. * - * Each text sits in a div, because help holding paragraphs or lists would end a paragraph before them and leave the element the control names empty. + * A text sits in a paragraph, which WordPress's admin styles as a description, unless it holds a block element, which would end the paragraph before it and leave the element the control names empty, so such a text sits in a div. * * @since 2.0.0 * @version 2.0.0 @@ -534,12 +534,14 @@ protected static function render_legend( array $field ): string { protected static function render_help( array $help ): string { $html = ''; foreach ( $help as $help_id => $text ) { - $html .= ' 'description', 'id' => $help_id, ) - ) . '>' . wp_kses_post( $text ) . '
'; + ) . ">$text"; } return $html; diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index 6725010..b934e12 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -83,11 +83,11 @@ public function test_each_input_type_renders_as_that_html_input_type( string $ty self::assertSame( $type, self::render( $type )->getElementById( 'acme.key' )->getAttribute( 'type' ) ); } - public function test_help_text_is_linked_from_the_control_by_aria_describedby(): void { - $document = self::render( 'text', array( 'desc' => 'Shown below.' ) ); + public function test_help_text_is_linked_from_the_control_by_aria_describedby_and_styled_as_a_description(): void { + $document = self::render( 'text', array( 'desc' => 'Shown below.' ) ); self::assertSame( 'acme.key.description', $document->getElementById( 'acme.key' )->getAttribute( 'aria-describedby' ) ); - self::assertTrue( $document->getElementById( 'acme.key.description' )->matches( '.description' ) ); + self::assertTrue( $document->getElementById( 'acme.key.description' )->matches( 'p.description' ) ); self::assertSame( 'Shown below.', self::text( $document->getElementById( 'acme.key.description' ) ) ); } @@ -95,6 +95,7 @@ public function test_help_holding_paragraphs_and_lists_stays_inside_the_element_ $document = self::render( 'text', array( 'desc' => '

First.

  • Second.
' ) ); $help = $document->getElementById( 'acme.key.description' ); + self::assertTrue( $help?->matches( 'div.description' ) ); self::assertNotNull( $help?->querySelector( 'p' ) ); self::assertNotNull( $help?->querySelector( 'ul > li' ) ); self::assertSame( 'First.Second.', self::text( $help ) ); From b392faacb52fe8721e4695a49df099c7e4011182 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:32:38 +0100 Subject: [PATCH 46/47] Keep help in a div for every element that ends a paragraph X344 prints help as a paragraph unless it holds a block element, but the check listed only some of the elements that end a paragraph, so help holding a fieldset, main, hgroup, menu or search element, all of which wp_kses_post() keeps, sat in a paragraph the parser closed before that element, and the element the control names was empty. The check lists every element HTML closes a paragraph for, and a row per element Codex reproduced keeps the help inside the described div. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/src/Forms.php | 4 ++-- .../Settings/FormsRenderControlTest.php | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 66dee1d..49dd4af 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -522,7 +522,7 @@ protected static function render_legend( array $field ): string { /** * Returns the markup of the help texts. * - * A text sits in a paragraph, which WordPress's admin styles as a description, unless it holds a block element, which would end the paragraph before it and leave the element the control names empty, so such a text sits in a div. + * A text sits in a paragraph, which WordPress's admin styles as a description, unless it holds an element that ends a paragraph, which would end it before that element and leave the element the control names empty, so such a text sits in a div. * * @since 2.0.0 * @version 2.0.0 @@ -535,7 +535,7 @@ protected static function render_help( array $help ): string { $html = ''; foreach ( $help as $help_id => $text ) { $text = wp_kses_post( $text ); - $tag = 1 === \preg_match( '#<(?:address|article|aside|blockquote|details|div|dl|figure|footer|h[1-6]|header|hr|nav|ol|p|pre|section|table|ul)\b#i', $text ) ? 'div' : 'p'; + $tag = 1 === \preg_match( '#<(?:address|article|aside|blockquote|details|dialog|div|dl|fieldset|figcaption|figure|footer|form|h[1-6]|header|hgroup|hr|main|menu|nav|ol|p|pre|search|section|table|ul)\b#i', $text ) ? 'div' : 'p'; $html .= "<$tag" . self::render_attributes( array( 'class' => 'description', diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index b934e12..009da9d 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -101,6 +101,24 @@ public function test_help_holding_paragraphs_and_lists_stays_inside_the_element_ self::assertSame( 'First.Second.', self::text( $help ) ); } + public static function paragraph_closing_help(): array { + return array( + 'fieldset' => array( '
Read this first.
' ), + 'hgroup' => array( '
Read this first.
' ), + 'main' => array( '
Read this first.
' ), + 'menu' => array( '
  • Read this first.
  • ' ), + 'search' => array( 'Read this first.' ), + ); + } + + #[DataProvider( 'paragraph_closing_help' )] + public function test_help_holding_an_element_that_ends_a_paragraph_stays_inside_the_element_the_control_names( string $desc ): void { + $help = self::render( 'text', array( 'desc' => $desc ) )->getElementById( 'acme.key.description' ); + + self::assertTrue( $help?->matches( 'div.description' ) ); + self::assertSame( 'Read this first.', self::text( $help ) ); + } + public static function non_input_controls(): array { return array( 'textarea' => array( 'textarea', array(), 'textarea' ), From f89f6c1d4e728adad47c09299626988db98223b3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:42:08 +0100 Subject: [PATCH 47/47] Name the angle-bracketed text the post editor does not speak The post editor reads a delivered notice aloud through wp.a11y.speak() from a plain-text copy whose entities are decoded, and speak() replaces anything shaped like a tag with a space, so a message holding a literal angle-bracketed placeholder, such as <TOKEN>, was read without it while the editor showed it. No limitation said so. As X345 rules, limitation 7 names the gap, and the delivery keeps wp.a11y.speak(). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6bcf4e2..20bdf64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. -7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. +7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. It reads each notice aloud through `wp.a11y.speak()`, which drops text between angle brackets, so a literal angle-bracketed placeholder in a message goes unspoken. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. 8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted, and the current user sees a grant or revocation from the next request. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked. 9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key. 10. The framework's queries name tables through `%i`, so a `wpdb` replacement whose `prepare()` predates WordPress 6.2 breaks them. A `Lock` lives in the current site's options table, and a hold is judged by the TTL its holder wrote. A holder learns of a takeover only at its next `refresh()`, so data the lock protects is written with a compare-and-swap, such as `OptionRows::replace()`, where a stale write matters.