From ae49711317ceff0cbe64375a4f058f1b1d75b629 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:54:46 +0100 Subject: [PATCH 01/12] Update packages Refresh the root and fixture Composer locks and the npm lock. The Composer locks take phpunit/phpunit 13.4.7 and roave/security-advisories; the npm lock takes @playwright/test 1.65.0 with its playwright packages, and patch releases of glob, minimatch, sass, schema-utils, chrome-launcher and the browser data packages, all past the seven-day cooldown, which holds back Playwright 1.65.1. The cooldown made npm update move @babel/helper-create-regexp-features- plugin back from 7.29.10 to 7.29.7, so its entry keeps trunk's 7.29.10. WooCommerce 11.3.0 is still the newest stable release, and WordPress's core pins stay at their branches' newest patches. Assisted-by: Claude Code:claude-opus-5-5 --- composer.lock | 34 ++++----- package-lock.json | 76 +++++++++---------- tests/Fixtures/consumer-a/composer.lock | 16 ++-- tests/Fixtures/consumer-b/composer.lock | 16 ++-- .../Fixtures/personas/bootstrap/composer.lock | 16 ++-- tests/Fixtures/personas/core/composer.lock | 16 ++-- .../Fixtures/personas/settings/composer.lock | 16 ++-- tests/Fixtures/personas/shared/composer.lock | 16 ++-- .../personas/woocommerce/composer.lock | 16 ++-- 9 files changed, 111 insertions(+), 111 deletions(-) diff --git a/composer.lock b/composer.lock index 0475c73..4f778f3 100644 --- a/composer.lock +++ b/composer.lock @@ -2470,16 +2470,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.4.6", + "version": "13.4.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b" + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4821376960d0e1e5a7b7b700955ebcab33ecd71b", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6458d44279e3b4fc492d4c41fe8e677efed7a2c", + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c", "shasum": "" }, "require": { @@ -2493,7 +2493,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.5.0", + "phpunit/php-code-coverage": "^14.5.2", "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.1", @@ -2509,7 +2509,7 @@ "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.2", - "sebastian/version": "^7.0.1", + "sebastian/version": "^7.1.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -2550,7 +2550,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.7" }, "funding": [ { @@ -2558,8 +2558,8 @@ "type": "other" } ], - "time": "2026-11-02T07:12:28+00:00", - "published-time": "2026-11-02T07:13:08+00:00" + "time": "2026-11-13T11:10:10+00:00", + "published-time": "2026-11-13T11:11:23+00:00" }, { "name": "roave/security-advisories", @@ -2567,12 +2567,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2708,7 +2708,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -3052,7 +3052,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -3164,7 +3164,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3737,8 +3737,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sebastian/cli-parser", diff --git a/package-lock.json b/package-lock.json index 857b320..cb51f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3010,9 +3010,9 @@ } }, "node_modules/@isaacs/nonrecursive-glob-match": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", - "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", "dev": true, "license": "BlueOak-1.0.0" }, @@ -5423,13 +5423,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -9548,9 +9548,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001822", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001822.tgz", - "integrity": "sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==", + "version": "1.0.30001823", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz", + "integrity": "sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==", "dev": true, "funding": [ { @@ -9720,9 +9720,9 @@ } }, "node_modules/chrome-launcher": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.2.tgz", - "integrity": "sha512-sI90u5qf3854bTrWsQMMdsQfSjjMbeSn7QtxVb9zrikf+QgMfHQ71KepHW7ZfixQKrDlAFKb+qnVlSR+xepBzw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.3.tgz", + "integrity": "sha512-IpQuNUYnpXXq3VBRJInM7zTZDiAp/cu+zy0X74NjyQtFcZIQBbI64i0PYQO25wNwgmgw2cPBYitVmFz53N11Rg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11111,9 +11111,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.477", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.477.tgz", - "integrity": "sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==", + "version": "1.5.478", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz", + "integrity": "sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==", "dev": true, "license": "ISC" }, @@ -13017,13 +13017,13 @@ } }, "node_modules/glob": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", - "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==", + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.4.tgz", + "integrity": "sha512-mMiwNKoJZ4HAIRVJxotO6LsNRReA30gQwP9E2/p94tfLv+jygCNpKDjhhSL6OLY6LE371EZm5EAf7RpYuUqlsw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^11.0.2", + "minimatch": "^11.0.5", "minipass": "^7.1.3", "path-scurry": "^3.0.0" }, @@ -13065,13 +13065,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", - "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/nonrecursive-glob-match": "^1.0.3", + "@isaacs/nonrecursive-glob-match": "^1.1.0", "brace-expansion": "^5.0.12" }, "engines": { @@ -17916,13 +17916,13 @@ } }, "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -17932,9 +17932,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -19824,9 +19824,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.107.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.107.1.tgz", - "integrity": "sha512-aJ1/LvxOOb04YhmiBjzZn3jVwdHKol+OVhoMV6fFl02fF9T3i+5Mg9l/CnzqtM+NX5Jn5o3lAbs/ZJivTCLHfQ==", + "version": "1.108.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.108.1.tgz", + "integrity": "sha512-sIYgSvOPeD9wABwDS/ofNjHASSck+7h3jLkk7wG1NPFL3XeS6vOQjBlAFSmRV09kZr1yt/Tuayt6kKtBbLjHNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19904,9 +19904,9 @@ "peer": true }, "node_modules/schema-utils": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.2.tgz", - "integrity": "sha512-iKlak3OFHF0dgD1nZRvgWQzG+P1TiEcMnLSBtIVBg937z5Jd6WE1c3SyZkXIyCfPHowL35yMsVvIjotm1c1qiw==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.3.tgz", + "integrity": "sha512-qIfo3f+lRNqnMPgpXSKvUhsT3pY5U+ppmgGr4j8LanFP3W/Ze+xj8DY/dPb5qVu3m6qFIMyMWKVRLNKxrNhxWg==", "dev": true, "license": "MIT", "dependencies": { @@ -19926,7 +19926,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.112.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock index 7cba8f6..c933f7c 100644 --- a/tests/Fixtures/consumer-a/composer.lock +++ b/tests/Fixtures/consumer-a/composer.lock @@ -2173,12 +2173,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2314,7 +2314,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2658,7 +2658,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2770,7 +2770,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3343,8 +3343,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 9bdb98a..71e7ce1 100644 --- a/tests/Fixtures/consumer-b/composer.lock +++ b/tests/Fixtures/consumer-b/composer.lock @@ -2086,12 +2086,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2227,7 +2227,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2571,7 +2571,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2683,7 +2683,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3256,8 +3256,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 a983027..807cf8d 100644 --- a/tests/Fixtures/personas/bootstrap/composer.lock +++ b/tests/Fixtures/personas/bootstrap/composer.lock @@ -1685,12 +1685,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -1826,7 +1826,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2170,7 +2170,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2282,7 +2282,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -2855,8 +2855,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 6273806..d867e76 100644 --- a/tests/Fixtures/personas/core/composer.lock +++ b/tests/Fixtures/personas/core/composer.lock @@ -2051,12 +2051,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2192,7 +2192,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2536,7 +2536,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2648,7 +2648,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3221,8 +3221,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 489f4ec..2fff0ef 100644 --- a/tests/Fixtures/personas/settings/composer.lock +++ b/tests/Fixtures/personas/settings/composer.lock @@ -1894,12 +1894,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2035,7 +2035,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2379,7 +2379,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2491,7 +2491,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3064,8 +3064,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 766148d..6b236bc 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -1689,12 +1689,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -1830,7 +1830,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2174,7 +2174,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2286,7 +2286,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -2859,8 +2859,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+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 b8818bf..ba1f287 100644 --- a/tests/Fixtures/personas/woocommerce/composer.lock +++ b/tests/Fixtures/personas/woocommerce/composer.lock @@ -1981,12 +1981,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2122,7 +2122,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2466,7 +2466,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2578,7 +2578,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3151,8 +3151,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", From 3635a66d3edba7d13931ed65f1137f20e8b238df Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:59:36 +0100 Subject: [PATCH 02/12] Expect consumer-a's settings in rows of their own consumer-a keeps its uninstall consent inside one array row, which its uninstall reads through the Uninstaller's consent key, and its Reports gate reads a second setting under a composed name that Options does not know. Every plugin stores each setting in its own autoloaded row, under a whole name on Options, seeded from stored-form defaults (X rulings 414, 416, 418 and 428), offers integrators a narrow accessor for a setting (X ruling 413), and hooks after its includes loop, so that an include that throws leaves nothing of it hooked (X ruling 415). The rows expect the first request to store each default in its own autoloaded row and to keep a stored value, the accessor to read what the Reports gate reads, each seeding step to carry the fingerprint of the key set it stores, an include that throws to show the build notice with the plugin unbooted, and uninstall to read the consent from its row. Assisted-by: Claude Code:claude-opus-5-5 --- .../ConsumerA/ConsumerABuildTest.php | 13 ++++ .../ConsumerA/ConsumerASettingsTest.php | 76 +++++++++++++++++++ .../ConsumerA/ConsumerAUninstallTest.php | 10 +-- .../Integration/Support/consumer-request.php | 1 + 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/ConsumerA/ConsumerASettingsTest.php diff --git a/tests/Integration/ConsumerA/ConsumerABuildTest.php b/tests/Integration/ConsumerA/ConsumerABuildTest.php index 7147225..6e7d3fc 100644 --- a/tests/Integration/ConsumerA/ConsumerABuildTest.php +++ b/tests/Integration/ConsumerA/ConsumerABuildTest.php @@ -46,6 +46,19 @@ public function test_a_missing_build_file_shows_the_build_notice_to_plugin_manag self::assertStringNotContainsString( self::BUILD_NOTICE, $editor['body'] ); } + public function test_an_include_that_throws_shows_the_build_notice_and_leaves_the_plugin_unbooted(): void { + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + \file_put_contents( WP_PLUGIN_DIR . '/acme-consumer-a/includes/throws.php', "consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + + self::assertNull( $request['died'] ); + self::assertStringContainsString( self::BUILD_NOTICE, $request['body'] ); + self::assertSame( 0, $request['records']['consumer_a']['boot_succeeded'] ); + self::assertNotSame( 'booted', $request['records']['consumer_a']['status'] ); + self::assertFalse( $request['records']['export_hooked'] ); + } + public function test_a_site_below_the_php_floor_keeps_the_safeguard_and_gets_core_s_message_and_the_updater_while_nothing_else_loads(): void { $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a', array(), array( 'Requires PHP' => '99' ) ); $request = $this->consumer_request( diff --git a/tests/Integration/ConsumerA/ConsumerASettingsTest.php b/tests/Integration/ConsumerA/ConsumerASettingsTest.php new file mode 100644 index 0000000..9f17566 --- /dev/null +++ b/tests/Integration/ConsumerA/ConsumerASettingsTest.php @@ -0,0 +1,76 @@ + array( 'yes', true ), + 'never-saved' => array( null, false ), + ); + } + + public function test_the_first_request_stores_each_default_in_an_autoloaded_row_of_its_own(): void { + global $wpdb; + + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + wp_cache_flush(); + + $autoload = $wpdb->get_results( $wpdb->prepare( 'SELECT option_name, autoload FROM %i WHERE option_name IN ( %s, %s ) ORDER BY option_name', $wpdb->options, 'acme_ca_delete_data', 'acme_ca_reports' ), \ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the rows' autoload flags. + self::assertSame( 'no', get_option( 'acme_ca_delete_data' ) ); + self::assertSame( 'no', get_option( 'acme_ca_reports' ) ); + self::assertSame( array( 'acme_ca_delete_data', 'acme_ca_reports' ), \array_column( $autoload, 'option_name' ) ); + self::assertSame( array(), \array_diff( \array_column( $autoload, 'autoload' ), wp_autoload_values_to_autoload() ) ); + self::assertFalse( get_option( 'acme_ca_settings' ) ); + } + + public function test_seeding_leaves_a_stored_setting_as_it_is(): void { + update_option( 'acme_ca_delete_data', 'yes' ); + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + wp_cache_flush(); + + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); + self::assertSame( 'no', get_option( 'acme_ca_reports' ) ); + } + + #[DataProvider( 'stored_reports_settings' )] + public function test_the_reports_accessor_reads_what_the_reports_gate_reads( ?string $stored, bool $enabled ): void { + if ( ! \is_null( $stored ) ) { + update_option( 'acme_ca_reports', $stored ); + } + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + + self::assertSame( $enabled, $request['records']['reports_enabled'] ); + self::assertSame( $enabled, $request['records']['reports_page_class'] ); + } + + #[RunInSeparateProcess] + public function test_each_seeding_step_records_the_key_set_it_stores(): void { + require_once self::FIXTURES . '/consumer-a/vendor/autoload.php'; + + $seeding = \array_filter( \call_user_func( array( 'Acme\\ConsumerA\\Migrations', 'get_steps' ) ), static fn ( mixed $step ): bool => $step instanceof \Closure && 'add_settings' === new \ReflectionFunction( $step )->getName() ); + $keys = \array_keys( \constant( 'Acme\\ConsumerA\\Options::DEFAULTS' ) ); + \sort( $keys ); + $fingerprint = \hash( 'xxh3', \implode( "\n", $keys ) ); + + self::assertCount( \count( self::SEEDED_KEY_SETS ), $seeding, 'Each seeding step needs its fingerprint in SEEDED_KEY_SETS.' ); + self::assertSame( \array_last( self::SEEDED_KEY_SETS ), $fingerprint, "Options::DEFAULTS' keys changed: append self::add_settings( ... ) to Migrations::get_steps() and '$fingerprint' to SEEDED_KEY_SETS." ); + } +} diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 891b95d..a2ddeb2 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -43,7 +43,7 @@ public function test_a_consenting_web_uninstall_removes_the_footprint_and_the_fi self::assertTrue( $request['records']['deleted'] ); self::assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . self::DIR ); - self::assertFalse( get_option( 'acme_ca_settings' ) ); + self::assertFalse( get_option( 'acme_ca_delete_data' ) ); self::assertFalse( get_option( 'acme_ca_revision' ) ); self::assertSame( '', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); self::assertFalse( get_option( 'acme_ca_notices' ) ); @@ -64,7 +64,7 @@ public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_ke 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( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( '1', get_option( 'acme_ca_revision' ) ); } @@ -76,7 +76,7 @@ public function test_a_web_uninstall_that_cannot_run_the_engine_keeps_the_data_l self::assertTrue( $request['records']['deleted'] ); self::assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . self::DIR ); - self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); self::assertSame( 1, \substr_count( (string) \file_get_contents( $this->log ), 'Acme Consumer A kept its data on uninstall' ) ); } @@ -91,7 +91,7 @@ public function test_a_wp_cli_uninstall_that_cannot_run_the_engine_fails_and_kee 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( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); } @@ -103,7 +103,7 @@ protected function delete_request_log(): void { } protected function seed_footprint(): void { - update_option( 'acme_ca_settings', array( 'delete_data' => 'yes' ) ); + update_option( 'acme_ca_delete_data', 'yes' ); update_option( 'acme_ca_revision', 1 ); update_option( 'acme_ca_notices', array( 'seeded' ) ); update_network_option( null, 'acme_ca_network_notices', array( 'seeded' ) ); diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index c2937d8..8234e22 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -131,6 +131,7 @@ static function () use ( $spec, $acme_capability, $acme_record_boots, $acme_safe 'safeguard_attached' => $acme_safeguard_priority(), 'export_hooked' => has_action( 'admin_post_acme_ca_export' ), 'reports_page_class' => class_exists( 'Acme\ConsumerA\Reports\ReportsPage', false ), + 'reports_enabled' => function_exists( 'acme_ca_is_reports_enabled' ) ? acme_ca_is_reports_enabled() : null, 'updater_hooked' => has_filter( 'update_plugins_github.com' ), 'schedules_hooked' => has_filter( 'cron_schedules' ), 'safeguards_loaded' => class_exists( 'Acme\\ConsumerA\\Safeguards', false ), From 225ca9f83563476ba5980496e3049fa6347a677e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:03:05 +0100 Subject: [PATCH 03/12] Store consumer-a's settings in rows of their own consumer-a's uninstall consent and its reports switch become two options named whole on Options, each holding its own value, with stored-form defaults that the seeding step adds as autoloaded rows, leaving any stored value as it is; the step reads the defaults' keys, so a release that adds settings lists it again. Options reads a setting as a boolean, falling back to its default read the same way, since PHPStan reads a final class's constant by its literal values. The Reports gate reads its switch through Options, and includes/reports.php offers the same reading to integrators as acme_ca_is_reports_enabled(). functions.php hooks after its includes loop, so an include that throws reaches the entry's catch with nothing hooked. The uninstall consent names its own option, the footprint's network option is written whole, and the PREFIX and FOOTPRINT docblocks say what each covers. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/functions.php | 27 ++++++------ .../Fixtures/consumer-a/includes/reports.php | 29 ++++++++++++ tests/Fixtures/consumer-a/src/Migrations.php | 13 ++++-- tests/Fixtures/consumer-a/src/Options.php | 44 ++++++++++++++++--- tests/Fixtures/consumer-a/src/Plugin.php | 6 +-- .../consumer-a/src/Reports/Reports.php | 4 +- tests/Fixtures/consumer-a/uninstall.php | 7 +-- 7 files changed, 97 insertions(+), 33 deletions(-) create mode 100644 tests/Fixtures/consumer-a/includes/reports.php diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php index e072332..8922740 100644 --- a/tests/Fixtures/consumer-a/functions.php +++ b/tests/Fixtures/consumer-a/functions.php @@ -1,6 +1,6 @@ $value ) { + if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { + throw new \RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } } } diff --git a/tests/Fixtures/consumer-a/src/Options.php b/tests/Fixtures/consumer-a/src/Options.php index 6522312..bede49e 100644 --- a/tests/Fixtures/consumer-a/src/Options.php +++ b/tests/Fixtures/consumer-a/src/Options.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Names the plugin's stored settings and their defaults. + * Names the plugin's stored settings and their defaults, and reads each setting, guarding the stored value. * * @since 1.0.0 * @version 1.0.0 @@ -14,24 +14,37 @@ final class Options { // region FIELDS AND CONSTANTS /** - * The option that holds the plugin's settings: 'yes' under delete_data where the site consents to uninstall removing the plugin's data. + * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * * @since 1.0.0 * @version 1.0.0 * * @var string */ - public const string SETTINGS = Plugin::PREFIX . '_settings'; + public const string DELETE_DATA = 'acme_ca_delete_data'; /** - * The settings the plugin stores on its first migration, which keep the plugin's data on uninstall. + * The option that holds 'yes' where the site switches the reports on. * * @since 1.0.0 * @version 1.0.0 * - * @var array{delete_data: 'yes'|'no'} + * @var string + */ + public const string REPORTS = 'acme_ca_reports'; + + /** + * The default of each option, in the form the option stores it. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array */ - public const array DEFAULTS = array( 'delete_data' => 'no' ); + public const array DEFAULTS = array( + self::DELETE_DATA => 'no', + self::REPORTS => 'no', + ); // endregion @@ -46,4 +59,23 @@ final class Options { protected function __construct() {} // endregion + + // region METHODS + + /** + * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return bool + */ + public static function get_bool( string $key ): bool { + $default = self::DEFAULTS[ $key ] ?? null; + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? \filter_var( $default, \FILTER_VALIDATE_BOOL ); + } + + // endregion } diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index 9465716..23930bf 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -36,7 +36,7 @@ final class Plugin { public const string SLUG = 'acme-consumer-a'; /** - * The prefix of the plugin's options, transients and scheduled jobs. + * The prefix of the names the plugin stores its data under, and its Action Scheduler group. * * @since 1.0.0 * @version 1.0.0 @@ -63,7 +63,7 @@ final class Plugin { ); /** - * The plugin's stored data by kind, which covers every option, transient, meta key and scheduled job under the plugin's prefix. + * The plugin's stored data by kind, which uninstall reads. * * @since 1.0.0 * @version 1.0.0 @@ -75,7 +75,7 @@ final class Plugin { 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_ca_network_notices' ), 'cron_hooks' => array( 'acme_consumer_a/migration/run' ), 'as_groups' => array( self::PREFIX ), ); diff --git a/tests/Fixtures/consumer-a/src/Reports/Reports.php b/tests/Fixtures/consumer-a/src/Reports/Reports.php index 5f4eaae..f7b06c3 100644 --- a/tests/Fixtures/consumer-a/src/Reports/Reports.php +++ b/tests/Fixtures/consumer-a/src/Reports/Reports.php @@ -2,7 +2,7 @@ namespace Acme\ConsumerA\Reports; -use Acme\ConsumerA\Plugin; +use Acme\ConsumerA\Options; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\CompositeComponentInterface; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ConditionalComponentInterface; @@ -25,7 +25,7 @@ final class Reports implements CompositeComponentInterface, ConditionalComponent */ #[\Override] public static function should_load(): bool { - return 'yes' === get_option( Plugin::PREFIX . '_reports', 'no' ); + return Options::get_bool( Options::REPORTS ); } /** diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index deb4230..4e494cc 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -31,7 +31,7 @@ require_once __DIR__ . '/vendor/autoload.php'; $acme_ca_footprint = Plugin::FOOTPRINT; - $acme_ca_consent = Options::SETTINGS; + $acme_ca_consent = Options::DELETE_DATA; $acme_ca_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); @@ -53,10 +53,7 @@ $acme_ca_identity, $acme_ca_footprint, array( - 'consent' => array( - 'option' => $acme_ca_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_ca_consent ), 'meta_types' => $acme_ca_meta_types, ) ); From 7fd04ce036e2e349b816a27ccc2966afb96bb38a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:03:26 +0100 Subject: [PATCH 04/12] Expect the core persona's settings in rows of their own The core persona keeps its greeting and its uninstall consent in one array row, which its uninstall reads through the Uninstaller's consent key, while every plugin stores each setting in its own row under a whole name (X rulings 414, 416 and 418), and core's README excerpts the persona's uninstall. The rows expect the first request to store each default in its own row and a consenting uninstall to remove both. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/Personas/CorePersonaTest.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php index 612805f..fa6b567 100644 --- a/tests/Integration/Personas/CorePersonaTest.php +++ b/tests/Integration/Personas/CorePersonaTest.php @@ -24,23 +24,21 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with self::assertSame( 1, $request['records']['persona_core']['boot_succeeded'] ); self::assertSame( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\Migration\\MigrationRunner', $request['records']['persona_core']['migrations'] ); self::assertTrue( $request['records']['greeting_shortcode'] ); - self::assertSame( array( 'greeting' => 'Hello' ), get_option( 'acme_pc_settings' ) ); + self::assertSame( 'Hello', get_option( 'acme_pc_greeting' ) ); + self::assertSame( 'no', get_option( 'acme_pc_delete_data' ) ); + self::assertFalse( 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_greeting', 'Hello' ); + update_option( 'acme_pc_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_greeting' ) ); + self::assertFalse( get_option( 'acme_pc_delete_data' ) ); self::assertFalse( get_option( 'acme_pc_revision' ) ); } } From eac4d3450dca4ce91a21a9d2da0c7ca3ead65069 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:05:01 +0100 Subject: [PATCH 05/12] Store the core persona's settings in rows of their own The core persona's greeting and uninstall consent become two options named whole on Options, with stored-form defaults the seeding step adds as autoloaded rows, leaving a stored value as it is. The greeting reads through a generic string reader by key in place of a reader for that one setting, the uninstall consent names its own option, and the footprint's network option is written whole; core's README excerpts follow. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/README.md | 7 +--- tests/Fixtures/personas/core/src/Greeting.php | 2 +- .../Fixtures/personas/core/src/Migrations.php | 13 +++++-- tests/Fixtures/personas/core/src/Options.php | 37 +++++++++++++------ tests/Fixtures/personas/core/src/Plugin.php | 2 +- tests/Fixtures/personas/core/uninstall.php | 7 +--- 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 24c4266..40e57e9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -201,7 +201,7 @@ The plugin's `uninstall.php` hands its footprint, a constant of its composition 'option_prefixes' => array( self::PREFIX . '_' ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_pc_network_notices' ), 'cron_hooks' => array( 'acme_persona_core/migration/run' ), 'as_groups' => array( self::PREFIX ), ); @@ -212,10 +212,7 @@ Uninstaller::run( $acme_pc_identity, $acme_pc_footprint, array( - 'consent' => array( - 'option' => $acme_pc_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_pc_consent ), ) ); ``` diff --git a/tests/Fixtures/personas/core/src/Greeting.php b/tests/Fixtures/personas/core/src/Greeting.php index 10a2779..7ec3dd4 100644 --- a/tests/Fixtures/personas/core/src/Greeting.php +++ b/tests/Fixtures/personas/core/src/Greeting.php @@ -51,7 +51,7 @@ public function register_shortcode(): void { * @return string */ public function render(): string { - return esc_html( Options::get_greeting() ); + return esc_html( Options::get_string( Options::GREETING ) ); } // endregion diff --git a/tests/Fixtures/personas/core/src/Migrations.php b/tests/Fixtures/personas/core/src/Migrations.php index ae040b1..c798896 100644 --- a/tests/Fixtures/personas/core/src/Migrations.php +++ b/tests/Fixtures/personas/core/src/Migrations.php @@ -44,18 +44,23 @@ public static function get_steps(): array { // region HELPERS /** - * Stores the plugin's settings with their defaults. + * Stores each setting's default in an autoloaded row of its own and leaves a stored setting as it is. + * + * Each release that adds settings lists this step again, so that existing sites store the new defaults. * * @since 1.0.0 * @version 1.0.0 * - * @throws \RuntimeException Thrown when the settings could not be stored. + * @throws \RuntimeException Thrown when a default could not be stored. * * @return void */ protected static function add_settings(): void { - if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) { - throw new \RuntimeException( 'The settings could not be stored.' ); + wp_prime_option_caches( \array_keys( Options::DEFAULTS ) ); + foreach ( Options::DEFAULTS as $key => $value ) { + if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { + throw new \RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } } } diff --git a/tests/Fixtures/personas/core/src/Options.php b/tests/Fixtures/personas/core/src/Options.php index 48da6c8..61db542 100644 --- a/tests/Fixtures/personas/core/src/Options.php +++ b/tests/Fixtures/personas/core/src/Options.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Names the plugin's stored settings and their defaults, and reads them. + * Names the plugin's stored settings and their defaults, and reads each setting, guarding the stored value. * * @since 1.0.0 * @version 1.0.0 @@ -14,24 +14,37 @@ final class Options { // region FIELDS AND CONSTANTS /** - * The option that holds the plugin's settings: the greeting, and 'yes' under delete_data where the site consents to uninstall removing the plugin's data. + * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * * @since 1.0.0 * @version 1.0.0 * * @var string */ - public const string SETTINGS = Plugin::PREFIX . '_settings'; + public const string DELETE_DATA = 'acme_pc_delete_data'; /** - * The settings the plugin stores on its first migration. + * The option that holds the greeting. * * @since 1.0.0 * @version 1.0.0 * - * @var array{greeting: string} + * @var string + */ + public const string GREETING = 'acme_pc_greeting'; + + /** + * The default of each option, in the form the option stores it. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array */ - public const array DEFAULTS = array( 'greeting' => 'Hello' ); + public const array DEFAULTS = array( + self::DELETE_DATA => 'no', + self::GREETING => 'Hello', + ); // endregion @@ -50,17 +63,19 @@ protected function __construct() {} // region METHODS /** - * Returns the stored greeting, or the default one where none is stored. + * Returns an option as a string, or its default where no string is stored. * * @since 1.0.0 * @version 1.0.0 * + * @param string $key The option's key. + * * @return string */ - public static function get_greeting(): string { - $settings = get_option( self::SETTINGS, self::DEFAULTS ); - - return \is_array( $settings ) && \is_string( $settings['greeting'] ?? null ) ? $settings['greeting'] : self::DEFAULTS['greeting']; + public static function get_string( string $key ): string { + $default = self::DEFAULTS[ $key ] ?? ''; + $stored = get_option( $key, $default ); + return \is_string( $stored ) ? $stored : $default; } // endregion diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index ad67182..bc12ef4 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -70,7 +70,7 @@ final class Plugin { 'option_prefixes' => array( self::PREFIX . '_' ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_pc_network_notices' ), 'cron_hooks' => array( 'acme_persona_core/migration/run' ), 'as_groups' => array( self::PREFIX ), ); diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php index a16410f..b441c70 100644 --- a/tests/Fixtures/personas/core/uninstall.php +++ b/tests/Fixtures/personas/core/uninstall.php @@ -30,7 +30,7 @@ require_once __DIR__ . '/vendor/autoload.php'; $acme_pc_footprint = Plugin::FOOTPRINT; - $acme_pc_consent = Options::SETTINGS; + $acme_pc_consent = Options::DELETE_DATA; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } @@ -50,9 +50,6 @@ $acme_pc_identity, $acme_pc_footprint, array( - 'consent' => array( - 'option' => $acme_pc_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_pc_consent ), ) ); From c6ae3fd4d624a8ff1c2d5c0e800bd38ac2e342e3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:07:01 +0100 Subject: [PATCH 06/12] Read the uninstall consent from its own option only The Uninstaller's consent took a key, under which it read the consent from an array row, a shape that only a settings group stored in one option needs. Every plugin now stores each setting in its own row, the one plugin that grouped its consent moves to flat rows at its port, and consumer-a and the core persona already read their own rows (X ruling 417), so the key, its shapes and its rows go. An array stored under the consent option no longer consents, which a retaining row holds, and core's README names the two ways a site consents. Assisted-by: Claude Code:claude-opus-5-5 --- packages/core/README.md | 2 +- packages/core/src/Uninstall/Uninstaller.php | 13 ++++--------- .../Integration/Core/Uninstall/UninstallerTest.php | 13 +++++-------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 40e57e9..fd0a8b4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -194,7 +194,7 @@ final class Greeting implements ComponentInterface { `Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet the right-to-left variant wp-scripts writes beside it. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported. As a best effort, a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version and, for a script, no dependencies, and a stylesheet without a `-rtl.css` beside it keeps its own file on right-to-left sites. -The plugin's `uninstall.php` hands its footprint, a constant of its composition root, 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, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data: +The plugin's `uninstall.php` hands its footprint, a constant of its composition root, 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` or a constant defined as `true`. The file loads the engine as the main file does, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data: ```php public const array FOOTPRINT = array( diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 9f3c9b4..5776e0b 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -14,7 +14,7 @@ * @version 2.0.0 * * @phpstan-type Footprint array{cron_hooks?: list, as_hooks?: list, as_groups?: list, options?: list, option_prefixes?: list, transients?: list, post_types?: list, comment_types?: list, meta?: array>, meta_prefixes?: array>, user_meta_site?: list, user_meta_site_prefixes?: list, user_meta_global?: list, user_meta_global_prefixes?: list, network_options?: list, capabilities?: list, tables?: list} - * @phpstan-type Args 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} + * @phpstan-type Args array{consent?: array{option?: string, constant?: string}, meta_types?: array, cache_groups?: list}>, cleanup?: callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void} */ final class Uninstaller { // region FIELDS AND CONSTANTS @@ -51,7 +51,7 @@ final class Uninstaller { * * @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{consent: array{option: 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 PluginIdentity $identity The plugin's identity. * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. @@ -82,7 +82,7 @@ protected function __construct( * * @param PluginIdentity $identity The plugin's identity. * @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 is none, which keeps the footprint. + * @param array $args Optional. The consent (an option 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 read or delete, a site's pass or the cleanup failed, once every site was visited. * @@ -96,7 +96,6 @@ public static function run( PluginIdentity $identity, array $footprint, array $a ); $args['consent'] += array( 'option' => '', - 'key' => '', 'constant' => '', ); @@ -234,11 +233,7 @@ protected function uninstall_site(): void { \error_log( '[' . plugin_basename( $this->identity->file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled. } - $consent = get_option( $this->args['consent']['option'] ); - if ( '' !== $this->args['consent']['key'] ) { - $consent = \is_array( $consent ) ? $consent[ $this->args['consent']['key'] ] ?? null : null; - } - if ( 'yes' !== $consent && ! self::is_constant_true( $this->args['consent']['constant'] ) ) { + if ( 'yes' !== get_option( $this->args['consent']['option'] ) && ! self::is_constant_true( $this->args['consent']['constant'] ) ) { return; } diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 9d65733..1817efc 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -26,9 +26,7 @@ public static function retaining_consents(): array { 'option-one' => array( self::stored_consent( '1' ) ), 'option-uppercase-yes' => array( self::stored_consent( 'YES' ) ), 'option-true' => array( self::stored_consent( true ) ), - 'grouped-row-without-key' => array( self::stored_consent( array( 'other' => 'yes' ), 'remove_data' ) ), - 'grouped-key-no' => array( self::stored_consent( array( 'remove_data' => 'no' ), 'remove_data' ) ), - 'flat-yes-read-as-grouped' => array( self::stored_consent( 'yes', 'remove_data' ) ), + 'option-array-holding-yes' => array( self::stored_consent( array( 'remove_data' => 'yes' ) ) ), 'constant-yes-string' => array( self::defined_consent( 'yes' ) ), 'constant-undefined' => array( static fn ( string $prefix ): array => array( 'consent' => array( 'constant' => \strtoupper( "{$prefix}_undefined" ) ) ) ), ); @@ -36,8 +34,7 @@ public static function retaining_consents(): array { public static function consents(): array { return array( - 'flat-yes' => array( self::stored_consent( 'yes' ) ), - 'grouped-yes' => array( self::stored_consent( array( 'remove_data' => 'yes' ), 'remove_data' ) ), + 'option-yes' => array( self::stored_consent( 'yes' ) ), 'constant-true' => array( self::defined_consent( true ) ), 'constant-over-stored-no' => array( static function ( string $prefix ): array { @@ -498,11 +495,11 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } - protected static function stored_consent( mixed $value, string $key = '' ): \Closure { - return static function ( string $prefix ) use ( $value, $key ): array { + protected static function stored_consent( mixed $value ): \Closure { + return static function ( string $prefix ) use ( $value ): array { update_option( "{$prefix}_remove_data", $value ); - return array( 'consent' => array( 'option' => "{$prefix}_remove_data" ) + ( '' === $key ? array() : array( 'key' => $key ) ) ); + return array( 'consent' => array( 'option' => "{$prefix}_remove_data" ) ); }; } From a1923a28a05a0b05472dade5b8f92037e967bd03 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:09:51 +0100 Subject: [PATCH 07/12] Expect number fields held to their min, max and step Forms checks only a number's grammar, so a field stores 2.5 under a step of 1, 0 under a min of 1, and 3.0 or 1e3 as typed, which Options' integer reader then reads as its default; on a WooCommerce tab a number field without validate stores WooCommerce's text unchecked, and one without a step renders under the browser's step of 1 while Forms renders any. The framework holds every number field to the min, max and step it declares, on both hosts, without a validate per field (X rulings 420 and 429). The rows expect a number to store as one canonical text, a value below min, above max or off the grid from min or zero to be rejected with a message naming what it missed, the grid checked exactly on its decimal digits, malformed attributes read as the browser reads them, a number too large to check rejected, and the WooCommerce bridge to apply the same rules on the classic form and over REST and to render a number field without a step with step any. A translated message that names no value is returned as it is, percent signs included. Assisted-by: Claude Code:claude-opus-5-5 --- .../Settings/FormsSanitizeFieldsTest.php | 18 ++- .../Settings/FormsSanitizeNumberTest.php | 113 ++++++++++++++++++ .../WooCommerce/Setting/SettingsTest.php | 53 ++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/Settings/FormsSanitizeNumberTest.php diff --git a/tests/Integration/Settings/FormsSanitizeFieldsTest.php b/tests/Integration/Settings/FormsSanitizeFieldsTest.php index d9e58b6..8318627 100644 --- a/tests/Integration/Settings/FormsSanitizeFieldsTest.php +++ b/tests/Integration/Settings/FormsSanitizeFieldsTest.php @@ -40,8 +40,8 @@ public static function accepted(): array { 'number-integer' => array( 'number', '12', '12' ), 'number-is-trimmed' => array( 'number', ' 4 ', '4' ), 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ), - 'number-leading-point' => array( 'number', '.5', '.5' ), - 'number-exponent' => array( 'number', '1e3', '1e3' ), + 'number-leading-point' => array( 'number', '.5', '0.5' ), + 'number-exponent' => array( 'number', '1e3', '1000' ), 'number-cleared' => array( 'number', '', '' ), 'number-from-an-int' => array( 'number', 5, '5' ), 'number-from-a-float' => array( 'number', 1.5, '1.5' ), @@ -122,6 +122,20 @@ public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_on self::assertSame( '1', self::sanitize( $field, '1' ) ); } + public function test_a_translated_message_holding_a_percent_sign_is_returned_as_it_is(): void { + $translate = static fn ( string $translation, string $text ): string => 'Enter a valid value.' === $text ? 'Saisissez 100% des chiffres.' : $translation; + add_filter( 'gettext_wp-framework', $translate, 10, 2 ); + + try { + $result = self::sanitize( self::field( 'text' ), array( 'a' ) ); + } finally { + remove_filter( 'gettext_wp-framework', $translate ); + } + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( 'Saisissez 100% des chiffres.', $result->get_error_message() ); + } + public function test_a_custom_type_without_validate_is_rejected(): void { $field = array( 'type' => 'text', diff --git a/tests/Integration/Settings/FormsSanitizeNumberTest.php b/tests/Integration/Settings/FormsSanitizeNumberTest.php new file mode 100644 index 0000000..4db16bd --- /dev/null +++ b/tests/Integration/Settings/FormsSanitizeNumberTest.php @@ -0,0 +1,113 @@ + array( array(), '.5', '0.5' ), + 'canonical-exponent' => array( array(), '1e3', '1000' ), + 'canonical-negative-exponent' => array( array(), '25E-1', '2.5' ), + 'canonical-small-fraction' => array( array(), '1.5e-7', '0.00000015' ), + 'canonical-trailing-zeros' => array( array(), '2.50', '2.5' ), + 'canonical-leading-zeros' => array( array(), '007', '7' ), + 'canonical-whole-decimal' => array( array(), '3.0', '3' ), + 'canonical-negative-zero' => array( array(), '-0.0', '0' ), + 'canonical-float' => array( array(), 2.0, '2' ), + 'cleared-below-its-min' => array( array( 'min' => 1 ), '', '' ), + 'decimal-within-bounds-no-step' => array( + array( + 'min' => 1, + 'max' => 10, + ), + '2.5', + '2.5', + ), + 'at-its-min' => array( array( 'min' => '1' ), '1', '1' ), + 'at-its-max' => array( array( 'max' => '10' ), '10.0', '10' ), + 'whole-step-from-its-min' => array( + array( + 'min' => 1, + 'step' => 2, + ), + '5', + '5', + ), + 'whole-step-from-zero' => array( array( 'step' => 5 ), '-10', '-10' ), + 'whole-step-stores-integer-text' => array( array( 'step' => '1' ), '4.000', '4' ), + 'whole-step-from-an-int' => array( array( 'step' => 1 ), 7, '7' ), + 'decimal-step-checked-exactly' => array( array( 'step' => '0.01' ), '0.3', '0.3' ), + 'decimal-step-from-a-decimal-min' => array( + array( + 'min' => '0.5', + 'step' => '0.25', + ), + '1.25', + '1.25', + ), + 'step-any' => array( array( 'step' => 'any' ), '3.14159', '3.14159' ), + 'step-any-in-capitals' => array( array( 'step' => 'ANY' ), '1.5', '1.5' ), + 'unparseable-min-ignored' => array( array( 'min' => 'abc' ), '-5', '-5' ), + 'empty-max-ignored' => array( array( 'max' => '' ), '99', '99' ), + 'zero-step-counts-as-one' => array( array( 'step' => '0' ), '4', '4' ), + 'negative-step-counts-as-one' => array( array( 'step' => -2 ), '3', '3' ), + 'unparseable-step-counts-as-one' => array( array( 'step' => 'big' ), '6', '6' ), + 'whole-step-near-the-integer-edge' => array( array( 'step' => 1 ), '9223372036854775807', '9223372036854775807' ), + ); + } + + #[DataProvider( 'accepted' )] + public function test_a_number_within_its_bounds_and_on_its_grid_stores_as_canonical_text( array $attributes, mixed $raw, string $expected ): void { + self::assertSame( $expected, self::sanitize( $attributes, $raw ) ); + } + + public static function rejected(): array { + return array( + 'below-its-min' => array( array( 'min' => 1 ), '0', 'number_below_min', 'Enter a number of at least 1.' ), + 'below-a-decimal-min' => array( array( 'min' => '0.50' ), '0.25', 'number_below_min', 'Enter a number of at least 0.5.' ), + 'below-a-min-named-in-capitals' => array( array( 'MIN' => 5 ), '1', 'number_below_min', 'Enter a number of at least 5.' ), + 'above-its-max' => array( array( 'max' => 10 ), '10.5', 'number_above_max', 'Enter a number of at most 10.' ), + 'off-a-whole-step' => array( array( 'step' => 1 ), '2.5', 'number_off_step', 'Enter a number in steps of 1 from 0.' ), + 'off-a-step-from-its-min' => array( + array( + 'min' => 1, + 'step' => 2, + ), + '4', + 'number_off_step', + 'Enter a number in steps of 2 from 1.', + ), + 'off-a-decimal-step' => array( array( 'step' => '0.01' ), '0.305', 'number_off_step', 'Enter a number in steps of 0.01 from 0.' ), + 'off-the-step-a-zero-step-means' => array( array( 'step' => '0' ), '4.5', 'number_off_step', 'Enter a number in steps of 1 from 0.' ), + 'beyond-the-integer-range' => array( array( 'step' => 1 ), '1e19', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + 'beyond-the-range-at-its-decimal' => array( array( 'step' => '0.01' ), '100000000000000000', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + 'too-small-to-hold' => array( array(), '1e-400', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + ); + } + + #[DataProvider( 'rejected' )] + public function test_a_number_outside_its_bounds_or_off_its_grid_is_rejected_naming_what_it_missed( array $attributes, mixed $raw, string $code, string $message ): void { + $result = self::sanitize( $attributes, $raw ); + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( $code, $result->get_error_code() ); + self::assertSame( $message, $result->get_error_message() ); + } + + protected static function sanitize( array $attributes, mixed $raw ): mixed { + $field = array( + 'type' => 'number', + 'id' => 'key', + 'custom_attributes' => $attributes, + ); + $result = Forms::sanitize_fields( array( $field ), array( 'key' => $raw ) ); + + return $result['errors']['key'] ?? $result['values']['key']; + } +} diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 45033b5..d606d21 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -244,6 +244,46 @@ public function test_a_field_without_validate_stores_woocommerces_value(): void self::assertSame( 'bold', get_option( "{$this->prefix}_name" ) ); } + public static function bounded_numbers(): array { + return array( + 'whole-number-as-integer-text' => array( '4.0', '4', array() ), + 'off-its-step' => array( '2.5', '3', array( 'Count was not saved and keeps its previous value. The other changes were saved. Enter a number in steps of 1 from 1.' ) ), + 'below-its-min' => array( '0', '3', array( 'Count was not saved and keeps its previous value. The other changes were saved. Enter a number of at least 1.' ) ), + ); + } + + #[DataProvider( 'bounded_numbers' )] + public function test_a_number_field_without_validate_is_held_to_its_bounds_and_step( string $posted, string $stored, array $messages ): void { + update_option( "{$this->prefix}_count", '3' ); + $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->bounded_count() ) ) ) ); + + $saved = self::document( $this->save( $spec, '', array( "{$this->prefix}_count" => $posted ) )['body'] ); + + self::assertSame( $stored, get_option( "{$this->prefix}_count" ) ); + self::assertSame( $messages, self::texts( $saved, '#message.error' ) ); + } + + public function test_over_rest_a_number_field_without_validate_is_held_to_its_bounds_and_step(): void { + update_option( "{$this->prefix}_count", '3' ); + $this->add_tab_in_process( array( '' => $this->in_process_section( array( $this->bounded_count() ) ) ) ); + + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_count", array( 'value' => '2.5' ) ); + $off_step = get_option( "{$this->prefix}_count" ); + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_count", array( 'value' => 5.0 ) ); + + self::assertSame( '3', $off_step ); + self::assertSame( '5', get_option( "{$this->prefix}_count" ) ); + } + + public function test_a_number_field_woocommerce_draws_takes_any_step_unless_it_sets_one(): void { + $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->field( 'number', 'free' ), $this->bounded_count() ) ) ) ); + + $form = self::document( $this->open( $spec )['body'] ); + + self::assertSame( 'any', $form->getElementById( "{$this->prefix}_free" )?->getAttribute( 'step' ) ); + self::assertSame( '1', $form->getElementById( "{$this->prefix}_count" )?->getAttribute( 'step' ) ); + } + public function test_validate_receives_the_raw_submission_alone_and_its_return_value_is_stored(): void { $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->field( 'text', 'name', array( 'validate' => 'acme:uppercase' ) ) ) ) ) ); @@ -573,6 +613,19 @@ protected function field( string $type, string $name, array $keys = array() ): a ); } + protected function bounded_count(): array { + return $this->field( + 'number', + 'count', + array( + 'custom_attributes' => array( + 'min' => 1, + 'step' => 1, + ), + ) + ); + } + protected function custom_field( array $keys = array() ): array { return $this->field( "{$this->prefix}_widget", 'widget', $keys + array( 'render' => 'acme:render' ) ); } From 06225c654b21d57b07b894055022d913ca9f9734 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:13:32 +0100 Subject: [PATCH 08/12] Hold number fields to their min, max and step Forms' number sanitizer reads the min, max and step the control renders with and rejects a number below min, above max or off the grid the step lays from min, or from zero, naming what it missed; with no step, the control's step any leaves only the bounds. Attributes read as the browser reads them, so an unparseable bound is ignored and a step that is neither any nor positive counts as 1. Every accepted number stores as one canonical text, without an exponent, a plus sign or padding zeros, so a whole-number step stores integer text that the integer readers read. The grid is checked on scaled integers built from the decimal digits, since floating point refuses 0.3 on a 0.01 grid, and a number those integers cannot hold, or one a float rounds to zero, is rejected. Only a rejection message that names values goes through sprintf(), so a translation's percent sign cannot throw. The WooCommerce bridge runs the sanitizer for a number field without validate, on the classic form and over REST alike, and gives the number fields WooCommerce draws Forms' default step, as ProductDataTab does. Limitation 4 names the one save path the bridge does not see, WooCommerce's rest-api-v4 routes (X ruling 429), and the field with a step and no min, whose steps the save counts from zero while the browser counts them from the value the control renders with (X ruling 430). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/settings/src/Forms.php | 158 +++++++++++++++++--- packages/woocommerce/src/Setting/Fields.php | 16 +- 3 files changed, 147 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b02c52..d5520d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,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 `{root}/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 `{prefix}_revision_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 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. +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. WooCommerce's `rest-api-v4` routes, a feature it ships off, store the fields on its own tabs through `update_option()`, so no `validate` or number bound runs there. A number field with a `step` and no parseable `min` counts its steps from zero on save, while the browser counts them from the value the control renders with, so a rendered value off the grid from zero is rejected at each save, and where the browser validates the form, the field saves no other number. 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, consumer-a's entry, which requires it inside its one `try`, shows the build notice, or rethrows in local and development environments, while an entry that requires it outside a `try` fatals at include; the uninstall shim keeps the data on a web request, while WP-CLI fails and keeps the files. On PHP 7.4 a failed `require` is fatal at include either way. 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. 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. diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 49dd4af..49fa153 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -137,7 +137,7 @@ public static function sanitize_fields( array $fields, array $submitted ): array 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ), 'email' => self::sanitize_email( $raw ), 'url' => self::sanitize_url( $raw ), - 'number' => self::sanitize_number( $raw ), + 'number' => self::sanitize_number( $field, $raw ), 'checkbox' => self::sanitize_checkbox( $raw ), 'select', 'radio' => self::sanitize_choice( $field, $raw ), 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ), @@ -693,18 +693,19 @@ protected static function sanitize_url( mixed $raw ): string|\WP_Error { } /** - * Returns the submission as a trimmed number string in HTML's floating-point grammar, or a WP_Error for anything else that is not empty. + * Returns the submission as the canonical text of a number, or a WP_Error for anything else that is not empty, and for a number below the field's min, above its max or off the grid its step lays from its min, or from zero. * - * A number input renders any other numeric string, such as +1 or 1., as empty, so the next save would clear it. + * The attributes are those the control renders with, read as the browser reads them, so an unparseable min or max is ignored and a step that is neither any nor a positive number counts as 1. The grid is checked on the decimal digits, which floating point cannot hold for a step such as 0.01, so a number whose digits at the grid's precision leave PHP's integer range is rejected. * * @since 2.0.0 * @version 2.0.0 * - * @param mixed $raw The unslashed submission. + * @param Field $field The field. + * @param mixed $raw The unslashed submission. * * @return string|\WP_Error */ - protected static function sanitize_number( mixed $raw ): string|\WP_Error { + protected static function sanitize_number( array $field, mixed $raw ): string|\WP_Error { if ( \is_float( $raw ) && ! \is_finite( $raw ) ) { return self::reject( 'invalid_number' ); } @@ -712,12 +713,116 @@ protected static function sanitize_number( mixed $raw ): string|\WP_Error { return self::reject( 'invalid_value' ); } - $number = \trim( (string) $raw ); - if ( '' === $number ) { + $text = \trim( (string) $raw ); + if ( '' === $text ) { return ''; } - return 1 === \preg_match( '/\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/', $number ) && \is_finite( (float) $number ) ? $number : self::reject( 'invalid_number' ); + $number = self::to_canonical_number( $text ); + if ( \is_null( $number ) ) { + return self::reject( 'invalid_number' ); + } + + $attributes = self::get_custom_attributes( $field ) + self::DEFAULT_CUSTOM_ATTRIBUTES['number']; + $min = self::to_canonical_number( $attributes['min'] ?? '' ); + $max = self::to_canonical_number( $attributes['max'] ?? '' ); + if ( ! \is_null( $min ) && self::compare_numbers( $number, $min ) < 0 ) { + return self::reject( 'number_below_min', $min ); + } + if ( ! \is_null( $max ) && self::compare_numbers( $number, $max ) > 0 ) { + return self::reject( 'number_above_max', $max ); + } + if ( 'any' === \strtolower( $attributes['step'] ) ) { + return $number; + } + + $step = self::to_canonical_number( $attributes['step'] ); + if ( \is_null( $step ) || '0' === $step || \str_starts_with( $step, '-' ) ) { + $step = '1'; + } + + $base = $min ?? '0'; + $places = \max( \array_map( static fn ( string $part ): int => \strlen( \explode( '.', "$part." )[1] ), array( $number, $base, $step ) ) ); + $scaled = array(); + foreach ( array( $number, $base, $step ) as $part ) { + list( $whole, $fraction ) = \explode( '.', "$part." ); + $digits = \ltrim( \ltrim( $whole, '-' ) . \str_pad( $fraction, $places, '0' ), '0' ); + $scaled[] = \filter_var( ( \str_starts_with( $whole, '-' ) ? '-' : '' ) . ( '' === $digits ? '0' : $digits ), \FILTER_VALIDATE_INT ); + } + + // The difference between the number and the grid's origin must stay an integer too, which PHP would turn into a float past its range. + list( $value, $origin, $interval ) = $scaled; + if ( ! \is_int( $value ) || ! \is_int( $origin ) || ! \is_int( $interval ) || ( $origin < 0 ? $value > \PHP_INT_MAX + $origin : $value < \PHP_INT_MIN + $origin ) ) { + return self::reject( 'invalid_number' ); + } + + return 0 === ( $value - $origin ) % $interval ? $number : self::reject( 'number_off_step', $step, $base ); + } + + /** + * Returns a number in HTML's floating-point grammar as its canonical text, without an exponent, a plus sign or padding zeros and with -0 as 0, or null for any other text and for a number a float cannot hold. + * + * The text keeps every digit given, because a float would round them. A number input renders any other numeric text, such as +1 or 1., as empty, so the next save would clear it. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $text The text. + * + * @return string|null + */ + protected static function to_canonical_number( string $text ): ?string { + $text = \trim( $text ); + if ( 1 !== \preg_match( '/\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/', $text ) || ! \is_finite( (float) $text ) ) { + return null; + } + + list( $mantissa, $exponent ) = \explode( 'e', \strtolower( $text ) ) + array( 1 => '0' ); + list( $whole, $fraction ) = \explode( '.', \ltrim( $mantissa, '-' ) ) + array( 1 => '' ); + $digits = $whole . $fraction; + if ( '' === \trim( $digits, '0' ) ) { + return '0'; + } + if ( 0.0 === (float) $text ) { // Digits a float rounds to zero would take an exponent's worth of zeros to write out. + return null; + } + + $point = \strlen( $whole ) + (int) $exponent; + if ( $point < 1 ) { + $digits = \str_repeat( '0', 1 - $point ) . $digits; + $point = 1; + } + + $digits = \str_pad( $digits, $point, '0' ); + $whole = \ltrim( \substr( $digits, 0, $point ), '0' ); + $fraction = \rtrim( \substr( $digits, $point ), '0' ); + + return ( \str_starts_with( $mantissa, '-' ) ? '-' : '' ) . ( '' === $whole ? '0' : $whole ) . ( '' === $fraction ? '' : ".$fraction" ); + } + + /** + * Returns -1, 0 or 1 as the first canonical number is less than, equal to or greater than the second. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $first The first number, as to_canonical_number() writes it. + * @param string $second The second number, as to_canonical_number() writes it. + * + * @return int + */ + protected static function compare_numbers( string $first, string $second ): int { + $negative = \str_starts_with( $first, '-' ); + if ( \str_starts_with( $second, '-' ) !== $negative ) { + return $negative ? -1 : 1; + } + + list( $first_whole, $first_fraction ) = \explode( '.', \ltrim( $first, '-' ) ) + array( 1 => '' ); + list( $second_whole, $second_fraction ) = \explode( '.', \ltrim( $second, '-' ) ) + array( 1 => '' ); + $places = \max( \strlen( $first_fraction ), \strlen( $second_fraction ) ); + $order = ( \strlen( $first_whole ) <=> \strlen( $second_whole ) ) ?: ( \strcmp( $first_whole . \str_pad( $first_fraction, $places, '0' ), $second_whole . \str_pad( $second_fraction, $places, '0' ) ) <=> 0 ); + + return $negative ? -$order : $order; } /** @@ -826,25 +931,32 @@ protected static function is_disabled_choice( array $field, int|string $key ): b * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param 'invalid_value'|'invalid_choice'|'invalid_email'|'invalid_url'|'invalid_number'|'unknown_type'|'missing_validate' $code + * @phpstan-param 'invalid_value'|'invalid_choice'|'invalid_email'|'invalid_url'|'invalid_number'|'number_below_min'|'number_above_max'|'number_off_step'|'unknown_type'|'missing_validate' $code * - * @param string $code The rejection's code. + * @param string $code The rejection's code. + * @param string ...$values The values the message names, such as the bound a number missed. * * @return \WP_Error */ - protected static function reject( string $code ): \WP_Error { - return new \WP_Error( - $code, - match ( $code ) { - 'invalid_value' => __( 'Enter a valid value.', 'wp-framework' ), - 'invalid_choice' => __( 'Choose one of the available options.', 'wp-framework' ), - 'invalid_email' => __( 'Enter a valid email address, such as name@example.com.', 'wp-framework' ), - 'invalid_url' => __( 'Enter a valid URL, such as https://example.com.', 'wp-framework' ), - 'invalid_number' => __( 'Enter a number, such as 12 or 3.5.', 'wp-framework' ), - 'unknown_type' => __( 'This field cannot be saved because its type is not supported.', 'wp-framework' ), - 'missing_validate' => __( 'This field cannot be saved because nothing validates its value.', 'wp-framework' ), - } - ); + protected static function reject( string $code, string ...$values ): \WP_Error { + $message = match ( $code ) { + 'invalid_value' => __( 'Enter a valid value.', 'wp-framework' ), + 'invalid_choice' => __( 'Choose one of the available options.', 'wp-framework' ), + 'invalid_email' => __( 'Enter a valid email address, such as name@example.com.', 'wp-framework' ), + 'invalid_url' => __( 'Enter a valid URL, such as https://example.com.', 'wp-framework' ), + 'invalid_number' => __( 'Enter a number, such as 12 or 3.5.', 'wp-framework' ), + /* translators: %s: The smallest number the field accepts. */ + 'number_below_min' => __( 'Enter a number of at least %s.', 'wp-framework' ), + /* translators: %s: The largest number the field accepts. */ + 'number_above_max' => __( 'Enter a number of at most %s.', 'wp-framework' ), + /* translators: 1: The step between the numbers the field accepts. 2: The number the steps start from. */ + 'number_off_step' => __( 'Enter a number in steps of %1$s from %2$s.', 'wp-framework' ), + 'unknown_type' => __( 'This field cannot be saved because its type is not supported.', 'wp-framework' ), + 'missing_validate' => __( 'This field cannot be saved because nothing validates its value.', 'wp-framework' ), + }; + + // Only a message that names values goes through sprintf(), so that a translation's literal percent sign cannot throw. + return new \WP_Error( $code, array() === $values ? $message : \sprintf( $message, ...$values ) ); } /** diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 4380498..2b5f29d 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -89,7 +89,9 @@ public static function check_id( string $id ): void { // region HOOKS /** - * Takes ownership of the fields of one of the helper's sections, as WooCommerce's settings filter leaves them, and hooks the row printer to each type among them that WooCommerce does not draw and Forms does. + * Takes ownership of the fields of one of the helper's sections, as WooCommerce's settings filter leaves them, gives each one WooCommerce draws the attributes Forms renders its type with unless it sets them, and hooks the row printer to each type among them that WooCommerce does not draw and Forms does. + * + * WooCommerce prints only a field's own attributes, so a number field without a step would take the browser's step of 1 while its save accepts any. * * @since 2.0.0 * @version 2.0.0 @@ -104,15 +106,19 @@ public function claim( array $settings, string $section_id ): array { return $settings; } - foreach ( $settings as $setting ) { + foreach ( $settings as $index => $setting ) { if ( ! self::is_field( $setting ) ) { continue; } - $this->owned[ $setting['id'] ?? '' ] = $setting; // is_field() has checked the id, which the Field shape leaves optional. if ( self::is_drawn_by_forms( $setting ) ) { add_action( "woocommerce_admin_field_{$setting['type']}", array( $this, 'render_row' ) ); + } elseif ( isset( Forms::DEFAULT_CUSTOM_ATTRIBUTES[ $setting['type'] ] ) ) { + $setting['custom_attributes'] = ( $setting['custom_attributes'] ?? array() ) + Forms::DEFAULT_CUSTOM_ATTRIBUTES[ $setting['type'] ]; + $settings[ $index ] = $setting; } + + $this->owned[ $setting['id'] ?? '' ] = $setting; // is_field() has checked the id, which the Field shape leaves optional. } return $settings; @@ -121,7 +127,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 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. + * 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. A number field without validate goes through Forms' number sanitizer, which holds it to its min, max and step, and any other field WooCommerce draws without validate stores WooCommerce's value. * * @since 2.0.0 * @version 2.0.0 @@ -144,7 +150,7 @@ public function sanitize_option( mixed $value, mixed $option, mixed $raw ): mixe 'multiselect', 'multi_select_countries' => array(), default => null, }; - if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) ) { + if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) && 'number' !== $field['type'] ) { $field['validate'] = static fn (): mixed => StoredData::is_storable_data( $value ) ? $value : null; } From 0ae75a53eeddacb7882d352d820b94269cc84b51 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:19:29 +0100 Subject: [PATCH 09/12] Expect options pages to store each field in its own row OptionsPage stores each tab's fields as one array option, so a plugin keeps two storage shapes, one per host, with their own constants, readers and consent forms, a setting cannot move to a WooCommerce tab without a data migration, and every admin request builds every tab's fields. Pages store one row per field, as WooCommerce's tabs do, ask only for the shown or posted tab's fields, and register only the posted tab's fields on options.php; because a row can name any option, a field id outside the plugin's prefix or the helper id pattern is dropped with a notice (X ruling 416). The rows expect the definition to give a tab's fields on their own, through get_tab_fields() and a fields filter that contributes them, each field registered in the tab's group typed string or array, nothing registered outside options.php, a dropped field neither rendered nor stored, a tab without a field to save rendered without a form, and every save, rejection and rendering row on the field's own row. The rows for an id holding an entity and for a stored id the definition dropped go, since the pattern refuses the first and a row of its own keeps the second. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/Settings/acme-options-page.php | 59 +- .../Settings/AdminPage/OptionsPageTest.php | 562 ++++++++++-------- .../Support/RecordedDefinition.php | 5 + 3 files changed, 361 insertions(+), 265 deletions(-) diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php index aa2c191..a28e3db 100644 --- a/tests/Fixtures/Settings/acme-options-page.php +++ b/tests/Fixtures/Settings/acme-options-page.php @@ -2,7 +2,7 @@ /** * Registers the request spec's options page with a plain closure on plugins_loaded. * - * A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. The spec may also contribute tabs and fields through the page's tabs filter, and update options programmatically on admin_init. + * A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. The outcome also lists the tabs whose fields the page asked for, and the _doing_it_wrong() calls in place of their notices. The spec may also contribute tabs through the page's tabs filter and fields through its fields filter, and update options programmatically on admin_init. * * @package Acme */ @@ -13,44 +13,55 @@ require_once dirname( __DIR__, 3 ) . '/vendor/autoload.php'; -$acme_with_behaviors = static function ( array $tabs ): array { - foreach ( $tabs as &$tab ) { - $tab['fields'] = RecordedDefinition::with_behaviors( $tab['fields'] ); - } - - return $tabs; -}; +add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); +add_action( + 'doing_it_wrong_run', + static function ( string $function_name, string $message ): void { + $GLOBALS['acme_outcome']['doing_it_wrong'][] = array( $function_name, $message ); + }, + 10, + 2 +); add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { + static function () use ( $spec ): void { $page = new OptionsPage( - new PluginIdentity( __FILE__, $spec['slug'], 'acme' ), + new PluginIdentity( __FILE__, $spec['slug'], $spec['prefix'] ), $spec['id'], - new RecordedDefinition( static fn (): array => array( 'tabs' => $acme_with_behaviors( $spec['page']['tabs'] ) ) + $spec['page'] ) + new RecordedDefinition( + static function ( string $page_id, ?string $tab_slug = null ) use ( $spec ): array { + if ( is_null( $tab_slug ) ) { + return $spec['page']; + } + + $GLOBALS['acme_outcome']['asked'][] = $tab_slug; + + return RecordedDefinition::with_behaviors( $spec['fields'][ $tab_slug ] ?? array() ); + } + ) ); $page->register_hooks(); } ); -if ( isset( $spec['contributed'] ) ) { +$acme_hook_root = str_replace( '-', '_', $spec['slug'] ); +if ( isset( $spec['contributed']['tabs'] ) ) { add_filter( - str_replace( '-', '_', $spec['slug'] ) . '/options_page/tabs', - static function ( array $tabs, string $page_id ) use ( $spec, $acme_with_behaviors ): array { - if ( $spec['id'] !== $page_id ) { - return $tabs; - } - - foreach ( $tabs as &$tab ) { - $tab['fields'] = array_merge( $tab['fields'], $spec['contributed']['fields'][ $tab['slug'] ] ?? array() ); - } - - return $acme_with_behaviors( array_merge( $tabs, $spec['contributed']['tabs'] ?? array() ) ); - }, + "$acme_hook_root/options_page/tabs", + static fn ( array $tabs, string $page_id ): array => $spec['id'] === $page_id ? array_merge( $tabs, $spec['contributed']['tabs'] ) : $tabs, 10, 2 ); } +if ( isset( $spec['contributed']['fields'] ) ) { + add_filter( + "$acme_hook_root/options_page/fields", + static fn ( array $fields, string $tab_slug, string $page_id ): array => $spec['id'] === $page_id ? array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed']['fields'][ $tab_slug ] ?? array() ) ) : $fields, + 10, + 3 + ); +} if ( isset( $spec['update'] ) ) { add_action( diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 25bade3..7ac23a6 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.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 PHPUnit\Framework\Attributes\CoversClass; @@ -21,6 +22,7 @@ final class OptionsPageTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; + use RecordsDoingItWrongTrait; use RunsAdminRequestsTrait; use SerializesFormsTrait; @@ -41,19 +43,28 @@ final class OptionsPageTest extends TestCase { protected int $admin; protected int $editor; protected string $id; - protected string $option; + protected string $group; + protected mixed $pagenow; #[\Override] protected function setUp(): void { - $this->admin = $this->make_user( 'acme_options_admin', 'administrator' ); - $this->editor = $this->make_user( 'acme_options_editor', 'editor' ); - $this->id = "{$this->prefix}_settings"; - $this->option = "{$this->prefix}_general"; + $this->admin = $this->make_user( 'acme_options_admin', 'administrator' ); + $this->editor = $this->make_user( 'acme_options_editor', 'editor' ); + $this->id = "{$this->prefix}_settings"; + $this->group = "{$this->id}_general"; + $this->pagenow = $GLOBALS['pagenow'] ?? null; } #[\Override] protected function tearDown(): void { delete_transient( 'settings_errors' ); + $GLOBALS['pagenow'] = $this->pagenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores what an in-process row replaced. + unset( $_REQUEST['option_page'] ); + foreach ( \array_keys( get_registered_settings() ) as $name ) { + if ( \str_starts_with( $name, $this->prefix ) ) { + unregister_setting( $this->group, $name ); + } + } } public static function parents(): array { @@ -118,79 +129,203 @@ public function test_the_definition_receives_the_pages_id_once(): void { self::assertSame( array( array( $this->id ) ), $definition->calls ); } + public function test_only_the_tab_a_request_shows_or_saves_asks_for_its_fields(): void { + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) + ); + + $elsewhere = $this->admin_request( 'GET', 'index.php', $this->admin, self::FIXTURE, $spec ); + $shown = $this->open( $spec, $this->admin, 'second' ); + $saved = $this->save( $spec, $this->admin, self::form_data( $shown['body'], array( "{$this->prefix}_other" => 'new' ) ) ); + + self::assertArrayNotHasKey( 'asked', $elsewhere ); + self::assertSame( array( 'second' ), $shown['asked'] ); + self::assertSame( array( 'second' ), $saved['asked'] ); + self::assertSame( 'new', get_option( "{$this->prefix}_other" ) ); + } + + public function test_a_field_id_outside_the_plugins_prefix_or_the_helper_id_pattern_is_dropped_with_a_notice(): void { + $this->record_doing_it_wrong(); + $fields = array( + array( + 'type' => 'text', + 'id' => "{$this->prefix}_name", + ), + array( + 'type' => 'text', + 'id' => "{$this->prefix}x_name", + ), + array( + 'type' => 'text', + 'id' => "{$this->prefix}_Name", + ), + ); + $page = new OptionsPage( + $this->identity, + $this->id, + new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array() )['page'] : $fields ) + ); + $GLOBALS['pagenow'] = 'options.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as options.php does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + self::assertSame( array( "{$this->prefix}_name" ), \array_values( \array_filter( \array_keys( get_registered_settings() ), fn ( string $name ): bool => \str_starts_with( $name, $this->prefix ) ) ) ); + self::assertSame( array( OptionsPage::class . '::get_fields', OptionsPage::class . '::get_fields' ), $this->doing_it_wrong ); + } + + public function test_a_dropped_field_neither_renders_nor_stores_what_is_posted_for_it(): void { + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array(), array( 'contributed' => array( 'fields' => array( 'general' => array( $this->field( 'text', 'blogname', array( 'id' => "{$this->prefix}x_blogname" ) ) ) ) ) ) ); + $form = $this->open( $spec, $this->admin ); + $data = self::form_data( $form['body'], array( "{$this->prefix}_name" => 'new' ) ) + array( "{$this->prefix}x_blogname" => 'injected' ); + + $this->save( $spec, $this->admin, $data ); + + self::assertSame( array( 'Name' ), self::row_titles( $form['body'] ) ); + self::assertCount( 1, $form['doing_it_wrong'] ?? array() ); + self::assertStringContainsString( "{$this->prefix}x_blogname", $form['doing_it_wrong'][0][1] ?? '' ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); + self::assertFalse( get_option( "{$this->prefix}x_blogname" ) ); + } + + public function test_each_field_registers_in_the_tabs_group_typed_by_the_value_it_stores(): void { + $fields = array( + $this->field( 'text', 'name' ), + $this->field( 'checkbox', 'flag' ), + $this->field( 'multiselect', 'colors', array( 'options' => self::COLORS ) ), + $this->field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), + ); + $page = new OptionsPage( + $this->identity, + $this->id, + new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array(), array( 'capability' => 'edit_pages' ) )['page'] : $fields ) + ); + $GLOBALS['pagenow'] = 'options.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as options.php does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + $registered = get_registered_settings(); + self::assertSame( + array( + "{$this->prefix}_name" => array( $this->group, 'string', false ), + "{$this->prefix}_flag" => array( $this->group, 'string', false ), + "{$this->prefix}_colors" => array( $this->group, 'array', false ), + "{$this->prefix}_days" => array( $this->group, 'array', false ), + ), + \array_map( static fn ( array $setting ): array => array( $setting['group'], $setting['type'], $setting['show_in_rest'] ), \array_intersect_key( $registered, \array_flip( \array_column( $fields, 'id' ) ) ) ) + ); + self::assertSame( 'edit_pages', apply_filters( "option_page_capability_$this->group", 'manage_options' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Applies core's filter. + } + + public function test_a_request_outside_options_php_registers_no_setting(): void { + $page = new OptionsPage( $this->identity, $this->id, new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array() )['page'] : array( $this->field( 'text', 'name' ) ) ) ); + $GLOBALS['pagenow'] = 'admin-post.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as another admin screen does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + self::assertArrayNotHasKey( "{$this->prefix}_name", get_registered_settings() ); + } + + public function test_a_tab_without_a_field_to_save_renders_no_form(): void { + $intro = array( + 'type' => 'title', + 'title' => 'Intro', + ); + $form = $this->open( $this->spec( array( $intro ) ), $this->admin ); + $page = self::document( $form['body'] ); + + self::assertSame( 'Acme settings', $page->querySelector( '.wrap h1' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertNull( $page->querySelector( 'form[action="options.php"]' ) ); + self::assertSame( array( 'general' ), $form['asked'] ); + self::assertNotContains( OptionsPage::class . '::get_fields', \array_column( $form['doing_it_wrong'] ?? array(), 0 ) ); + } + public function test_a_first_save_validates_once_and_stores_the_result_without_an_error(): void { - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'v' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'v' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'v!' ), get_option( $this->option ) ); - self::assertSame( array( array( 'validate', 'name', 'v' ) ), $saved['records'] ); + self::assertSame( 'v!', get_option( "{$this->prefix}_name" ) ); + self::assertSame( array( array( 'validate', "{$this->prefix}_name", 'v' ) ), $saved['records'] ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_programmatic_update_outside_options_php_is_stored_as_given(): void { - $value = array( 'name' => ' raw ' ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( $this->option => $value ) ) ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( "{$this->prefix}_name" => ' raw ' ) ) ); - $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->group", $this->admin, self::FIXTURE, $spec ); - self::assertSame( $value, get_option( $this->option ) ); + self::assertSame( ' raw ', get_option( "{$this->prefix}_name" ) ); self::assertSame( array(), $request['records'] ); } #[DataProvider( 'parents' )] public function test_a_rejected_field_keeps_its_value_and_its_message_shows_once( string $parent_file ): void { - update_option( $this->option, array( 'name' => 'old' ) ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ), array( 'parent' => $parent_file ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'too long' ) ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ), array( 'parent' => $parent_file ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'too long' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Name was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } + public function test_a_rejected_field_never_stored_stays_absent(): void { + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'too long' ) ); + + $this->save( $spec, $this->admin, $data ); + + self::assertFalse( get_option( "{$this->prefix}_name" ) ); + } + #[DataProvider( 'parents' )] public function test_a_valid_save_shows_settings_saved_once( string $parent_file ): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'parent' => $parent_file ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'parent' => $parent_file ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_rejection_without_a_message_says_that_the_value_is_not_valid(): void { - update_option( $this->option, array( 'name' => 'old' ) ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:reject-silently' ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject-silently' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Name was not saved because its value is not valid, and keeps its previous value. The other changes were saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_built_in_rejection_carries_what_to_enter(): void { - update_option( $this->option, array( 'email' => 'ops@example.com' ) ); - $spec = $this->spec( array( self::field( 'email', 'email' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[email]" => 'not an email' ) ); + update_option( "{$this->prefix}_email", 'ops@example.com' ); + $spec = $this->spec( array( $this->field( 'email', 'email' ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_email" => 'not an email' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'email' => 'ops@example.com' ), get_option( $this->option ) ); + self::assertSame( 'ops@example.com', get_option( "{$this->prefix}_email" ) ); self::assertSame( array( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_labelled_and_named_by_its_id(): void { + $site = "{$this->prefix}_site"; $spec = $this->spec( array( - self::field( 'email', 'email', array( 'title' => 'Email' ) ), + $this->field( 'email', 'email', array( 'title' => 'Email' ) ), array( 'type' => 'url', - 'id' => 'site', + 'id' => $site, ), ) ); @@ -198,20 +333,20 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ $data = self::form_data( $form, array( - "{$this->option}[email]" => 'not an email', - "{$this->option}[site]" => 'javascript:alert(1)', + "{$this->prefix}_email" => 'not an email', + $site => 'javascript:alert(1)', ) ); $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( array( 'Email', $site ), self::row_titles( $form ) ); + self::assertSame( $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( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.', - 'site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.', + "$site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.", ), self::notices( $page ) ); @@ -220,58 +355,43 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ #[DataProvider( 'multi_value_types' )] public function test_a_fully_cleared_multi_value_field_stores_an_empty_array( string $type ): void { - update_option( $this->option, array( 'colors' => array( 'red', 'blue' ) ) ); - $spec = $this->spec( array( self::field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array() ) ); + update_option( "{$this->prefix}_colors", array( 'red', 'blue' ) ); + $spec = $this->spec( array( $this->field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array() ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); + self::assertSame( array(), get_option( "{$this->prefix}_colors" ) ); } public function test_an_unchecked_checkbox_stores_no_while_a_text_field_posts(): void { - update_option( - $this->option, - array( - 'alpha' => 'yes', - 'beta' => 'yes', - 'name' => 'old', - ) - ); - $spec = $this->spec( array( self::field( 'checkbox', 'alpha' ), self::field( 'checkbox', 'beta' ), self::field( 'text', 'name' ) ) ); + update_option( "{$this->prefix}_alpha", 'yes' ); + update_option( "{$this->prefix}_beta", 'yes' ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'checkbox', 'alpha' ), $this->field( 'checkbox', 'beta' ), $this->field( 'text', 'name' ) ) ); $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( - "{$this->option}[beta]" => false, - "{$this->option}[name]" => 'new', + "{$this->prefix}_beta" => false, + "{$this->prefix}_name" => 'new', ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( - array( - 'alpha' => 'yes', - 'beta' => 'no', - 'name' => 'new', - ), - get_option( $this->option ) - ); + self::assertSame( array( 'yes', 'no', 'new' ), $this->stored( 'alpha', 'beta', 'name' ) ); } public function test_a_disabled_field_keeps_its_value_and_renders_no_hidden_input(): void { - $stored = array( - 'name' => 'old', - 'agree' => 'yes', - 'colors' => array( 'red' ), - 'other' => 'old', - ); - update_option( $this->option, $stored ); + update_option( "{$this->prefix}_name", 'old' ); + update_option( "{$this->prefix}_agree", 'yes' ); + update_option( "{$this->prefix}_colors", array( 'red' ) ); + update_option( "{$this->prefix}_other", 'old' ); $spec = $this->spec( array( - self::field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), - self::field( 'checkbox', 'agree', array( 'disabled' => true ) ), - self::field( + $this->field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), + $this->field( 'checkbox', 'agree', array( 'disabled' => true ) ), + $this->field( 'multiselect', 'colors', array( @@ -279,23 +399,23 @@ public function test_a_disabled_field_keeps_its_value_and_renders_no_hidden_inpu 'custom_attributes' => array( 'disabled' => 'disabled' ), ) ), - self::field( 'text', 'other' ), + $this->field( 'text', 'other' ), ) ); $form = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->option}[other]" => 'new' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->prefix}_other" => 'new' ) ) ); - self::assertSame( \array_replace( $stored, array( 'other' => 'new' ) ), get_option( $this->option ) ); - self::assertSame( array( 'other' ), \array_keys( self::form_data( $form )[ $this->option ] ) ); - self::assertCount( 0, self::document( $form )->querySelectorAll( 'form input[type="hidden"][name^="' . $this->option . '["]' ) ); + self::assertSame( array( 'old', 'yes', array( 'red' ), 'new' ), $this->stored( 'name', 'agree', 'colors', 'other' ) ); + self::assertSame( array( "{$this->prefix}_other" ), \array_values( \array_filter( \array_keys( self::form_data( $form ) ), fn ( string $name ): bool => \str_starts_with( $name, $this->prefix ) ) ) ); + self::assertCount( 0, self::document( $form )->querySelectorAll( 'form input[type="hidden"][name^="' . $this->prefix . '"]' ) ); } - public function test_a_tab_whose_every_control_is_disabled_leaves_the_row_unchanged(): void { - update_option( $this->option, array( 'name' => 'old' ) ); + public function test_a_tab_whose_every_control_is_disabled_leaves_its_rows_unchanged(): void { + update_option( "{$this->prefix}_name", 'old' ); $spec = $this->spec( array( - self::field( + $this->field( 'text', 'name', array( @@ -309,41 +429,41 @@ public function test_a_tab_whose_every_control_is_disabled_leaves_the_row_unchan $saved = $this->save( $spec, $this->admin, $data ); - self::assertArrayNotHasKey( $this->option, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertArrayNotHasKey( "{$this->prefix}_name", $data ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array(), $saved['records'] ); } public function test_a_capability_above_manage_options_refuses_an_administrators_save(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'capability' => 'acme_manage_settings' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'capability' => 'acme_manage_settings' ) ); $data = array( - 'option_page' => $this->option, - 'action' => 'update', - $this->option => array( 'name' => 'new' ), + 'option_page' => $this->group, + 'action' => 'update', + "{$this->prefix}_name" => 'new', ); $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( 403, $saved['died']['status'] ?? null ); self::assertStringContainsString( 'Sorry, you are not allowed to manage options for this site.', $saved['died']['message'] ?? '' ); - self::assertFalse( get_option( $this->option ) ); + self::assertFalse( get_option( "{$this->prefix}_name" ) ); } public function test_a_capability_below_manage_options_lets_its_holder_save(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'capability' => 'edit_pages' ) ); - $data = self::form_data( $this->open( $spec, $this->editor )['body'], array( "{$this->option}[name]" => 'new' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'capability' => 'edit_pages' ) ); + $data = self::form_data( $this->open( $spec, $this->editor )['body'], array( "{$this->prefix}_name" => 'new' ) ); $this->save( $spec, $this->editor, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); } #[DataProvider( 'relocated_option_pages' )] public function test_an_option_page_outside_the_post_body_or_padded_still_validates( string $variant ): void { - update_option( $this->option, array( 'size' => 's' ) ); - $spec = $this->spec( + update_option( "{$this->prefix}_size", 's' ); + $spec = $this->spec( array( - self::field( + $this->field( 'select', 'size', array( @@ -355,38 +475,38 @@ public function test_an_option_page_outside_the_post_body_or_padded_still_valida ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); - $data[ $this->option ]['size'] = 'xl'; - $query = ''; + $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); + $data[ "{$this->prefix}_size" ] = 'xl'; + $query = ''; if ( 'query-string' === $variant ) { unset( $data['option_page'] ); - $query = '?option_page=' . \rawurlencode( $this->option ); + $query = '?option_page=' . \rawurlencode( $this->group ); } else { - $data['option_page'] = " $this->option "; + $data['option_page'] = " $this->group "; } $saved = $this->save( $spec, $this->admin, $data, $query ); self::assertNull( $saved['died'] ); - self::assertSame( array( 'size' => 's' ), get_option( $this->option ) ); + self::assertSame( 's', get_option( "{$this->prefix}_size" ) ); } #[DataProvider( 'multi_value_types' )] public function test_two_selections_store_exactly_those_two( string $type ): void { - $spec = $this->spec( array( self::field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array( 'red', 'teal' ) ) ); + $spec = $this->spec( array( $this->field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array( 'red', 'teal' ) ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'colors' => array( 'red', 'teal' ) ), get_option( $this->option ) ); + self::assertSame( array( 'red', 'teal' ), get_option( "{$this->prefix}_colors" ) ); } #[DataProvider( 'multi_value_types' )] public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_array( string $type ): void { - update_option( $this->option, array( 'colors' => array( 'red' ) ) ); + update_option( "{$this->prefix}_colors", array( 'red' ) ); $spec = $this->spec( array( - self::field( + $this->field( $type, 'colors', array( @@ -396,39 +516,36 @@ public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_ar ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array() ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array() ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( array( 'validate', 'colors', array() ) ), $saved['records'] ); - self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); + self::assertSame( array( array( 'validate', "{$this->prefix}_colors", array() ) ), $saved['records'] ); + self::assertSame( array(), get_option( "{$this->prefix}_colors" ) ); } public function test_a_tampered_value_for_a_disabled_field_is_ignored(): void { - $stored = array( - 'name' => 'old', - 'agree' => 'no', - 'other' => 'old', - ); - update_option( $this->option, $stored ); - $spec = $this->spec( + update_option( "{$this->prefix}_name", 'old' ); + update_option( "{$this->prefix}_agree", 'no' ); + update_option( "{$this->prefix}_other", 'old' ); + $spec = $this->spec( array( - self::field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), - self::field( 'checkbox', 'agree', array( 'disabled' => true ) ), - self::field( 'text', 'other' ), + $this->field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), + $this->field( 'checkbox', 'agree', array( 'disabled' => true ) ), + $this->field( 'text', 'other' ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); - $data[ $this->option ]['name'] = 'tampered'; - $data[ $this->option ]['agree'] = 'yes'; + $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); + $data[ "{$this->prefix}_name" ] = 'tampered'; + $data[ "{$this->prefix}_agree" ] = 'yes'; $this->save( $spec, $this->admin, $data ); - self::assertSame( $stored, get_option( $this->option ) ); + self::assertSame( array( 'old', 'no', 'old' ), $this->stored( 'name', 'agree', 'other' ) ); } public function test_the_submenu_entry_and_the_page_exist_only_for_users_holding_the_capability(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'parent' => 'tools.php' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'parent' => 'tools.php' ) ); $link = '#menu-tools a[href$="?page=' . $this->id . '"]'; $admin_menu = $this->admin_request( 'GET', 'tools.php', $this->admin, self::FIXTURE, $spec )['body']; @@ -441,14 +558,6 @@ 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', @@ -464,28 +573,30 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an 'days' => array( '2' ), 'agree' => 'yes', ); - update_option( $this->option, $stored ); + foreach ( $stored as $name => $value ) { + update_option( "{$this->prefix}_$name", $value ); + } $spec = $this->spec( array( - self::field( 'text', 'name' ), - self::field( 'number', 'count' ), - self::field( 'email', 'email' ), - self::field( 'url', 'site' ), - self::field( 'textarea', 'notes' ), - self::field( 'checkbox', 'on' ), - self::field( 'checkbox', 'off' ), - self::field( 'select', 'size', array( 'options' => self::COLORS ) ), - self::field( 'radio', 'tone', array( 'options' => self::NUMBERED ) ), - self::field( 'multiselect', 'colors', array( 'options' => self::NUMBERED ) ), - self::field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), - self::field( 'checkbox', 'agree', array( 'desc' => 'I agree' ) ), + $this->field( 'text', 'name' ), + $this->field( 'number', 'count' ), + $this->field( 'email', 'email' ), + $this->field( 'url', 'site' ), + $this->field( 'textarea', 'notes' ), + $this->field( 'checkbox', 'on' ), + $this->field( 'checkbox', 'off' ), + $this->field( 'select', 'size', array( 'options' => self::COLORS ) ), + $this->field( 'radio', 'tone', array( 'options' => self::NUMBERED ) ), + $this->field( 'multiselect', 'colors', array( 'options' => self::NUMBERED ) ), + $this->field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), + $this->field( 'checkbox', 'agree', array( 'desc' => 'I agree' ) ), ) ); $form = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->option}[name]" => 'new' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->prefix}_name" => 'new' ) ) ); - self::assertSame( \array_replace( $stored, array( 'name' => 'new' ) ), get_option( $this->option ) ); + self::assertSame( \array_values( \array_replace( $stored, array( 'name' => 'new' ) ) ), $this->stored( ...\array_keys( $stored ) ) ); $labels = array(); foreach ( self::document( $form )->querySelectorAll( 'form tr' ) as $row ) { $for = $row->querySelector( 'th label' )?->getAttribute( 'for' ); @@ -496,16 +607,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->prefix}_name", + 'Count' => "{$this->prefix}_count", + 'Email' => "{$this->prefix}_email", + 'Site' => "{$this->prefix}_site", + 'Notes' => "{$this->prefix}_notes", + 'On' => "{$this->prefix}_on", + 'Off' => "{$this->prefix}_off", + 'Size' => "{$this->prefix}_size", 'Tone' => null, - 'Colors' => "$this->option.colors", + 'Colors' => "{$this->prefix}_colors", 'Days' => null, 'Agree' => null, ), @@ -513,124 +624,90 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an ); } - public function test_a_submitted_id_outside_the_definition_is_not_stored(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); - $data[ $this->option ]['injected'] = 'x'; + public function test_a_submitted_id_outside_the_tab_is_not_stored(): void { + $spec = $this->spec( array( $this->field( 'text', 'name' ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ) + array( "{$this->prefix}_injected" => 'x' ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); - } - - public function test_a_stored_id_absent_from_the_definition_keeps_its_value(): void { - update_option( - $this->option, - array( - 'name' => 'old', - 'retired' => 'kept', - ) - ); - $spec = $this->spec( array( self::field( 'text', 'name' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); - - $this->save( $spec, $this->admin, $data ); - - self::assertSame( - array( - 'name' => 'new', - 'retired' => 'kept', - ), - get_option( $this->option ) - ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); + self::assertFalse( get_option( "{$this->prefix}_injected" ) ); } - public function test_tabs_and_fields_contributed_through_the_tabs_filter_render_and_save(): void { - $addon = "{$this->prefix}_addon"; - $spec = $this->spec( - array( self::field( 'text', 'name' ) ), + public function test_tabs_and_fields_contributed_through_the_filters_render_and_save(): void { + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), array(), array( 'contributed' => array( - 'fields' => array( 'general' => array( self::field( 'text', 'extra', array( 'validate' => 'acme:append' ) ) ) ), - 'tabs' => array( self::tab( 'addon', $addon, array( self::field( 'text', 'key' ) ) ) ), + 'tabs' => array( self::tab( 'addon' ) ), + 'fields' => array( + 'general' => array( $this->field( 'text', 'extra', array( 'validate' => 'acme:append' ) ) ), + 'addon' => array( $this->field( 'text', 'key' ) ), + ), ), ) ); $general = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $general, array( "{$this->option}[extra]" => 'e' ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'addon' )['body'], array( "{$addon}[key]" => 'k' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $general, array( "{$this->prefix}_extra" => 'e' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'addon' )['body'], array( "{$this->prefix}_key" => 'k' ) ) ); self::assertSame( array( 'General', 'Addon' ), self::tab_titles( $general ) ); - self::assertSame( - array( - 'name' => '', - 'extra' => 'e!', - ), - get_option( $this->option ) - ); - self::assertSame( array( 'key' => 'k' ), get_option( $addon ) ); + self::assertSame( array( '', 'e!', 'k' ), $this->stored( 'name', 'extra', 'key' ) ); } public function test_each_tab_shows_its_own_form_and_an_unknown_tab_shows_the_first(): void { - $second = "{$this->prefix}_second"; - $spec = $this->spec( - array( self::field( 'text', 'name' ) ), - array( - 'tabs' => array( - self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ), - self::tab( 'second', $second, array( self::field( 'text', 'other' ) ) ), - ), - ) + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) ); - $second_tab = self::document( $this->open( $spec, $this->admin, 'second' )['body'] ); + $second = $this->open( $spec, $this->admin, 'second' )['body']; + $second_tab = self::document( $second ); $unknown = self::document( $this->open( $spec, $this->admin, 'missing' )['body'] ); - $single = self::document( $this->open( $this->spec( array( self::field( 'text', 'name' ) ) ), $this->admin )['body'] ); + $single = self::document( $this->open( $this->spec( array( $this->field( 'text', 'name' ) ) ), $this->admin )['body'] ); - self::assertSame( $second, $second_tab->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( "{$this->id}_second", $second_tab->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( array( 'Other' ), self::row_titles( $second ) ); self::assertSame( 'Second', $second_tab->querySelector( '.nav-tab[aria-current="page"]' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( $this->option, $unknown->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( $this->group, $unknown->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); self::assertSame( 'General', $unknown->querySelector( '.nav-tab[aria-current="page"]' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNull( $single->querySelector( '.nav-tab' ) ); } - public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first_tabs_field(): void { + public function test_saving_one_tab_leaves_the_other_tabs_fields_as_they_are(): void { + update_option( "{$this->prefix}_name", 'first' ); $spec = $this->spec( - array( self::field( 'text', 'name' ) ), - array( - 'tabs' => array( - self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ), - self::tab( 'second', $this->option, array( self::field( 'text', 'other' ) ) ), - ), - ) + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'first' ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->option}[other]" => 'second' ) ) ); + $saved = $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->prefix}_other" => 'second' ) ) ); - self::assertSame( - array( - 'name' => 'first', - 'other' => 'second', - ), - get_option( $this->option ) - ); + self::assertNull( $saved['died'] ); + self::assertSame( array( 'first', 'second' ), $this->stored( 'name', 'other' ) ); } protected function spec( array $fields, array $page = array(), array $extra = array() ): array { - return array( - 'id' => $this->id, - 'slug' => $this->slug, - 'page' => $page + array( - 'parent' => 'options-general.php', - 'capability' => 'manage_options', - 'page_title' => 'Acme settings', - 'menu_title' => 'Acme', - 'tabs' => array( self::tab( 'general', $this->option, $fields ) ), + return \array_replace_recursive( + array( + 'id' => $this->id, + 'slug' => $this->slug, + 'prefix' => $this->prefix, + 'page' => $page + array( + 'parent' => 'options-general.php', + 'capability' => 'manage_options', + 'page_title' => 'Acme settings', + 'menu_title' => 'Acme', + 'tabs' => array( self::tab( 'general' ) ), + ), + 'fields' => array( 'general' => $fields ), ), - ) + $extra; + $extra + ); } protected function open( array $spec, int $user_id, string $tab = '' ): array { @@ -650,20 +727,23 @@ protected function follow( array $spec, int $user_id, array $saved ): array { return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $user_id, self::FIXTURE, $spec ); } - protected static function tab( string $slug, string $option, array $fields ): array { + // Reads each named field's row, as get_option() returns it. + protected function stored( string ...$names ): array { + return \array_map( fn ( string $name ): mixed => get_option( "{$this->prefix}_$name" ), $names ); + } + + protected static function tab( string $slug ): array { return array( - 'slug' => $slug, - 'title' => \ucfirst( $slug ), - 'option' => $option, - 'fields' => $fields, + 'slug' => $slug, + 'title' => \ucfirst( $slug ), ); } - protected static function field( string $type, string $id, array $keys = array() ): array { + protected function field( string $type, string $name, array $keys = array() ): array { return $keys + array( 'type' => $type, - 'id' => $id, - 'title' => \ucfirst( $id ), + 'id' => "{$this->prefix}_$name", + 'title' => \ucfirst( $name ), ); } diff --git a/tests/Integration/Support/RecordedDefinition.php b/tests/Integration/Support/RecordedDefinition.php index e1726d5..28019f3 100644 --- a/tests/Integration/Support/RecordedDefinition.php +++ b/tests/Integration/Support/RecordedDefinition.php @@ -20,6 +20,11 @@ public function get_options_page( string $page_id ): array { return $this->record( $page_id ); } + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return $this->record( $page_id, $tab_slug ); + } + #[\Override] public function get_object_form( string $form_id, int $object_id ): array { return $this->record( $form_id, $object_id ); From ff99ed626881c62b545c518de09ad870e79bf24b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:22:27 +0100 Subject: [PATCH 10/12] Expect the settings persona and consumer-b on field rows The settings persona and consumer-b store their page's fields in one array option, and the persona validates its limit by hand, while pages store each field in its own row (X ruling 416) and Forms holds a number to its min and step (X ruling 420). The rows expect the persona's limit in acme_ps_limit as canonical text, refused below its min with Forms' message, and consumer-b's greeting posting under its own id. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/ConsumerB/ConsumerBBootTest.php | 2 +- tests/Integration/Personas/SettingsPersonaTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php index 2729189..aea3249 100644 --- a/tests/Integration/ConsumerB/ConsumerBBootTest.php +++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php @@ -21,6 +21,6 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r self::assertNull( $request['died'] ); self::assertFalse( $request['records']['woocommerce_present'] ); self::assertSame( 'booted', $request['records']['consumer_b']['status'] ); - self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] ); + self::assertStringContainsString( 'name="acme_cb_greeting"', $request['body'] ); } } diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php index 3124df5..e834396 100644 --- a/tests/Integration/Personas/SettingsPersonaTest.php +++ b/tests/Integration/Personas/SettingsPersonaTest.php @@ -25,18 +25,18 @@ public function test_a_valid_limit_saved_through_options_php_is_stored(): void { $saved = $this->save_limit( '5' ); self::assertNull( $saved['died'] ); - self::assertSame( array( 'limit' => 5 ), get_option( 'acme_ps_settings' ) ); + self::assertSame( '5', get_option( 'acme_ps_limit' ) ); } public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows_on_the_page(): void { - update_option( 'acme_ps_settings', array( 'limit' => 5 ) ); + update_option( 'acme_ps_limit', '5' ); $saved = $this->save_limit( '0' ); - self::assertSame( array( 'limit' => 5 ), get_option( 'acme_ps_settings' ) ); + self::assertSame( '5', get_option( 'acme_ps_limit' ) ); self::assertIsString( $saved['location'] ); $page = $this->consumer_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $saved['user'], $saved['spec'] ); - 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'] ); + self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a 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 { @@ -60,7 +60,7 @@ public function test_the_post_form_saves_its_field_on_the_post_through_the_block protected function save_limit( string $limit ): array { $user = $this->make_user( 'acme_ps_admin', 'administrator' ); $spec = array( 'activate' => $this->install_consumer( 'personas/settings', 'acme-persona-settings' ) ); - $data = self::serialize_form( $this->consumer_request( 'GET', 'options-general.php?page=acme_ps', $user, $spec )['body'], 'form[action="options.php"]', array( 'acme_ps_settings[limit]' => $limit ) ); + $data = self::serialize_form( $this->consumer_request( 'GET', 'options-general.php?page=acme_ps', $user, $spec )['body'], 'form[action="options.php"]', array( 'acme_ps_limit' => $limit ) ); return $this->consumer_request( 'POST', 'options.php', $user, $spec, $data ) + array( 'user' => $user, From ae613907561bba661df277636e8fa3fd4c82ea1b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:24:45 +0100 Subject: [PATCH 11/12] Store each options page field in its own row OptionsPage stored each tab's fields as one array option and built every tab's fields on every admin request. Its definition now gives the page's tabs as slugs and titles and each tab's fields apart, through get_tab_fields() and the {root}/options_page/fields filter, which passes the tab's slug and the page's id; the page asks only for the fields of the tab it shows or the options.php request saves (X ruling 416). Each tab posts under the settings group {page id}_{tab slug}, whose capability the page sets, and only the posted tab's fields register, as string or array settings without a REST schema (X ruling 328), each sanitized through Forms on its own and keeping its stored value where the form posted nothing or the field rejects it, its rejection filed under its id. The page renders the shown tab from its primed rows, each control posting under its field's id, which Forms' rows take with an empty prefix, and renders no form for a tab without a field to save, whose group options.php would refuse. A field whose id does not start with the plugin's prefix, or is not a valid helper id, is left out with a _doing_it_wrong() notice, because the page's capability would otherwise write any option a filter named, a core option such as default_role among them; the label no longer needs its id escaped, since such an id holds no entity. The settings persona and consumer-b store their fields in rows, the persona's limit held to its min and step by Forms in place of its own validator; the settings README, which asks a number field with a step to state its min (X ruling 430), AGENTS.md's file map and convention 8 describe the rows. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 4 +- packages/settings/README.md | 43 +++-- .../settings/src/AdminPage/OptionsPage.php | 170 ++++++++++++++---- .../OptionsPageDefinitionInterface.php | 18 +- packages/settings/src/Forms.php | 6 +- tests/Fixtures/consumer-b/src/Settings.php | 34 ++-- .../personas/settings/src/Settings.php | 53 +++--- 7 files changed, 221 insertions(+), 107 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5520d6..87ad366 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ wordpress-framework/ │ └── Uninstall/Uninstaller ├── packages/settings/ ahegyes/wp-framework-settings `require.php` · core ^2.0 │ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal)) -│ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each tab's fields in one option through the Settings API +│ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each field in its own option through the Settings API │ └── EditScreen/{ObjectForm, ObjectFormDefinitionInterface, ObjectFormHostInterface, ObjectStoreInterface, FormTable (@internal), Hosts/{PostMetaBox, UserProfile, TermForm}, Stores/MetadataStore} · forms store each field on the edited object through a store ├── packages/woocommerce/ ahegyes/wp-framework-woocommerce `require.php` · settings ^2.0 · WooCommerce as host, at the plugin's `WC requires at least` │ └── src/ psr-4 DeepWebSolutions\Framework\WooCommerce\ · WooCommerceRequirement @@ -83,7 +83,7 @@ actionlint && zizmor .github/ 5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public. 6. **Shared logic.** The global namespace holds only a plugin's contract. `functions.php` declares its `@api` functions, the accessor among them, which returns the composition root while the root's members stay internal unless tagged `@api`, and registers its hooks as static closures; `includes/`, which `functions.php` loads, holds more `@api` functions and constants, grouped by topic. Every other symbol is a member of a final class in `src/` named for its concept: the composition root, which also holds the footprint, a component, or a class that registers no hooks, such as `Options`, whose constants name the stored settings and their defaults and whose public statics read them, and `Migrations`, whose one public method lists its protected static steps as first-class callables, while a batched step is a final class of its own and a meta key stays on the class that stores it. Built scripts and styles register through core's `Assets`, which the container defines. No namespace or folder is named for being internal. 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. -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 build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_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 `''`. +8. **Settings.** Each setting is one autoloaded option on every host, its field id the whole, flat option name, which on an options page starts with the plugin's `{prefix}_`. Defaults are constants in stored form; a translated default is never stored, and the component that shows the setting supplies it after `init`. A number field states its bounds only in `custom_attributes`. 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 build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_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/`. diff --git a/packages/settings/README.md b/packages/settings/README.md index bdfc77e..a2d9266 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -65,7 +65,7 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage -An `OptionsPage` stores each tab's fields in one option through the Settings API, and an `ObjectForm` stores each field on the edited object through a store. The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: +An `OptionsPage` stores each field in its own option through the Settings API, and an `ObjectForm` stores each field on the edited object through a store. The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: ```php use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; @@ -116,7 +116,7 @@ add_action( ); ``` -A definition object declares the page and its fields as data. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays: +A definition object declares the page and, apart, each tab's fields as data, so the page asks only for the fields of the tab it shows or saves. Each field is stored in the option its id names, which must start with the plugin's prefix and an underscore, since the page's capability writes it; the page leaves out any other field and reports it through `_doing_it_wrong()`. A number field is held to the `min`, `max` and `step` in its `custom_attributes`, and one with a `step` states its `min` too, from which both the browser and the save count the steps. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays. Another plugin adds tabs through the `{root}/options_page/tabs` filter and their fields, or fields of an existing tab, through `{root}/options_page/fields`: ```php final class Settings implements OptionsPageDefinitionInterface { @@ -131,22 +131,8 @@ final class Settings implements OptionsPageDefinitionInterface { 'menu_title' => __( 'Acme Persona Settings', 'acme-persona-settings' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-persona-settings' ), - 'option' => self::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'default' => '10', - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => array( $this, 'validate_limit' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-persona-settings' ), ), ), ); @@ -154,10 +140,23 @@ final class Settings implements OptionsPageDefinitionInterface { // ... - public function validate_limit( mixed $raw ): int|\WP_Error { - $limit = \filter_var( $raw, \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - - return false === $limit ? new \WP_Error( 'acme_ps_limit', __( 'Enter a whole number of at least 1.', 'acme-persona-settings' ) ) : $limit; + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'number', + 'id' => 'acme_ps_limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + ), + default => array(), + }; } // ... diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 41a9c3b..bcc8801 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -9,16 +9,16 @@ \defined( 'ABSPATH' ) || exit; /** - * Adds a submenu page whose tabs each edit one option row through the Settings API. + * Adds a submenu page whose tabs edit their fields through the Settings API, each field stored in the option its id names. * - * Each tab's form posts to options.php under a settings group named after its option, so WordPress checks the nonce and the page's capability before the page sanitizes the submission. + * Each tab's form posts to options.php under the settings group {page id}_{tab slug}, so WordPress checks the nonce and the page's capability before the page sanitizes the submission. Because a field can name any option, which the page's capability then writes, a field id must start with the plugin's prefix. * * @since 2.0.0 * @version 2.0.0 * * @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 Tab array{slug: string, title: string} * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} */ final class OptionsPage { @@ -34,6 +34,26 @@ final class OptionsPage { */ protected ?array $page = null; + /** + * The fields of each tab asked for, by the tab's slug, filtered and without the fields whose ids the page refuses. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array> + */ + protected array $fields = array(); + + /** + * The fields of the tab the current options.php request saves, by id, which register_settings() keeps for their sanitization. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array + */ + protected array $posted = array(); + // endregion // region MAGIC METHODS @@ -44,7 +64,7 @@ final class OptionsPage { * @since 2.0.0 * @version 2.0.0 * - * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's tabs filter. + * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's filters and whose prefix starts its field ids. * @param string $id The page's menu slug. * @param OptionsPageDefinitionInterface $definition The page's definition. * @@ -93,7 +113,7 @@ public function add_page(): void { } /** - * Registers each tab's option as a setting in a group of its own name and hooks the option's capability and sanitization. + * Registers, on the options.php request that saves one of the page's tabs, that tab's fields as settings of its group, and hooks the group's capability and each field's sanitization. * * @since 2.0.0 * @version 2.0.0 @@ -101,11 +121,25 @@ public function add_page(): void { * @return void */ public function register_settings(): void { - foreach ( \array_unique( \array_column( $this->get_page()['tabs'], 'option' ) ) as $option ) { - register_setting( $option, $option, array( 'type' => 'object' ) ); - add_filter( "option_page_capability_$option", array( $this, 'get_capability' ) ); - add_filter( "pre_update_option_$option", array( $this, 'sanitize_submission' ), 10, 3 ); + if ( 'options.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) { + return; } + + // WordPress core reads option_page from the request, not only the body, and trims it, so the same reading decides whether the submission is the page's. + $group = \is_string( $_REQUEST['option_page'] ?? null ) ? sanitize_text_field( wp_unslash( $_REQUEST['option_page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- options.php verifies the nonce before it saves. + $tab = \array_find( $this->get_page()['tabs'], fn ( array $tab ): bool => $group === $this->get_group( $tab['slug'] ) ); + if ( \is_null( $tab ) ) { + return; + } + + foreach ( $this->get_fields( $tab['slug'] ) as $field ) { + if ( isset( $field['id'] ) ) { + $this->posted[ $field['id'] ] = $field; + register_setting( $group, $field['id'], array( 'type' => \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ? 'array' : 'string' ) ); + add_filter( "pre_update_option_{$field['id']}", array( $this, 'sanitize_submission' ), 10, 3 ); + } + } + add_filter( "option_page_capability_$group", array( $this, 'get_capability' ) ); } /** @@ -121,41 +155,31 @@ public function get_capability(): string { } /** - * Returns the row to store for the option's own options.php submission, in which only the accepted fields change. + * Returns the value to store for a field of the posted tab, or its stored value where the form posted nothing for it or the field rejects it. * - * Other writes of the option pass through unchanged. A rejected field keeps its stored value and adds a settings error that names it. + * A rejection adds a settings error that names the field. * * @since 2.0.0 * @version 2.0.0 * - * @param mixed $value The submitted row, unslashed by options.php, or null when the form posted none of its fields. - * @param mixed $old_value The stored row. - * @param string $option The option's name. + * @param mixed $value The submitted value, unslashed by options.php, or null when the form posted none. + * @param mixed $old_value The stored value. + * @param string $option The option's name, which is the field's id. * * @return mixed */ public function sanitize_submission( mixed $value, mixed $old_value, string $option ): mixed { - // WordPress core reads option_page from the request, not only the body, and trims it, so the same reading decides whether the submission is this option's. - $option_page = \is_string( $_REQUEST['option_page'] ?? null ) ? sanitize_text_field( wp_unslash( $_REQUEST['option_page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- options.php verifies the nonce before it saves. - if ( 'options.php' !== ( $GLOBALS['pagenow'] ?? '' ) || $option_page !== $option ) { + $field = $this->posted[ $option ] ?? null; + if ( \is_null( $field ) ) { return $value; } - $fields = array(); - foreach ( $this->get_page()['tabs'] as $tab ) { - if ( $option === $tab['option'] ) { - $fields = \array_merge( $fields, $tab['fields'] ); - } + $result = Forms::sanitize_fields( array( $field ), \is_null( $value ) ? array() : array( $option => $value ) ); + if ( isset( $result['errors'][ $option ] ) ) { + add_settings_error( $option, $option, esc_html( Forms::get_rejection_message( Forms::get_title( $field ), $result['errors'][ $option ] ) ) ); } - $result = Forms::sanitize_fields( $fields, \is_array( $value ) ? $value : array() ); - foreach ( $fields as $field ) { - if ( isset( $field['id'], $result['errors'][ $field['id'] ] ) ) { - add_settings_error( $option, $field['id'], esc_html( Forms::get_rejection_message( Forms::get_title( $field ), $result['errors'][ $field['id'] ] ) ) ); - } - } - - return array() === $result['values'] ? $old_value : \array_replace( \is_array( $old_value ) ? $old_value : array(), $result['values'] ); + return \array_key_exists( $option, $result['values'] ) ? $result['values'][ $option ] : $old_value; } /** @@ -190,18 +214,29 @@ public function render_page(): void { echo ''; } - $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'] ); + $fields = $this->get_fields( $tab['slug'] ); + $ids = \array_column( $fields, 'id' ); + wp_prime_option_caches( $ids ); + $stored = array(); + foreach ( $ids as $id ) { + $value = get_option( $id ); + if ( false !== $value ) { // A field without a stored row shows its default. + $stored[ $id ] = $value; } + } + $rows = Forms::get_rows( $fields, $stored, '' ); + if ( array() === $rows ) { // A tab without a field to save registers no settings group, so options.php would refuse its form. + echo ''; + return; + } + + foreach ( $rows as $row ) { add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } echo '
'; - settings_fields( $option ); + settings_fields( $this->get_group( $tab['slug'] ) ); echo ''; do_settings_fields( $this->id, 'default' ); echo ''; @@ -240,7 +275,7 @@ protected function get_page(): array { $page = $this->definition->get_options_page( $this->id ); /** - * Filters the tabs of the options page, so that other plugins can add tabs or fields. + * Filters the tabs of the options page, so that other plugins can add tabs, whose fields come through the page's fields filter. * * @since 2.0.0 * @version 2.0.0 @@ -255,5 +290,66 @@ protected function get_page(): array { return $this->page; } + /** + * Returns the fields of one of the page's tabs, evaluating and filtering them on first use, without a field whose id does not start with the plugin's prefix or is not a valid helper id, which it reports through _doing_it_wrong(). + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $tab_slug The tab's slug. + * + * @return list + */ + protected function get_fields( string $tab_slug ): array { + if ( ! isset( $this->fields[ $tab_slug ] ) ) { + /** + * Filters the fields of one tab of the options page, so that other plugins can add fields, whose ids start with the plugin's prefix. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param list $fields The tab's fields. + * @param string $tab_slug The tab's slug. + * @param string $page_id The page's menu slug. + */ + $fields = apply_filters( "{$this->identity->hook_root}/options_page/fields", $this->definition->get_tab_fields( $this->id, $tab_slug ), $tab_slug, $this->id ); + + $this->fields[ $tab_slug ] = array(); + foreach ( $fields as $field ) { + $id = $field['id'] ?? null; + try { + if ( ! \is_null( $id ) ) { + Forms::check_helper_id( $id ); + } + $kept = \is_null( $id ) || \str_starts_with( $id, "{$this->identity->prefix}_" ); + } catch ( InvalidArgumentException ) { + $kept = false; + } + + if ( $kept ) { + $this->fields[ $tab_slug ][] = $field; + } else { + _doing_it_wrong( __METHOD__, esc_html( "Options page '$this->id' leaves out field '$id', whose id is not '{$this->identity->prefix}_' followed by lowercase letters, digits, underscores and hyphens." ), '2.0.0' ); + } + } + } + + return $this->fields[ $tab_slug ]; + } + + /** + * Returns the settings group of one of the page's tabs, which names its form's nonce and capability filter. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $tab_slug The tab's slug. + * + * @return string + */ + protected function get_group( string $tab_slug ): string { + return "{$this->id}_$tab_slug"; + } + // endregion } diff --git a/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php b/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php index 143a38b..29e72f8 100644 --- a/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php +++ b/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php @@ -2,15 +2,18 @@ namespace DeepWebSolutions\Framework\Settings\AdminPage; +use DeepWebSolutions\Framework\Settings\Forms; + \defined( 'ABSPATH' ) || exit; /** - * Defines an options page, which asks for its definition once, on first use after init, so that its labels are translated in the viewer's locale. + * Defines an options page, which asks for its definition once, on first use after init, so that its labels are translated in the viewer's locale, and for a tab's fields only when that tab shows or saves. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Page from OptionsPage + * @phpstan-import-type Field from Forms */ interface OptionsPageDefinitionInterface { /** @@ -24,4 +27,17 @@ interface OptionsPageDefinitionInterface { * @return Page */ public function get_options_page( string $page_id ): array; + + /** + * Returns the fields of one of the page's tabs, each stored in the option its id names, which must not throw. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $page_id The page's menu slug. + * @param string $tab_slug The tab's slug. + * + * @return list + */ + public function get_tab_fields( string $page_id, string $tab_slug ): array; } diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 49fa153..e0dcf2a 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -288,7 +288,7 @@ public static function escape_attribute( string $value ): string { * * @param list $fields The fields. * @param array $values The stored values, keyed by field id. - * @param string $prefix The prefix of the names and HTML ids, such as an option or a form id. + * @param string $prefix The prefix of the names and HTML ids, such as a form id, or an empty string where each field posts under its own id. * * @return list */ @@ -304,8 +304,8 @@ public static function get_rows( array $fields, array $values, string $prefix ): $row = array( 'field' => $field, 'value' => \array_key_exists( $field['id'], $values ) ? $values[ $field['id'] ] : ( $field['default'] ?? '' ), - 'name' => "{$prefix}[{$field['id']}]", - 'id' => "$prefix.{$field['id']}", + 'name' => '' === $prefix ? $field['id'] : "{$prefix}[{$field['id']}]", + 'id' => '' === $prefix ? $field['id'] : "$prefix.{$field['id']}", ); if ( ! self::is_group( $field ) ) { $row['label_for'] = $row['id']; diff --git a/tests/Fixtures/consumer-b/src/Settings.php b/tests/Fixtures/consumer-b/src/Settings.php index e7fdeb1..de95d07 100644 --- a/tests/Fixtures/consumer-b/src/Settings.php +++ b/tests/Fixtures/consumer-b/src/Settings.php @@ -10,7 +10,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Adds the plugin's settings page under Settings, whose one tab stores its fields in the plugin's settings option. + * Adds the plugin's settings page under Settings, whose one tab stores each of its fields in an option of its own. * * @since 1.0.0 * @version 1.0.0 @@ -60,20 +60,32 @@ public function get_options_page( string $page_id ): array { 'menu_title' => __( 'Acme Consumer B', 'acme-consumer-b' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-consumer-b' ), - 'option' => Plugin::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'text', - 'id' => 'greeting', - 'title' => __( 'Greeting', 'acme-consumer-b' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-consumer-b' ), ), ), ); } + /** + * {@inheritDoc} + * + * @since 1.0.0 + * @version 1.0.0 + */ + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'text', + 'id' => 'acme_cb_greeting', + 'title' => __( 'Greeting', 'acme-consumer-b' ), + ), + ), + default => array(), + }; + } + // endregion } diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php index 8d605c2..db8df7c 100644 --- a/tests/Fixtures/personas/settings/src/Settings.php +++ b/tests/Fixtures/personas/settings/src/Settings.php @@ -7,7 +7,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Defines the plugin's settings page under Settings, whose one tab stores a limit of at least 1 in the plugin's settings option. + * Defines the plugin's settings page under Settings, whose one tab stores a whole-number limit of at least 1 in an option of its own. * * @since 1.0.0 * @version 1.0.0 @@ -26,7 +26,7 @@ final class Settings implements OptionsPageDefinitionInterface { public const string SLUG = 'acme-persona-settings'; /** - * The prefix of the plugin's admin page and option. + * The prefix of the plugin's admin page and of the names it stores its data under. * * @since 1.0.0 * @version 1.0.0 @@ -54,45 +54,36 @@ public function get_options_page( string $page_id ): array { 'menu_title' => __( 'Acme Persona Settings', 'acme-persona-settings' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-persona-settings' ), - 'option' => self::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'default' => '10', - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => array( $this, 'validate_limit' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-persona-settings' ), ), ), ); } - // endregion - - // region METHODS - /** - * Returns the submitted limit as an integer, or an error when it is not a whole number of at least 1. + * {@inheritDoc} * * @since 1.0.0 * @version 1.0.0 - * - * @param mixed $raw The submitted value. - * - * @return int|\WP_Error */ - public function validate_limit( mixed $raw ): int|\WP_Error { - $limit = \filter_var( $raw, \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - - return false === $limit ? new \WP_Error( 'acme_ps_limit', __( 'Enter a whole number of at least 1.', 'acme-persona-settings' ) ) : $limit; + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'number', + 'id' => 'acme_ps_limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + ), + default => array(), + }; } // endregion From 7541bf9597bc1c2f6b8d9ff37b410e4c74c1a8e5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:25:57 +0100 Subject: [PATCH 12/12] Write the personas' stored names whole The woocommerce and settings personas compose their option and meta names from their prefix, which the package READMEs copy, while a stored name is written whole, so that a contract string can be found and an edit of the prefix cannot rename stored data (X ruling 414). The names are unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- packages/settings/README.md | 2 +- packages/woocommerce/README.md | 2 +- tests/Fixtures/personas/settings/src/Details.php | 2 +- tests/Fixtures/personas/woocommerce/src/Options.php | 2 +- tests/Fixtures/personas/woocommerce/src/Packing.php | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/settings/README.md b/packages/settings/README.md index a2d9266..8730b91 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -177,7 +177,7 @@ final class Details implements ObjectFormDefinitionInterface { 'fields' => array( array( 'type' => 'text', - 'id' => '_' . Settings::PREFIX . '_subtitle', + 'id' => '_acme_ps_subtitle', 'title' => __( 'Subtitle', 'acme-persona-settings' ), ), ), diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index ac4db99..ddef2ab 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -151,7 +151,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD return array( array( 'type' => 'text', - 'id' => self::PREFIX . '_default_box', + 'id' => 'acme_pw_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), array( diff --git a/tests/Fixtures/personas/settings/src/Details.php b/tests/Fixtures/personas/settings/src/Details.php index 32112e9..29d7d1e 100644 --- a/tests/Fixtures/personas/settings/src/Details.php +++ b/tests/Fixtures/personas/settings/src/Details.php @@ -29,7 +29,7 @@ public function get_object_form( string $form_id, int $object_id ): array { 'fields' => array( array( 'type' => 'text', - 'id' => '_' . Settings::PREFIX . '_subtitle', + 'id' => '_acme_ps_subtitle', 'title' => __( 'Subtitle', 'acme-persona-settings' ), ), ), diff --git a/tests/Fixtures/personas/woocommerce/src/Options.php b/tests/Fixtures/personas/woocommerce/src/Options.php index 6e6adb8..3898535 100644 --- a/tests/Fixtures/personas/woocommerce/src/Options.php +++ b/tests/Fixtures/personas/woocommerce/src/Options.php @@ -21,7 +21,7 @@ final class Options { * * @var string */ - public const string DELETE_DATA = Packing::PREFIX . '_delete_data'; + public const string DELETE_DATA = 'acme_pw_delete_data'; // endregion diff --git a/tests/Fixtures/personas/woocommerce/src/Packing.php b/tests/Fixtures/personas/woocommerce/src/Packing.php index 735d848..713809c 100644 --- a/tests/Fixtures/personas/woocommerce/src/Packing.php +++ b/tests/Fixtures/personas/woocommerce/src/Packing.php @@ -44,7 +44,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD * * @var string */ - public const string META_BOX = '_' . self::PREFIX . '_box'; + public const string META_BOX = '_acme_pw_box'; /** * The plugin's stored data by kind: every option under the plugin's prefix, and each product's box. @@ -74,7 +74,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array return array( array( 'type' => 'text', - 'id' => self::PREFIX . '_default_box', + 'id' => 'acme_pw_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), array(