diff --git a/.gitattributes b/.gitattributes index 59927d5..bae49d2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,7 +10,6 @@ /CODE_OF_CONDUCT.md export-ignore /commitlint.config.js export-ignore /composer.lock export-ignore -/husky.config.js export-ignore /package-lock.json export-ignore /package.json export-ignore /phpcs.xml.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/fixtures/template-extension/README.md b/.github/fixtures/template-extension/README.md new file mode 100644 index 0000000..b103620 --- /dev/null +++ b/.github/fixtures/template-extension/README.md @@ -0,0 +1,31 @@ +# Parent template output goldens + +These HTML files capture the parent templates at commit +`28db7c3f58387e08176664163fdbf92ab515d572`, before the new extension blocks. +Every byte, including whitespace, is compared without output normalization. + +The harness uses the parent's real Twig namespace registration, autoescaping, +attribute/BEM helpers, and head/menu/footer partials. WordPress functions and +actions emit deterministic hook markers. Only the literal date input `now` is +frozen at `2026-10-22T12:00:00Z`; formatting still delegates to Twig's real date +formatter in UTC, keeping the footer's year stable. + +The eight `content-*` files cover every combination of title, menu, and +populated/empty content. Empty cases render the real base directly and compare +it with an empty ArrayLoader child content block at runtime. Populated cases +use that child template to supply the real base's content block. The two +`page-*` files render the real parent page fallback with a post and without one. + +Verify without rewriting fixtures: + +```sh +php .github/scripts/template-extension-smoke.php +``` + +Capture intentionally, after reviewing the rendered-output change: + +```sh +php .github/scripts/template-extension-smoke.php --update-goldens +``` + +Updating goldens is an explicit review step. Do not use the capture option in CI. diff --git a/.github/fixtures/template-extension/content-no-title-menu-empty.html b/.github/fixtures/template-extension/content-no-title-menu-empty.html new file mode 100644 index 0000000..dbcc87d --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-menu-empty.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-no-title-menu-populated.html b/.github/fixtures/template-extension/content-no-title-menu-populated.html new file mode 100644 index 0000000..7077e77 --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-menu-populated.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + +
+ +

Content & fixture

+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-empty.html b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html new file mode 100644 index 0000000..f5957a2 --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-populated.html b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html new file mode 100644 index 0000000..75ae1e3 --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + +
+ +

Content & fixture

+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-title-menu-empty.html b/.github/fixtures/template-extension/content-title-menu-empty.html new file mode 100644 index 0000000..202624c --- /dev/null +++ b/.github/fixtures/template-extension/content-title-menu-empty.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-title-menu-populated.html b/.github/fixtures/template-extension/content-title-menu-populated.html new file mode 100644 index 0000000..7227eae --- /dev/null +++ b/.github/fixtures/template-extension/content-title-menu-populated.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + +
+ + +

Content & fixture

+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-title-no-menu-empty.html b/.github/fixtures/template-extension/content-title-no-menu-empty.html new file mode 100644 index 0000000..8f77c23 --- /dev/null +++ b/.github/fixtures/template-extension/content-title-no-menu-empty.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + diff --git a/.github/fixtures/template-extension/content-title-no-menu-populated.html b/.github/fixtures/template-extension/content-title-no-menu-populated.html new file mode 100644 index 0000000..836219c --- /dev/null +++ b/.github/fixtures/template-extension/content-title-no-menu-populated.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + +
+ + +

Content & fixture

+ + + + + + + + diff --git a/.github/fixtures/template-extension/page-no-post.html b/.github/fixtures/template-extension/page-no-post.html new file mode 100644 index 0000000..001935a --- /dev/null +++ b/.github/fixtures/template-extension/page-no-post.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + +
+ +

No page content was found.

+
+ + + + + + + + diff --git a/.github/fixtures/template-extension/page-post.html b/.github/fixtures/template-extension/page-post.html new file mode 100644 index 0000000..bba3813 --- /dev/null +++ b/.github/fixtures/template-extension/page-post.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + +
+ +
+
+

Post & fixture

+
+ +
+

Content & fixture

+
+
+
+ + + + + + + + diff --git a/.github/fixtures/twig-helper-contract.json b/.github/fixtures/twig-helper-contract.json new file mode 100644 index 0000000..8dccf9f --- /dev/null +++ b/.github/fixtures/twig-helper-contract.json @@ -0,0 +1,252 @@ +{ + "schemaVersion": 1, + "description": "Observed WordPress parent and Core helper behavior, including known differences. Expectations are reviewed literals; this check never rewrites them. repeat joins call output with | and records the final attributes/context.", + "reference": { + "coreVersion": "4.5.0", + "corpus": "emulsify-core/src/extensions/twig/__fixtures__/parity-v1.json", + "documentation": "emulsify-core/docs/twig-php-parity.md" + }, + "cases": [ + { + "id": "bem-block", + "helper": "bem", + "args": ["button"], + "php": {"attributes": {"class": ["button"]}, "html": "class=\"button\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["button"]}, "html": "class=\"button\"", "contextAfter": {}} + }, + { + "id": "bem-element-modifiers-and-extras", + "helper": "bem", + "args": ["title", ["small", "small"], "card", ["js-click", "title"]], + "php": {"attributes": {"class": ["card__title", "card__title--small", "js-click", "title"]}, "html": "class=\"card__title card__title--small js-click title\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__title", "card__title--small", "js-click", "title"]}, "html": "class=\"card__title card__title--small js-click title\"", "contextAfter": {}} + }, + { + "id": "bem-object-block-element", + "helper": "bem", + "args": [{"block": "card", "element": "title", "modifiers": ["small"]}], + "php": {"attributes": {"class": ["card__title", "card__title--small"]}, "html": "class=\"card__title card__title--small\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__title", "card__title--small"]}, "html": "class=\"card__title card__title--small\"", "contextAfter": {}} + }, + { + "id": "bem-object-camelcase-aliases", + "helper": "bem", + "args": [{"baseClass": "button", "blockName": "card", "extra": ["utility"]}], + "php": {"attributes": {"class": ["card__button", "utility"]}, "html": "class=\"card__button utility\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__button", "utility"]}, "html": "class=\"card__button utility\"", "contextAfter": {}} + }, + { + "id": "bem-object-explicit-snakecase-keys", + "helper": "bem", + "args": [{"base_class": "title", "blockname": "card", "block": "ignored", "element": "ignored", "attributes": {"data-count": 0}}], + "php": {"attributes": {"data-count": "0", "class": ["card__title"]}, "html": "data-count=\"0\" class=\"card__title\"", "contextAfter": {}}, + "js": {"attributes": {"data-count": "0", "class": ["card__title"]}, "html": "data-count=\"0\" class=\"card__title\"", "contextAfter": {}} + }, + { + "id": "bem-nested-lists-and-punctuation", + "helper": "bem", + "args": ["Ünicode card/1", ["wide space", ["nested"], "", null, false], "", ["u:wide", "2xl"]], + "php": {"attributes": {"class": ["nicode", "card-1", "card-1--wide", "space", "card-1--nested", "u-wide", "_2xl"]}, "html": "class=\"nicode card-1 card-1--wide space card-1--nested u-wide _2xl\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["nicode", "card-1", "card-1--wide", "space", "card-1--nested", "u-wide", "_2xl"]}, "html": "class=\"nicode card-1 card-1--wide space card-1--nested u-wide _2xl\"", "contextAfter": {}} + }, + { + "id": "bem-boolean-and-numeric-values", + "helper": "bem", + "args": ["card", [false, 0, true, ["nested"]], "", [0, "u:wide", "2xl"]], + "php": {"attributes": {"class": ["card", "card--0", "card--1", "card--nested", "_0", "u-wide", "_2xl"]}, "html": "class=\"card card--0 card--1 card--nested _0 u-wide _2xl\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card", "card--0", "card--true", "card--nested", "u-wide", "_2xl"]}, "html": "class=\"card card--0 card--true card--nested u-wide _2xl\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "PHP casts true modifiers to 1 and retains numeric zero extra classes; Core stringifies true and drops numeric zero class values. Both omit false modifiers and retain zero modifiers."}, + {"field": "html", "reason": "The differing boolean modifier and numeric zero class tokens remain visible in the exact serialized HTML."} + ] + }, + { + "id": "bem-array-context-and-attribute-precedence", + "helper": "bem", + "context": {"id": "context", "class": ["context", "seed"], "title": "present"}, + "args": ["button", ["wide"], "", ["utility"], {"id": "option", "class": ["seed", "button"]}], + "php": {"attributes": {"id": "context", "class": ["seed", "button", "button--wide", "utility", "context"], "title": "present"}, "html": "id=\"context\" class=\"seed button button--wide utility context\" title=\"present\"", "contextAfter": {"id": "context", "class": ["context", "seed"], "title": "present"}}, + "js": {"attributes": {"id": "context", "class": ["seed", "button", "button--wide", "utility", "context"], "title": "present"}, "html": "id=\"context\" class=\"seed button button--wide utility context\" title=\"present\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The parent copies caller attributes without consuming them; Core clears invocation context attributes after a helper call."}] + }, + { + "id": "bem-bag-context", + "helper": "bem", + "contextKind": "bag", + "context": {"id": "existing", "class": ["context", "button"]}, + "args": ["button", ["wide"]], + "php": {"attributes": {"class": ["button", "button--wide", "context"], "id": "existing"}, "html": "class=\"button button--wide context\" id=\"existing\"", "contextAfter": {"id": "existing", "class": ["context", "button"]}}, + "js": {"attributes": {"class": ["button", "button--wide", "context"], "id": "existing"}, "html": "class=\"button button--wide context\" id=\"existing\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The parent also copies its AttributeBag context without mutating the original bag; Core clears the caller's context slot."}] + }, + { + "id": "bem-empty", + "helper": "bem", + "args": ["", [], "", []], + "php": {"attributes": {}, "html": "", "contextAfter": {}}, + "js": {"attributes": {}, "html": "", "contextAfter": {}} + }, + { + "id": "add-class-merging-and-list-replacement", + "helper": "add_attributes", + "context": {"class": ["base", "shared"], "aria-describedby": ["old", "same"]}, + "args": [{"class": ["shared", "new"], "aria-describedby": ["new", "same"]}], + "php": {"attributes": {"class": ["base", "shared", "new"], "aria-describedby": ["new", "same"]}, "html": "class=\"base shared new\" aria-describedby=\"new same\"", "contextAfter": {"class": ["base", "shared"], "aria-describedby": ["old", "same"]}}, + "js": {"attributes": {"class": ["base", "shared", "new"], "aria-describedby": ["new", "same"]}, "html": "class=\"base shared new\" aria-describedby=\"new same\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "Class merging and non-class list replacement agree, but only Core consumes caller attributes."}] + }, + { + "id": "add-booleans-aria-and-falsy-classes", + "helper": "add_attributes", + "args": [{"disabled": true, "hidden": false, "data-null": null, "aria-expanded": false, "aria-selected": true, "aria-checked": "false", "class": [true, false, 0, null, ""]}], + "php": {"attributes": {"disabled": true, "aria-selected": true, "aria-checked": "false", "class": ["_1", "_0"]}, "html": "disabled aria-selected aria-checked=\"false\" class=\"_1 _0\"", "contextAfter": {}}, + "js": {"attributes": {"disabled": true, "aria-selected": true, "aria-checked": "false", "class": ["true"]}, "html": "disabled aria-selected aria-checked=\"false\" class=\"true\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "PHP class casts keep true as 1 and numeric zero; Core retains true as the token true and drops numeric zero. Both treat boolean ARIA input like HTML booleans; ARIA string values are the portable form."}, + {"field": "html", "reason": "The class token differences remain serialized; neither runtime silently coerces boolean ARIA input into the strings required by ARIA."} + ] + }, + { + "id": "add-boolean-overrides-and-empty-string", + "helper": "add_attributes", + "context": {"disabled": true, "aria-expanded": "true", "title": "before", "data-null": "before"}, + "args": [{"disabled": false, "aria-expanded": false, "title": "", "data-null": null}], + "php": {"attributes": {"disabled": true, "aria-expanded": "true", "title": "", "data-null": "before"}, "html": "disabled aria-expanded=\"true\" title=\"\" data-null=\"before\"", "contextAfter": {"disabled": true, "aria-expanded": "true", "title": "before", "data-null": "before"}}, + "js": {"attributes": {"disabled": true, "aria-expanded": "true", "title": "", "data-null": "before"}, "html": "disabled aria-expanded=\"true\" title=\"\" data-null=\"before\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "Both ignore false/null replacements and preserve empty strings; WordPress retains the source context and Core consumes it."}] + }, + { + "id": "add-escaping-and-bag-context", + "helper": "add_attributes", + "contextKind": "bag", + "context": {"id": "old"}, + "args": [{"title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": 0, "disabled": true}], + "php": {"attributes": {"id": "old", "title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": "0", "disabled": true}, "html": "id=\"old\" title=\"A & "B" <C> 'D'\" href=\"/?a=1&b=2\" data-count=\"0\" disabled", "contextAfter": {"id": "old"}}, + "js": {"attributes": {"id": "old", "title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": "0", "disabled": true}, "html": "id=\"old\" title=\"A & "B" <C> 'D'\" href=\"/?a=1&b=2\" data-count=\"0\" disabled", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The exact escaping, numeric stringification, and boolean serialization agree; only Core consumes the caller's bag context."}] + }, + { + "id": "add-invalid-names-and-nested-values", + "helper": "add_attributes", + "args": [{"bad name": "ignored", "_keys": ["ignored"], "data-good": [true, false, 0, "a&b", ["nested"]], "aria-expanded": "false", "aria-selected": "true", "class": "class=\"legacy legacy extra\""}], + "php": {"attributes": {"data-good": ["1", "0", "a&b", "nested"], "aria-expanded": "false", "aria-selected": "true", "class": ["legacy", "extra"]}, "html": "data-good=\"1 0 a&b nested\" aria-expanded=\"false\" aria-selected=\"true\" class=\"legacy extra\"", "contextAfter": {}}, + "js": {"attributes": {"data-good": ["true", "0", "a&b", "nested"], "aria-expanded": "false", "aria-selected": "true", "class": ["legacy", "extra"]}, "html": "data-good=\"true 0 a&b nested\" aria-expanded=\"false\" aria-selected=\"true\" class=\"legacy extra\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "Inside non-class attribute lists PHP casts true to 1, while Core stringifies it to true. Invalid names, _keys, legacy class strings, and explicit ARIA strings agree."}, + {"field": "html", "reason": "The true list-item cast difference remains visible in serialized data-good."} + ] + }, + { + "id": "add-repeated-context-consumption", + "helper": "add_attributes", + "repeat": 2, + "context": {"id": "existing", "class": ["context"]}, + "args": [{"class": ["extra"]}], + "php": {"attributes": {"id": "existing", "class": ["context", "extra"]}, "html": "id=\"existing\" class=\"context extra\"|id=\"existing\" class=\"context extra\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"class": ["extra"]}, "html": "id=\"existing\" class=\"context extra\"|class=\"extra\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "The second WordPress helper call still merges source context attributes, whereas Core consumed them during the first call."}, + {"field": "html", "reason": "WordPress repeats source context attributes in the second output; Core prints them only on the first call."}, + {"field": "contextAfter", "reason": "The parent preserves caller context on both calls; Core clears it after the first call."} + ] + }, + { + "id": "add-no-arguments-context-leak", + "helper": "add_attributes", + "context": {"id": "existing", "class": ["context"]}, + "contextVars": {"fixture_secret": "do-not-print"}, + "args": [], + "php": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, + "differences": [ + {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} + ] + }, + { + "id": "bem-no-arguments-context-options-leak", + "helper": "bem", + "context": {"id": "existing", "class": ["context"]}, + "contextVars": {"block": "unrelated-block"}, + "args": [], + "php": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, + "differences": [ + {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} + ] + }, + { + "id": "add-event-handler-names", + "helper": "add_attributes", + "args": [{"onclick": "x()", "onMouseOver": "y()", "data-ok": "1"}], + "php": {"attributes": {"data-ok": "1"}, "html": "data-ok=\"1\"", "contextAfter": {}}, + "js": {"attributes": {"onclick": "x()", "onMouseOver": "y()", "data-ok": "1"}, "html": "onclick=\"x()\" onMouseOver=\"y()\" data-ok=\"1\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "The WordPress parent intentionally rejects every case-insensitive on-prefixed attribute name to prevent inline event handlers. Core currently accepts those names."}, + {"field": "html", "reason": "PHP omits event-handler attributes from serialized markup; Core currently serializes them. Both retain data-ok."} + ] + }, + { + "id": "add-wordpress-url-protocols", + "helper": "add_attributes", + "args": [{"href":"javascript:alert(1)","SRC":"javascript:alert(2)","action":"javascript:alert(3)","formaction":"java\nscript:alert(4)","xlink:href":"javascript:alert(5)","data-ok":"1"}], + "php": {"attributes":{"href":"alert(1)","SRC":"alert(2)","action":"alert(3)","formaction":"alert(4)","xlink:href":"alert(5)","data-ok":"1"},"html":"href=\"alert(1)\" SRC=\"alert(2)\" action=\"alert(3)\" formaction=\"alert(4)\" xlink:href=\"alert(5)\" data-ok=\"1\"","contextAfter":{}}, + "js": {"attributes":{"href":"javascript:alert(1)","SRC":"javascript:alert(2)","action":"javascript:alert(3)","formaction":"java\nscript:alert(4)","xlink:href":"javascript:alert(5)","data-ok":"1"},"html":"href=\"javascript:alert(1)\" SRC=\"jav&#x61;script:alert(2)\" action=\"javascript&#58;alert(3)\" formaction=\"java\nscript:alert(4)\" xlink:href=\"javascript:alert(5)\" data-ok=\"1\"","contextAfter":{}}, + "differences": [ + {"field":"attributes","reason":"WordPress sanitizes URL-bearing attribute values with its allowed protocol policy, including mixed-case names and obfuscated schemes; Core preserves them."}, + {"field":"html","reason":"WordPress sanitizes URL-bearing attribute values with its allowed protocol policy, including mixed-case names and obfuscated schemes; Core preserves them."} + ] + }, + { + "id": "add-data-uri-default-policy", + "helper": "add_attributes", + "args": [{"src":"data:image/png;base64,AA==","href":"/relative?a=1&b=2","data-label":"safe"}], + "php": {"attributes":{"src":"image/png;base64,AA==","href":"/relative?a=1&b=2","data-label":"safe"},"html":"src=\"image/png;base64,AA==\" href=\"/relative?a=1&b=2\" data-label=\"safe\"","contextAfter":{}}, + "js": {"attributes":{"src":"data:image/png;base64,AA==","href":"/relative?a=1&b=2","data-label":"safe"},"html":"src=\"data:image/png;base64,AA==\" href=\"/relative?a=1&b=2\" data-label=\"safe\"","contextAfter":{}}, + "differences": [ + {"field":"attributes","reason":"WordPress excludes data from its default allowed URL protocols; Core preserves the data URI. Relative URLs and data-* attributes remain supported."}, + {"field":"html","reason":"WordPress excludes data from its default allowed URL protocols; Core preserves the data URI. Relative URLs and data-* attributes remain supported."} + ] + }, + { + "id": "add-framework-expressions-and-srcdoc", + "helper": "add_attributes", + "args": [{"srcdoc":"","x-on:click":"x()","hx-on:click":"y()","v-on:click":"z()",":href":"url","x-bind:href":"url","v-bind:href":"url","x-data":"x()","x-init":"x()","v-html":"html","hx-vals":"js:x()","hx-vars":"x:x()","hx-headers":"js:x()","hx-request":"js:x()","hx-trigger":"click[x()]","data-hx-on:click":"x()","data-hx-vals":"js:x()","hx-get":"/fragment","aria-label":"Safe","data-ok":"1"}], + "php": {"attributes":{"hx-get":"/fragment","aria-label":"Safe","data-ok":"1"},"html":"hx-get=\"/fragment\" aria-label=\"Safe\" data-ok=\"1\"","contextAfter":{}}, + "js": {"attributes":{"srcdoc":"","x-on:click":"x()","hx-on:click":"y()","v-on:click":"z()",":href":"url","x-bind:href":"url","v-bind:href":"url","x-data":"x()","x-init":"x()","v-html":"html","hx-vals":"js:x()","hx-vars":"x:x()","hx-headers":"js:x()","hx-request":"js:x()","hx-trigger":"click[x()]","data-hx-on:click":"x()","data-hx-vals":"js:x()","hx-get":"/fragment","aria-label":"Safe","data-ok":"1"},"html":"srcdoc=\"<script>x()</script>\" x-on:click=\"x()\" hx-on:click=\"y()\" v-on:click=\"z()\" :href=\"url\" x-bind:href=\"url\" v-bind:href=\"url\" x-data=\"x()\" x-init=\"x()\" v-html=\"html\" hx-vals=\"js:x()\" hx-vars=\"x:x()\" hx-headers=\"js:x()\" hx-request=\"js:x()\" hx-trigger=\"click[x()]\" data-hx-on:click=\"x()\" data-hx-vals=\"js:x()\" hx-get=\"/fragment\" aria-label=\"Safe\" data-ok=\"1\"","contextAfter":{}}, + "differences": [ + {"field":"attributes","reason":"WordPress rejects srcdoc, Alpine/Vue directive and shorthand binding names, and htmx handler or evaluation-bearing attributes (including data-hx aliases); Core accepts them. Static htmx, data-* and aria-* attributes remain supported."}, + {"field":"html","reason":"WordPress rejects srcdoc, Alpine/Vue directive and shorthand binding names, and htmx handler or evaluation-bearing attributes (including data-hx aliases); Core accepts them. Static htmx, data-* and aria-* attributes remain supported."} + ] + }, + { + "id": "add-case-insensitive-class", + "helper": "add_attributes", + "args": [{"CLASS":["first.bad"],"class":"second","ClAsS":"third","viewBox":"0 0 10 10"}], + "php": {"attributes":{"class":["first-bad","second","third"],"viewBox":"0 0 10 10"},"html":"class=\"first-bad second third\" viewBox=\"0 0 10 10\"","contextAfter":{}}, + "js": {"attributes":{"CLASS":["first.bad"],"class":["second"],"ClAsS":"third","viewBox":"0 0 10 10"},"html":"CLASS=\"first.bad\" class=\"second\" ClAsS=\"third\" viewBox=\"0 0 10 10\"","contextAfter":{}}, + "differences": [ + {"field":"attributes","reason":"WordPress recognizes and merges class case-insensitively while preserving authored case for SVG attributes; Core treats differently cased class names as separate attributes."}, + {"field":"html","reason":"WordPress recognizes and merges class case-insensitively while preserving authored case for SVG attributes; Core treats differently cased class names as separate attributes."} + ] + }, + { + "id": "add-plain-object-boundary", + "helper": "add_attributes", + "argumentKind": "object", + "args": [{"post_password":"secret","post_title":"Private"}], + "php": {"attributes":{},"html":"","contextAfter":{}}, + "js": {"attributes":{"post_password":"secret","post_title":"Private"},"html":"post_password=\"secret\" post_title=\"Private\"","contextAfter":{}}, + "differences": [ + {"field":"attributes","reason":"WordPress accepts arrays, Traversables and AttributeBag instances as attribute sources, never arbitrary object properties. Core accepts plain JavaScript objects."}, + {"field":"html","reason":"WordPress accepts arrays, Traversables and AttributeBag instances as attribute sources, never arbitrary object properties. Core accepts plain JavaScript objects."} + ] + }, + { + "id": "add-empty", + "helper": "add_attributes", + "args": [{}], + "php": {"attributes": {}, "html": "", "contextAfter": {}}, + "js": {"attributes": {}, "html": "", "contextAfter": {}} + } + ] +} diff --git a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js index b2d1ee5..65cd72b 100644 --- a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js +++ b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js @@ -1,11 +1,30 @@ +import { renderTwig } from '@emulsify/core/storybook'; + import template from './ci-readiness.twig'; +const render = renderTwig(template); + export default { title: 'CI/Readiness fixture', }; export const Default = { - render: (args) => template(args), + render: (args) => render({ ...args, link_text: '' }), + // Anchor this CI-only readiness delay to Pa11y injection: navigation's + // network-idle wait must not consume the delay before pa11y.wait begins. + play: async ({ canvasElement, args }) => { + await new Promise((resolve) => { + const waitForPa11y = () => { + if (window.__pa11y) { + setTimeout(resolve, 750); + } else { + requestAnimationFrame(waitForPa11y); + } + }; + waitForPa11y(); + }); + canvasElement.querySelector('a').textContent = args.link_text; + }, args: { title: 'CI accessibility readiness', summary: diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs b/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs new file mode 100644 index 0000000..59c5975 --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs @@ -0,0 +1 @@ +export const unusedProjectValue = () => 17; diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js new file mode 100644 index 0000000..7f06807 --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js @@ -0,0 +1 @@ +export const projectValue = 42; diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js new file mode 100644 index 0000000..e3d0e6e --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js @@ -0,0 +1,5 @@ +import { projectValue } from './jest-value.js'; + +it('executes an ESM import from project source', () => { + expect(projectValue).toBe(42); +}); diff --git a/.github/scripts/a11y-runner.test.cjs b/.github/scripts/a11y-runner.test.cjs new file mode 100644 index 0000000..72396a1 --- /dev/null +++ b/.github/scripts/a11y-runner.test.cjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +// Exercise the real Core runner with a controlled Pa11y boundary. Browser-based +// coverage uses the CI Storybook fixture and the generated theme's a11y command. +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); +const coreRoot = + process.env.EMULSIFY_CORE_ROOT || + path.join(repoRoot, 'whisk/node_modules/@emulsify/core'); + +function write(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +} + +function runFixture(storyIds) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-a11y-runner-')); + try { + write(path.join(root, 'package.json'), '{"type":"module"}'); + const installedCore = path.join(root, 'node_modules/@emulsify/core'); + write(path.join(installedCore, 'package.json'), '{"type":"module"}'); + for (const file of ['scripts/a11y.js', 'config/a11y.config.js']) { + write( + path.join(installedCore, file), + fs.readFileSync(path.join(coreRoot, file)), + ); + } + write( + path.join(root, 'config/emulsify-core/run-a11y.js'), + fs.readFileSync( + path.join(repoRoot, 'whisk/config/emulsify-core/run-a11y.js'), + ), + ); + write( + path.join(root, 'config/emulsify-core/a11y.config.js'), + `export default ${JSON.stringify({ + concurrency: 2, + storybookBuildDir: '.out', + components: [], + ignore: { codes: ['fixture.ignored'] }, + pa11y: { wait: 321 }, + })};\n`, + ); + write( + path.join(root, '.out/index.json'), + JSON.stringify({ + entries: Object.fromEntries( + storyIds.map((id) => [id, { id, type: 'story' }]), + ), + }), + ); + write( + path.join(root, '.out/iframe.html'), + '
Served from the configured .out directory.
', + ); + write( + path.join(root, 'node_modules/pa11y/package.json'), + '{"type":"module","exports":"./index.js"}', + ); + write( + path.join(root, 'node_modules/pa11y/index.js'), + ` +import fs from 'node:fs'; +let active = 0; +const record = (entry) => fs.appendFileSync('pa11y-calls.jsonl', JSON.stringify(entry) + '\\n'); +export default async function pa11y(url, options) { + const storyId = new URL(url).searchParams.get('id'); + active += 1; + record({ storyId, active, options }); + try { + const response = await fetch(url); + if (!response.ok || !(await response.text()).includes('configured .out directory')) { + throw new Error('The configured Storybook build was not served.'); + } + await new Promise((resolve) => setTimeout(resolve, storyId === 'clean-first' ? 60 : 20)); + if (storyId.startsWith('failed-')) throw new Error('fixture navigation failure: ' + storyId); + return { pageUrl: url, issues: [ + { code: 'fixture.ignored', type: 'error', message: 'Ignored fixture issue' }, + ...(storyId === 'findings' ? [{ code: 'fixture.reported', type: 'error', message: 'Reported fixture issue' }] : []), + ] }; + } finally { + active -= 1; + } +} +`, + ); + const result = spawnSync( + process.execPath, + ['config/emulsify-core/run-a11y.js'], + { + cwd: root, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, CI: 'true' }, + }, + ); + assert.ifError(result.error); + assert.equal( + result.signal, + null, + 'The runner must close its HTTP server after every outcome.', + ); + const callsPath = path.join(root, 'pa11y-calls.jsonl'); + const calls = fs.existsSync(callsPath) + ? fs.readFileSync(callsPath, 'utf8').trim().split('\n').map(JSON.parse) + : []; + return { ...result, calls }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test('accessibility runner rejects zero discovered stories before starting scans', () => { + const result = runFixture([]); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /No Storybook stories were discovered; accessibility checks did not run/, + ); + assert.deepEqual(result.calls, []); +}); + +test('accessibility runner delegates configured HTTP scans and bounded workers to Core', () => { + const result = runFixture(['clean-first', 'clean-second', 'clean-third']); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.calls.map(({ storyId }) => storyId), + ['clean-first', 'clean-second', 'clean-third'], + ); + assert.equal(Math.max(...result.calls.map(({ active }) => active)), 2); + for (const { options } of result.calls) { + assert.equal( + options.wait, + 321, + 'The project Pa11y wait must reach every scan.', + ); + assert.deepEqual(options.runners, ['axe']); + assert.ok( + !options.chromeLaunchConfig?.args?.some((arg) => + ['--no-sandbox', '--disable-setuid-sandbox'].includes(arg), + ), + ); + } + assert.match( + result.stdout, + /Accessibility summary: 3 attempted, 3 clean, 0 with findings, 0 failed to execute/, + ); + assert.ok( + result.stdout.indexOf('?id=clean-first') < + result.stdout.indexOf('?id=clean-second'), + 'Reports retain selected-story order despite completion order.', + ); +}); + +test('accessibility runner reports every finding and execution failure before closing', () => { + const result = runFixture([ + 'failed-first', + 'findings', + 'clean-last', + 'failed-last', + ]); + assert.equal(result.status, 1); + assert.deepEqual( + result.calls.map(({ storyId }) => storyId), + ['failed-first', 'findings', 'clean-last', 'failed-last'], + ); + assert.match(result.stdout, /Reported fixture issue/); + assert.doesNotMatch(result.stdout, /Ignored fixture issue/); + assert.match( + result.stdout, + /Accessibility summary: 4 attempted, 1 clean, 1 with findings, 2 failed to execute/, + ); + assert.match( + result.stderr, + /AggregateError: Accessibility execution failed for 2 stories/, + ); + assert.match(result.stdout, /fixture navigation failure: failed-first/); + assert.match(result.stdout, /fixture navigation failure: failed-last/); +}); diff --git a/.github/scripts/asset-manifest-smoke.php b/.github/scripts/asset-manifest-smoke.php index fa79faf..c2e77f0 100644 --- a/.github/scripts/asset-manifest-smoke.php +++ b/.github/scripts/asset-manifest-smoke.php @@ -216,6 +216,8 @@ function emulsify_asset_manifest_smoke_remove( string $path ): void { require_once $repo_root . '/includes/Support/AssetRecord.php'; require_once $repo_root . '/includes/Support/AssetEnqueuer.php'; require_once $repo_root . '/includes/Support/AssetManifest.php'; + require_once $repo_root . '/includes/Support/Diagnostics.php'; + require_once $repo_root . '/includes/Blocks/ComponentLocator.php'; require_once $repo_root . '/includes/Runtime/Assets.php'; require_once $repo_root . '/includes/Editor/Enhancements.php'; diff --git a/.github/scripts/attribute-hardening-test-cases.php b/.github/scripts/attribute-hardening-test-cases.php new file mode 100644 index 0000000..67e947e --- /dev/null +++ b/.github/scripts/attribute-hardening-test-cases.php @@ -0,0 +1,56 @@ + Expected and actual values. + */ +function emulsify_attribute_hardening_cases(): array { + $cases = array(); + foreach ( array( 'href', 'SRC', 'action', 'formaction', 'xlink:href', 'poster', 'data' ) as $name ) { + foreach ( array( 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'java script:alert(1)', "java\tscript:alert(1)", "java\nscript:alert(1)", "java\0script:alert(1)", 'javascript:javascript:alert(1)' ) as $url ) { + $cases[ $name . ' strips ' . json_encode( $url ) ] = array( 'alert(1)', ( new AttributeBag( array( $name => $url ) ) )->toArray()[ $name ] ?? null ); + } + } + + foreach ( array( '/relative?a=1&b=2', '../next', '#details', 'https://example.com/path', 'mailto:hello@example.com', 'tel:+15555550123' ) as $url ) { + $cases[ 'keeps URL ' . $url ] = array( $url, ( new AttributeBag( array( 'href' => $url ) ) )->toArray()['href'] ?? null ); + } + $cases['disallows data URIs under WordPress default protocols'] = array( 'image/png;base64,AA==', ( new AttributeBag( array( 'src' => 'data:image/png;base64,AA==' ) ) )->toArray()['src'] ?? null ); + + $unsafe_names = array( 'srcdoc', 'SRCDOC', 'x-on:click', 'X-ON:click', 'hx-on', 'hx-on:click', 'hx-on--after-request', 'data-HX-on:click', 'v-on:click', ':href', 'x-bind:href', 'v-bind:href', 'x-data', 'x-init', 'v-html', 'hx-vals', 'hx-vars', 'hx-headers', 'hx-request', 'hx-trigger', 'data-hx-vals', '@click', 'onclick', 'ONFOCUS' ); + $unsafe = array_fill_keys( $unsafe_names, 'alert(1)' ); + $unsafe['data-ok'] = '1'; + $unsafe['aria-label'] = 'Safe'; + $unsafe['hx-get'] = '/fragment'; + $unsafe['viewBox'] = '0 0 10 10'; + $unsafe['preserveAspectRatio'] = 'xMidYMid'; + $set = new AttributeBag(); + foreach ( $unsafe as $name => $value ) { + $set->set( $name, $value ); + } + $merge = new AttributeBag(); + $merge->merge( $unsafe ); + foreach ( array( 'constructor' => new AttributeBag( $unsafe ), 'set' => $set, 'merge' => $merge ) as $operation => $bag ) { + $cases[ $operation . ' rejects executable attribute names, preserves static and SVG names' ] = array( + array( 'data-ok' => '1', 'aria-label' => 'Safe', 'hx-get' => '/fragment', 'viewBox' => '0 0 10 10', 'preserveAspectRatio' => 'xMidYMid' ), + $bag->toArray(), + ); + } + $cases['class names are normalized and merged case-insensitively'] = array( array( 'class' => array( 'first-bad', 'second', 'third' ) ), ( new AttributeBag( array( 'CLASS' => array( 'first.bad' ), 'class' => 'second', 'ClAsS' => 'third' ) ) )->toArray() ); + $private_post = (object) array( 'post_password' => 'secret', 'post_title' => 'Private' ); + $merged = new AttributeBag( array( 'data-ok' => '1' ) ); + $merged->merge( $private_post ); + $cases['plain objects never become attributes'] = array( array(), ( new AttributeBag( $private_post ) )->toArray() ); + $cases['merging objects cannot expose their public properties'] = array( array( 'data-ok' => '1' ), $merged->toArray() ); + $cases['Traversables remain supported'] = array( array( 'data-ok' => '1' ), ( new AttributeBag( new ArrayIterator( array( 'data-ok' => '1' ) ) ) )->toArray() ); + + return $cases; +} diff --git a/.github/scripts/attribute-hardening-wordpress-smoke.php b/.github/scripts/attribute-hardening-wordpress-smoke.php new file mode 100644 index 0000000..eec4eef --- /dev/null +++ b/.github/scripts/attribute-hardening-wordpress-smoke.php @@ -0,0 +1,28 @@ + list( $expected, $actual ) ) { + if ( $expected !== $actual ) { + $failures[] = $label . ': expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ); + } +} +$helpers = new Twig(); +$private_post = new WP_Post( (object) array( 'ID' => 1, 'post_password' => 'secret', 'post_title' => 'Private' ) ); +foreach ( array( $helpers->add_attributes( $private_post ), $helpers->add_attributes( array( 'attributes' => $private_post ), array( 'data-ok' => '1' ) ), $helpers->bem( array( 'attributes' => $private_post ), 'card' ) ) as $bag ) { + if ( array_key_exists( 'post_password', $bag->toArray() ) ) { + $failures[] = 'A real WP_Post must not become HTML attributes.'; + } +} +if ( $failures ) { + throw new RuntimeException( implode( "\n", $failures ) ); +} +echo "Real WordPress attribute hardening checks passed.\n"; diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php index b8a3170..d141fa7 100644 --- a/.github/scripts/attribute-helper-smoke.php +++ b/.github/scripts/attribute-helper-smoke.php @@ -5,6 +5,7 @@ * @package Emulsify */ +require_once __DIR__ . '/wordpress-attribute-test-adapter.php'; require_once __DIR__ . '/../../includes/Support/AttributeBag.php'; require_once __DIR__ . '/../../includes/Support/FileDiscovery.php'; require_once __DIR__ . '/../../includes/Runtime/Twig.php'; @@ -38,10 +39,7 @@ function emulsify_attribute_helper_assert_same( string $label, $expected, $actua */ function emulsify_register_smoke_twig_functions( \Twig\Environment $environment, Twig $helpers ): void { foreach ( $helpers->functions( array() ) as $name => $definition ) { - $options = array( - 'is_safe' => $definition['is_safe'] ?? array(), - 'needs_context' => $definition['needs_context'] ?? false, - ); + $options = $definition['options'] ?? array(); $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $options ) ); } @@ -54,6 +52,8 @@ function emulsify_register_smoke_twig_functions( \Twig\Environment $environment, * @return void */ function emulsify_register_smoke_wordpress_twig_shims( \Twig\Environment $environment ): void { + require_once __DIR__ . '/wordpress-text-test-adapter.php'; + emulsify_register_wordpress_text_test_filters( $environment ); $environment->addFunction( new \Twig\TwigFunction( 'function', @@ -145,7 +145,68 @@ function emulsify_attribute_helper_parse_parent_templates( Twig $helpers, string return $checked; } +require_once __DIR__ . '/attribute-hardening-test-cases.php'; + +$hardening_failures = array(); +foreach ( emulsify_attribute_hardening_cases() as $label => list( $expected, $actual ) ) { + if ( $expected !== $actual ) { + $hardening_failures[] = $label . ': expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ); + } +} +if ( $hardening_failures ) { + fwrite( STDERR, implode( "\n", $hardening_failures ) . "\n" ); + exit( 1 ); +} + $helpers = new Twig(); +$private_post = (object) array( 'post_password' => 'secret' ); +foreach ( array( $helpers->add_attributes( $private_post ), $helpers->add_attributes( array( 'attributes' => $private_post ), array( 'data-ok' => '1' ) ), $helpers->bem( array( 'attributes' => $private_post ), 'card' ), $helpers->bem( (object) array( 'block' => 'card', 'attributes' => $private_post ) ) ) as $bag ) { + if ( array_key_exists( 'post_password', $bag->toArray() ) ) { + fwrite( STDERR, "Helper must not turn a post-like object's public properties into HTML attributes.\n" ); + exit( 1 ); + } +} + + +$event_attributes = array( + 'onclick' => 'x()', + 'onMouseOver' => 'y()', + 'ONFOCUS' => 'z()', + 'on' => 'ignored', + 'once' => 'ignored', + 'data-onload' => 'allowed', + 'data-ok' => '1', +); +$set_bag = new AttributeBag(); + +foreach ( $event_attributes as $name => $value ) { + $set_bag->set( $name, $value ); +} + +$merge_bag = new AttributeBag(); +$merge_bag->merge( $event_attributes ); + +foreach ( + array( + 'constructor' => new AttributeBag( $event_attributes ), + 'set' => $set_bag, + 'merge' => $merge_bag, + ) as $operation => $event_bag +) { + emulsify_attribute_helper_assert_same( + 'AttributeBag ' . $operation . ' drops event-handler names from its map', + array( + 'data-onload' => 'allowed', + 'data-ok' => '1', + ), + $event_bag->toArray() + ); + emulsify_attribute_helper_assert_same( + 'AttributeBag ' . $operation . ' does not serialize event-handler names', + 'data-onload="allowed" data-ok="1"', + (string) $event_bag + ); +} emulsify_attribute_helper_assert_same( 'bem positional syntax', diff --git a/.github/scripts/block-scoped-assets-smoke.php b/.github/scripts/block-scoped-assets-smoke.php index f79c12a..883c93d 100644 --- a/.github/scripts/block-scoped-assets-smoke.php +++ b/.github/scripts/block-scoped-assets-smoke.php @@ -456,15 +456,58 @@ static function ( array $records, array $component ): array { 'title' => 'Scanner Card', 'assets' => array( 'frontend' => array( - 'css' => array( array( 'path' => 'card.css' ) ), + 'css' => array( array( 'path' => 'card.css' ), array( 'path' => 'styles/./nested.css' ) ), + 'js' => array( array( 'path' => 'scripts//nested.js' ) ), + ), + 'editor' => array( + 'css' => array( array( 'path' => 'editor/./editor.css' ) ), + 'js' => array( array( 'path' => 'editor//editor.js' ) ), ), ), ) ); emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/card.css', '.card{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/styles/nested.css', '.nested{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/scripts/nested.js', 'window.nested = true;' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/editor/editor.css', '.editor{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/editor/editor.js', 'window.editor = true;' ); emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/plain/plain.css', '.plain{}' ); + $filtered_components = $work_root . '/filtered-components'; + emulsify_block_assets_smoke_write_json( + $filtered_components . '/card/card.component.json', + array( 'assets' => array( 'css' => array( 'styles/filtered.css' ) ) ) + ); + emulsify_block_assets_smoke_write( $filtered_components . '/card/styles/filtered.css', '.filtered{}' ); + add_filter( + 'emulsify_theme_asset_directories', + static function ( array $roots, string $directory ) use ( $filtered_components ): array { + if ( 'dist/components' === $directory ) { + $roots[] = array( 'path' => $filtered_components, 'uri' => 'https://example.test/filtered-components' ); + } - ( new Emulsify\Theme\Runtime\Assets() )->styles(); + return $roots; + }, + 10, + 2 + ); + + $scanner_assets = new Emulsify\Theme\Runtime\Assets(); + $scanner_assets->styles(); + $scanner_assets->frontend_scripts(); + + emulsify_block_assets_smoke_assert( + ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-styles-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-editor-editor'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-component-card-scripts-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-component-card-scripts-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-component-card-editor-editor'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-component-card-editor-editor'] ), + 'Global component scanning must skip frontend and editor-only subfolder assets, including internal dots and repeated separators, without a manifest.' + ); + emulsify_block_assets_smoke_assert( + ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-styles-filtered'] ), + 'Scoped metadata in filtered asset-only roots must stay scoped without requiring a component-roots filter.' + ); emulsify_block_assets_smoke_assert( ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-card'] ), diff --git a/.github/scripts/bootstrap-loader-smoke.php b/.github/scripts/bootstrap-loader-smoke.php index 48492ac..2ef8ef1 100644 --- a/.github/scripts/bootstrap-loader-smoke.php +++ b/.github/scripts/bootstrap-loader-smoke.php @@ -74,6 +74,7 @@ function emulsify_bootstrap_loader_run_php( string $code ): void { 'Emulsify\\Theme\\Cli\\GenerateChildThemeCommand', 'Emulsify\\Theme\\Support\\AttributeBag', 'Emulsify\\Theme\\Support\\AssetRecord', + 'Emulsify\\Theme\\Support\\LogoutUrl', 'Emulsify\\Theme\\Support\\AssetEnqueuer', 'Emulsify\\Theme\\Support\\Diagnostics', 'Emulsify\\Theme\\Support\\AssetManifest', diff --git a/.github/scripts/build-dist.test.cjs b/.github/scripts/build-dist.test.cjs new file mode 100644 index 0000000..d8e7df7 --- /dev/null +++ b/.github/scripts/build-dist.test.cjs @@ -0,0 +1,154 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const builder = fs.readFileSync(path.resolve(__dirname, '../../scripts/build-dist.sh'), 'utf8'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-build-dist-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const repo = path.join(root, 'checkout'); + const bin = path.join(root, 'bin'); + const composerLog = path.join(root, 'composer.json'); + fs.mkdirSync(repo); + fs.mkdirSync(bin); + const write = (relative, contents) => { + const file = path.join(repo, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + const git = (...args) => execFileSync('git', [ + '-c', 'commit.gpgsign=false', '-c', 'tag.gpgSign=false', + '-c', 'user.name=Distribution Test', '-c', 'user.email=dist@example.invalid', + ...args, + ], { cwd: repo, encoding: 'utf8', stdio: 'pipe' }).trim(); + + for (const relative of [ + '404.php', 'AGENTS.md', 'LICENSE', 'README.md', 'UPGRADE.md', 'archive.php', + 'author.php', 'functions.php', 'index.php', 'page.php', 'screenshot.png', + 'search.php', 'single.php', 'style.css', 'theme.json', + 'docs/wp-cli-child-theme-generation.md', 'includes/sentinel.php', + 'src/sentinel.txt', 'templates/sentinel.twig', 'whisk/README.md', + 'whisk/AGENTS.md', 'whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md', + 'whisk/docs/wordpress-integration.md', 'whisk/package.json', + 'whisk/project.emulsify.json', 'whisk/style.css', 'whisk/.cli/init.js', + ]) { + write(relative, `committed ${relative}\n`); + } + write('composer.json', '{"name":"fixture/production"}\n'); + write('composer.lock', '{"committed":true}\n'); + // The release builder must read these Git objects even though git archive + // would omit them. Root Composer metadata is needed only during installation. + write('.gitattributes', '/composer.lock export-ignore\n/whisk/package.json export-ignore\n'); + write('.gitignore', 'dist-artifact/\n'); + write('scripts/build-dist.sh', builder); + git('init', '-b', 'main'); + git('add', '.'); + git('commit', '-m', 'chore: create distribution fixture'); + + const composer = path.join(bin, 'composer'); + fs.writeFileSync(composer, `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.argv.find((argument) => argument.startsWith('--working-dir=')).slice('--working-dir='.length); +fs.writeFileSync(process.env.EMULSIFY_BUILD_TEST_COMPOSER_LOG, JSON.stringify({ + lock: fs.readFileSync(path.join(root, 'composer.lock'), 'utf8'), + manifest: fs.readFileSync(path.join(root, 'composer.json'), 'utf8'), +})); +fs.mkdirSync(path.join(root, 'vendor'), { recursive: true }); +fs.writeFileSync(path.join(root, 'vendor/autoload.php'), ' spawnSync('bash', ['scripts/build-dist.sh'], { + cwd: repo, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + EMULSIFY_BUILD_TEST_COMPOSER_LOG: composerLog, + }, + }); + const contents = (relative) => execFileSync('unzip', ['-p', archive, `emulsify/${relative}`], { encoding: 'utf8' }); + const list = () => execFileSync('unzip', ['-Z1', archive], { encoding: 'utf8' }).trim().split('\n'); + return { repo, write, git, run, archive, contents, list, composerLog }; +} + +function assertBuilt(result) { + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +} + +test('distribution uses committed bytes and excludes staged or untracked additions', (t) => { + const build = fixture(t); + build.write('includes/sentinel.php', 'uncommitted runtime edit\n'); + build.write('includes/staged-secret.php', 'staged secret\n'); + build.write('whisk/untracked-secret.txt', 'untracked secret\n'); + build.git('add', 'includes/sentinel.php', 'includes/staged-secret.php'); + assertBuilt(build.run()); + assert.equal(build.contents('includes/sentinel.php'), 'committed includes/sentinel.php\n'); + assert.ok(!build.list().includes('emulsify/includes/staged-secret.php')); + assert.ok(!build.list().includes('emulsify/whisk/untracked-secret.txt')); +}); + +test('distribution installs committed Composer metadata without export-ignore losses', (t) => { + const build = fixture(t); + build.write('composer.lock', '{"uncommitted":true}\n'); + build.write('composer.json', '{"name":"uncommitted/production"}\n'); + assertBuilt(build.run()); + assert.deepEqual(JSON.parse(fs.readFileSync(build.composerLog, 'utf8')), { + lock: '{"committed":true}\n', manifest: '{"name":"fixture/production"}\n', + }); + assert.equal(build.contents('whisk/package.json'), 'committed whisk/package.json\n'); + const entries = build.list(); + assert.ok(entries.includes('emulsify/whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md')); + assert.ok(entries.includes('emulsify/vendor/autoload.php')); + assert.ok(!entries.some((entry) => /(?:^|\/)composer\.(?:json|lock)$|\/\.cli\//.test(entry))); +}); + +test('distribution refuses committed symlinks instead of copying external contents', (t) => { + const build = fixture(t); + const external = path.join(path.dirname(build.repo), 'private.txt'); + fs.writeFileSync(external, 'must never ship\n'); + fs.symlinkSync(external, path.join(build.repo, 'includes/external.txt')); + build.git('add', 'includes/external.txt'); + build.git('commit', '-m', 'test: add symlink fixture'); + const result = build.run(); + assert.notEqual(result.status, 0, 'A tracked symlink must abort packaging before its target is copied.'); + assert.match(result.stderr, /Refusing.*(?:tracked|committed).*symlink/i); + assert.match(result.stderr, /includes\/external\.txt/); + assert.ok(!fs.existsSync(build.archive)); + assert.ok(!fs.existsSync(build.composerLog), 'Symlink rejection must precede Composer installation.'); +}); + +test('distribution stays on its starting commit if HEAD moves during staging', (t) => { + const build = fixture(t); + const actualGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const bin = path.join(path.dirname(build.repo), 'bin'); + const marker = path.join(bin, 'moved-head'); + const gitShim = path.join(bin, 'git'); + fs.writeFileSync(gitShim, `#!/usr/bin/env node +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const args = process.argv.slice(2); +const git = ${JSON.stringify(actualGit)}; +const root = ${JSON.stringify(build.repo)}; +const marker = ${JSON.stringify(marker)}; +if (args.includes('cat-file') && !fs.existsSync(marker)) { + fs.writeFileSync(marker, 'moved'); + fs.writeFileSync(root + '/includes/sentinel.php', 'new commit during staging\\n'); + execFileSync(git, ['-C', root, 'add', 'includes/sentinel.php']); + execFileSync(git, ['-C', root, '-c', 'commit.gpgsign=false', '-c', 'user.name=Distribution Test', '-c', 'user.email=dist@example.invalid', 'commit', '-m', 'test: simulate concurrent commit']); +} +const result = spawnSync(git, args, { stdio: 'inherit' }); +process.exit(result.status ?? 1); +`); + fs.chmodSync(gitShim, 0o755); + const original = build.git('rev-parse', 'HEAD'); + assertBuilt(build.run()); + assert.ok(fs.existsSync(marker), 'The fixture must move HEAD before the remaining payload is staged.'); + assert.notEqual(build.git('rev-parse', 'HEAD'), original); + assert.equal(build.contents('includes/sentinel.php'), 'committed includes/sentinel.php\n'); +}); diff --git a/.github/scripts/child-theme-generator-smoke.php b/.github/scripts/child-theme-generator-smoke.php index 142ef2b..e623823 100644 --- a/.github/scripts/child-theme-generator-smoke.php +++ b/.github/scripts/child-theme-generator-smoke.php @@ -279,7 +279,6 @@ function emulsify_cli_smoke_lint_php( string $path ): void { $style = file_get_contents( $destination . '/style.css' ); $package = emulsify_cli_smoke_json( $destination . '/package.json' ); $project = emulsify_cli_smoke_json( $destination . '/project.emulsify.json' ); - $page = file_get_contents( $destination . '/templates/page.twig' ); $functions = file_get_contents( $destination . '/functions.php' ); $smoke_pattern = emulsify_cli_smoke_json( $destination . '/patterns/smoke-pattern.json' ); @@ -297,8 +296,7 @@ function emulsify_cli_smoke_lint_php( string $path ): void { emulsify_cli_smoke_assert( 'acme-child' === $project['project']['machineName'], 'project.emulsify.json should update machineName.' ); emulsify_cli_smoke_assert( 'emulsify-wordpress' === $project['project']['generatedFrom'], 'project.emulsify.json should identify the generated child theme source.' ); emulsify_cli_smoke_assert( emulsify_cli_smoke_expected_version() === $project['project']['generatedFromVersion'], 'project.emulsify.json should record the generated child theme source version.' ); - emulsify_cli_smoke_assert( false !== strpos( $page, 'acme-child-page' ), 'Example template should update slug class.' ); - emulsify_cli_smoke_assert( false === strpos( $page, 'whisk-page' ), 'Example template should not keep the whisk slug class.' ); + emulsify_cli_smoke_assert( ! file_exists( $destination . '/templates' ), 'Fresh generated child themes should inherit parent templates without a local templates directory.' ); emulsify_cli_smoke_assert( false !== strpos( $functions, 'Acme Theme child theme hooks.' ), 'functions.php should update visible Whisk label.' ); emulsify_cli_smoke_assert( is_file( $destination . '/src/components/.gitkeep' ), 'Generated child theme should keep the empty component source placeholder.' ); emulsify_cli_smoke_assert( is_file( $destination . '/patterns/.gitkeep' ), 'Generated child theme should keep the empty pattern placeholder.' ); @@ -515,7 +513,7 @@ function emulsify_cli_smoke_lint_php( string $path ): void { $GLOBALS['emulsify_mkdir_failure'] = static function ( string $target ): bool { $normalized = str_replace( '\\', '/', $target ); - return false !== strpos( $normalized, '/rollback-child.tmp-' ) && str_ends_with( $normalized, '/templates' ); + return false !== strpos( $normalized, '/rollback-child.tmp-' ) && str_ends_with( $normalized, '/config' ); }; try { @@ -576,6 +574,18 @@ static function ( array $message ): bool { emulsify_cli_smoke_assert( 'active-child' === $GLOBALS['emulsify_activated_theme'], '--activate should call switch_theme() with the child slug.' ); + $external_directory = $work_root . '/external-starter-directory'; + mkdir( $external_directory ); + file_put_contents( $external_directory . '/secret.txt', 'outside starter' ); + symlink( $external_directory, $parent_root . '/whisk/linked-directory' ); + symlink( $external_directory . '/secret.txt', $parent_root . '/whisk/linked-file.txt' ); + WP_CLI::$messages = array(); + $cli( array( 'Symlink Theme' ), array( 'machine-name' => 'symlink-child' ) ); + emulsify_cli_smoke_assert( ! file_exists( $theme_root . '/symlink-child/linked-directory' ), 'A symlinked starter directory must be skipped rather than copied as an empty directory.' ); + emulsify_cli_smoke_assert( ! file_exists( $theme_root . '/symlink-child/linked-file.txt' ), 'A symlinked starter file must not copy an external file.' ); + $symlink_warnings = array_filter( WP_CLI::$messages, static fn( array $message ): bool => 'warning' === $message['type'] && str_contains( $message['message'], 'Skipping symlink' ) ); + emulsify_cli_smoke_assert( 2 === count( $symlink_warnings ), 'Each skipped starter symlink must report a warning.' ); + echo "Child theme generator smoke checks passed.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); diff --git a/.github/scripts/component-locator-smoke.php b/.github/scripts/component-locator-smoke.php index 955a385..4b90317 100644 --- a/.github/scripts/component-locator-smoke.php +++ b/.github/scripts/component-locator-smoke.php @@ -13,6 +13,7 @@ $GLOBALS['emulsify_locator_smoke_hooks'] = array(); $GLOBALS['emulsify_locator_transients'] = array(); $GLOBALS['emulsify_locator_environment'] = 'development'; +$GLOBALS['emulsify_locator_failures'] = array(); if ( ! function_exists( 'add_filter' ) ) { function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool { @@ -74,6 +75,18 @@ function get_template_directory(): string { } } +if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) { + function get_stylesheet_directory_uri(): string { + return $GLOBALS['emulsify_locator_child_uri'] ?? 'https://example.test/themes/child'; + } +} + +if ( ! function_exists( 'get_template_directory_uri' ) ) { + function get_template_directory_uri(): string { + return 'https://example.test/themes/parent'; + } +} + if ( ! function_exists( 'wp_get_theme' ) ) { function wp_get_theme( string $stylesheet = '' ) { return new class( $stylesheet ) { @@ -187,6 +200,62 @@ function emulsify_locator_smoke_assert( bool $condition, string $message ): void } } +/** Records independent deployment regressions so a baseline run reports each. */ +function emulsify_locator_smoke_check( bool $condition, string $message ): void { + if ( ! $condition ) { + $GLOBALS['emulsify_locator_failures'][] = $message; + } +} + +/** Counts real directory opens while delegating to PHP's native file wrapper. */ +final class ComponentDiscoveryFileSpy { + public $context; + private $stream; + private $directory; + public static array $directory_opens = array(); + public static int $stat_calls = 0; + + private static function native( callable $operation ) { + stream_wrapper_restore( 'file' ); + try { + return $operation(); + } finally { + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', self::class ); + } + } + public function url_stat( $path, $flags ) { + ++self::$stat_calls; + $stat = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat'; + return self::native( static fn() => $flags & STREAM_URL_STAT_QUIET ? @$stat( $path ) : $stat( $path ) ); + } + public function stream_open( $path, $mode, $options, &$opened_path ): bool { + $this->stream = self::native( static fn() => fopen( $path, $mode, (bool) ( $options & STREAM_USE_PATH ) ) ); + return is_resource( $this->stream ); + } + public function stream_read( $length ) { return fread( $this->stream, $length ); } + public function stream_eof() { return feof( $this->stream ); } + public function stream_stat() { return fstat( $this->stream ); } + public function stream_seek( $offset, $whence = SEEK_SET ) { return 0 === fseek( $this->stream, $offset, $whence ); } + public function stream_tell() { return ftell( $this->stream ); } + public function stream_close(): void { fclose( $this->stream ); } + public function stream_set_option( $option, $first, $second ) { return false; } + public function dir_opendir( $path, $options ): bool { + self::$directory_opens[] = $path; + $this->directory = self::native( static fn() => opendir( $path ) ); + return is_resource( $this->directory ); + } + public function dir_readdir() { return readdir( $this->directory ); } + public function dir_rewinddir(): bool { rewinddir( $this->directory ); return true; } + public function dir_closedir(): bool { closedir( $this->directory ); return true; } +} + +/** Gets the private persistent key without relying on the transient payload. */ +function emulsify_locator_smoke_cache_key(): string { + return ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'component_files_cache_key' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator() ); +} + /** * Writes a fixture file, creating its parent directory when needed. * @@ -314,6 +383,7 @@ static function ( array $args ): string { emulsify_locator_smoke_write( $parent . '/dist/components/parent-native/block.json', '{"name":"emulsify/parent-native"}' ); require_once $repo_root . '/includes/Support/FileDiscovery.php'; + require_once $repo_root . '/includes/Support/AssetManifest.php'; require_once $repo_root . '/includes/Support/AssetRecord.php'; require_once $repo_root . '/includes/Support/Diagnostics.php'; require_once $repo_root . '/includes/Blocks/ComponentLocator.php'; @@ -493,6 +563,7 @@ static function (): int { $GLOBALS['emulsify_locator_transients'] = array(); emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-card/cache-card.component.json', '{"title":"Cache Card"}' ); emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-card/cache-card.twig', '
Cache card
' ); + emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-card/cache-card.js', 'export default {};' ); $cache_locator = new Emulsify\Theme\Blocks\ComponentLocator(); @@ -500,15 +571,69 @@ static function (): int { array( 'cache-card' ) === emulsify_locator_smoke_relatives( $cache_locator->acf_components() ), 'Missing persistent discovery cache should fall back to filesystem scanning.' ); + $serialized_cache = serialize( $GLOBALS['emulsify_locator_transients'] ); + emulsify_locator_smoke_check( + ! str_contains( $serialized_cache, $work_root ), + 'Persistent discovery must store root-relative paths, never absolute deployment paths.' + ); + clearstatcache(); + $warm_key_before = emulsify_locator_smoke_cache_key(); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', ComponentDiscoveryFileSpy::class ); + try { + $warm_records = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + } finally { + stream_wrapper_restore( 'file' ); + } + emulsify_locator_smoke_assert( + array( 'cache-card' ) === emulsify_locator_smoke_relatives( $warm_records ) && array() === ComponentDiscoveryFileSpy::$directory_opens, + 'Enabled persistent discovery cache should be reused across locator instances.' + ); + echo 'Warm discovery cache: ' . count( ComponentDiscoveryFileSpy::$directory_opens ) . ' directory opens; ' . ComponentDiscoveryFileSpy::$stat_calls . " stat calls.\n"; + for ( $lookup = 0; $lookup < 3; ++$lookup ) { + clearstatcache(); + emulsify_locator_smoke_check( + $warm_key_before === emulsify_locator_smoke_cache_key() + && $warm_records === ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(), + 'Unchanged warm lookups must preserve the cache key and component records.' + ); + } + $asset_key_before = emulsify_locator_smoke_cache_key(); + unlink( $cache_child . '/dist/components/cache-card/cache-card.js' ); + clearstatcache(); + ComponentDiscoveryFileSpy::$directory_opens = array(); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', ComponentDiscoveryFileSpy::class ); + try { + $without_asset = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + } finally { + stream_wrapper_restore( 'file' ); + } + emulsify_locator_smoke_check( + $asset_key_before === emulsify_locator_smoke_cache_key() + && $warm_records === $without_asset && array() === ComponentDiscoveryFileSpy::$directory_opens, + 'Removing an unrelated built asset must preserve the metadata cache without directory scans.' + ); + echo 'Warm metadata cache after removing JS: ' . count( ComponentDiscoveryFileSpy::$directory_opens ) . " directory opens.\n"; + $GLOBALS['emulsify_locator_child_uri'] = 'https://new.example.test/current-theme'; + $current_uri_records = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + 'https://new.example.test/current-theme/dist/components' === $current_uri_records[0]['root_uri'], + 'Cached files must use the current component root URI.' + ); + unset( $GLOBALS['emulsify_locator_child_uri'] ); + $build_root_mtime = filemtime( $cache_child . '/dist/components' ); emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-late/cache-late.component.json', '{"title":"Cache Late"}' ); emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-late/cache-late.twig', '
Cache late
' ); + touch( $cache_child . '/dist/components', $build_root_mtime + 10 ); + clearstatcache(); $cached_locator = new Emulsify\Theme\Blocks\ComponentLocator(); - emulsify_locator_smoke_assert( - ! in_array( 'cache-late', emulsify_locator_smoke_relatives( $cached_locator->acf_components() ), true ), - 'Enabled persistent discovery cache should be reused across locator instances.' + emulsify_locator_smoke_check( + in_array( 'cache-late', emulsify_locator_smoke_relatives( $cached_locator->acf_components() ), true ), + 'A new component directory must invalidate discovery without a version bump or asset manifest.' ); emulsify_locator_smoke_assert( Emulsify\Theme\Blocks\ComponentLocator::clear_discovery_cache(), @@ -538,16 +663,13 @@ static function (): int { ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); emulsify_locator_smoke_write( $version_child . '/dist/components/version-late/version-late.component.json', '{"title":"Version Late"}' ); emulsify_locator_smoke_write( $version_child . '/dist/components/version-late/version-late.twig', '
Version late
' ); - - emulsify_locator_smoke_assert( - ! in_array( 'version-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), - 'Persistent discovery cache should hold until an invalidating key part changes.' - ); + clearstatcache(); + $version_key_before = emulsify_locator_smoke_cache_key(); $GLOBALS['emulsify_locator_theme_versions']['version-child'] = '1.0.1'; emulsify_locator_smoke_assert( - in_array( 'version-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), + $version_key_before !== emulsify_locator_smoke_cache_key() && in_array( 'version-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), 'Changing the child theme version should change the persistent discovery cache key.' ); @@ -568,17 +690,13 @@ static function (): int { ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-late/mtime-late.component.json', '{"title":"Mtime Late"}' ); emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-late/mtime-late.twig', '
Mtime late
' ); - - emulsify_locator_smoke_assert( - ! in_array( 'mtime-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), - 'Persistent discovery cache should include the manifest mtime in its key.' - ); + $manifest_key_before = emulsify_locator_smoke_cache_key(); touch( $mtime_child . '/dist/emulsify-assets.json', time() + 10 ); clearstatcache( true, $mtime_child . '/dist/emulsify-assets.json' ); emulsify_locator_smoke_assert( - in_array( 'mtime-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), + $manifest_key_before !== emulsify_locator_smoke_cache_key() && in_array( 'mtime-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ), 'Changing the asset manifest mtime should change the persistent discovery cache key.' ); @@ -609,6 +727,217 @@ static function (): int { ); unset( $GLOBALS['emulsify_locator_transient_override'] ); + // Release directories may move while slugs, versions and manifests stay fixed. + $release_a = $work_root . '/releases/a/child'; + $release_b = $work_root . '/releases/b/child'; + foreach ( array( $release_a, $release_b ) as $release ) { + emulsify_locator_smoke_write( $release . '/dist/components/deployed/deployed.component.json', '{"title":"Deployed"}' ); + emulsify_locator_smoke_write( $release . '/dist/components/deployed/deployed.twig', '
Deployed
' ); + } + $GLOBALS['emulsify_locator_stylesheet'] = 'unchanged-release'; + $GLOBALS['emulsify_locator_theme_versions']['unchanged-release'] = '1.0.0'; + $GLOBALS['emulsify_locator_child_theme'] = $release_a; + $GLOBALS['emulsify_locator_transients'] = array(); + ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + $GLOBALS['emulsify_locator_child_theme'] = $release_b; + $moved = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + $release_b . '/dist/components/deployed' === ( $moved[0]['path'] ?? '' ), + 'A release-directory deploy must use the new theme path with unchanged versions and slugs.' + ); + + $current = $work_root . '/current'; + emulsify_locator_smoke_assert( symlink( $release_a, $current ), 'Unable to create the release symlink fixture.' ); + $GLOBALS['emulsify_locator_child_theme'] = $current; + $GLOBALS['emulsify_locator_transients'] = array(); + ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + $symlink_key_before = emulsify_locator_smoke_cache_key(); + emulsify_locator_smoke_write( $release_b . '/dist/components/native/block.json', '{"name":"fixture/current-release"}' ); + unlink( $current ); + emulsify_locator_smoke_assert( symlink( $release_b, $current ), 'Unable to retarget the release symlink fixture.' ); + clearstatcache( true ); + $retargeted_locator = new Emulsify\Theme\Blocks\ComponentLocator(); + $retargeted = $retargeted_locator->acf_components(); + emulsify_locator_smoke_check( + $symlink_key_before !== emulsify_locator_smoke_cache_key() + && realpath( $release_b . '/dist/components/deployed' ) === realpath( $retargeted[0]['path'] ?? '' ) + && 'fixture/current-release' === ( $retargeted_locator->native_block_directories()[0]['name'] ?? '' ), + 'A retargeted theme symlink must resolve cached records against the active release.' + ); + + // Filtered discovery roots are part of the state, even outside either theme. + $original_hooks = $GLOBALS['emulsify_locator_smoke_hooks']; + $GLOBALS['emulsify_locator_filtered_root'] = $release_a . '/dist/components'; + add_filter( + 'emulsify_theme_component_roots', + static fn(): array => array( array( 'path' => $GLOBALS['emulsify_locator_filtered_root'], 'source' => 'filtered', 'priority' => 0 ) ) + ); + $GLOBALS['emulsify_locator_transients'] = array(); + ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + $GLOBALS['emulsify_locator_filtered_root'] = $release_b . '/dist/components'; + $filtered = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + $release_b . '/dist/components/deployed' === ( $filtered[0]['path'] ?? '' ), + 'Changing filtered component roots must invalidate cached absolute paths.' + ); + add_filter( + 'emulsify_theme_component_discovery_cache_key_parts', + static fn(): array => array( 'custom' => 'constant-key' ), + 100 + ); + $GLOBALS['emulsify_locator_filtered_root'] = $release_a . '/dist/components'; + $GLOBALS['emulsify_locator_transients'] = array(); + ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + $custom_key = emulsify_locator_smoke_cache_key(); + $GLOBALS['emulsify_locator_filtered_root'] = $release_b . '/dist/components'; + $custom_key_records = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + $custom_key === emulsify_locator_smoke_cache_key() + && $release_b . '/dist/components/deployed' === ( $custom_key_records[0]['path'] ?? '' ), + 'Root receipts must reject another root even when a filter intentionally replaces every cache key part.' + ); + $GLOBALS['emulsify_locator_smoke_hooks'] = $original_hooks; + unset( $GLOBALS['emulsify_locator_filtered_root'] ); + + // Force a formerly valid payload to exercise read validation independently of key changes. + $GLOBALS['emulsify_locator_child_theme'] = $release_b; + $GLOBALS['emulsify_locator_transients'] = array(); + emulsify_locator_smoke_write( $release_b . '/dist/components/healthy/healthy.component.json', '{"title":"Healthy"}' ); + emulsify_locator_smoke_write( $release_b . '/dist/components/healthy/healthy.twig', '
Healthy
' ); + ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + $cached_payload = $GLOBALS['emulsify_locator_transients'][ emulsify_locator_smoke_cache_key() ]; + emulsify_locator_smoke_remove( $release_b . '/dist/components/deployed' ); + emulsify_locator_smoke_write( $release_b . '/dist/components/recovered/recovered.component.json', '{"title":"Recovered"}' ); + emulsify_locator_smoke_write( $release_b . '/dist/components/recovered/recovered.twig', '
Recovered
' ); + // Keep the receipt current so this exercises per-file validation, not only + // the earlier root-state mismatch check. + $cached_payload['roots'] = ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'component_roots_signature' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator() ); + emulsify_locator_smoke_check( + null === ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'normalize_cached_component_files' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator(), $cached_payload['files'] ), + 'Per-file cache validation must reject a missing artifact even when its root receipt matches.' + ); + $GLOBALS['emulsify_locator_transient_override'] = $cached_payload; + $recovered = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + unset( $GLOBALS['emulsify_locator_transient_override'] ); + emulsify_locator_smoke_check( + array( 'healthy', 'recovered' ) === emulsify_locator_smoke_relatives( $recovered ), + 'A missing cached file must discard the whole index and rescan, including newly built components.' + ); + + // Poisoned relative paths must invalidate the complete payload. Each case + // uses a current receipt so root validation cannot hide a path-validation bug. + $outside_directory = dirname( $release_b ) . '/outside'; + emulsify_locator_smoke_write( $outside_directory . '/outside.component.json', '{"title":"Outside"}' ); + emulsify_locator_smoke_write( $outside_directory . '/outside.twig', '
Outside
' ); + emulsify_locator_smoke_assert( + symlink( $outside_directory, $release_b . '/dist/components/escaping' ), + 'Unable to create the cache-containment symlink fixture.' + ); + $attack_payload = $GLOBALS['emulsify_locator_transients'][ emulsify_locator_smoke_cache_key() ] ?? $cached_payload; + $attack_payload['roots'] = ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'component_roots_signature' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator() ); + $poisoned_files = array( + 'traversal' => array( 'root' => 0, 'relative' => '../../../outside/outside.component.json' ), + 'absolute' => array( 'root' => 0, 'relative' => $outside_directory . '/outside.component.json' ), + 'dot traversal' => array( 'root' => 0, 'relative' => 'healthy/../healthy/healthy.component.json' ), + 'symlink escape' => array( 'root' => 0, 'relative' => 'escaping/outside.component.json' ), + 'root index' => array( 'root' => '0', 'relative' => 'healthy/healthy.component.json' ), + ); + foreach ( $poisoned_files as $label => $poisoned_file ) { + $attack_payload['files'] = array( $poisoned_file ); + $GLOBALS['emulsify_locator_transient_override'] = $attack_payload; + $unpoisoned = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + unset( $GLOBALS['emulsify_locator_transient_override'] ); + emulsify_locator_smoke_check( + array( 'healthy', 'recovered' ) === emulsify_locator_smoke_relatives( $unpoisoned ), + 'Unsafe cached ' . $label . ' must invalidate the whole index and rescan contained components.' + ); + } + + // A temporarily inaccessible subtree must not become a complete warm index. + $partial_child = $work_root . '/partial-build-child'; + foreach ( array( 'healthy', 'temporarily-unreadable' ) as $name ) { + emulsify_locator_smoke_write( $partial_child . '/dist/components/' . $name . '/' . $name . '.component.json', '{"title":"Partial build"}' ); + emulsify_locator_smoke_write( $partial_child . '/dist/components/' . $name . '/' . $name . '.twig', '
Partial build
' ); + } + $GLOBALS['emulsify_locator_child_theme'] = $partial_child; + $GLOBALS['emulsify_locator_transients'] = array(); + $unreadable_directory = $partial_child . '/dist/components/temporarily-unreadable'; + chmod( $unreadable_directory, 0000 ); + clearstatcache(); + if ( ! is_readable( $unreadable_directory ) ) { + $partial_key_before = emulsify_locator_smoke_cache_key(); + try { + $partial = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + array( 'healthy' ) === emulsify_locator_smoke_relatives( $partial ) && array() === $GLOBALS['emulsify_locator_transients'], + 'Incomplete discovery must preserve readable components without persisting a partial index.' + ); + } catch ( Throwable $exception ) { + emulsify_locator_smoke_check( false, 'Unreadable component subtree must not throw: ' . $exception->getMessage() ); + } finally { + chmod( $unreadable_directory, 0700 ); + clearstatcache(); + } + $recovered = ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components(); + emulsify_locator_smoke_check( + $partial_key_before === emulsify_locator_smoke_cache_key() + && array( 'healthy', 'temporarily-unreadable' ) === emulsify_locator_smoke_relatives( $recovered ), + 'Restored directory access must recover components without a build token or version bump.' + ); + } else { + chmod( $unreadable_directory, 0700 ); + echo "Incomplete discovery case skipped: current user can read mode-000 directories.\n"; + } + + $metadata_warnings = array(); + $unreadable_native = $work_root . '/unreadable/block.json'; + $unreadable_acf = $work_root . '/unreadable/card.component.json'; + emulsify_locator_smoke_write( $unreadable_native, '{"name":"fixture/unreadable"}' ); + emulsify_locator_smoke_write( $unreadable_acf, '{"title":"Unreadable"}' ); + chmod( $unreadable_native, 0000 ); + chmod( $unreadable_acf, 0000 ); + clearstatcache(); + set_error_handler( + static function ( int $severity, string $message ) use ( &$metadata_warnings ): bool { + $metadata_warnings[] = $message; + return true; + } + ); + try { + $native_name = ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'native_block_name' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator(), $work_root . '/removed/block.json' ); + $acf_metadata = ( new ReflectionMethod( Emulsify\Theme\Blocks\AcfBlocks::class, 'metadata' ) ) + ->invoke( new Emulsify\Theme\Blocks\AcfBlocks(), $work_root . '/removed/card.component.json' ); + if ( ! is_readable( $unreadable_native ) && ! is_readable( $unreadable_acf ) ) { + $native_unreadable = ( new ReflectionMethod( Emulsify\Theme\Blocks\ComponentLocator::class, 'native_block_name' ) ) + ->invoke( new Emulsify\Theme\Blocks\ComponentLocator(), $unreadable_native ); + $acf_unreadable = ( new ReflectionMethod( Emulsify\Theme\Blocks\AcfBlocks::class, 'metadata' ) ) + ->invoke( new Emulsify\Theme\Blocks\AcfBlocks(), $unreadable_acf ); + emulsify_locator_smoke_check( + '' === $native_unreadable && null === $acf_unreadable, + 'Unreadable native and ACF metadata must be skipped.' + ); + } else { + echo "Unreadable metadata case skipped: current user can read mode-000 files.\n"; + } + } finally { + restore_error_handler(); + chmod( $unreadable_native, 0600 ); + chmod( $unreadable_acf, 0600 ); + } + emulsify_locator_smoke_check( + '' === $native_name && null === $acf_metadata && array() === $metadata_warnings, + 'Missing or unreadable metadata after discovery must be skipped without PHP read warnings.' + ); + + emulsify_locator_smoke_assert( + array() === $GLOBALS['emulsify_locator_failures'], + implode( "\n", $GLOBALS['emulsify_locator_failures'] ) + ); + echo "Component locator smoke checks passed.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); diff --git a/.github/scripts/component-request-cache-smoke.php b/.github/scripts/component-request-cache-smoke.php new file mode 100644 index 0000000..92e93c8 --- /dev/null +++ b/.github/scripts/component-request-cache-smoke.php @@ -0,0 +1,287 @@ +loader_paths( $value ); + } + return 'timber/cache/enable_extension' === $hook ? false : $value; +} +function apply_filters_deprecated( $hook, $arguments, ...$rest ) { return $arguments[0]; } +function do_action( $hook, ...$arguments ): void {} +function do_action_deprecated( $hook, ...$arguments ): void {} + +/** Uses the installed Timber implementation, bypassing only location discovery. */ +final class ComponentCacheTimberLoader extends \Timber\Loader { + public function __construct() { $this->locations = array(); } + public function compile( string $name ): string { + $chosen = $this->choose_template( $name ); + if ( false === $chosen ) { + throw new RuntimeException( 'Missing fixture component: ' . $name ); + } + return $this->render_template( $chosen, array() ); + } +} + +/** + * Delegates to PHP's native file wrapper, counting actual directory opens. + * + * The production loader uses fully qualified FilesystemIterator, so replacing + * that class or adding production counters would not test the same IO path. + */ +final class ComponentCacheFileSpy { + public $context; + private $stream; + private $directory; + public static array $directory_opens = array(); + public static array $file_opens = array(); + public static array $stat_calls = array(); + + private static function native( callable $operation ) { + stream_wrapper_restore( 'file' ); + try { + return $operation(); + } finally { + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', self::class ); + } + } + public function url_stat( $path, $flags ) { + self::$stat_calls[] = $path; + $stat = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat'; + return self::native( static fn() => $flags & STREAM_URL_STAT_QUIET ? @$stat( $path ) : $stat( $path ) ); + } + public function stream_open( $path, $mode, $options, &$opened_path ): bool { + self::$file_opens[] = realpath( $path ); + $this->stream = self::native( static fn() => fopen( $path, $mode, (bool) ( $options & STREAM_USE_PATH ) ) ); + return is_resource( $this->stream ); + } + public function stream_read( $length ) { return fread( $this->stream, $length ); } + public function stream_eof() { return feof( $this->stream ); } + public function stream_stat() { return fstat( $this->stream ); } + public function stream_seek( $offset, $whence = SEEK_SET ) { return 0 === fseek( $this->stream, $offset, $whence ); } + public function stream_tell() { return ftell( $this->stream ); } + public function stream_close(): void { fclose( $this->stream ); } + public function stream_set_option( $option, $first, $second ) { return false; } + public function dir_opendir( $path, $options ): bool { + self::$directory_opens[] = realpath( $path ); + $this->directory = self::native( static fn() => opendir( $path ) ); + return is_resource( $this->directory ); + } + public function dir_readdir() { return readdir( $this->directory ); } + public function dir_rewinddir(): bool { rewinddir( $this->directory ); return true; } + public function dir_closedir(): bool { closedir( $this->directory ); return true; } +} + +function component_cache_write( string $path, string $contents ): void { + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0777, true ); + } + file_put_contents( $path, $contents ); +} +function component_cache_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException( $message ); + } +} +function component_cache_remove( string $path ): void { + $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); + foreach ( $files as $file ) { + $file->isDir() && ! $file->isLink() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $path ); +} +function component_cache_reset(): void { + if ( method_exists( ThemeTwig::class, 'clear_request_cache' ) ) { + ThemeTwig::clear_request_cache(); + } +} +function component_cache_compile( string $reference ): string { + return ( new ComponentCacheTimberLoader() )->compile( $reference ); +} + +$temporary = sys_get_temp_dir() . '/emulsify-component-cache-' . bin2hex( random_bytes( 5 ) ); +$child = $temporary . '/child'; +$parent = $temporary . '/parent'; +$GLOBALS['component_cache_child'] = $child; +$GLOBALS['component_cache_parent'] = $parent; +$spying = false; + +try { + component_cache_write( $child . '/project.emulsify.json', '{"project":{"machineName":"fixture"}}' ); + foreach ( array( 'one', 'two', 'three' ) as $name ) { + component_cache_write( $child . '/src/components/group/' . $name . '/' . $name . '.twig', '{% include "fixture:parent" %}|' . $name ); + } + component_cache_write( $parent . '/src/components/group/parent/parent.twig', 'parent' ); + component_cache_write( $child . '/src/components/direct.twig', 'direct' ); + $child_root = realpath( $child . '/src/components' ); + $parent_root = realpath( $parent . '/src/components' ); + + if ( in_array( '--benchmark', $argv, true ) ) { + for ( $index = 0; $index < 500; ++$index ) { + $name = sprintf( 'g%04d', $index ); + component_cache_write( $child_root . '/' . $name . '/item/item.twig', $name ); + } + component_cache_write( $child_root . '/g0499/target/target.twig', 'target' ); + $results = array(); + foreach ( array( 'direct', 'target' ) as $name ) { + $times = array(); + for ( $sample = 0; $sample < 7; ++$sample ) { + component_cache_reset(); + $start = hrtime( true ); + for ( $render = 0; $render < 10; ++$render ) { + component_cache_assert( $name === component_cache_compile( 'fixture:' . $name ), 'Benchmark rendered the wrong component.' ); + } + $times[] = ( hrtime( true ) - $start ) / 1e6 / 10; + } + sort( $times ); + $results[ $name . '_ms_per_compile' ] = $times[3]; + } + echo json_encode( array( 'php' => PHP_VERSION, 'generated_group_entries' => 1500, 'samples' => 7, 'compiles_per_sample' => 10, 'medians' => $results ), JSON_PRETTY_PRINT ) . "\n"; + } else { + // Load the relevant vendor classes before installing the IO observer. + class_exists( \Twig\Loader\FilesystemLoader::class ); + class_exists( \Emulsify\Theme\Support\ProjectConfig::class ); + class_exists( \Emulsify\Theme\Twig\ProjectComponentLoader::class ); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', ComponentCacheFileSpy::class ); + $spying = true; + foreach ( array( 'one', 'two', 'three' ) as $name ) { + component_cache_assert( 'parent|' . $name === component_cache_compile( 'fixture:' . $name ), 'Timber must render each grouped entry and included parent component.' ); + } + stream_wrapper_restore( 'file' ); + $spying = false; + component_cache_assert( 9 === $GLOBALS['component_cache_filters'], 'Three Timber compiles must each apply the real loader filter three times.' ); + $counts = array_count_values( array_filter( ComponentCacheFileSpy::$directory_opens ) ); + $config_reads = count( array_filter( ComponentCacheFileSpy::$file_opens, static fn( $path ) => $path === realpath( $child . '/project.emulsify.json' ) ) ); + echo 'Observed directory opens: ' . json_encode( $counts ) . "; project JSON reads: $config_reads\n"; + component_cache_assert( 1 === ( $counts[ $child_root ] ?? 0 ) && 1 === ( $counts[ $parent_root ] ?? 0 ), 'Each component root must be scanned once across all Timber loader instances in the request.' ); + foreach ( $counts as $directory => $count ) { + component_cache_assert( 1 === $count, 'A group directory was scanned more than once: ' . $directory ); + } + component_cache_assert( 1 === $config_reads, 'Project configuration must be read once per request.' ); + $mutable_loader = ( new ThemeTwig() )->loader_paths( new \Twig\Loader\FilesystemLoader() ); + component_cache_assert( realpath( $child_root . '/group/one/one.twig' ) === $mutable_loader->getCacheKey( '@components/one' ), 'The late-registration fixture must begin with a cached grouped alias.' ); + component_cache_write( $temporary . '/late-components/one.twig', 'prepended plugin component' ); + $mutable_loader->prependPath( $temporary . '/late-components', 'components' ); + foreach ( array( '@components/one', 'fixture:one' ) as $reference ) { + component_cache_assert( realpath( $temporary . '/late-components/one.twig' ) === $mutable_loader->getCacheKey( $reference ), 'Late prepended component roots must replace cached aliases: ' . $reference ); + } + foreach ( array( '@components/appended-only', 'fixture:appended-only' ) as $reference ) { + component_cache_assert( ! $mutable_loader->exists( $reference ), 'The late-registration fixture must cache a missing alias: ' . $reference ); + } + component_cache_write( $temporary . '/appended-components/one.twig', 'lower-priority appended component' ); + component_cache_write( $temporary . '/appended-components/appended-only.twig', 'appended-only component' ); + $mutable_loader->addPath( $temporary . '/appended-components', 'components' ); + foreach ( array( '@components/one', 'fixture:one' ) as $reference ) { + component_cache_assert( realpath( $temporary . '/late-components/one.twig' ) === $mutable_loader->getCacheKey( $reference ), 'Late appended component roots must preserve earlier priority: ' . $reference ); + } + foreach ( array( '@components/appended-only', 'fixture:appended-only' ) as $reference ) { + component_cache_assert( realpath( $temporary . '/appended-components/appended-only.twig' ) === $mutable_loader->getCacheKey( $reference ), 'Late appended component roots must expose previously missing aliases: ' . $reference ); + } + ComponentCacheFileSpy::$stat_calls = array(); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', ComponentCacheFileSpy::class ); + $spying = true; + $first_loader = ( new ThemeTwig() )->loader_paths( new \Twig\Loader\FilesystemLoader() ); + component_cache_assert( ! $first_loader->exists( 'fixture:never-component' ), 'The missing-resolution fixture must not exist.' ); + $first_miss_stats = count( array_filter( ComponentCacheFileSpy::$stat_calls, static fn( $path ) => str_contains( $path, 'never-component' ) ) ); + $second_loader = ( new ThemeTwig() )->loader_paths( new \Twig\Loader\FilesystemLoader() ); + component_cache_assert( ! $second_loader->exists( 'fixture:never-component' ), 'A missing component must remain missing through another loader.' ); + $second_miss_stats = count( array_filter( ComponentCacheFileSpy::$stat_calls, static fn( $path ) => str_contains( $path, 'never-component' ) ) ); + $alias = $temporary . '/child-alias'; + component_cache_assert( symlink( $child, $alias ), 'Unable to create a canonical-root alias fixture.' ); + $GLOBALS['component_cache_child'] = $alias; + component_cache_assert( 'parent|one' === component_cache_compile( 'fixture:one' ), 'A canonical theme-root alias must resolve the same component.' ); + $GLOBALS['component_cache_child'] = $child; + stream_wrapper_restore( 'file' ); + $spying = false; + component_cache_assert( $first_miss_stats > 0 && $first_miss_stats === $second_miss_stats, 'Negative resolutions must be shared without repeating filesystem candidate checks.' ); + $alias_counts = array_count_values( array_filter( ComponentCacheFileSpy::$directory_opens ) ); + component_cache_assert( 1 === ( $alias_counts[ $child_root ] ?? 0 ) && 1 === ( $alias_counts[ $parent_root ] ?? 0 ), 'Canonical root aliases must share group discovery.' ); + $alias_config_reads = count( array_filter( ComponentCacheFileSpy::$file_opens, static fn( $path ) => $path === realpath( $child . '/project.emulsify.json' ) ) ); + component_cache_assert( 1 === $alias_config_reads, 'Canonical root aliases must share decoded configuration.' ); + + component_cache_write( $child_root . '/common.twig', 'child common' ); + component_cache_write( $parent_root . '/common.twig', 'parent common' ); + $child_first = new \Emulsify\Theme\Twig\ProjectComponentLoader( 'fixture', array( $child_root, $parent_root ), new \Twig\Loader\FilesystemLoader() ); + $parent_first = new \Emulsify\Theme\Twig\ProjectComponentLoader( 'fixture', array( $parent_root, $child_root ), new \Twig\Loader\FilesystemLoader() ); + component_cache_assert( realpath( $child_root . '/common.twig' ) === $child_first->getCacheKey( 'fixture:common' ), 'The first root must win before its shared result is cached.' ); + component_cache_assert( realpath( $parent_root . '/common.twig' ) === $parent_first->getCacheKey( 'fixture:common' ), 'Resolution caches must preserve each loader\'s root ordering.' ); + component_cache_assert( realpath( $child_root . '/group/one/one.twig' ) === $child_first->getCacheKey( '@plugin/one.twig' ), 'An unregistered namespace may use component shorthand.' ); + component_cache_write( $temporary . '/plugin/one.twig', 'plugin one' ); + $plugin_files = new \Twig\Loader\FilesystemLoader(); + $plugin_files->addPath( $temporary . '/plugin', 'plugin' ); + $plugin_loader = new \Emulsify\Theme\Twig\ProjectComponentLoader( 'fixture', array( $child_root, $parent_root ), $plugin_files ); + component_cache_assert( realpath( $temporary . '/plugin/one.twig' ) === $plugin_loader->getCacheKey( '@plugin/one.twig' ), 'A shared shorthand hit must never shadow a namespace another loader registers.' ); + + component_cache_assert( method_exists( ThemeTwig::class, 'clear_request_cache' ), 'Long-running workers need an explicit component/config cache reset boundary.' ); + $loader = ( new ThemeTwig() )->loader_paths( new \Twig\Loader\FilesystemLoader() ); + $original = $loader->getCacheKey( 'fixture:one' ); + component_cache_assert( ! $loader->exists( 'fixture:late' ), 'The lifecycle fixture must begin with a negative lookup.' ); + component_cache_write( $child_root . '/new-group/late/late.twig', 'late' ); + component_cache_write( $child_root . '/one.twig', 'new direct one' ); + component_cache_write( $child . '/project.emulsify.json', '{"project":{"machineName":"changed"}}' ); + component_cache_reset(); + component_cache_assert( realpath( $child_root . '/one.twig' ) === $loader->getCacheKey( 'fixture:one' ) && $original !== $loader->getCacheKey( 'fixture:one' ), 'Reset must invalidate successful resolutions on existing loader objects.' ); + component_cache_assert( $loader->exists( 'fixture:late' ), 'Reset must invalidate negative resolutions and discover new nested groups.' ); + component_cache_assert( 'changed' === \Emulsify\Theme\Support\ProjectConfig::machine_name(), 'Reset must reread same-path configuration rewrites.' ); + component_cache_write( $child_root . '/switchable/swap.twig', 'inside root' ); + component_cache_write( $temporary . '/outside/swap.twig', 'outside root' ); + component_cache_reset(); + component_cache_assert( realpath( $child_root . '/switchable/swap.twig' ) === $loader->getCacheKey( 'fixture:swap' ), 'A contained file must resolve before the mutation fixture.' ); + unlink( $child_root . '/switchable/swap.twig' ); + rmdir( $child_root . '/switchable' ); + component_cache_assert( symlink( $temporary . '/outside', $child_root . '/switchable' ), 'Unable to replace the fixture ancestor with a symlink.' ); + component_cache_reset(); + component_cache_assert( ! $loader->exists( 'fixture:swap' ), 'Reset must discard a cached hit whose ancestor now escapes the root.' ); + + // CLI/queue mutation safety is the default policy; the HTTP count above + // explicitly opts into request sharing because this harness runs in CLI. + $GLOBALS['component_cache_enabled'] = null; + component_cache_assert( false === \Emulsify\Theme\Support\ProjectConfig::request_cache_enabled(), 'Real CLI processes must bypass request caches by default, without a filter override.' ); + component_cache_write( $child . '/project.emulsify.json', '{"project":{"machineName":"fixture"}}' ); + $worker_loader = ( new ThemeTwig() )->loader_paths( new \Twig\Loader\FilesystemLoader() ); + component_cache_assert( realpath( $child_root . '/one.twig' ) === $worker_loader->getCacheKey( 'fixture:one' ), 'A worker begins at the new direct candidate.' ); + component_cache_assert( ! $worker_loader->exists( 'fixture:worker-late' ), 'The worker fixture must begin with a negative lookup.' ); + unlink( $child_root . '/one.twig' ); + component_cache_write( $child_root . '/worker-group/worker-late/worker-late.twig', 'worker late' ); + component_cache_write( $child . '/project.emulsify.json', '{"project":{"machineName":"worker_changed"}}' ); + clearstatcache( true ); + component_cache_assert( $original === $worker_loader->getCacheKey( 'fixture:one' ), 'Cache-disabled workers must see a deleted successful candidate without an explicit reset.' ); + component_cache_assert( $worker_loader->exists( 'fixture:worker-late' ), 'Cache-disabled workers must discover a new group after a negative lookup.' ); + component_cache_assert( 'worker_changed' === \Emulsify\Theme\Support\ProjectConfig::machine_name(), 'Cache-disabled workers must see rewritten configuration without reset.' ); + echo "Component request-cache smoke checks passed.\n"; + } +} finally { + if ( $spying ) { + stream_wrapper_restore( 'file' ); + } + if ( is_dir( $temporary ) ) { + component_cache_remove( $temporary ); + } +} diff --git a/.github/scripts/component-resolution-contract.cjs b/.github/scripts/component-resolution-contract.cjs new file mode 100644 index 0000000..641af6e --- /dev/null +++ b/.github/scripts/component-resolution-contract.cjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +// Resolve the same fixture trees through installed Core and the WordPress loader. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { spawnSync } = require('node:child_process'); +const { pathToFileURL } = require('node:url'); + +async function main() { + assert(process.argv.length <= 3, 'Usage: component-resolution-contract.cjs [installed-theme-directory]'); + const project = path.resolve(process.argv[2] || path.join(__dirname, '../../whisk')); + const requireProject = createRequire(path.join(project, 'package.json')); + const corePackage = requireProject.resolve('@emulsify/core/package.json'); + const core = JSON.parse(fs.readFileSync(corePackage, 'utf8')); + assert.equal(core.version, '4.5.0', 'Review component-resolution expectations before changing the Core 4.5.0 baseline.'); + const coreRoot = path.dirname(corePackage); + const loadCore = (file) => import(pathToFileURL(path.join(coreRoot, file))); + const { resolveComponentReference } = await loadCore('config/vite/utils/twig-component-resolver.js'); + const { resolveProjectConfig } = await loadCore('config/vite/project-config.js'); + const { makeTwigPluginOptions, emulsifyTwigModulePlugin } = await loadCore('config/vite/plugins/twig/twig-module.js'); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-component-contract-')); + const fixtures = []; + const write = (root, relative, text = relative) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + return file; + }; + const canonical = (file) => file ? fs.realpathSync(file) : null; + const add = (id, implementations = null, extra = {}) => { + const child = path.join(temporary, id, 'child'); + const parent = path.join(temporary, id, 'parent'); + fs.mkdirSync(child, { recursive: true }); + fs.mkdirSync(parent, { recursive: true }); + const config = { project: { machineName: 'whisk', platform: 'wordpress' } }; + if (implementations) config.variant = { structureImplementations: implementations }; + if (!extra.missingConfig) write(child, 'project.emulsify.json', JSON.stringify(config)); + const fixture = { id, child, parent, namespaces: {}, references: [], differences: {}, ...extra }; + fixtures.push(fixture); + return fixture; + }; + const aliases = ['@components/button/button.twig', '@components/button', 'project:button', '@organisms/button', 'anything:button', '@anything:button', 'whisk:button', '@whisk/button', 'components:button', 'components::button', 'components/button']; + try { + const ordinary = add('ordinary'); + const componentRoot = path.join(ordinary.child, 'src/components'); + const files = [ + 'atoms/button/button.twig', 'ui/controls/deep/deep.twig', + '01-first/duplicate/duplicate.twig', '02-second/duplicate/duplicate.twig', + 'a-depth/more/breadth/breadth.twig', 'z-depth/breadth/breadth.twig', + '\u{1F600}/unicode/unicode.twig', '\u{E000}/unicode/unicode.twig', + 'flat.twig', 'flat.html.twig', 'flat/flat.twig', 'nested/nested.html.twig', + 'exact', 'exact.twig', 'html.html.twig', 'html/html.twig', + 'direct.twig', 'atoms/direct/direct.twig', 'badge.twig', + 'atoms/override/override.twig', 'missing.twig', + ]; + for (const file of files) write(componentRoot, file.replace('\\u{1F600}', '\u{1F600}').replace('\\u{E000}', '\u{E000}')); + write(ordinary.parent, 'src/components/override.twig'); + write(ordinary.parent, 'src/components/parent-only.twig'); + write(ordinary.child, 'components/legacy-only.twig'); + ordinary.references.push(...aliases, + ...['deep', 'duplicate', 'breadth', 'unicode', 'flat', 'nested', 'exact', 'html', 'direct', 'override', 'badge.twig', './badge', 'missing/../badge', 'atoms\\button', 'atoms//button', 'atoms/button/'].map((name) => `whisk:${name}`), + '@components/deep', '@components/missing/button', 'whisk:missing/button', + 'whisk:missing-component', '@components/../outside', 'whisk:../../outside', + 'whisk:external', 'whisk:escape-file', 'whisk:inside-file', 'whisk:inside-directory/button.twig', + 'whisk:parent-only', 'whisk:legacy-only', + 'flat', 'atoms/button/button.twig', 'missing/button', '@flat', 'whisk:../flat', 'whisk:bad\0name', + 'whisk:escape-directory/external/external.twig'); + const outside = path.join(temporary, 'outside'); + write(outside, 'external/external.twig'); + write(outside, 'escape.twig'); + fs.symlinkSync(outside, path.join(componentRoot, 'escape-directory')); + fs.symlinkSync(path.join(outside, 'escape.twig'), path.join(componentRoot, 'escape-file.twig')); + fs.symlinkSync(path.join(componentRoot, 'badge.twig'), path.join(componentRoot, 'inside-file.twig')); + fs.symlinkSync(path.join(componentRoot, 'atoms/button'), path.join(componentRoot, 'inside-directory')); + for (const [index, directory] of ['node_modules', '.git', '.cache', '.vite', '.out', '.coverage', 'dist'].entries()) { + write(componentRoot, `${directory}/skip-${index}/skip-${index}.twig`); + write(componentRoot, `atoms/${directory}/nested-skip-${index}/nested-skip-${index}.twig`); + ordinary.references.push(`whisk:skip-${index}`, `@components/nested-skip-${index}`); + } + ordinary.differences['whisk:parent-only'] = { + reason: 'WordPress extends Core with parent-theme fallback roots after every child candidate.', + wordpress: canonical(path.join(ordinary.parent, 'src/components/parent-only.twig')), + }; + ordinary.differences['whisk:legacy-only'] = { + reason: 'WordPress retains the secondary child components root for existing generated themes.', + wordpress: canonical(path.join(ordinary.child, 'components/legacy-only.twig')), + }; + for (const reference of ['flat', 'atoms/button/button.twig', '@flat']) { + ordinary.differences[reference] = { + reason: 'WordPress leaves bare paths and @name without a slash to native Twig rather than shadowing theme templates with component files.', + wordpress: null, + }; + } + ordinary.differences['whisk:../flat'] = { + reason: 'WordPress rejects leading traversal before grouped lookup, even when Core finds a final candidate contained by the root.', + wordpress: null, + }; + + const custom = add('custom-first', [{ name: 'custom', directory: 'custom-twig' }, { name: '@unsafe', directory: '../outside' }]); + write(custom.child, 'custom-twig/teaser.twig'); + write(custom.child, 'custom-twig/atoms/button/button.twig'); + write(custom.child, 'src/components/example-card/example-card.twig'); + custom.references.push(...aliases, 'whisk:teaser', '@components/teaser.twig', 'whisk:example-card', '@components/example-card/example-card.twig'); + custom.pluginReferences = ['@custom/teaser.twig', '@custom/button.twig']; + + const explicit = add('explicit-components', [{ name: 'custom', directory: 'custom-twig' }, { name: 'components', directory: 'design-system' }]); + write(explicit.child, 'custom-twig/teaser.twig'); + write(explicit.child, 'design-system/atoms/button/button.twig'); + write(explicit.child, 'src/components/button/button.twig'); + explicit.references.push(...aliases, 'whisk:teaser', '@components/teaser.twig'); + explicit.pluginReferences = ['@custom/teaser.twig']; + + const safeFirst = add('unsafe-first', [{ name: '@unsafe', directory: '../outside' }, { name: 'custom', directory: 'custom-twig' }]); + write(safeFirst.child, 'custom-twig/atoms/button/button.twig'); + write(safeFirst.child, 'src/components/button/button.twig'); + safeFirst.references.push(...aliases); + + const normalized = add('normalized-components', [{ name: ' Custom ', directory: 'custom-twig' }, { name: ' COMPONENTS ', directory: 'design-system' }]); + write(normalized.child, 'custom-twig/teaser.twig'); + write(normalized.child, 'design-system/atoms/button/button.twig'); + write(normalized.child, 'src/components/button/button.twig'); + normalized.references.push(...aliases); + normalized.pluginReferences = ['@custom/teaser.twig']; + + const unnamed = add('unnamed-first', [{ directory: 'custom-twig' }]); + write(unnamed.child, 'custom-twig/atoms/button/button.twig'); + write(unnamed.child, 'custom-twig/teaser.twig'); + write(unnamed.child, 'src/components/button/button.twig'); + unnamed.references.push(...aliases); + unnamed.pluginReferences = ['@structure-1/teaser.twig']; + + const legacy = add('legacy-only'); + write(legacy.child, 'components/atoms/button/button.twig'); + legacy.references.push(...aliases); + + const literal = add('literal-prefixes'); + write(literal.child, 'src/components/literal:button.twig'); + write(literal.child, 'src/components/@literal/button.twig'); + write(literal.child, 'src/components/atoms/button/button.twig'); + literal.references.push('literal:button', '@literal/button'); + + const missing = add('missing-config', null, { missingConfig: true }); + write(missing.child, 'src/components/atoms/button/button.twig'); + missing.references.push('@components/button', 'project:button', '@organisms/button'); + + const registered = add('registered-namespaces'); + write(registered.child, 'src/components/button.twig'); + write(registered.child, 'templates/button.twig'); + const pluginRoot = path.join(registered.child, 'plugin'); + write(pluginRoot, 'present.twig'); + registered.namespaces = { plugin: pluginRoot, templates: path.join(registered.child, 'templates'), __main__: path.join(registered.child, 'templates') }; + registered.pluginReferences = ['@templates/button.twig', '@plugin/present.twig', '@plugin/button.twig', 'plugin:button.twig', 'button.twig']; + registered.differences['button.twig'] = { + reason: 'Bare WordPress references retain native Twig controller/template paths even when Core would choose a component file.', + wordpress: canonical(path.join(registered.child, 'templates/button.twig')), + }; + + const collision = add('registered-project-name'); + write(collision.child, 'src/components/button.twig'); + write(collision.child, 'plugin/button.twig'); + collision.namespaces = { whisk: path.join(collision.child, 'plugin') }; + collision.pluginReferences = ['@whisk/button.twig']; + + const registeredComponents = add('registered-components'); + write(registeredComponents.child, 'src/components/atoms/button/button.twig'); + const registeredComponentRoot = path.join(registeredComponents.child, 'plugin-components'); + write(registeredComponentRoot, 'atoms/button/button.twig'); + registeredComponents.namespaces = { components: registeredComponentRoot }; + registeredComponents.references.push(...aliases); + + const filtered = add('filtered-components'); + filtered.componentRootOverride = path.join(temporary, filtered.id, 'filtered-components'); + write(filtered.componentRootOverride, 'atoms/button/button.twig'); + for (const theme of [filtered.child, filtered.parent]) { + for (const relative of ['src/components', 'components']) { + assert(!fs.existsSync(path.join(theme, relative)), 'The filtered-root fixture must not register a native components namespace.'); + } + } + filtered.references.push(...aliases); + + const expected = {}; + for (const fixture of fixtures) { + const env = resolveProjectConfig(fixture.child, {}); + const options = makeTwigPluginOptions(env); + options.namespaces = { ...options.namespaces, ...fixture.namespaces, ...(fixture.componentRootOverride ? { components: fixture.componentRootOverride } : {}) }; + const groups = new Map(); + expected[fixture.id] = Object.fromEntries(fixture.references.map((reference) => [reference, canonical(resolveComponentReference(reference, options.namespaces, groups))])); + if (fixture.pluginReferences) { + const plugin = emulsifyTwigModulePlugin(options); + plugin.buildStart(); + for (const [index, reference] of fixture.pluginReferences.entries()) { + const entry = write(fixture.child, `contract-entry-${index}.twig`, `{% include ${JSON.stringify(reference)} %}`); + const dependencies = []; + const transformed = await plugin.transform.call({ addWatchFile(file) { dependencies.push(file); } }, fs.readFileSync(entry, 'utf8'), entry); + assert(transformed && typeof transformed.code === 'string'); + assert(dependencies.length <= 1, `${fixture.id}: fixture templates must have no transitive includes.`); + expected[fixture.id][reference] = canonical(dependencies[0]); + fixture.references.push(reference); + } + } + } + const php = spawnSync('php', [path.join(__dirname, 'component-resolution-contract.php')], { + encoding: 'utf8', input: JSON.stringify(fixtures), maxBuffer: 1024 * 1024 * 10, + }); + if (php.error) throw php.error; + assert.equal(php.status, 0, `WordPress resolution helper failed:\n${php.stderr}\n${php.stdout}`); + const actual = JSON.parse(php.stdout); + const failures = []; + let cases = 0; + let differences = 0; + for (const fixture of fixtures) { + for (const reference of fixture.references) { + cases++; + const corePath = expected[fixture.id][reference]; + const wordpressPath = actual[fixture.id][reference]; + const difference = fixture.differences[reference]; + if (difference) { + differences++; + assert(difference.reason.trim(), 'Every intentional difference needs a reason.'); + assert.notEqual(corePath, difference.wordpress, 'Remove obsolete intentional differences.'); + if (wordpressPath !== difference.wordpress) failures.push(`${fixture.id} ${reference}: expected intentional WordPress result ${difference.wordpress}; got ${wordpressPath}`); + console.log(`DIFFERENCE ${fixture.id} ${reference}: ${difference.reason}`); + } else if (corePath !== wordpressPath) { + failures.push(`${fixture.id} ${reference}: Core ${corePath}; WordPress ${wordpressPath}`); + } + } + } + assert.equal(failures.length, 0, `Component resolution differs from Core ${core.version} across ${cases} references and ${fixtures.length} trees:\n${failures.join('\n')}`); + console.log(`PASS ${cases} component references across ${fixtures.length} trees on Core ${core.version}; ${differences} documented WordPress differences.`); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/.github/scripts/component-resolution-contract.php b/.github/scripts/component-resolution-contract.php new file mode 100644 index 0000000..d4484a8 --- /dev/null +++ b/.github/scripts/component-resolution-contract.php @@ -0,0 +1,53 @@ + $path ) { + $loader->addPath( $path, $namespace ); + } + $loader = ( new \Emulsify\Theme\Runtime\Twig() )->loader_paths( $loader ); + $resolved = array(); + foreach ( $fixture['references'] as $reference ) { + try { + $source = $loader->getSourceContext( $reference ); + $resolved[ $reference ] = realpath( $source->getPath() ) ?: $source->getPath(); + $cache_key = $loader->getCacheKey( $reference ); + if ( ! $loader->exists( $reference ) || ! $loader->isFresh( $reference, time() + 1 ) || ( realpath( $cache_key ) ?: $cache_key ) !== $resolved[ $reference ] ) { + throw new RuntimeException( 'Loader APIs disagree for ' . $reference ); + } + } catch ( \Twig\Error\LoaderError $exception ) { + $resolved[ $reference ] = null; + } + } + $results[ $fixture['id'] ] = $resolved; +} + +echo json_encode( $results, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES ); diff --git a/.github/scripts/context-wordpress-smoke.php b/.github/scripts/context-wordpress-smoke.php new file mode 100644 index 0000000..bc96530 --- /dev/null +++ b/.github/scripts/context-wordpress-smoke.php @@ -0,0 +1,83 @@ +' ); + file_put_contents( $directory . '/late.twig', '

LateCoreMapProbe

' ); + file_put_contents( $directory . '/recursive.twig', '
{{ post.content|raw }}
' ); + foreach ( array( 'Context post A', 'Context post B' ) as $title ) { + $id = wp_insert_post( array( 'post_type' => 'post', 'post_status' => 'publish', 'post_title' => $title ), true ); + emulsify_block_context_assert( ! is_wp_error( $id ), 'Could not create mapped-block post fixture.' ); + $created_posts[] = $id; + } + $query = new WP_Query( array( 'p' => $created_posts[0] ) ); + $query->get_queried_object(); + $GLOBALS['wp_query'] = $query; + $GLOBALS['wp_the_query'] = $query; + $GLOBALS['post'] = get_post( $created_posts[0] ); + $the_post_calls = 0; + $add_filter( 'the_post', static function () use ( &$the_post_calls ): void { ++$the_post_calls; } ); + foreach ( $block_names as $name ) { + emulsify_block_context_assert( ! WP_Block_Type_Registry::get_instance()->is_registered( $name ), 'Fixture block name already registered.' ); + } + $dynamic_renders = 0; + register_block_type( + $block_names[0], + array( + 'render_callback' => static function () use ( &$dynamic_renders ): string { + ++$dynamic_renders; + return '

DynamicInnerBlockProbe

'; + }, + ) + ); + $registered[] = $block_names[0]; + register_block_type( + $block_names[1], + array( + 'uses_context' => array( 'postId' ), + 'render_callback' => static fn(): string => '

RecursiveBlockProbe

', + ) + ); + $registered[] = $block_names[1]; + $add_filter( + 'render_block_core/paragraph', + static fn( string $html ): string => $html . '' + ); + $add_filter( + 'pre_render_block', + static function ( $html, array $block ) { + return ! empty( $block['attrs']['fixtureShortCircuit'] ) ? '

ShortCircuitInnerProbe

' : $html; + }, + 10, + 2 + ); + $examples = array( + 'group' => '

GroupInnerProbe

DiscardedInnerProbe

', + 'quote' => '

QuoteInnerProbe

', + 'nested' => '

NestedInnerProbe

', + ); + $baseline = array_map( 'do_blocks', $examples ); + $heading = '

ContentOnlyProbe

'; + $heading_baseline = do_blocks( $heading ); + $unmapped = '

UnmappedBytesProbe & text

'; + $unmapped_baseline = do_blocks( $unmapped ); + $map = array( + 'core/group' => $relative . '/inner.twig', + 'core/quote' => $relative . '/inner.twig', + 'core/post-title' => $relative . '/post.twig', + 'core/heading' => $relative . '/content.twig', + ); + $map_filter = static fn( array $values ): array => array_merge( $values, $map ); + $add_filter( 'emulsify_theme_core_block_twig_template_map', $map_filter ); + $add_filter( 'emulsify_theme_core_block_twig_rendering_enabled', static fn(): bool => true ); + $renderer = new CoreBlockTwigRenderer(); + $renderer->register(); + $renderers[] = $renderer; + foreach ( $examples as $name => $source ) { + $dynamic_renders = 0; + $actual = do_blocks( $source ); + emulsify_block_context_assert( $actual === $baseline[ $name ], 'Mapped ' . $name . ' must retain full rendered inner content byte-for-byte, including final child filters.' ); + if ( 'group' === $name ) { + emulsify_block_context_assert( 1 === $dynamic_renders && 1 === substr_count( $actual, 'DynamicInnerBlockProbe' ) && 1 === substr_count( $actual, 'ShortCircuitInnerProbe' ), 'Inner dynamic and short-circuited blocks must appear once and never render twice.' ); + } + } + emulsify_block_context_assert( do_blocks( $heading ) === $heading_baseline, 'Templates printing content must preserve their rendered output byte-for-byte.' ); + emulsify_block_context_assert( do_blocks( $unmapped ) === $unmapped_baseline, 'Unmapped blocks must preserve every output byte.' ); + $late_map = static function ( array $values ) use ( $relative ): array { + $values['core/heading'] = $relative . '/late.twig'; + return $values; + }; + $add_filter( 'emulsify_theme_core_block_twig_template_map', $late_map, 11 ); + emulsify_block_context_assert( '

LateCoreMapProbe

' === do_blocks( $heading ), 'A template-map filter registered after initial rendering must take effect.' ); + emulsify_block_context_assert( 0 === $the_post_calls, 'The first mapped block context must not call setup() on a singular request.' ); + for ( $index = 0; $index < 20; ++$index ) { + $post_id = $created_posts[ $index % 2 ]; + $instance = new WP_Block( parse_blocks( '' )[0], array( 'postId' => $post_id ) ); + $global_before = $GLOBALS['post']; + $output = $instance->render(); + emulsify_block_context_assert( '' === $output, 'Mapped blocks must use their own postId context across repeated Query Loop post contexts.' ); + emulsify_block_context_assert( $global_before === $GLOBALS['post'], 'Mapped block context must not replace the global post.' ); + } + emulsify_block_context_assert( 0 === $the_post_calls, 'Many mapped blocks must not call setup() or fire the_post again.' ); + emulsify_block_context_assert( 1 === $context_builds, 'Many mapped blocks must reuse a single global Timber context build.' ); + + $add_filter( + 'query_loop_block_query_vars', + static fn( array $values ): array => array_merge( $values, array( 'post__in' => $created_posts, 'orderby' => 'post__in', 'posts_per_page' => 2 ) ) + ); + $query_blocks = '
'; + remove_filter( 'render_block', array( $renderer, 'render_block' ), 20 ); + $the_post_calls = 0; + do_blocks( $query_blocks ); + $baseline_post_calls = $the_post_calls; + add_filter( 'render_block', array( $renderer, 'render_block' ), 20, 3 ); + $the_post_calls = 0; + $query_output = do_blocks( $query_blocks ); + foreach ( $created_posts as $post_id ) { + emulsify_block_context_assert( 1 === substr_count( $query_output, 'data-context-post="' . $post_id . '"' ), 'Real Query Loop iterations must render each block post exactly once without context leakage.' ); + } + emulsify_block_context_assert( $the_post_calls === $baseline_post_calls, 'Mapped Query Loop rendering must not add the_post callbacks beyond WordPress itself.' ); + + remove_filter( 'render_block', array( $renderer, 'render_block' ), 20 ); + $add_filter( + 'emulsify_theme_core_block_twig_template_map', + static function ( array $values ) use ( $relative ): array { + $values['emulsify-fixture/recursive'] = $relative . '/recursive.twig'; + return $values; + }, + 12 + ); + $recursion_id = wp_insert_post( array( 'post_type' => 'post', 'post_status' => 'publish', 'post_title' => 'Recursive block fixture', 'post_content' => '' ), true ); + emulsify_block_context_assert( ! is_wp_error( $recursion_id ), 'Could not create recursive block fixture.' ); + $created_posts[] = $recursion_id; + $query = new WP_Query( array( 'p' => $recursion_id ) ); + $query->get_queried_object(); + $GLOBALS['wp_query'] = $query; + $GLOBALS['wp_the_query'] = $query; + $GLOBALS['post'] = get_post( $recursion_id ); + $recursion_calls = 0; + $add_filter( + 'emulsify_theme_core_block_twig_context', + static function ( array $context ) use ( &$recursion_calls ): array { + // Bound a regression before it can exhaust memory in the fixture. + if ( ++$recursion_calls > 8 ) throw new RuntimeException( 'Mapped content recursion did not stop.' ); + return $context; + } + ); + $recursive_renderer = new CoreBlockTwigRenderer(); + $recursive_renderer->register(); + $renderers[] = $recursive_renderer; + $output = do_blocks( get_post_field( 'post_content', $recursion_id ) ); + emulsify_block_context_assert( 1 === substr_count( $output, 'RecursiveBlockProbe' ) && $recursion_calls <= 2, 'Mapped post.content recursion must stop with WordPress markup and retain its content once.' ); + echo "Mapped block WordPress context smoke passed.\n"; +} finally { + foreach ( $renderers as $renderer ) remove_filter( 'render_block', array( $renderer, 'render_block' ), 20 ); + foreach ( array_reverse( $filters ) as $filter ) remove_filter( $filter[0], $filter[1], $filter[2] ); + foreach ( $created_posts as $id ) wp_delete_post( $id, true ); + foreach ( $registered as $name ) unregister_block_type( $name ); + if ( is_dir( $directory ) ) { + foreach ( glob( $directory . '/*.twig' ) as $file ) unlink( $file ); + rmdir( $directory ); + } + Timber::$context_cache = $saved_context; + foreach ( $saved_globals as $name => $saved ) { + if ( $saved[0] ) $GLOBALS[ $name ] = $saved[1]; + else unset( $GLOBALS[ $name ] ); + } +} diff --git a/.github/scripts/core-block-twig-renderer-smoke.php b/.github/scripts/core-block-twig-renderer-smoke.php index 94cc2b5..8dbd354 100644 --- a/.github/scripts/core-block-twig-renderer-smoke.php +++ b/.github/scripts/core-block-twig-renderer-smoke.php @@ -23,6 +23,11 @@ final class Timber { * @var array */ public static $compiled = array(); + public static $context_calls = 0; + public static $global_context_calls = 0; + public static $post_queries = array(); + public static $recursive_calls = 0; + public static $recursive_renderer; /** * Gets Timber context. @@ -30,9 +35,25 @@ final class Timber { * @return array Context. */ public static function context(): array { + ++self::$context_calls; + if ( ! empty( $GLOBALS['emulsify_core_block_reset_post'] ) ) { + $GLOBALS['post'] = $GLOBALS['emulsify_core_block_queried_post']; + } + return self::$context; + } + + public static function context_global(): array { + ++self::$global_context_calls; return self::$context; } + public static function get_post( $query = false ) { + self::$post_queries[] = $query; + $query = false === $query ? ( $GLOBALS['emulsify_core_block_queried_post'] ?? null ) : $query; + $id = is_object( $query ) ? $query->ID : $query; + return $id ? (object) array( 'ID' => (int) $id ) : null; + } + /** * Compiles a template. * @@ -42,6 +63,19 @@ public static function context(): array { */ public static function compile( string $template, array $context ): string { self::$compiled[] = compact( 'template', 'context' ); + if ( false !== strpos( $template, 'recursive.twig' ) ) { + if ( ++self::$recursive_calls > 4 ) { + throw new \RuntimeException( 'Unbounded mapped post.content recursion.' ); + } + return '
' . self::$recursive_renderer->render_block( $context['content'], $context['block'], $context['wp_block'] ) . '
'; + } + + if ( false !== strpos( $template, 'inner.twig' ) ) { + return $context['inner_content']; + } + if ( false !== strpos( $template, 'content.twig' ) ) { + return $context['content']; + } if ( false !== strpos( $template, 'throws.twig' ) ) { throw new \RuntimeException( 'Template failed.' ); @@ -51,7 +85,7 @@ public static function compile( string $template, array $context ): string { '

%s:%s:%s

', $template, $context['block_name'] ?? '', - $context['attributes']['className'] ?? '' + $context['block_attributes']['className'] ?? '' ); } } @@ -179,6 +213,16 @@ function apply_filters( string $hook, $value, ...$arguments ) { } } + function remove_filter( string $hook, callable $callback, int $priority = 10 ): bool { + foreach ( $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ][ $priority ] ?? array() as $index => $entry ) { + if ( $entry['callback'] === $callback ) { + unset( $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ][ $priority ][ $index ] ); + return true; + } + } + return false; + } + if ( ! function_exists( 'get_stylesheet_directory' ) ) { function get_stylesheet_directory(): string { return $GLOBALS['emulsify_core_block_twig_smoke_child']; @@ -244,6 +288,11 @@ function emulsify_core_block_twig_smoke_assert( bool $condition, string $message } } + function render_block( array $block ): string { + ++$GLOBALS['emulsify_core_block_rerenders']; + return 'Unexpected second render'; + } + /** * Writes a fixture file. * @@ -310,6 +359,8 @@ function emulsify_core_block_twig_smoke_remove( string $path ): void { 'blockName' => 'core/paragraph', 'attrs' => array( 'className' => 'intro', + 'level' => 2, + 'anchor' => 'saved-anchor', ), 'innerContent' => array( 'Original' ), 'innerBlocks' => array(), @@ -342,13 +393,15 @@ static function ( array $map ): array { 'emulsify_theme_core_block_twig_context', static function ( array $context ): array { $context['smoke_context'] = true; + $context['legacy_level'] = $context['attributes']['level'] ?? 'empty'; return $context; } ); \Timber\Timber::$context = array( - 'site' => 'Smoke Site', + 'site' => 'Smoke Site', + 'attributes' => array( 'data-global' => 'must not leak' ), ); $enabled = new \Emulsify\Theme\Blocks\CoreBlockTwigRenderer(); @@ -361,9 +414,24 @@ static function ( array $context ): array { $mapped = apply_filters( 'render_block', $original, $block, null ); + emulsify_core_block_twig_smoke_assert( + array() === \Timber\Timber::$compiled[0]['context']['attributes'], + 'Mapped blocks must start with an empty HTML attributes array.' + ); + foreach ( array( 'block_attributes', 'block', 'block_metadata' ) as $key ) { + $actual = \Timber\Timber::$compiled[0]['context'][ $key ]; + emulsify_core_block_twig_smoke_assert( + $block['attrs'] === ( 'block_attributes' === $key ? $actual : $actual['attrs'] ), + 'Mapped block data must remain available through ' . $key . '.' + ); + } + emulsify_core_block_twig_smoke_assert( + 'empty' === \Timber\Timber::$compiled[0]['context']['legacy_level'], + 'Legacy array reads must be empty without throwing or falling back to core markup.' + ); emulsify_core_block_twig_smoke_assert( false !== strpos( $mapped, 'dist/components/paragraph/paragraph.twig:core/paragraph:intro' ), - 'Mapped block should render through Timber compile with attributes.' + 'Mapped block should render through Timber compile with block_attributes.' ); emulsify_core_block_twig_smoke_assert( true === \Timber\Timber::$compiled[0]['context']['smoke_context'], @@ -443,6 +511,128 @@ static function ( array $context ): array { 'Class cleanup should remove base wp-block classes only when enabled.' ); + unset( $GLOBALS['emulsify_core_block_twig_smoke_filters']['emulsify_theme_core_block_twig_cleanup_classes_enabled'] ); + $GLOBALS['emulsify_core_block_rerenders'] = 0; + emulsify_core_block_twig_smoke_write( $child . '/dist/components/inner.twig', '{{ inner_content|raw }}' ); + emulsify_core_block_twig_smoke_write( $child . '/dist/components/content.twig', '{{ content|raw }}' ); + add_filter( + 'emulsify_theme_core_block_twig_template_map', + static function ( array $map ): array { + $map['core/group'] = 'dist/components/inner.twig'; + $map['core/quote'] = 'dist/components/inner.twig'; + $map['core/details'] = 'dist/components/content.twig'; + return $map; + } + ); + $composite_errors = array(); + foreach ( array( 'core/group', 'core/quote' ) as $name ) { + $wrapper = 'core/group' === $name ? 'div' : 'blockquote'; + $before = '<' . $wrapper . ' class="saved-wrapper">'; + $after = ''; + $first = '

First rendered child

'; + $second = ''; + $html = $before . $first . '\n' . $second . $after; + $parsed = array( + 'blockName' => $name, + 'attrs' => array(), + 'innerContent' => array( $before, null, '\n', null, $after ), + 'innerBlocks' => array( array( 'blockName' => 'core/paragraph' ), array( 'blockName' => 'core/html' ) ), + ); + $actual = $enabled->render_block( $html, $parsed ); + if ( $actual !== $html || 1 !== substr_count( $actual, $first ) || 1 !== substr_count( $actual, $second ) ) { + $composite_errors[] = $name . ' must preserve already rendered children, their filters and the original wrapper exactly once.'; + } + $parsed['blockName'] = 'core/details'; + emulsify_core_block_twig_smoke_assert( $html === $enabled->render_block( $html, $parsed ), 'Mapped templates using content must keep every original byte.' ); + } + emulsify_core_block_twig_smoke_assert( 0 === $GLOBALS['emulsify_core_block_rerenders'], 'Inner content must never render a child for a second time.' ); + emulsify_core_block_twig_smoke_assert( array() === $composite_errors, implode( "\n", $composite_errors ) ); + + // Mirror Timber::context()'s queried-post setup and count the cheaper + // global-context entry point independently. + $GLOBALS['emulsify_core_block_twig_smoke_caps'] = array(); + $GLOBALS['post'] = (object) array( 'ID' => 77 ); + $GLOBALS['emulsify_core_block_queried_post'] = (object) array( 'ID' => 999 ); + $GLOBALS['emulsify_core_block_reset_post'] = true; + \Timber\Timber::$context = array( 'site' => 'Global site', 'post' => (object) array( 'ID' => 999 ) ); + \Timber\Timber::$context_calls = 0; + \Timber\Timber::$global_context_calls = 0; + \Timber\Timber::$compiled = array(); + $performance = new \Emulsify\Theme\Blocks\CoreBlockTwigRenderer(); + $performance_errors = array(); + $posts = array(); + for ( $index = 0; $index < 20; ++$index ) { + $instance = (object) array( 'context' => array( 'postId' => 41 + $index % 2 ) ); + $performance->render_block( $original, $block, $instance ); + $latest = end( \Timber\Timber::$compiled ); + $posts[] = ( $latest['context']['post']->ID ?? null ) === $instance->context['postId']; + } + if ( 1 !== \Timber\Timber::$global_context_calls || 0 !== \Timber\Timber::$context_calls ) { + $performance_errors[] = 'Many mapped blocks must build the base global Timber context once without calling context().'; + } + if ( in_array( false, $posts, true ) || 77 !== $GLOBALS['post']->ID ) { + $performance_errors[] = 'Block postId values must select each block post without changing the WordPress global post.'; + } + $GLOBALS['post'] = (object) array( 'ID' => 77 ); + $performance->render_block( $original, $block ); + $latest = end( \Timber\Timber::$compiled ); + if ( 77 !== ( $latest['context']['post']->ID ?? null ) ) { + $performance_errors[] = 'A block without explicit post context must use the current global post rather than the cached base post.'; + } + $performance->render_block( $original, $block, (object) array( 'context' => array( 'postId' => null ) ) ); + $latest = end( \Timber\Timber::$compiled ); + if ( null !== $latest['context']['post'] ) { + $performance_errors[] = 'An explicit missing postId must not reuse another block post.'; + } + + $late_filter = static function ( array $map ): array { + $map['core/heading'] = 'dist/components/inner.twig'; + $map['core/table'] = 'dist/components'; + $map['core/recursive'] = 'dist/components/recursive.twig'; + return $map; + }; + add_filter( 'emulsify_theme_core_block_twig_template_map', $late_filter ); + $heading = array( 'blockName' => 'core/heading', 'innerContent' => array( 'Late mapping' ) ); + if ( 'Late mapping' !== $performance->render_block( '

Late mapping

', $heading ) ) { + $performance_errors[] = 'A template-map filter registered after the first render must take effect.'; + } + $count = count( \Timber\Timber::$compiled ); + if ( 'Original
' !== $performance->render_block( 'Original
', array( 'blockName' => 'core/table' ) ) || $count !== count( \Timber\Timber::$compiled ) ) { + $performance_errors[] = 'A readable directory must not count as a mapped Twig file.'; + } + + emulsify_core_block_twig_smoke_write( $child . '/dist/components/recursive.twig', '{{ post.content|raw }}' ); + \Timber\Timber::$recursive_renderer = $performance; + $recursive = $performance->render_block( '

Core fallback

', array( 'blockName' => 'core/recursive' ) ); + if ( '

Core fallback

' !== $recursive || 1 !== \Timber\Timber::$recursive_calls ) { + $performance_errors[] = 'Recursive mapped rendering must immediately use WordPress markup without compiling again.'; + } + remove_filter( 'emulsify_theme_core_block_twig_template_map', $late_filter ); + if ( '

Late mapping

' !== $performance->render_block( '

Late mapping

', $heading ) ) { + $performance_errors[] = 'Removing a template-map filter must invalidate its mapping.'; + } + $dynamic_template = 'dist/components/inner.twig'; + add_filter( + 'emulsify_theme_core_block_twig_template_map', + static function ( array $map ) use ( &$dynamic_template ): array { + $map['core/list'] =& $dynamic_template; + return $map; + } + ); + $list = array( 'blockName' => 'core/list', 'innerContent' => array( 'Dynamic' ) ); + $first = $performance->render_block( '
    Dynamic
', $list ); + $dynamic_template = 'dist/components/content.twig'; + $second = $performance->render_block( '
    Dynamic
', $list ); + if ( 'Dynamic' !== $first || '
    Dynamic
' !== $second ) { + $performance_errors[] = 'A dynamic map callback must be evaluated again even when its registration is unchanged.'; + } + $performance->render_block( '
Error fallback
', array( 'blockName' => 'core/code' ) ); + $recovered = $performance->render_block( $original, $block ); + if ( false === strpos( $recovered, 'mapped' ) ) { + $performance_errors[] = 'Exceptions must unwind the recursion guard so later mapped blocks still render.'; + } + emulsify_core_block_twig_smoke_assert( array() === $performance_errors, implode( "\n", $performance_errors ) ); + echo "Core block Twig renderer smoke checks passed.\n"; } finally { emulsify_core_block_twig_smoke_remove( $work_root ); diff --git a/.github/scripts/docs-command-check.cjs b/.github/scripts/docs-command-check.cjs index 72b3d3b..cf1b73c 100644 --- a/.github/scripts/docs-command-check.cjs +++ b/.github/scripts/docs-command-check.cjs @@ -8,6 +8,14 @@ const repoRoot = path.resolve(__dirname, '../..'); // Documentation that describes the repository itself. Each entry is scoped to a // heading so unrelated prose in the same file cannot satisfy the check. const ROOT_CHECKS = [ + { + relativePath: 'docs/timber-and-twig-authoring.md', + heading: 'Helper characterization', + packagePath: 'package.json', + packageLabel: 'the maintainer checkout', + includeInlineCode: true, + expectedScripts: ['test:twig-helpers', 'whisk:install'], + }, { relativePath: 'README.md', heading: 'Working inside a generated child theme', @@ -98,6 +106,16 @@ const ROOT_CHECKS = [ // against the Whisk source and once against real generated output, so a command // cannot drift between the template and the theme a project actually receives. const THEME_DOC_CHECKS = [ + ...[ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', + ].map((relativePath) => ({ + relativePath, + packagePath: 'package.json', + packageLabel: 'the generated child theme', + includeInlineCode: true, + })), { relativePath: 'README.md', packagePath: 'package.json', diff --git a/.github/scripts/fallback-navigation-smoke.php b/.github/scripts/fallback-navigation-smoke.php new file mode 100644 index 0000000..0190ab6 --- /dev/null +++ b/.github/scripts/fallback-navigation-smoke.php @@ -0,0 +1,231 @@ +loadHTML( '' . $html . '' ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + return new DOMXPath( $document ); +} + +/** + * Records an assertion without stopping the other regression checks. + * + * @param bool $condition Assertion result. + * @param string $message Failure explanation. + * @param array $errors Collected failures. + * @return void + */ +function emulsify_navigation_expect( bool $condition, string $message, array &$errors ): void { + if ( ! $condition ) { + $errors[] = $message; + } +} + +$integration = new Twig(); +$loader = new \Twig\Loader\FilesystemLoader(); +$loader->addPath( __DIR__ . '/../../templates', 'templates' ); +$environment = new \Twig\Environment( $loader, $integration->environment_options( array( 'cache' => false ) ) ); + +foreach ( $integration->functions( array() ) as $name => $definition ) { + $environment->addFunction( + new \Twig\TwigFunction( + $name, + $definition['callable'], + $definition['options'] ?? array() + ) + ); +} + +$environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); +emulsify_register_wordpress_text_test_filters( $environment ); +$integration->extensions( $environment ); + +$hostile = '">'; +$urls = array( + 'Previous' => '/?s=needle&paged=4&filter=a%26b', + '1' => '/?s=needle&paged=1&filter=a%26b', + $hostile => '/?s=needle&paged=6&filter=a%26b', + 'Next' => '/?s=needle&paged=6&filter=a%26b', +); +$display_url = static function ( string $url ): string { + return str_replace( '&', '&', $url ); +}; +$pagination_context = array( + 'pagination' => array( + 'prev' => array( 'link' => $display_url( $urls['Previous'] ) ), + 'pages' => array( + array( 'class' => 'page-number page-numbers', 'title' => '1', 'link' => $display_url( $urls['1'] ), 'current' => false ), + array( 'class' => 'dots', 'title' => '…' ), + array( 'class' => 'page-number page-numbers current', 'title' => '5', 'current' => true ), + array( 'class' => 'page-number page-numbers', 'title' => $hostile, 'link' => $display_url( $urls[ $hostile ] ), 'current' => false ), + ), + 'next' => array( 'link' => $display_url( $urls['Next'] ) ), + ), +); +$pagination = $environment->render( + '@templates/partials/pagination.twig', + $pagination_context +); +$pager_dom = emulsify_navigation_dom( $pagination ); +$checks = array( + 'pagination current page' => array(), + 'pagination literal ellipsis' => array(), + 'pagination page prefixes' => array(), + 'pagination URL escaping' => array(), + 'pagination localized numbers' => array(), + 'menu current item' => array(), +); + +$current = $pager_dom->query( '//nav//*[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $current->length && '5' === trim( $current->item( 0 )->textContent ), 'Only the actual current page must have aria-current="page".', $checks['pagination current page'] ); +emulsify_navigation_expect( 1 === $pager_dom->query( '//li[contains(concat(" ", normalize-space(@class), " "), " pagination__item--current ")]' )->length, 'The current item must preserve its BEM modifier.', $checks['pagination current page'] ); + +// Current state must also work when a pagination provider supplies a link. +$pagination_context['pagination']['pages'][2]['link'] = $display_url( '/?s=needle&paged=5&filter=a%26b' ); +$linked_current_dom = emulsify_navigation_dom( $environment->render( '@templates/partials/pagination.twig', $pagination_context ) ); +$linked_current = $linked_current_dom->query( '//nav//*[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $linked_current->length && '5' === trim( $linked_current->item( 0 )->lastChild->textContent ), 'An explicitly current item must be marked even when it supplies a link.', $checks['pagination current page'] ); + +$dots = $pager_dom->query( '//nav//*[contains(concat(" ", normalize-space(@class), " "), " dots ")]' ); +emulsify_navigation_expect( 1 === $dots->length && 'span' === $dots->item( 0 )->nodeName && '…' === trim( $dots->item( 0 )->textContent ), 'Timber ellipsis items must render as non-interactive spans with the literal … character.', $checks['pagination literal ellipsis'] ); +emulsify_navigation_expect( false !== strpos( $pagination, '…' ) && false === strpos( $pagination, '&hellip;' ), 'The ellipsis must not appear as literal entity text.', $checks['pagination literal ellipsis'] ); +emulsify_navigation_expect( in_array( array( '…', 'emulsify' ), $GLOBALS['emulsify_navigation_translations'] ?? array(), true ), 'The literal ellipsis must pass through the theme translation domain.', $checks['pagination literal ellipsis'] ); + +$links = $pager_dom->query( '//nav//a' ); +emulsify_navigation_expect( count( $urls ) === $links->length, 'Previous, numeric, and next pagination links must all render.', $checks['pagination URL escaping'] ); + +$numbered_links = $pager_dom->query( '//nav//a[contains(concat(" ", normalize-space(@class), " "), " page-number ")]' ); +$prefix_query = './span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]'; +emulsify_navigation_expect( 2 === $numbered_links->length, 'The fixture must contain two numbered pagination links.', $checks['pagination page prefixes'] ); +foreach ( $numbered_links as $link ) { + $prefixes = $pager_dom->query( $prefix_query, $link ); + emulsify_navigation_expect( 1 === $prefixes->length && 'Page ' === $prefixes->item( 0 )->textContent, 'Each numbered pagination link must contain one screen-reader-text span with Page and a trailing space.', $checks['pagination page prefixes'] ); +} +$linked_prefixes = $linked_current_dom->query( $prefix_query, $linked_current->item( 0 ) ); +emulsify_navigation_expect( 1 === $linked_prefixes->length && 'Page ' === $linked_prefixes->item( 0 )->textContent, 'A current page supplied as a numbered link must retain the Page prefix.', $checks['pagination page prefixes'] ); +emulsify_navigation_expect( 2 === $pager_dom->query( '//nav//span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]' )->length, 'Previous, next, unlinked current, and ellipsis items must not acquire Page prefix spans.', $checks['pagination page prefixes'] ); +emulsify_navigation_expect( in_array( array( 'Page', 'emulsify' ), $GLOBALS['emulsify_navigation_translations'] ?? array(), true ), 'The Page prefix must use the parent theme translation domain.', $checks['pagination page prefixes'] ); + +$GLOBALS['emulsify_navigation_translation_overrides']['emulsify']['Page'] = $hostile; +$translated_pagination = $environment->render( '@templates/partials/pagination.twig', $pagination_context ); +$translated_dom = emulsify_navigation_dom( $translated_pagination ); +$translated_prefixes = $translated_dom->query( '//nav//span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]' ); +emulsify_navigation_expect( $translated_prefixes->length > 0 && $hostile . ' ' === $translated_prefixes->item( 0 )->textContent && 0 === $translated_dom->query( '//script' )->length, 'Translated Page prefixes must remain HTML escaped and retain their separating space.', $checks['pagination page prefixes'] ); +unset( $GLOBALS['emulsify_navigation_translation_overrides'] ); + +$localized_pagination = $environment->render( + '@templates/partials/pagination.twig', + array( + 'pagination' => array( + 'pages' => array( + array( 'title' => '1 000', 'current' => true ), + array( 'title' => '1 001', 'link' => '/?paged=1001', 'current' => false ), + ), + ), + ) +); +$localized_dom = emulsify_navigation_dom( $localized_pagination ); +$localized_current = $localized_dom->query( '//nav//span[@aria-current="page"]' ); +$localized_link = $localized_dom->query( '//nav//a' ); +emulsify_navigation_expect( 1 === $localized_current->length && "1\u{00A0}000" === $localized_current->item( 0 )->textContent, 'Current page numbers must render a non-breaking thousands separator.', $checks['pagination localized numbers'] ); +emulsify_navigation_expect( 1 === $localized_link->length && "Page 1\u{00A0}001" === $localized_link->item( 0 )->textContent, 'Linked page numbers must retain the Page prefix and a non-breaking thousands separator.', $checks['pagination localized numbers'] ); +emulsify_navigation_expect( false === strpos( $localized_pagination, ' ' ) && false === strpos( $localized_pagination, '&nbsp;' ), 'Localized numbers must contain the actual non-breaking space, not literal or double-escaped entity text.', $checks['pagination localized numbers'] ); + +$expected_urls = array_values( $urls ); +foreach ( $links as $index => $link ) { + $title = trim( $link->textContent ); + $actual = $link->getAttribute( 'href' ); + $expected = $expected_urls[ $index ] ?? ''; + parse_str( (string) parse_url( $actual, PHP_URL_QUERY ), $actual_query ); + parse_str( (string) parse_url( $expected, PHP_URL_QUERY ), $expected_query ); + emulsify_navigation_expect( '' !== $expected && $actual === $expected && $actual_query === $expected_query && null === parse_url( $actual, PHP_URL_FRAGMENT ), sprintf( '%s link must decode once to %s; got %s.', $title, $expected, $actual ), $checks['pagination URL escaping'] ); +} + +$raw_url = '/?s=x&paged=2"e=" onmouseover="alert(1)'; +$attributes = $environment->createTemplate( '{{ title }}' )->render( array( 'url' => $raw_url, 'title' => $hostile ) ); +$raw_dom = emulsify_navigation_dom( $attributes ); +$raw_link = $raw_dom->query( '//a' )->item( 0 ); +emulsify_navigation_expect( $raw_url === $raw_link->getAttribute( 'href' ) && $hostile === $raw_link->getAttribute( 'title' ) && ! $raw_link->hasAttribute( 'onmouseover' ) && 0 === $raw_dom->query( '//script' )->length && false !== strpos( $attributes, '&paged=' ), 'AttributeBag must continue escaping raw ampersands, quotes, and hostile titles.', $checks['pagination URL escaping'] ); +emulsify_navigation_expect( 0 === $pager_dom->query( '//script' )->length, 'Pagination titles must remain HTML escaped.', $checks['pagination URL escaping'] ); + +$menu = $environment->render( + '@templates/partials/menu.twig', + array( + 'items' => array( + array( + 'title' => 'Ancestor', + 'link' => '/ancestor/', + 'current' => false, + 'current_item_ancestor' => true, + 'current_item_parent' => true, + 'classes' => array( 'current-menu-ancestor', 'current-menu-parent' ), + 'children' => array( + array( 'title' => 'Current', 'link' => '/current/', 'current' => true ), + ), + ), + array( 'title' => $hostile, 'link' => '/unrelated/?a=1&b=2', 'current' => false ), + ), + ) +); +$menu_dom = emulsify_navigation_dom( $menu ); +$current = $menu_dom->query( '//ul//a[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $current->length && '/current/' === $current->item( 0 )->getAttribute( 'href' ), 'Only the actual current menu link must have aria-current="page"; ancestors stay unmarked.', $checks['menu current item'] ); +emulsify_navigation_expect( 1 === $menu_dom->query( '//ul[contains(concat(" ", normalize-space(@class), " "), " menu--sub ")]' )->length && 0 === $menu_dom->query( '//script' )->length, 'Nested menu BEM classes and HTML-escaped titles must remain intact.', $checks['menu current item'] ); + +$failed = false; + +foreach ( $checks as $name => $errors ) { + if ( $errors ) { + $failed = true; + fwrite( STDERR, 'FAIL ' . $name . ': ' . implode( ' ', $errors ) . "\n" ); + } else { + echo 'PASS ' . $name . "\n"; + } +} + +exit( $failed ? 1 : 0 ); diff --git a/.github/scripts/file-discovery-smoke.php b/.github/scripts/file-discovery-smoke.php new file mode 100644 index 0000000..7817974 --- /dev/null +++ b/.github/scripts/file-discovery-smoke.php @@ -0,0 +1,185 @@ +isDir() && ! $file->isLink() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $path ); +} + +/** Removes a selected directory exactly when SPL attempts to open it. */ +final class DiscoveryVanishingDirectory { + public $context; + private $directory; + public static string $remove_on_open; + public static bool $removed = false; + + private static function native( callable $operation ) { + stream_wrapper_restore( 'file' ); + try { + return $operation(); + } finally { + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', self::class ); + } + } + public function url_stat( $path, $flags ) { + return self::native( static fn() => $flags & STREAM_URL_STAT_LINK ? @lstat( $path ) : @stat( $path ) ); + } + public function dir_opendir( $path, $options ): bool { + if ( ! self::$removed && $path === self::$remove_on_open ) { + self::native( static function () use ( $path ): void { discovery_remove( $path ); } ); + self::$removed = true; + } + $this->directory = self::native( static fn() => @opendir( $path ) ); + return is_resource( $this->directory ); + } + public function dir_readdir() { return readdir( $this->directory ); } + public function dir_rewinddir(): bool { rewinddir( $this->directory ); return true; } + public function dir_closedir(): bool { closedir( $this->directory ); return true; } +} + +$root = sys_get_temp_dir() . '/emulsify-file-discovery-' . bin2hex( random_bytes( 5 ) ); +$failures = array(); +$wrapped = false; +$denied = $root . '/permissions/a-denied'; +try { + discovery_write( $root . '/ordinary/z.js' ); + discovery_write( $root . '/ordinary/a/deep.CSS' ); + discovery_write( $root . '/ordinary/a/readme.txt' ); + $expected = array( $root . '/ordinary/a/deep.CSS', $root . '/ordinary/z.js' ); + discovery_assert( $expected === FileDiscovery::recursive_files( $root . '/ordinary', array( '.CSS', 'js' ) ), 'Discovery must retain deterministic ordering and extension normalization.' ); + discovery_assert( array( $root . '/ordinary/z.js' ) === FileDiscovery::directory_files( $root . '/ordinary', array( 'js' ) ), 'Non-recursive discovery must retain its immediate-file behavior.' ); + $complete = false; + FileDiscovery::file_records( array( array( 'path' => $root . '/ordinary' ) ), array( 'js' ), true, $complete ); + discovery_assert( $complete, 'A complete file-record scan must set its completion flag.' ); + + discovery_write( $root . '/permissions/z-readable/visible.component.json' ); + discovery_write( $denied . '/hidden.component.json' ); + chmod( $denied, 0000 ); + clearstatcache( true ); + if ( ! is_readable( $denied ) ) { + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + try { + $complete = true; + $actual = FileDiscovery::recursive_files( $root . '/permissions', array( 'json' ), $complete ); + discovery_assert( array( $root . '/permissions/z-readable/visible.component.json' ) === $actual, 'An unreadable component branch must not hide readable siblings.' ); + discovery_assert( ! $complete, 'An unreadable branch must mark its scan incomplete so it cannot be cached.' ); + FileDiscovery::recursive_files( $root . '/permissions', array( 'json' ) ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'An unreadable branch must report once per root only in debug mode.' ); + } catch ( Throwable $error ) { + $failures[] = 'Unreadable subdirectory: ' . $error->getMessage(); + } + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + discovery_assert( array() === FileDiscovery::normalize_roots( array( $denied ) ), 'An unreadable root must be excluded.' ); + FileDiscovery::normalize_roots( array( $denied ) ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'An existing unreadable root must report once in debug mode.' ); + } else { + echo "Permission bits are bypassed by this user; the vanished-directory race below still exercises failure handling.\n"; + } + chmod( $denied, 0755 ); + + discovery_write( $root . '/race/z-readable/visible.json' ); + discovery_write( $root . '/race/a-vanishing/hidden.json' ); + DiscoveryVanishingDirectory::$remove_on_open = $root . '/race/a-vanishing'; + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', DiscoveryVanishingDirectory::class ); + $wrapped = true; + try { + $complete = true; + $actual = FileDiscovery::file_records( array( array( 'path' => $root . '/race' ) ), array( 'json' ), true, $complete ); + discovery_assert( DiscoveryVanishingDirectory::$removed, 'The race fixture must actually remove a directory during the scan.' ); + discovery_assert( array( $root . '/race/z-readable/visible.json' ) === array_column( $actual, 'path' ), 'A vanished branch must not stop discovery of readable siblings.' ); + discovery_assert( ! $complete, 'A vanished branch must mark file records incomplete so they cannot be cached.' ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'A vanished branch must report once per root only in debug mode.' ); + } catch ( Throwable $error ) { + $failures[] = 'Vanished subdirectory: ' . $error->getMessage(); + } finally { + stream_wrapper_restore( 'file' ); + $wrapped = false; + } + discovery_write( $root . '/race-flat/hidden.json' ); + DiscoveryVanishingDirectory::$remove_on_open = $root . '/race-flat'; + DiscoveryVanishingDirectory::$removed = false; + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + stream_wrapper_unregister( 'file' ); + stream_wrapper_register( 'file', DiscoveryVanishingDirectory::class ); + $wrapped = true; + try { + $complete = true; + discovery_assert( array() === FileDiscovery::directory_files( $root . '/race-flat', array(), $complete ), 'A vanished non-recursive root must return an empty result.' ); + discovery_assert( DiscoveryVanishingDirectory::$removed && ! $complete, 'A vanished non-recursive root must mark its scan incomplete.' ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'A vanished non-recursive root must report only in debug mode.' ); + } finally { + stream_wrapper_restore( 'file' ); + $wrapped = false; + } + + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + try { + $complete = true; + discovery_assert( array() === FileDiscovery::recursive_files( $root . '/missing', array(), $complete ), 'A missing scan root must return an empty result.' ); + discovery_assert( ! $complete, 'A missing root must mark its scan incomplete.' ); + FileDiscovery::recursive_files( $root . '/missing' ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'A missing scan root must not be silently dropped in debug mode.' ); + } catch ( Throwable $error ) { + $failures[] = 'Missing root: ' . $error->getMessage(); + } + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + $complete = true; + discovery_assert( array() === FileDiscovery::directory_files( $root . '/missing-flat', array(), $complete ) && ! $complete, 'A missing non-recursive root must be harmless and mark its scan incomplete.' ); + discovery_assert( ( WP_DEBUG ? 1 : 0 ) === count( $GLOBALS['file_discovery_diagnostics'] ) - $before, 'A missing non-recursive root must report only in debug mode.' ); + $before = count( $GLOBALS['file_discovery_diagnostics'] ); + FileDiscovery::normalize_roots( array( $root . '/missing-optional' ) ); + discovery_assert( $before === count( $GLOBALS['file_discovery_diagnostics'] ), 'Optional missing roots must stay quiet.' ); + if ( $failures ) { + throw new RuntimeException( implode( "\n", $failures ) ); + } + echo 'File discovery smoke passed in ' . ( WP_DEBUG ? 'debug' : 'production' ) . " mode.\n"; + if ( WP_DEBUG ) { + passthru( escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __FILE__ ) . ' --production', $status ); + discovery_assert( 0 === $status, 'Production-mode discovery must also pass.' ); + } +} finally { + if ( $wrapped ) { + stream_wrapper_restore( 'file' ); + } + if ( is_dir( $denied ) ) { + chmod( $denied, 0755 ); + } + discovery_remove( $root ); +} diff --git a/.github/scripts/formatted-text-dom-smoke.php b/.github/scripts/formatted-text-dom-smoke.php new file mode 100644 index 0000000..28d6b16 --- /dev/null +++ b/.github/scripts/formatted-text-dom-smoke.php @@ -0,0 +1,60 @@ +loadHTML( '' . $fixture['html'] ); +libxml_clear_errors(); +$xpath = new DOMXPath( $document ); + +foreach ( $fixture['assertions'] as $assertion ) { + $nodes = $xpath->query( $assertion['xpath'] ); + if ( false === $nodes || 0 === $nodes->length ) { + throw new RuntimeException( $fixture['name'] . ': missing ' . $assertion['xpath'] ); + } + foreach ( $nodes as $node ) { + $actual = isset( $assertion['attribute'] ) ? $node->getAttribute( $assertion['attribute'] ) : trim( $node->textContent ); + if ( isset( $assertion['contains'] ) ) { + $matches = str_contains( $actual, $assertion['contains'] ); + } else { + $matches = $actual === $assertion['text']; + } + if ( ! $matches ) { + throw new RuntimeException( $fixture['name'] . ': ' . $assertion['xpath'] . ' visible value was ' . json_encode( $actual ) . '; expected ' . json_encode( $assertion['text'] ?? $assertion['contains'] ) ); + } + if ( preg_match( '/&(?:amp|quot|lt|gt|#\d+|#x[0-9a-f]+);/i', $actual ) ) { + throw new RuntimeException( $fixture['name'] . ': literal HTML entity in DOM value.' ); + } + $allowed = $assertion['elements'] ?? array(); + foreach ( $node->getElementsByTagName( '*' ) as $element ) { + if ( ! in_array( strtolower( $element->tagName ), $allowed, true ) ) { + throw new RuntimeException( $fixture['name'] . ': unexpected ' . $element->tagName . ' inside ' . $assertion['xpath'] ); + } + foreach ( $element->attributes as $attribute ) { + if ( str_starts_with( strtolower( $attribute->name ), 'on' ) ) { + throw new RuntimeException( $fixture['name'] . ': executable event attribute survived.' ); + } + if ( in_array( strtolower( $attribute->name ), array( 'src', 'href' ), true ) && preg_match( '/^\s*(?:javascript|vbscript|data):/i', $attribute->value ) ) { + throw new RuntimeException( $fixture['name'] . ': unsafe URL protocol survived.' ); + } + } + } + if ( isset( $assertion['elementCount'] ) && $node->getElementsByTagName( '*' )->length !== $assertion['elementCount'] ) { + throw new RuntimeException( $fixture['name'] . ': safe inline title markup was lost.' ); + } + if ( isset( $assertion['attribute'] ) ) { + foreach ( $node->attributes as $attribute ) { + if ( ! in_array( $attribute->name, array( 'src', 'alt' ), true ) ) { + throw new RuntimeException( $fixture['name'] . ': image attribute breakout: ' . $attribute->name ); + } + } + } + } +} + +echo "DOM text and attributes passed.\n"; diff --git a/.github/scripts/generated-audit-wrappers.test.cjs b/.github/scripts/generated-audit-wrappers.test.cjs new file mode 100644 index 0000000..d9481a9 --- /dev/null +++ b/.github/scripts/generated-audit-wrappers.test.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +// Opt-in integration: generate the real child theme and install a packed Core +// artifact. No network installation is added to the ordinary contract tests. +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); +const excluded = new Set(['.git', 'node_modules', 'dist', '.out', '.coverage', '.cache']); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function run(command, args, cwd, env) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: 'utf8', + timeout: 180000, + maxBuffer: 8 * 1024 * 1024, + }); + assert.ifError(result.error); + assert.equal(result.signal, null, `Unexpected signal: ${result.signal}`); + return result; +} + +function assertWrapper(result, direct, footer) { + assert.equal(result.status, direct.status, 'Wrapper must preserve the exact Core exit status.'); + let report; + assert.doesNotThrow(() => { + // Parsing a substring or dropping a footer would conceal this regression. + report = JSON.parse(result.stdout); + }, 'The entire stdout stream must be valid JSON.'); + assert.deepEqual(report, JSON.parse(direct.stdout), 'Wrapper must forward every argument unchanged.'); + assert.equal(report.schemaVersion, 1); + assert.equal(report.tool.name, '@emulsify/core'); + assert.equal(result.stderr, `${direct.stderr}\n${footer}\n`, 'The exact documentation footer belongs on stderr.'); + assert.ok(!result.stdout.includes(footer), 'Documentation footer must not enter stdout.'); +} + +test('real generated audit wrappers retain the packed Core CLI contract', { timeout: 240000 }, async (t) => { + assert.ok(process.env.EMULSIFY_CORE_TARBALL, 'Set EMULSIFY_CORE_TARBALL to a local npm pack .tgz artifact.'); + const tarball = path.resolve(process.env.EMULSIFY_CORE_TARBALL); + const tarballHash = createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'); + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-generated-audit-')); + const consumer = path.join(workRoot, 'generated child with spaces'); + const env = { + ...process.env, + CI: '1', + FORCE_COLOR: '0', + PUPPETEER_SKIP_DOWNLOAD: 'true', + npm_config_cache: process.env.npm_config_cache || path.join(workRoot, 'npm-cache'), + }; + + try { + fs.cpSync(path.join(repoRoot, 'whisk'), consumer, { + recursive: true, + filter: (source) => !excluded.has(path.basename(source)), + }); + const projectPath = path.join(consumer, 'project.emulsify.json'); + const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + Object.assign(project.project, { + name: 'Audit Fixture', machineName: 'audit-fixture', description: 'Generated audit wrapper contract.', + }); + writeJson(projectPath, project); + const generation = run(process.execPath, [path.join(consumer, '.cli/init.js')], consumer, env); + assert.equal(generation.status, 0, generation.stderr); + const packagePath = path.join(consumer, 'package.json'); + const generated = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const source = JSON.parse(fs.readFileSync(path.join(repoRoot, 'whisk/package.json'), 'utf8')); + assert.deepEqual(generated.scripts, source.scripts, 'Generation must retain the real starter scripts.'); + const install = run(npm, ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--save-exact', tarball], consumer, env); + assert.equal(install.status, 0, install.stderr); + const installed = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + assert.deepEqual(installed.scripts, generated.scripts); + const coreRoot = path.join(consumer, 'node_modules/@emulsify/core'); + assert.equal(fs.lstatSync(coreRoot).isSymbolicLink(), false, 'Use an installed package, not a source symlink.'); + const coreVersion = JSON.parse(fs.readFileSync(path.join(coreRoot, 'package.json'), 'utf8')).version; + t.diagnostic(JSON.stringify({ coreVersion, tarballSha256: tarballHash, generatedScripts: { audit: generated.scripts.audit, 'audit:twig-stories': generated.scripts['audit:twig-stories'] } })); + + const auditRoot = path.join(workRoot, 'audit root with spaces'); + const component = path.join(auditRoot, 'src/components/card'); + fs.mkdirSync(component, { recursive: true }); + writeJson(path.join(auditRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(component, 'card.twig'), '

{{ title }}

'); + fs.writeFileSync(path.join(component, 'card.stories.js'), 'import cardTwig from "./card.twig";\nexport const Card = (args) => cardTwig(args);\n'); + const cleanRoot = path.join(workRoot, 'clean root with spaces'); + const cleanComponent = path.join(cleanRoot, 'src/components/clean'); + fs.mkdirSync(cleanComponent, { recursive: true }); + writeJson(path.join(cleanRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(cleanComponent, 'clean.stories.js'), 'export default { title: "Audit/Clean" };\nexport const Clean = () => "

Clean

";\n'); + + for (const [script, executable, footer, threshold] of [ + ['audit', 'audit.js', 'Audit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration', ['--fail-on', 'warn']], + ['audit:twig-stories', 'audit-twig-stories.js', 'Migration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility', ['--fail-on-found']], + ]) { + const args = ['--root', auditRoot, '--json']; + const directRun = (options) => run(path.join(coreRoot, 'scripts', executable), options, consumer, env); + const wrapperRun = (options) => run(npm, ['run', script, '--silent', '--', ...options], consumer, env); + for (const [scenario, options, expectedStatus] of [ + ['clean scan', ['--root', cleanRoot, '--json', ...threshold], 0], + ['advisory findings', args, 0], + ['findings fail threshold', [...args, ...threshold], 1], + ['invalid argument', [...args, '--unknown-wrapper-option'], 2], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const direct = directRun(options); + assert.equal(direct.status, expectedStatus, direct.stderr); + const report = JSON.parse(direct.stdout); + if (scenario === 'clean scan') { + assert.deepEqual(report.findings, []); + } else if (expectedStatus !== 2) { + assert.ok(report.findings.some((finding) => finding.id === 'legacy-twig-story' && finding.path === 'src/components/card/card.stories.js' && finding.line === 2 && finding.severity === 'warn')); + } + const wrapped = wrapperRun(options); + assertWrapper(wrapped, direct, footer); + const executableName = executable === 'audit.js' ? 'emulsify-audit' : 'emulsify-audit-twig-stories'; + const directAlias = run(npx, ['--no-install', executableName, ...options], consumer, env); + assert.equal(directAlias.status, expectedStatus); + assert.deepEqual(JSON.parse(directAlias.stdout), report); + assert.equal(directAlias.stderr, direct.stderr); + t.diagnostic(JSON.stringify({ script, scenario, options, directStatus: direct.status, wrapperStatus: wrapped.status, npxNoInstallStatus: directAlias.status, wholeStdoutJson: true, footerOnStderr: true })); + }); + } + + for (const [scenario, mutate, expectedFailure] of [ + ['rejects stdout footer', (command) => command.replace(' >&2;', ';'), /entire stdout stream/], + ['rejects masked status', (command) => command.replace('exit $status', 'exit 0'), /exact Core exit status/], + ['rejects dropped arguments', (command) => command.replace('"$@"', ''), /exact Core exit status|forward every argument/], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const modified = structuredClone(installed); + modified.scripts[script] = mutate(modified.scripts[script]); + assert.notEqual(modified.scripts[script], installed.scripts[script]); + writeJson(packagePath, modified); + try { + const options = [...args, ...threshold]; + assert.throws(() => assertWrapper(wrapperRun(options), directRun(options), footer), expectedFailure); + } finally { + writeJson(packagePath, installed); + } + }); + } + } + } finally { + fs.rmSync(workRoot, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/generated-theme-contract.cjs b/.github/scripts/generated-theme-contract.cjs index f911bd7..df18f6e 100644 --- a/.github/scripts/generated-theme-contract.cjs +++ b/.github/scripts/generated-theme-contract.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { isDeepStrictEqual } = require('util'); +const yaml = require('js-yaml'); const { validateDocumentation } = require('./docs-command-check.cjs'); @@ -24,6 +25,7 @@ const SECTION_LABELS = { frontend: 'frontend metadata', references: 'file references', documentation: 'documentation', + guidance: 'AI guidance', placeholders: 'placeholder replacement', build: 'build', }; @@ -48,7 +50,12 @@ const REQUIRED_GENERATED_FILES = [ 'docs/development.md', 'docs/support-information.md', 'docs/upgrading.md', - 'templates/page.twig', +]; + +const REQUIRED_GUIDANCE_FILES = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', ]; // Generation-only tooling and build output must never reach a project repo. @@ -181,10 +188,10 @@ function checkEqual(addError, section, themeLabel, file, key, actual, want) { } function validateGenerationShape(validator, contract) { - const { themeDir, themeLabel, sourceDir } = contract; + const { themeDir, themeLabel, sourceDir, checkBuiltAssets } = contract; const entries = listFilesRecursive(themeDir); const relativeFiles = new Set( - entries.filter((entry) => entry.file).map((entry) => entry.relativePath), + entries.filter((entry) => entry.file).map((entry) => entry.relativePath.split(path.sep).join('/')), ); for (const requiredFile of REQUIRED_GENERATED_FILES) { @@ -209,6 +216,9 @@ function validateGenerationShape(validator, contract) { } for (const forbidden of FORBIDDEN_GENERATED_PATHS) { + if (checkBuiltAssets && forbidden !== '.cli') { + continue; + } if (fs.existsSync(path.join(themeDir, forbidden))) { validator.addError( 'generation', @@ -454,6 +464,7 @@ function validateScriptReferences(validator, contract, scripts) { function validateMarkdownReferences(validator, contract, entries) { const { themeDir, themeLabel } = contract; const themeRoot = path.resolve(themeDir); + const realThemeRoot = fs.realpathSync(themeRoot); for (const entry of entries) { if (!entry.file || !entry.relativePath.endsWith('.md')) { @@ -461,6 +472,8 @@ function validateMarkdownReferences(validator, contract, entries) { } const contents = readTextFile(path.join(themeDir, entry.relativePath)); + const relativePath = entry.relativePath.split(path.sep).join('/'); + const section = REQUIRED_GUIDANCE_FILES.includes(relativePath) || relativePath.startsWith('.agents/skills/') ? 'guidance' : 'references'; for (const match of contents.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { const target = match[1].replace(/^<|>$/g, '').split('#')[0].trim(); @@ -474,24 +487,57 @@ function validateMarkdownReferences(validator, contract, entries) { target, ); - if (!resolved.startsWith(themeRoot)) { - validator.addError( - 'references', + if (resolved !== themeRoot && !resolved.startsWith(`${themeRoot}${path.sep}`)) { + validator.addError(section, `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves outside the generated child theme; expected a self-contained path or published package.`, ); continue; } if (!fs.existsSync(resolved)) { - validator.addError( - 'references', + validator.addError(section, `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves to missing local path ${expected(path.relative(themeDir, resolved))}; expected it inside the generated child theme.`, ); + continue; + } + + const realTarget = fs.realpathSync(resolved); + if (realTarget !== realThemeRoot && !realTarget.startsWith(`${realThemeRoot}${path.sep}`)) { + validator.addError( + section, + `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves outside the generated child theme through a symbolic link; expected it inside the generated child theme.`, + ); } } } } +function validateGeneratedGuidance(validator, contract, entries, relativeFiles) { + const { themeDir, themeLabel, sourceDir, checkBuiltAssets } = contract; + for (const relativePath of REQUIRED_GUIDANCE_FILES) { + if (!relativeFiles.has(relativePath)) { + validator.addError('guidance', `${themeLabel} is missing required generated guidance file ${expected(relativePath)}.`); + continue; + } + const source = path.join(sourceDir, relativePath); + if (!checkBuiltAssets && (!fs.existsSync(source) || !fs.readFileSync(path.join(themeDir, relativePath)).equals(fs.readFileSync(source)))) { + validator.addError('guidance', `${themeLabel} changed guidance file ${expected(relativePath)} during generation; expected a byte-for-byte copy of the starter guidance.`); + } + } + + for (const entry of entries.filter((file) => file.file && /^\.agents[\\/]skills[\\/][^\\/]+[\\/]SKILL\.md$/.test(file.relativePath))) { + try { + const frontmatter = readTextFile(path.join(themeDir, entry.relativePath)).match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!frontmatter) throw new Error('expected YAML frontmatter bounded by ---'); + const metadata = yaml.load(frontmatter[1]); + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) || metadata.name !== path.basename(path.dirname(entry.relativePath))) throw new Error('frontmatter name must match the skill directory'); + if (typeof metadata.description !== 'string' || !metadata.description.trim()) throw new Error('frontmatter description must be a nonempty string'); + } catch (error) { + validator.addError('guidance', `${themeLabel} has invalid skill manifest in ${expected(entry.relativePath)}: ${error.message}.`); + } + } +} + function validateGeneratedDocumentation(validator, contract) { const { themeDir, themeLabel, machineName, displayName, description } = contract; @@ -505,7 +551,8 @@ function validateGeneratedDocumentation(validator, contract) { } for (const error of result.errors) { - validator.addError('documentation', `${themeLabel} ${error}`); + const section = REQUIRED_GUIDANCE_FILES.some((file) => error.replaceAll('\\', '/').includes(`${file}:`)) ? 'guidance' : 'documentation'; + validator.addError(section, `${themeLabel} ${error}`); } const readmePath = path.join(themeDir, 'README.md'); @@ -704,6 +751,7 @@ function validateGeneratedTheme(options) { description: normalizeWhitespace(description), parent: 'emulsify', sourceDir, + checkBuiltAssets, sourcePackage, sourceProjectFile, sourceProject: sourceProjectFile.project, @@ -712,9 +760,10 @@ function validateGeneratedTheme(options) { sourceDescription: normalizeWhitespace(sourceHeaders.Description), }; - const { entries } = validateGenerationShape(validator, contract); + const { entries, relativeFiles } = validateGenerationShape(validator, contract); validateWordPressMetadata(validator, contract); validateFrontendMetadata(validator, contract); + validateGeneratedGuidance(validator, contract, entries, relativeFiles); validateMarkdownReferences(validator, contract, entries); validateGeneratedDocumentation(validator, contract); validatePlaceholders(validator, contract, entries); diff --git a/.github/scripts/generated-theme-contract.test.cjs b/.github/scripts/generated-theme-contract.test.cjs index dad3ed6..a7f3e75 100644 --- a/.github/scripts/generated-theme-contract.test.cjs +++ b/.github/scripts/generated-theme-contract.test.cjs @@ -27,6 +27,11 @@ const excludedCopySegments = new Set([ const machineName = 'acme-site'; const displayName = 'Acme Site'; const description = 'Marketing site for Acme, Inc. (rebuild).'; +const guidanceFiles = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', +]; function copyStarter(source, destination) { fs.mkdirSync(destination, { recursive: true }); @@ -82,13 +87,14 @@ function generateTheme() { return { workRoot, target }; } -function validate(target) { +function validate(target, options = {}) { return validateGeneratedTheme({ themeDir: target, machineName, displayName, description, sourceDir: starterRoot, + ...options, }); } @@ -115,6 +121,36 @@ test('accepts a freshly generated child theme', () => { }); }); +test('freshly generated child themes inherit parent templates without local Twig files', () => { + withGeneratedTheme((target) => { + assert.equal(fs.existsSync(path.join(target, 'templates')), false); + assert.deepEqual( + fs.readdirSync(target, { recursive: true }).filter((file) => file.endsWith('.twig')), + [], + ); + }); +}); + +test('accepts a generated child theme without a local page template', () => { + withGeneratedTheme((target) => { + fs.rmSync(path.join(target, 'templates'), { recursive: true, force: true }); + const result = validate(target); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + +test('allows consumers to add an intentional page override after generation', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(target, 'templates/page.twig'), + "{% extends '@emulsify-tpl/page.twig' %}\n{% block content %}
{{ parent() }}
{% endblock %}\n", + ); + const result = validate(target); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + test('requires the generated documentation set', () => { withGeneratedTheme((target) => { fs.rmSync(path.join(target, 'README.md')); @@ -127,6 +163,129 @@ test('requires the generated documentation set', () => { }); }); +test('requires all generated AI guidance files', () => { + for (const file of guidanceFiles) { + withGeneratedTheme((target) => { + fs.rmSync(path.join(target, file)); + const result = validate(target); + assert.match(result.format(), /FAIL AI guidance/); + assert.ok(result.errors.some((error) => error.includes(`missing required generated guidance file "${file}"`)), result.format()); + }); + } +}); + +test('preserves guidance bytes during generation and permits project edits after a build', () => { + withGeneratedTheme((target) => { + for (const file of guidanceFiles) { + assert.deepEqual(fs.readFileSync(path.join(target, file)), fs.readFileSync(path.join(starterRoot, file))); + fs.appendFileSync(path.join(target, file), '\nProject guidance customized by the team.\n'); + } + const result = validate(target); + assert.match(result.format(), /FAIL AI guidance/); + for (const file of guidanceFiles) { + assert.ok(result.errors.some((error) => error.includes(`changed guidance file "${file}" during generation`)), result.format()); + } + for (const directory of ['dist', 'node_modules', '.out', '.coverage']) { + fs.mkdirSync(path.join(target, directory)); + } + const built = validate(target, { checkBuiltAssets: true }); + assert.deepEqual(built.errors, [], built.format()); + assert.match(built.format(), /PASS AI guidance/); + + fs.mkdirSync(path.join(target, '.cli')); + const leftover = validate(target, { checkBuiltAssets: true }); + assert.match(leftover.format(), /generation-only or build path "\.cli"/); + }); +}); + +test('validates skill YAML after project customization with actionable errors', () => { + for (const [contents, expected] of [ + ['Missing frontmatter\n', /expected YAML frontmatter/], + ['---\nname: [\n---\n', /invalid skill manifest/], + ['---\n- name: emulsify-wordpress-integration\n---\n', /name must match the skill directory/], + ['---\nname: another-skill\ndescription: Integration guidance\n---\n', /name must match the skill directory/], + ['---\nname: emulsify-wordpress-integration\ndescription: ""\n---\n', /description must be a nonempty string/], + ['---\nname: emulsify-wordpress-integration\ndescription: [Integration guidance]\n---\n', /description must be a nonempty string/], + ]) { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.writeFileSync(path.join(target, guidanceFiles[2]), contents); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), expected); + }); + } +}); + +test('accepts a customized skill with folded YAML and local links', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.writeFileSync(path.join(target, guidanceFiles[2]), '---\r\nname: "emulsify-wordpress-integration"\r\ndescription: >\r\n Project integration\r\n guidance.\r\n---\r\n[Integration guide](../../../docs/wordpress-integration.md)\r\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + +test('validates guidance links after customization and rejects missing or escaping targets', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + const skill = path.join(target, guidanceFiles[2]); + fs.appendFileSync(skill, '\n[Integration guide](../../../docs/wordpress-integration.md)\n'); + assert.deepEqual(validate(target, { checkBuiltAssets: true }).errors, []); + + const outside = path.join(path.dirname(target), `${machineName}-external`); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'outside.md'), 'Outside the theme.\n'); + fs.appendFileSync(skill, `\n[Missing guide](../../../docs/missing.md)\n[Outside theme](../../../../${machineName}-external/outside.md)\n`); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), /resolves to missing local path "docs\/missing\.md"/); + assert.match(result.format(), /resolves outside the generated child theme/); + }); +}); + +test('checks npm commands in every generated guidance entry point', () => { + for (const file of guidanceFiles) { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.appendFileSync(path.join(target, file), '\nRun `npm run impossible-script` for this project.\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.ok(result.errors.some((error) => error.includes(`${file}:`) && error.includes('documents npm run impossible-script')), result.format()); + assert.match(result.format(), /has no "impossible-script" script/); + }); + } +}); + +test('rejects guidance links through dependency symlinks outside the theme', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.mkdirSync(path.join(target, 'node_modules')); + fs.symlinkSync(path.join(target, 'docs'), path.join(target, 'node_modules', 'local-docs'), 'dir'); + fs.appendFileSync(path.join(target, 'AGENTS.md'), '\n[Local guide](node_modules/local-docs/development.md)\n'); + assert.deepEqual(validate(target, { checkBuiltAssets: true }).errors, []); + const outside = path.join(path.dirname(target), 'external-package'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'outside.md'), 'Outside the theme.\n'); + fs.symlinkSync(outside, path.join(target, 'node_modules', 'external-package'), 'dir'); + fs.appendFileSync(path.join(target, 'AGENTS.md'), '\n[External package](node_modules/external-package/outside.md)\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), /resolves outside the generated child theme through a symbolic link/); + }); +}); + +test('continues checking guidance for stale tokens and starter identity after customization', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.appendFileSync(path.join(target, guidanceFiles[0]), '\n%%EMULSIFY_MACHINE_NAME%%\n'); + fs.appendFileSync(path.join(target, guidanceFiles[2]), '\nnamespace: whiskHelper\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /stale documentation token/); + assert.match(result.format(), /stale starter machine name "whisk"/); + }); +}); + test('reports leftover documentation tokens', () => { withGeneratedTheme((target) => { const readmePath = path.join(target, 'README.md'); @@ -143,11 +302,10 @@ test('reports leftover documentation tokens', () => { test('reports a stale starter machine name', () => { withGeneratedTheme((target) => { - const templatePath = path.join(target, 'templates/page.twig'); - fs.writeFileSync( - templatePath, - fs.readFileSync(templatePath, 'utf8').replace(`${machineName}-page`, 'whisk-page'), - ); + const packagePath = path.join(target, 'package.json'); + const packageJson = readJson(packagePath); + packageJson.name = 'whisk'; + writeJson(packagePath, packageJson); const result = validate(target); assert.match(result.format(), /FAIL placeholder replacement/); diff --git a/.github/scripts/generation-parity-harness.php b/.github/scripts/generation-parity-harness.php index 95ed048..f188dce 100644 --- a/.github/scripts/generation-parity-harness.php +++ b/.github/scripts/generation-parity-harness.php @@ -51,9 +51,53 @@ function get_theme_root(): string { } } +// An installed fixture may supply the real formatting.php without booting a +// database. Standalone CI retains WordPress's text-field sanitization below, +// including percent-octet removal; trim(strip_tags()) hid generator drift. +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( $hook, $value, ...$arguments ) { + return $value; + } +} +if ( ! function_exists( 'is_utf8_charset' ) ) { + function is_utf8_charset(): bool { + return true; + } +} +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $name ) { + return 'blog_charset' === $name ? 'UTF-8' : false; + } +} +if ( ! function_exists( '_canonical_charset' ) ) { + function _canonical_charset( string $charset ): string { + return is_utf8_charset() ? 'UTF-8' : $charset; + } +} +$wordpress_formatting = getenv( 'EMULSIFY_WP_FORMATTING_FILE' ); +if ( is_string( $wordpress_formatting ) && '' !== $wordpress_formatting ) { + foreach ( array( 'compat-utf8.php', 'utf8.php', 'kses.php' ) as $dependency ) { + $dependency_path = dirname( $wordpress_formatting ) . '/' . $dependency; + if ( is_file( $dependency_path ) ) { + require_once $dependency_path; + } + } + require_once $wordpress_formatting; +} if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $value ): string { - return trim( strip_tags( $value ) ); + $value = preg_replace( '@<(script|style)\b[^>]*?>.*?@si', '', $value ); + $value = preg_replace_callback( + '%<[^>]*?((?=<)|>|$)%', + static fn( array $matches ): string => str_contains( $matches[0], '>' ) ? $matches[0] : htmlspecialchars( $matches[0], ENT_QUOTES, 'UTF-8', false ), + $value + ); + $value = str_replace( "<\n", "<\n", strip_tags( $value ) ); + $value = trim( preg_replace( '/[\r\n\t ]+/', ' ', $value ) ); + while ( preg_match( '/%[a-f0-9]{2}/i', $value, $match ) ) { + $value = trim( preg_replace( '/ +/', ' ', str_replace( $match[0], '', $value ) ) ); + } + return $value; } } diff --git a/.github/scripts/generation-parity-smoke.cjs b/.github/scripts/generation-parity-smoke.cjs index 0f588d1..9751767 100644 --- a/.github/scripts/generation-parity-smoke.cjs +++ b/.github/scripts/generation-parity-smoke.cjs @@ -47,6 +47,23 @@ const scenarios = [ displayName: 'Civic Portal', description: 'Resident services portal: forms, alerts & payments.', }, + { + machineName: 'unicode-portal', + inputMachineName: 'Unicode_Portal', + displayName: 'Café \"Portal\" / 100% site', + inputDisplayName: 'Café \"Portal\" / 100%20% site', + description: 'Quotes \" and \' / café & 100% coverage.', + inputDescription: 'Quotes \" and \' / café & 100%25% coverage.', + }, + // WP-CLI delegates HTML stripping to WordPress; standalone init requires the + // resulting plain text instead of trying to emulate PHP strip_tags(). + { + machineName: 'wordpress-text', + displayName: 'A', + inputWpDisplayName: 'A !excluded.has(path.basename(file)) }); + const projectPath = path.join(theme, 'project.emulsify.json'); + const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + Object.assign(project.project, { name: 'Project Jest Fixture', machineName: 'project-jest-fixture' }); + fs.writeFileSync(projectPath, JSON.stringify(project)); + const generation = spawnSync(process.execPath, [path.join(theme, '.cli/init.js')], { cwd: theme, encoding: 'utf8' }); + assert.equal(generation.status, 0, generation.stdout + generation.stderr); + fs.symlinkSync(modules, path.join(theme, 'node_modules'), 'junction'); + fs.mkdirSync(results); + fs.cpSync(path.join(repoRoot, '.github/fixtures/whisk-a11y/ci-readiness'), component, { recursive: true }); + + let result = run(); + assert.equal(result.status, 0, result.output); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'passed'), 'Jest must execute the colocated project ESM test.'); + const coverage = JSON.parse(fs.readFileSync(path.join(results, 'coverage-final.json'), 'utf8')); + assert.ok(coverage[sourceFile], 'Coverage must include executed project source.'); + assert.ok(Object.values(coverage[sourceFile].s).some((count) => count > 0), 'Executed source must have covered statements.'); + assert.ok(coverage[untestedFile], 'Coverage must include untested project source.'); + assert.ok(Object.values(coverage[untestedFile].s).every((count) => count === 0), 'Untested project statements must remain uncovered.'); + assert.ok(!Object.keys(coverage).some((file) => /\/(?:node_modules|config)\/|\.(?:test|stories)\./.test(file)), 'Coverage must exclude dependencies, configuration, tests, and stories.'); + + result = run('twatch', ['--watch=false']); + assert.equal(result.status, 0, result.output); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'passed'), 'The watch wrapper must also execute native ESM.'); + + const passing = fs.readFileSync(testFile, 'utf8'); + const failing = passing.replace('toBe(42)', 'toBe(0)'); + assert.notEqual(failing, passing, 'The deliberate failure must change the fixture assertion.'); + fs.writeFileSync(testFile, failing); + result = run(); + assert.notEqual(result.status, 0, 'A failing project test must fail npm test.'); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'failed'), 'The deliberate failure must come from the discovered project test.'); + + fs.rmSync(component, { recursive: true, force: true }); + result = run(); + assert.notEqual(result.status, 0, 'An empty generated project must fail npm test.'); + assert.match(result.output, /No tests found/, 'The empty project must fail because no tests exist.'); + console.log('PASS Generated project Jest ESM, coverage, watch wrapper, failure propagation, and empty-test handling.'); +} finally { + fs.rmSync(work, { recursive: true, force: true }); +} diff --git a/.github/scripts/lint-wrappers.test.cjs b/.github/scripts/lint-wrappers.test.cjs new file mode 100644 index 0000000..f32f861 --- /dev/null +++ b/.github/scripts/lint-wrappers.test.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const starterPackage = require('../../whisk/package.json'); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +for (const [wrapper, checks] of Object.entries({ + lint: ['lint-js', 'lint-styles'], + 'lint-fix': ['lint-js', 'lint-styles'], + format: ['lint-fix', 'prettier-fix'], +})) { + for (const failures of [[], [0], [1], [0, 1]]) { + test(`${wrapper} runs every check and propagates failures ${JSON.stringify(failures)}`, (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-lint-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const scripts = { [wrapper]: starterPackage.scripts[wrapper] }; + fs.writeFileSync(path.join(cwd, 'check.cjs'), 'require("node:fs").appendFileSync("ran", process.argv[2] + "\\n"); process.exit(Number(process.argv[3]));\n'); + checks.forEach((check, index) => { + scripts[check] = `node check.cjs ${check} ${failures.includes(index) ? 23 + index : 0}`; + }); + fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ scripts })); + const result = spawnSync(npm, ['run', wrapper], { cwd, encoding: 'utf8', timeout: 30000 }); + assert.ifError(result.error); + assert.equal(result.status === 0, failures.length === 0, result.stdout + result.stderr); + assert.deepEqual(fs.readFileSync(path.join(cwd, 'ran'), 'utf8').trim().split('\n'), checks); + }); + } +} diff --git a/.github/scripts/media-embed-browser.cjs b/.github/scripts/media-embed-browser.cjs new file mode 100644 index 0000000..1b6c46b --- /dev/null +++ b/.github/scripts/media-embed-browser.cjs @@ -0,0 +1,165 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { tooling } = require('./rendered-a11y.cjs'); + +// Use local responses when activating players: verify browser behavior without +// relying on provider availability, tracking, autoplay policies, or live videos. +async function verifyMediaEmbeds({ themeDir, url, outputDir }) { + const { puppeteer } = tooling(themeDir); + const browser = await puppeteer.launch({ headless: true }); + const evidence = []; + try { + for (const poster of ['neutral', 'provider']) { + for (const index of [0, 1]) { + const page = await browser.newPage(); + const requests = []; + const thumbnails = []; + let activated = false; + try { + await page.setJavaScriptEnabled(false); + await page.setRequestInterception(true); + page.on('request', (request) => { + const target = new URL(request.url()); + if (['www.youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com'].includes(target.hostname)) { + requests.push({ url: target.href, activated, referer: request.headers().referer }); + return request.respond({ status: 200, contentType: 'text/html', body: 'Fixture playerFixture player' }); + } + if (['i.ytimg.com', 'i.vimeocdn.com'].includes(target.hostname)) { + thumbnails.push({ url: target.href, referer: request.headers().referer }); + return request.respond({ status: 200, contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9WQAAAAASUVORK5CYII=', 'base64') }); + } + return request.continue(); + }); + const route = new URL(url); + route.searchParams.set('media_poster', poster); + const response = await page.goto(route.href, { waitUntil: 'networkidle0' }); + assert(response.ok()); + const frames = await page.$$('iframe[srcdoc]'); + assert.equal(frames.length, 2, 'Classic and block embeds must both use poster documents.'); + const allow = await frames[index].evaluate((element) => element.getAttribute('allow')); + const frame = await frames[index].contentFrame(); + const document = await frame.evaluate(() => ({ + language: document.documentElement.lang, + title: document.title, + scripts: document.scripts.length, + link: document.querySelector('a').href, + referrer: document.querySelector('a').referrerPolicy, + height: innerHeight, + images: [...document.images].map((image) => ({ referrer: image.referrerPolicy, alt: image.alt, loading: image.loading, decoding: image.decoding })), + hiddenTitle: document.querySelector('.emulsify-oembed-visually-hidden')?.textContent, + csp: document.querySelector('meta[http-equiv="Content-Security-Policy" i]')?.content, + })); + assert.equal(document.scripts, 0); + assert.equal(document.csp, "script-src 'none'", 'The poster must prohibit scripts through its own meta CSP.'); + assert(document.language && document.title && document.hiddenTitle); + const playerOrigin = new URL(document.link).origin; + for (const permission of ['autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media', 'clipboard-write', 'accelerometer', 'gyroscope']) { + const directive = allow.split(';').map((value) => value.trim()).find((value) => value.split(/\s+/)[0] === permission); + assert(directive?.split(/\s+/).includes(playerOrigin), `The iframe must delegate ${permission} to the player origin.`); + } + assert.equal(document.referrer, 'strict-origin-when-cross-origin'); + assert.equal(requests.length, 0, 'No player request is allowed before activation.'); + if (poster === 'neutral') assert.equal(thumbnails.length, 0, 'Neutral mode must not request provider thumbnails.'); + if (poster === 'provider' && index === 0) assert(thumbnails.length > 0, 'Provider mode must render the YouTube thumbnail.'); + for (const thumbnail of thumbnails) assert(!thumbnail.referer, 'Thumbnail requests must not disclose a referrer.'); + for (const image of document.images) { + assert(image.alt === '' && image.referrer === 'no-referrer'); + assert.equal(image.loading, 'lazy', 'Provider thumbnails must opt into native lazy loading.'); + assert.equal(image.decoding, 'async'); + } + let focused = false; + for (let count = 0; count < 30 && !focused; count++) { + await page.keyboard.press('Tab'); + focused = await frame.evaluate(() => document.activeElement?.matches('a.emulsify-oembed-poster')); + } + assert(focused, 'Tab must reach the native play link inside the frame.'); + const styles = await frame.evaluate(() => { + const link = document.querySelector('a'); + const icon = document.querySelector('.emulsify-oembed-poster__play').getBoundingClientRect(); + const label = document.querySelector('.emulsify-oembed-poster__label').getBoundingClientRect(); + const ring = getComputedStyle(link, '::after'); + return { focused: link.matches(':focus-visible'), outline: ring.outlineStyle, content: ring.content, icon: { width: icon.width, top: icon.top, bottom: icon.bottom }, label: { top: label.top, bottom: label.bottom }, height: innerHeight }; + }); + assert(styles.focused && styles.outline !== 'none' && styles.content !== 'none', 'Keyboard focus must draw the ring above the thumbnail.'); + assert(styles.icon.top >= 0 && styles.label.bottom <= styles.height, 'Controls must fit inside the frame.'); + if (styles.height <= 160) assert(styles.icon.width <= 50, 'Short frames need a 3rem control plus its borders.'); + await page.screenshot({ path: path.join(outputDir, `media-${poster}-${index}-focus.png`) }); + fs.writeFileSync(path.join(outputDir, `media-${poster}-${index}.html`), await frame.content()); + activated = true; + await Promise.all([frame.waitForNavigation({ waitUntil: 'domcontentloaded' }), page.keyboard.press('Enter')]); + const player = new URL(frame.url()); + assert.equal(player.href, document.link, 'The play link must navigate the same iframe.'); + assert.equal(player.searchParams.get('autoplay'), '1'); + assert.equal(player.hostname, index === 0 ? 'www.youtube-nocookie.com' : 'player.vimeo.com'); + if (index === 1) { + assert.equal(player.searchParams.get('h'), 'fixturehash'); + assert.equal(player.searchParams.get('dnt'), '1'); + } + assert.equal(requests.length, 1); + assert(requests[0].activated && requests[0].referer === new URL(url).origin + '/', 'Activation must send only the containing origin as referrer.'); + const client = await page.createCDPSession(); + const history = await client.send('Page.getNavigationHistory'); + assert(history.currentIndex > 0); + await client.send('Page.navigateToHistoryEntry', { entryId: history.entries[history.currentIndex - 1].id }); + await frame.waitForSelector('a.emulsify-oembed-poster'); + assert.equal(frame.url(), 'about:srcdoc', 'Back must restore the poster document.'); + evidence.push({ poster, index, document, styles, requests, thumbnails, back: frame.url() }); + } finally { + await page.close(); + } + } + } + // This run keeps JavaScript enabled: the poster's meta CSP must block both + // scripts and event handlers, then cease applying after player navigation. + // The real fixture policy omits self from frame-src for the srcdoc poster. + const page = await browser.newPage(); + const requests = []; + let activated = false; + try { + await page.setRequestInterception(true); + page.on('request', (request) => { + const target = new URL(request.url()); + if (['www.youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com'].includes(target.hostname)) { + requests.push({ url: target.href, activated }); + return request.respond({ status: 200, contentType: 'text/html', body: 'Fixture playerFixture player' }); + } + return request.continue(); + }); + const route = new URL(url); + route.searchParams.set('media_poster', 'neutral'); + const response = await page.goto(route.href, { waitUntil: 'networkidle0' }); + const policy = response.headers()['content-security-policy']; + assert(policy, 'The real fixture must send its Content-Security-Policy.'); + assert(!/frame-src[^;]*'self'/.test(policy), 'The test policy must omit self from frame-src.'); + const element = await page.$('iframe[srcdoc]'); + const frame = await element.contentFrame(); + await frame.waitForSelector('a.emulsify-oembed-poster'); + const srcdoc = await element.evaluate((iframe) => iframe.srcdoc); + await page.evaluate(() => { window.emulsifyPosterScriptRan = false; window.emulsifyPosterEventRan = false; }); + const hostileDocument = srcdoc.replace('', '').replace('', ''); + assert.notEqual(hostileDocument, srcdoc); + await Promise.all([ + frame.waitForNavigation({ waitUntil: 'load' }), + element.evaluate((iframe, document) => { iframe.srcdoc = document; }, hostileDocument), + ]); + assert.equal(await page.evaluate(() => window.emulsifyPosterScriptRan || window.emulsifyPosterEventRan), false, 'Poster meta CSP must block scripts and inline event handlers.'); + assert.equal(requests.length, 0, 'The poster must not request the player before activation.'); + activated = true; + await Promise.all([frame.waitForNavigation({ waitUntil: 'load' }), frame.click('a.emulsify-oembed-poster')]); + assert.equal(requests.length, 1); + assert(requests[0].activated); + assert.equal(new URL(frame.url()).hostname, 'www.youtube-nocookie.com'); + assert.equal(await frame.evaluate(() => document.body.dataset.playerScript), 'ran', 'The poster meta CSP must not restrict the activated player document.'); + evidence.push({ scriptPolicy: policy, requests, posterScriptsBlocked: true, playerScriptRan: true }); + } finally { + await page.close(); + } + } finally { + fs.writeFileSync(path.join(outputDir, 'media-browser.json'), JSON.stringify(evidence, null, 2) + '\n'); + await browser.close(); + } + console.log('Media posters passed JavaScript-free keyboard activation, privacy, network/referrer, permissions, lazy thumbnails, meta CSP, frame-src without self, compact focus, and Back-history checks.'); +} + +module.exports = { verifyMediaEmbeds }; diff --git a/.github/scripts/media-embed-failure-smoke.php b/.github/scripts/media-embed-failure-smoke.php new file mode 100644 index 0000000..540de79 --- /dev/null +++ b/.github/scripts/media-embed-failure-smoke.php @@ -0,0 +1,88 @@ +loadHTML( $html ); + $this->iframe = $document->getElementsByTagName( 'iframe' )->item( 0 ); + } + public function next_tag( $query = null ): bool { return true; } + public function has_self_closing_flag(): bool { return false; } + public function get_attribute( string $name ) { + return $this->iframe->hasAttribute( $name ) ? $this->iframe->getAttribute( $name ) : null; + } + } + function apply_filters( $hook, $value, ...$args ) { + return 'emulsify_theme_media_embed_options' === $hook ? array_merge( $value, array( 'click_to_load' => true, 'poster' => 'neutral' ) ) : $value; + } + function wp_parse_url( $url, $component = -1 ) { return parse_url( $url, $component ); } + function __( $text, $domain = '' ) { return $text; } + function get_bloginfo( $show ) { return 'en-US'; } + function is_rtl() { return false; } + function esc_url( $url ) { + return 'https' === parse_url( $url, PHP_URL_SCHEME ) ? htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', false ) : ''; + } + function esc_html( $text ) { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8', false ); } + function _doing_it_wrong( $function, $message, $version ) { ++$GLOBALS['media_diagnostics']; } + function wp_trigger_error( $function, $message, $level = E_USER_NOTICE ) { ++$GLOBALS['media_diagnostics']; } + + require $argv[1]; + $title = 'Clip\' & "quoted"'; + $url = 'https://www.youtube.com/watch?v=abcdefghijk&feature=fixture'; + $html = ''; + $thrown = null; + try { + $output = ( new \Emulsify\Theme\Media\Embeds() )->filter_html( $html, $url ); + } catch ( \Throwable $error ) { + $thrown = $error; + } + if ( 1 !== $GLOBALS['media_diagnostics'] ) { + throw new \RuntimeException( 'A poster failure must report exactly one WordPress diagnostic.' ); + } + if ( WP_DEBUG ) { + $expected = array( 'empty' => \UnexpectedValueException::class, 'error' => \LogicException::class, 'missing' => \RuntimeException::class )[ $GLOBALS['media_failure'] ]; + if ( ! $thrown instanceof $expected ) { + throw new \RuntimeException( 'Debug mode must rethrow the original poster failure.' ); + } + } else { + if ( null !== $thrown ) { + throw new \RuntimeException( 'Production poster failures must return a link, not throw.', 0, $thrown ); + } + $document = new \DOMDocument(); + $document->loadHTML( '' . $output ); + $anchors = $document->getElementsByTagName( 'a' ); + if ( 1 !== $anchors->length || $url !== $anchors->item( 0 )->getAttribute( 'href' ) || $title !== $anchors->item( 0 )->textContent + || 0 !== $document->getElementsByTagName( 'iframe' )->length || 0 !== $document->getElementsByTagName( 'img' )->length + || 0 !== ( new \DOMXPath( $document ) )->query( '//@*[starts-with(name(), "on")]' )->length ) { + throw new \RuntimeException( 'The fallback must contain only the escaped video link and title, with no provider request.' ); + } + } + echo "PASS isolated poster failure.\n"; +} diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php new file mode 100644 index 0000000..c9dd606 --- /dev/null +++ b/.github/scripts/media-embed-smoke.php @@ -0,0 +1,565 @@ +next_tag( 'IFRAME' ), 'Expected an iframe in transformed video markup.' ); + return $processor->get_attribute( $name ); +} + +/** + * Parses a srcdoc document without loading external resources. + * + * @param string $html HTML document. + * @return DOMDocument Parsed document. + */ +function emulsify_media_smoke_document( string $html ): DOMDocument { + $document = new DOMDocument(); + $previous = libxml_use_internal_errors( true ); + $loaded = $document->loadHTML( '' . $html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + emulsify_media_smoke_assert( $loaded, 'The poster must be a parseable HTML document.' ); + return $document; +} + +/** + * Builds local provider iframe markup with normal WordPress URL escaping. + * + * @param string $source Player URL. + * @param string $title Player title. + * @param int $height Frame height. + * @return string Provider markup. + */ +function emulsify_media_smoke_markup( string $source, string $title = 'Fixture video', int $height = 360 ): string { + return ''; +} + +$options = array(); +$embed_context = null; +$options_hook = static function ( array $defaults, $url, $attr, $post_id ) use ( &$options, &$embed_context ): array { + $embed_context = array( $url, $attr, $post_id ); + return array_merge( $defaults, $options ); +}; +$translation = static function ( string $translation, string $text, string $domain ): string { + return 'emulsify' === $domain && 'Play video' === $text ? 'Lire la vidéo' : $translation; +}; +$http_requests = 0; +$block_http = static function () use ( &$http_requests ) { + ++$http_requests; + return new WP_Error( 'emulsify_fixture_network', 'The media embed smoke blocks provider requests.' ); +}; +$fixtures = array( + 'youtube' => array( + 'url' => 'https://www.youtube.com/watch?v=abcdefghijk', + 'source' => 'https://www.youtube.com/embed/abcdefghijk?feature=oembed&x.y=a%20b&x.y=second&list[]=one&list[]=two&autoplay=0#chapter', + ), + 'vimeo' => array( + 'url' => 'https://vimeo.com/123456789/abc123', + 'source' => 'https://player.vimeo.com/video/123456789?h=abc123&x.y=a%20b&x.y=second&list[]=one&list[]=two&dnt=0#chapter', + ), +); +$GLOBALS['emulsify_media_regressions'] = array(); +$service = new Embeds(); +$canonical = 'https://vimeo.com/123456789/abc123'; +$metadata_key = Embeds::METADATA_PREFIX . md5( $canonical ); +$original_data = get_transient( $metadata_key ); +$backoff_key = 'emulsify_vimeo_poster_backoff_' . md5( $canonical ); +$original_backoff = get_transient( $backoff_key ); +$created_posts = array(); +$original_post = $GLOBALS['post'] ?? null; +$original_embed_post = $GLOBALS['wp_embed']->post_ID; +$original_embed_url = $GLOBALS['wp_embed']->last_url; +$original_embed_attr = $GLOBALS['wp_embed']->last_attr; +$child_dir = get_stylesheet_directory(); +$override = $child_dir . '/templates/partials/oembed-poster.twig'; +$original_file = is_file( $override ) ? file_get_contents( $override ) : null; +$created_dirs = array(); +$hostile_title = 'Fixture & "title"'; + +add_filter( 'emulsify_theme_media_embed_options', $options_hook, 999, 4 ); +add_filter( 'gettext', $translation, 999, 3 ); +add_filter( 'pre_http_request', $block_http, 999 ); + +try { + emulsify_media_smoke_assert( false === apply_filters( 'oembed_dataparse', false, (object) array( 'type' => 'unsupported' ), 'https://video.example.test/unsupported' ), 'Unsupported oEmbed data must retain WordPress\'s false result.' ); + foreach ( $fixtures as $name => $fixture ) { + $html = " \n" . emulsify_media_smoke_markup( $fixture['source'] ) . "\n "; + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ) === $html, 'Disabled options must preserve the exact ' . $name . ' bytes.' ); + foreach ( array( null, false ) as $missing_post_id ) { + $options = array(); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), $missing_post_id ) === $html, 'Missing post IDs must preserve disabled ' . $name . ' embeds byte for byte.' ); + emulsify_media_smoke_assert( array( $fixture['url'], array(), null ) === $embed_context, 'The options filter must receive a null post ID outside a post.' ); + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), $missing_post_id ); + $srcdoc = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_assert( is_string( $srcdoc ) && '' !== $srcdoc, 'Missing post IDs must allow ' . $name . ' poster rendering.' ); + $document = emulsify_media_smoke_document( $srcdoc ); + $player = $document->getElementsByTagName( 'a' )->item( 0 )->getAttribute( 'href' ); + emulsify_media_smoke_assert( str_contains( $player, 'autoplay=1' ) && str_contains( $player, 'youtube' === $name ? 'www.youtube-nocookie.com' : 'dnt=1' ), 'Missing post IDs must retain privacy and activation parameters.' ); + } + $options = array(); + } + + // The public filter can be applied by code other than WP_Embed. Preserve + // non-string results and normalize optional context before exposing it. + // Core's earlier callbacks require strings, so test our pass-through directly. + foreach ( array( null, false, 42, array( 'provider' => 'unavailable' ), (object) array( 'provider' => 'unavailable' ) ) as $value ) { + emulsify_media_smoke_assert( $value === $service->filter_html( $value, null, false, null ), 'Non-string embed results must pass through unchanged.' ); + } + foreach ( array( + array( 42, 42 ), + array( '42', 42 ), + array( '0042', 42 ), + array( PHP_INT_MAX, PHP_INT_MAX ), + array( (string) PHP_INT_MAX . '0', null ), + array( 'invalid', null ), + array( '-1', null ), + array( '1.5', null ), + array( '', null ), + array( -1, null ), + array( 0, null ), + ) as list( $post_id, $expected_id ) ) { + $html = emulsify_media_smoke_markup( $fixtures['youtube']['source'] ); + emulsify_media_smoke_assert( $html === apply_filters( 'embed_oembed_html', $html, null, false, $post_id ), 'Invalid optional context must not change disabled embeds.' ); + emulsify_media_smoke_assert( array( '', array(), $expected_id ) === $embed_context, 'The options filter must receive normalized URLs, attributes, and positive post IDs.' ); + } + foreach ( array( ' 42', "42\n", '+42', '0', '000', '42.0', '4e1' ) as $post_id ) { + apply_filters( 'embed_oembed_html', $html, '', array(), $post_id ); + emulsify_media_smoke_check( null === $embed_context[2], 'Only digit-only positive post-ID strings may normalize: ' . json_encode( $post_id ) ); + } + foreach ( array( true, 1.0, 1.5, array(), (object) array() ) as $post_id ) { + apply_filters( 'embed_oembed_html', $html, '', array(), $post_id ); + emulsify_media_smoke_assert( null === $embed_context[2], 'Non-integer post IDs must normalize to null.' ); + } + + // Simulate an Arabic interface without installing language packs. Restore it + // before a second render to catch language or direction cached per request. + $default_language = get_bloginfo( 'language' ); + $default_direction = $GLOBALS['wp_locale']->text_direction; + $rtl_locale = static function (): string { + return 'ar'; + }; + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + emulsify_media_smoke_assert( 'ar' !== $default_language && 'ltr' === $default_direction, 'The locale fixture must start with a distinct left-to-right site default.' ); + try { + add_filter( 'locale', $rtl_locale, 999 ); + $GLOBALS['wp_locale']->text_direction = 'rtl'; + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + $document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $output, 'srcdoc' ) ); + emulsify_media_smoke_assert( 'ar' === $document->documentElement->getAttribute( 'lang' ), 'Poster documents must declare the Arabic interface language rather than the site default.' ); + emulsify_media_smoke_assert( 'rtl' === $document->documentElement->getAttribute( 'dir' ), 'Poster documents must declare the right-to-left interface direction.' ); + } finally { + remove_filter( 'locale', $rtl_locale, 999 ); + $GLOBALS['wp_locale']->text_direction = $default_direction; + } + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + $document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $output, 'srcdoc' ) ); + emulsify_media_smoke_assert( $default_language === $document->documentElement->getAttribute( 'lang' ) && 'ltr' === $document->documentElement->getAttribute( 'dir' ), 'A second poster must return to the default language and left-to-right direction after locale restoration.' ); + $options = array(); + + // Cached data comes from WordPress's oEmbed parse pipeline, including the + // unlisted video's h value rather than merging distinct videos by ID. + $metadata = (object) array( + 'type' => 'video', + 'thumbnail_url' => 'https://i.vimeocdn.com/video/emulsify-fixture.jpg', + 'title' => $hostile_title, + ); + $vimeo_html = emulsify_media_smoke_markup( $fixtures['vimeo']['source'], $hostile_title ); + delete_transient( $metadata_key ); + foreach ( array( array(), array( 'privacy_mode' => true ), array( 'click_to_load' => true, 'poster' => 'neutral' ) ) as $disabled_cache_options ) { + $options = $disabled_cache_options; + $service->cache_oembed_data( $vimeo_html, $metadata, $canonical ); + emulsify_media_smoke_check( false === get_transient( $metadata_key ), 'Disabled or neutral posters must not write provider metadata.' ); + delete_transient( $metadata_key ); + } + $null_data = static function () { return null; }; + add_filter( 'oembed_dataparse', $null_data, 19 ); + try { + emulsify_media_smoke_check( null === apply_filters( 'oembed_dataparse', $vimeo_html, $metadata, $canonical ), 'Earlier null oEmbed results must pass through unchanged.' ); + } catch ( Throwable $error ) { + emulsify_media_smoke_check( false, 'Earlier null oEmbed result threw ' . get_class( $error ) ); + } finally { + remove_filter( 'oembed_dataparse', $null_data, 19 ); + } + $options = array( 'click_to_load' => true, 'poster' => 'provider' ); + emulsify_media_smoke_assert( $service->cache_oembed_data( $vimeo_html, $metadata, $canonical ) === $vimeo_html, 'Metadata capture must not edit the cached oEmbed HTML.' ); + emulsify_media_smoke_check( array( $canonical, array(), null ) === $embed_context, 'Metadata options must receive the original URL, empty attributes and no post ID.' ); + $cached = get_transient( $metadata_key ); + emulsify_media_smoke_assert( is_array( $cached ) && $metadata->thumbnail_url === ( $cached['thumbnail_url'] ?? null ), 'Vimeo metadata must be cached under its unlisted video URL.' ); + + foreach ( $fixtures as $name => $fixture ) { + $html = emulsify_media_smoke_markup( $fixture['source'], $hostile_title ); + $privacy_url = 'youtube' === $name ? str_replace( 'www.youtube.com', 'www.youtube-nocookie.com', $fixture['source'] ) : str_replace( 'dnt=0', 'dnt=1', $fixture['source'] ); + $options = array( 'privacy_mode' => true ); + $private = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ); + // The native src setter URL-encodes literal query brackets. Keep that + // security boundary while comparing every remaining query byte exactly. + emulsify_media_smoke_assert( esc_url_raw( $privacy_url ) === emulsify_media_smoke_attribute( $private, 'src' ), 'Privacy must preserve every unrelated query pair, Vimeo h, and fragment through WordPress URL normalization for ' . $name . '.' ); + emulsify_media_smoke_assert( null === emulsify_media_smoke_attribute( $private, 'srcdoc' ), 'Privacy alone must retain the real player for ' . $name . '.' ); + emulsify_media_smoke_assert( 'fixture-video' === emulsify_media_smoke_attribute( $private, 'class' ) && 'lazy' === emulsify_media_smoke_attribute( $private, 'loading' ), 'Privacy must preserve unrelated iframe attributes.' ); + + foreach ( array( false, true ) as $privacy ) { + foreach ( array( 'provider', 'neutral' ) as $poster ) { + $options = array( 'privacy_mode' => $privacy, 'click_to_load' => true, 'poster' => $poster ); + $output = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ); + $srcdoc = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_assert( is_string( $srcdoc ) && '' !== $srcdoc, 'Click-to-load must use a srcdoc poster.' ); + $source = emulsify_media_smoke_attribute( $output, 'src' ); + emulsify_media_smoke_assert( null === $source || '' === $source || 'about:blank' === $source, 'A poster must not request a player before activation.' ); + $document = emulsify_media_smoke_document( $srcdoc ); + $xpath = new DOMXPath( $document ); + $anchors = $document->getElementsByTagName( 'a' ); + emulsify_media_smoke_assert( 1 === $anchors->length, 'The poster must have exactly one native play link.' ); + $anchor = $anchors->item( 0 ); + $expected = $privacy ? $privacy_url : $fixture['source']; + $expected = false !== strpos( $expected, 'autoplay=0' ) ? str_replace( 'autoplay=0', 'autoplay=1', $expected ) : str_replace( '#chapter', '&autoplay=1#chapter', $expected ); + emulsify_media_smoke_assert( $expected === $anchor->getAttribute( 'href' ), 'Play must enable autoplay while preserving query bytes and Vimeo h.' ); + emulsify_media_smoke_assert( 'strict-origin-when-cross-origin' === $anchor->getAttribute( 'referrerpolicy' ), 'Play links must use the strict origin referrer policy.' ); + emulsify_media_smoke_assert( ! $anchor->hasAttribute( 'target' ) || '_self' === $anchor->getAttribute( 'target' ), 'Play must navigate the same frame.' ); + emulsify_media_smoke_assert( false !== strpos( $anchor->textContent, 'Lire la vidéo' ) && false !== strpos( $anchor->textContent, $hostile_title ), 'Play labels must be translated and retain the safely escaped video title. Actual: ' . $anchor->textContent ); + emulsify_media_smoke_assert( 1 === $xpath->query( '//a//span[contains(@class, "visually-hidden")]' )->length, 'The complete video title must be visually hidden inside the accessible link label.' ); + emulsify_media_smoke_assert( get_bloginfo( 'language' ) === $document->documentElement->getAttribute( 'lang' ), 'The poster document must declare the WordPress language.' ); + emulsify_media_smoke_assert( 0 === $xpath->query( '//iframe | //script | //noscript | //@*[starts-with(name(), "on")]' )->length, 'Posters must contain no nested player, script, or injected event handler.' ); + emulsify_media_smoke_check( 1 === $xpath->query( '//meta[translate(@http-equiv,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="content-security-policy" and @content="script-src \'none\'"]' )->length, 'Parent posters must prohibit scripts with a meta CSP.' ); + $images = $document->getElementsByTagName( 'img' ); + emulsify_media_smoke_assert( ( 'provider' === $poster ? 1 : 0 ) === $images->length, 'Only provider posters may request thumbnails.' ); + if ( 'provider' === $poster ) { + emulsify_media_smoke_check( 'lazy' === $images->item( 0 )->getAttribute( 'loading' ) && 'async' === $images->item( 0 )->getAttribute( 'decoding' ), 'Provider thumbnails must be lazy-loaded and asynchronously decoded.' ); + emulsify_media_smoke_assert( '' === $images->item( 0 )->getAttribute( 'alt' ) && 'no-referrer' === $images->item( 0 )->getAttribute( 'referrerpolicy' ), 'Provider thumbnails must be decorative and omit the referrer.' ); + } else { + emulsify_media_smoke_assert( 0 === $xpath->query( '//*[@src] | //link | //object | //embed' )->length, 'Neutral posters must not request third-party resources.' ); + } + $styles = $document->getElementsByTagName( 'style' ); + emulsify_media_smoke_assert( 1 === $styles->length && 1 === preg_match( '/:focus(?:-visible)?\b[^}]*outline\s*:/s', $styles->item( 0 )->textContent ), 'The isolated poster needs an inline visible focus ring.' ); + emulsify_media_smoke_assert( 1 === preg_match( '/@media[^\{]*max-height\s*:\s*160px/i', $styles->item( 0 )->textContent ), 'Posters must supply compact controls for frames at most 160px high.' ); + $allow = emulsify_media_smoke_attribute( $output, 'allow' ); + emulsify_media_smoke_check( is_string( $allow ) && str_contains( $allow, "camera 'none'" ), 'Explicit provider permission origin lists must remain unchanged.' ); + foreach ( array( 'clipboard-write', 'accelerometer', 'gyroscope' ) as $permission ) { + emulsify_media_smoke_check( is_string( $allow ) && 1 === preg_match( '/(?:^|;\s*)' . preg_quote( $permission, '/' ) . '\s+[^;]*https:\/\/' . preg_quote( (string) wp_parse_url( $expected, PHP_URL_HOST ), '/' ) . '(?:\s|;|$)/', $allow ), 'Kept iframe permissions must retain their provider origin: ' . $permission ); + } + foreach ( array( 'autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media' ) as $permission ) { + emulsify_media_smoke_assert( is_string( $allow ) && 1 === preg_match( '/(?:^|;\s*)' . preg_quote( $permission, '/' ) . '\s+[^;]*https:\/\/' . preg_quote( (string) wp_parse_url( $expected, PHP_URL_HOST ), '/' ) . '(?:\s|;|$)/', $allow ), 'The iframe must delegate ' . $permission . ' to the player origin.' ); + } + } + } + + $options = array(); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ) === $html, 'Changing options must affect cached oEmbed HTML immediately, without a cache flush.' ); + } + + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + foreach ( $fixtures as $name => $fixture ) { + $untitled = ''; + $fallback = apply_filters( 'embed_oembed_html', $untitled, $fixture['url'], array(), 0 ); + $expected_title = 'youtube' === $name ? 'YouTube video' : 'Vimeo video'; + emulsify_media_smoke_assert( $expected_title === emulsify_media_smoke_attribute( $fallback, 'title' ), 'Untitled posters must give their outer iframe a translated provider fallback title.' ); + $fallback_document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $fallback, 'srcdoc' ) ); + emulsify_media_smoke_assert( $expected_title === $fallback_document->getElementsByTagName( 'title' )->item( 0 )->textContent && false !== strpos( $fallback_document->getElementsByTagName( 'a' )->item( 0 )->textContent, $expected_title ), 'Untitled poster documents and play links must retain the same accessible fallback title.' ); + } + foreach ( array( + 'https://youtube.com.evil.example/embed/abcdefghijk', + 'https://evil.example/www.youtube.com/embed/abcdefghijk', + '//www.youtube.com/embed/abcdefghijk', + 'http://www.youtube.com/embed/abcdefghijk', + 'https://www.youtube.com@evil.example/embed/abcdefghijk', + 'https://user:pass@www.youtube.com/embed/abcdefghijk', + 'https://www.youtube.com:444/embed/abcdefghijk', + 'https://www.youtube.com/watch?v=abcdefghijk', + 'https://player.vimeo.com.evil.example/video/123456789', + 'https://video.example.test/embed/123456789', + ) as $source ) { + $html = emulsify_media_smoke_markup( $source ); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixtures['youtube']['url'], array(), 0 ) === $html, 'Unsupported and lookalike player URLs must remain untouched: ' . $source ); + } + $uppercase = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( 'https://WWW.YOUTUBE.COM/embed/abcdefghijk' ), $fixtures['youtube']['url'], array(), 0 ); + $uppercase_doc = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $uppercase, 'srcdoc' ) ); + emulsify_media_smoke_assert( 'https://www.youtube-nocookie.com/embed/abcdefghijk?autoplay=1' === $uppercase_doc->getElementsByTagName( 'a' )->item( 0 )->getAttribute( 'href' ), 'Recognized hostnames must be case insensitive.' ); + foreach ( array( + '

Extra markup

', + '', + '', + '', + '', + '', + '

Not an iframe

', + ) as $html ) { + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixtures['youtube']['url'], array(), 0 ) === $html, 'Malformed or compound HTML must pass through unchanged: ' . $html ); + } + + // Provider metadata is optional. A miss must return immediately and queue one + // bounded background lookup; cached failures must not queue on every render. + $options = array( 'click_to_load' => true, 'poster' => 'provider' ); + delete_transient( $metadata_key ); + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + $missing = apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( 0 === emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $missing, 'srcdoc' ) )->getElementsByTagName( 'img' )->length, 'Missing Vimeo metadata must fall back to a neutral poster.' ); + emulsify_media_smoke_assert( false !== wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Missing Vimeo metadata must schedule a background lookup.' ); + $first_cron = wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( $first_cron === wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Repeated renders must not duplicate the metadata lookup.' ); + emulsify_media_smoke_assert( 0 === $http_requests, 'Rendering must never synchronously fetch provider metadata.' ); + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + set_transient( $metadata_key, array(), HOUR_IN_SECONDS ); + apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( false === wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Negative metadata caches must prevent repeated lookup scheduling.' ); + delete_transient( $metadata_key ); + delete_transient( $backoff_key ); + $service->refresh_vimeo_poster( $canonical ); + emulsify_media_smoke_check( false === get_transient( $metadata_key ) && false !== get_transient( $backoff_key ), 'Failed metadata lookups must use a separate bounded backoff marker.' ); + $failed_requests = $http_requests; + $service->refresh_vimeo_poster( $canonical ); + emulsify_media_smoke_check( $failed_requests === $http_requests, 'A failed refresh must back off repeated provider requests.' ); + set_transient( $metadata_key, array( 'thumbnail_url' => $metadata->thumbnail_url, 'title' => 'Keep cached title' ), DAY_IN_SECONDS ); + $cached_before_refresh = get_transient( $metadata_key ); + delete_transient( $backoff_key ); + $service->refresh_vimeo_poster( $canonical ); + emulsify_media_smoke_check( $cached_before_refresh === get_transient( $metadata_key ), 'Failed refreshes must keep valid existing Vimeo metadata.' ); + delete_transient( $backoff_key ); + $successful_refresh = static function () use ( $metadata_key, $cached_before_refresh ) { + emulsify_media_smoke_check( $cached_before_refresh === get_transient( $metadata_key ), 'Cached metadata must remain available while a refresh fetch runs.' ); + return array( 'headers' => array(), 'body' => wp_json_encode( array( 'thumbnail_url' => 'https://i.vimeocdn.com/video/refreshed-fixture.jpg', 'title' => 'Refreshed fixture' ) ), 'response' => array( 'code' => 200, 'message' => 'OK' ) ); + }; + add_filter( 'pre_http_request', $successful_refresh, 1000 ); + try { + $service->refresh_vimeo_poster( $canonical ); + $refreshed = get_transient( $metadata_key ); + emulsify_media_smoke_check( is_array( $refreshed ) && 'Refreshed fixture' === ( $refreshed['title'] ?? '' ), 'A successful background refresh must replace old metadata.' ); + } finally { + remove_filter( 'pre_http_request', $successful_refresh, 1000 ); + } + + // Exercise WordPress's real content pipeline rather than only calling the + // filter: both a classic URL and a core/embed block must be transformed. + $provider_result = static function ( $result, string $url ) use ( $fixtures ) { + foreach ( $fixtures as $fixture ) { + if ( $url === $fixture['url'] ) { + return emulsify_media_smoke_markup( $fixture['source'] ); + } + } + return $result; + }; + add_filter( 'pre_oembed_result', $provider_result, 999, 2 ); + foreach ( $fixtures as $name => $fixture ) { + foreach ( array( 'classic', 'block' ) as $type ) { + $content = 'classic' === $type ? "\n" . $fixture['url'] . "\n" : '
' . "\n" . $fixture['url'] . "\n" . '
'; + $post_id = wp_insert_post( array( 'post_title' => 'Media smoke ' . $name . ' ' . $type, 'post_content' => $content, 'post_status' => 'publish' ), true ); + emulsify_media_smoke_assert( ! is_wp_error( $post_id ), 'The media smoke must be able to seed a temporary post.' ); + $created_posts[] = $post_id; + $GLOBALS['post'] = get_post( $post_id ); + $GLOBALS['wp_embed']->post_ID = $post_id; + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + $rendered = apply_filters( 'the_content', $content ); + emulsify_media_smoke_assert( is_string( emulsify_media_smoke_attribute( $rendered, 'srcdoc' ) ), 'The real ' . $type . ' content pipeline must transform ' . $name . ' embeds.' ); + $options = array(); + $disabled = apply_filters( 'the_content', $content ); + emulsify_media_smoke_assert( null === emulsify_media_smoke_attribute( $disabled, 'srcdoc' ) && $fixture['source'] === emulsify_media_smoke_attribute( $disabled, 'src' ), 'Disabling options must immediately restore the real ' . $type . ' cached player without a cache flush.' ); + } + } + // WP_Embed prefers its post_ID property over the global post. Clear both to + // exercise the real no-post shortcode path, with local provider responses. + $post_before_no_post = $GLOBALS['post']; + $embed_post_before_no_post = $GLOBALS['wp_embed']->post_ID; + try { + $GLOBALS['post'] = null; + $GLOBALS['wp_embed']->post_ID = null; + foreach ( $fixtures as $name => $fixture ) { + foreach ( array( false, true ) as $enabled ) { + $options = $enabled ? array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ) : array(); + $rendered = $GLOBALS['wp_embed']->shortcode( array(), $fixture['url'] ); + emulsify_media_smoke_assert( is_string( $rendered ) && '' !== $rendered, 'A no-post shortcode must return ' . $name . ' embed markup.' ); + emulsify_media_smoke_assert( null === $embed_context[2], 'The no-post shortcode must pass a null ID to the options filter.' ); + if ( $enabled ) { + emulsify_media_smoke_assert( is_string( emulsify_media_smoke_attribute( $rendered, 'srcdoc' ) ), 'A no-post shortcode must render the enabled poster.' ); + } else { + emulsify_media_smoke_assert( $fixture['source'] === emulsify_media_smoke_attribute( $rendered, 'src' ), 'A no-post shortcode must retain the disabled player.' ); + } + } + } + } finally { + $GLOBALS['post'] = $post_before_no_post; + $GLOBALS['wp_embed']->post_ID = $embed_post_before_no_post; + } + remove_filter( 'pre_oembed_result', $provider_result, 999 ); + + $options = array( 'click_to_load' => true, 'poster' => 'provider' ); + $playlist_html = emulsify_media_smoke_markup( 'https://www.youtube.com/embed/videoseries?list=PLfixture' ); + $output = apply_filters( 'embed_oembed_html', $playlist_html, 'https://www.youtube.com/playlist?list=PLfixture', array(), 0 ); + $playlist_doc = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $output, 'srcdoc' ) ); + emulsify_media_smoke_check( 0 === $playlist_doc->getElementsByTagName( 'img' )->length, 'YouTube playlists must not use videoseries as a thumbnail video ID.' ); + + // A project can override this document through the same child-first Twig + // namespace as other parent fallbacks. Preserve any existing project file. + foreach ( array( $child_dir . '/templates', $child_dir . '/templates/partials' ) as $directory ) { + if ( ! is_dir( $directory ) ) { + emulsify_media_smoke_assert( mkdir( $directory ), 'Unable to create the temporary child template directory.' ); + $created_dirs[] = $directory; + } + } + // Numeric references inside a single-quoted child attribute must survive + // the outer srcdoc boundary unchanged, including on WordPress 6.8. + $roundtrip_title = "Clip' onfocus='alert(document.domain)' autofocus='"; + $roundtrip_template = 'Roundtrip\' " A & B & ' <b>'; + $roundtrip = str_replace( '{{ media_embed.title }}', htmlspecialchars( $roundtrip_title, ENT_QUOTES, 'UTF-8' ), $roundtrip_template ); + file_put_contents( $override, $roundtrip_template ); + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'], $roundtrip_title ), $fixtures['youtube']['url'], array(), 0 ); + $actual_roundtrip = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_check( $roundtrip === $actual_roundtrip, 'srcdoc must round-trip every document byte, including numeric character references.' ); + $roundtrip_dom = emulsify_media_smoke_document( (string) $actual_roundtrip ); + emulsify_media_smoke_check( 0 === ( new DOMXPath( $roundtrip_dom ) )->query( '//@onfocus|//@autofocus' )->length, 'Single-quoted child attributes must not acquire injected handlers or autofocus.' ); + $clear_roundtrip = static function ( \Twig\Environment $environment ): \Twig\Environment { + $environment->removeCache( '@templates/partials/oembed-poster.twig' ); + return $environment; + }; + add_filter( 'timber/twig', $clear_roundtrip, 999 ); + $custom = 'Child poster

Child override

'; + emulsify_media_smoke_assert( false !== file_put_contents( $override, $custom ), 'Unable to write the temporary child poster override.' ); + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + emulsify_media_smoke_assert( $custom === emulsify_media_smoke_attribute( $output, 'srcdoc' ), 'Child @templates poster overrides must replace the parent fallback.' ); + remove_filter( 'timber/twig', $clear_roundtrip, 999 ); + + // Invalidate the already-compiled child template after changing it in this + // request. A normal request starts with the project's template already saved. + $refresh_template = static function ( \Twig\Environment $environment ): \Twig\Environment { + $environment->removeCache( '@templates/partials/oembed-poster.twig' ); + return $environment; + }; + $diagnostics = 0; + $diagnostic = static function ( $function ) use ( &$diagnostics ): void { + if ( str_contains( $function, 'Embeds::filter_html' ) ) { + ++$diagnostics; + } + }; + $suppress_expected_notice = static function ( $trigger, $function ) { + return str_contains( $function, 'Embeds::filter_html' ) ? false : $trigger; + }; + add_action( 'doing_it_wrong_run', $diagnostic ); + add_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999, 2 ); + emulsify_media_smoke_assert( false !== file_put_contents( $override, '{# Intentionally empty project override. #}' ), 'Unable to write the temporary empty poster override.' ); + add_filter( 'timber/twig', $refresh_template, 999 ); + $empty_rejected = false; + try { + apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + } catch ( UnexpectedValueException $exception ) { + $empty_rejected = true; + } finally { + remove_filter( 'timber/twig', $refresh_template, 999 ); + file_put_contents( $override, $custom ); + } + emulsify_media_smoke_assert( $empty_rejected, 'An empty project poster must fail closed instead of silently loading a third-party player.' ); + + // An invalid child Twig document must rethrow in the debug fixture, too. + file_put_contents( $override, '{% if %}' ); + add_filter( 'timber/twig', $refresh_template, 999 ); + $syntax_rejected = false; + try { + apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + } catch ( \Twig\Error\SyntaxError $exception ) { + $syntax_rejected = true; + } finally { + remove_filter( 'timber/twig', $refresh_template, 999 ); + file_put_contents( $override, $custom ); + } + emulsify_media_smoke_check( $syntax_rejected, 'Invalid child Twig must rethrow its original error in debug mode.' ); + emulsify_media_smoke_check( 2 === $diagnostics, 'Both debug poster failures must emit a WordPress diagnostic before rethrowing.' ); + remove_action( 'doing_it_wrong_run', $diagnostic ); + remove_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999 ); + // Isolate missing Timber and the non-debug constant without changing the + // fixture's WP_DEBUG setting or unloading its real Timber classes. + foreach ( array( 'empty', 'error', 'missing' ) as $failure ) { + foreach ( array( '0', '1' ) as $debug ) { + $process = proc_open( array( PHP_BINARY, __DIR__ . '/media-embed-failure-smoke.php', get_template_directory() . '/includes/Media/Embeds.php', $failure, $debug ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes ); + emulsify_media_smoke_assert( is_resource( $process ), 'Unable to launch isolated poster failure smoke.' ); + $failure_output = stream_get_contents( $pipes[1] ) . stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + emulsify_media_smoke_check( 0 === proc_close( $process ), 'Isolated ' . $failure . ' poster behavior with WP_DEBUG=' . $debug . ': ' . trim( $failure_output ) ); + } + } + if ( ! empty( $GLOBALS['emulsify_media_regressions'] ) ) { + throw new RuntimeException( "Media regressions:\n" . implode( "\n", array_unique( $GLOBALS['emulsify_media_regressions'] ) ) ); + } + + echo "PASS media embeds: missing post IDs and no-post shortcodes, normalized public filter context, off defaults, raw parameter preservation, privacy, translated srcdoc posters with Arabic RTL and restored LTR locales, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; +} finally { + remove_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); + remove_filter( 'gettext', $translation, 999 ); + remove_filter( 'pre_http_request', $block_http, 999 ); + if ( isset( $provider_result ) ) { + remove_filter( 'pre_oembed_result', $provider_result, 999 ); + } + $GLOBALS['post'] = $original_post; + $GLOBALS['wp_embed']->post_ID = $original_embed_post; + $GLOBALS['wp_embed']->last_url = $original_embed_url; + $GLOBALS['wp_embed']->last_attr = $original_embed_attr; + foreach ( $created_posts as $post_id ) { + wp_delete_post( $post_id, true ); + } + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + if ( false === $original_data ) { + delete_transient( $metadata_key ); + } else { + set_transient( $metadata_key, $original_data, DAY_IN_SECONDS ); + } + if ( false === $original_backoff ) { + delete_transient( $backoff_key ); + } else { + set_transient( $backoff_key, $original_backoff, HOUR_IN_SECONDS ); + } + if ( isset( $diagnostic ) ) { + remove_action( 'doing_it_wrong_run', $diagnostic ); + remove_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999 ); + } + if ( null !== $original_file ) { + file_put_contents( $override, $original_file ); + } elseif ( is_file( $override ) ) { + unlink( $override ); + } + foreach ( array_reverse( $created_dirs ) as $directory ) { + rmdir( $directory ); + } +} diff --git a/.github/scripts/missing-timber-smoke.php b/.github/scripts/missing-timber-smoke.php new file mode 100644 index 0000000..7f41331 --- /dev/null +++ b/.github/scripts/missing-timber-smoke.php @@ -0,0 +1,99 @@ +register(); +$failures = array(); +if ( 0 !== $GLOBALS['emulsify_missing_timber_hooks']['template_redirect'][1] ) { + $failures[] = 'The missing Timber handler must run before theme rendering.'; +} + +$cases = array( + 'theme HTML request' => array( array(), array(), true ), + 'unrelated query string' => array( array(), array( 'search' => 'sitemap', 'example' => 'feed' ), true ), + 'empty sitemap query' => array( array(), array( 'sitemap' => '', 'sitemap-stylesheet' => '' ), true ), + 'admin request' => array( array( 'admin' => true ), array(), false ), + 'AJAX request' => array( array( 'ajax' => true ), array(), false ), + 'JSON request' => array( array( 'json' => true ), array(), false ), + 'robots.txt request' => array( array( 'robots' => true ), array(), false ), + 'favicon.ico request' => array( array( 'favicon' => true ), array(), false ), + 'feed request' => array( array( 'feed' => true ), array(), false ), + 'trackback request' => array( array( 'trackback' => true ), array(), false ), + 'sitemap index request' => array( array(), array( 'sitemap' => 'index' ), false ), + 'sitemap provider request' => array( array(), array( 'sitemap' => 'posts', 'sitemap-subtype' => 'page' ), false ), + 'sitemap stylesheet request' => array( array(), array( 'sitemap-stylesheet' => 'sitemap' ), false ), + 'sitemap index stylesheet' => array( array(), array( 'sitemap-stylesheet' => 'index' ), false ), +); +foreach ( $cases as $label => list( $flags, $query, $should_die ) ) { + $GLOBALS['emulsify_missing_timber_flags'] = $flags; + $GLOBALS['emulsify_missing_timber_queries'] = $query; + $died = false; + try { + $handler->runtime_error(); + } catch ( RuntimeException $error ) { + $died = true; + if ( 500 !== $error->getCode() || ! str_contains( $error->getMessage(), 'composer require timber/timber' ) ) { + $failures[] = $label . ' did not show the actionable missing Timber error.'; + } + } + if ( $should_die !== $died ) { + $failures[] = $label . ( $should_die ? ' must stop HTML rendering.' : ' must remain available without Timber.' ); + } +} +if ( $failures ) { + fwrite( STDERR, implode( "\n", $failures ) . "\n" ); + exit( 1 ); +} +echo 'Missing Timber smoke checks passed (' . count( $cases ) . " request cases).\n"; diff --git a/.github/scripts/pattern-registry-smoke.php b/.github/scripts/pattern-registry-smoke.php index f85dbb1..ccc3206 100644 --- a/.github/scripts/pattern-registry-smoke.php +++ b/.github/scripts/pattern-registry-smoke.php @@ -14,6 +14,27 @@ $GLOBALS['emulsify_pattern_smoke_filters'] = array(); $GLOBALS['emulsify_pattern_smoke_patterns'] = array(); $GLOBALS['emulsify_pattern_smoke_categories'] = array(); +$GLOBALS['emulsify_pattern_smoke_registrations'] = array(); + +final class WP_Block_Patterns_Registry { + public static function get_instance(): self { + static $instance; + return $instance ??= new self(); + } + public function is_registered( string $name ): bool { + return isset( $GLOBALS['emulsify_pattern_smoke_patterns'][ $name ] ); + } +} + +final class WP_Block_Pattern_Categories_Registry { + public static function get_instance(): self { + static $instance; + return $instance ??= new self(); + } + public function is_registered( string $name ): bool { + return isset( $GLOBALS['emulsify_pattern_smoke_categories'][ $name ] ); + } +} if ( ! function_exists( 'add_action' ) ) { function add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool { @@ -96,6 +117,7 @@ function get_template_directory(): string { if ( ! function_exists( 'register_block_pattern' ) ) { function register_block_pattern( string $name, array $args ): bool { $GLOBALS['emulsify_pattern_smoke_patterns'][ $name ] = $args; + $GLOBALS['emulsify_pattern_smoke_registrations'][] = 'pattern:' . $name; return true; } @@ -104,6 +126,7 @@ function register_block_pattern( string $name, array $args ): bool { if ( ! function_exists( 'register_block_pattern_category' ) ) { function register_block_pattern_category( string $name, array $args ): bool { $GLOBALS['emulsify_pattern_smoke_categories'][ $name ] = $args; + $GLOBALS['emulsify_pattern_smoke_registrations'][] = 'category:' . $name; return true; } @@ -366,6 +389,13 @@ static function ( array $args, array $data ): array { 'Pattern args filter should alter final registration args.' ); + $registrations = $GLOBALS['emulsify_pattern_smoke_registrations']; + $patterns->register_patterns(); + emulsify_pattern_smoke_assert( + $registrations === $GLOBALS['emulsify_pattern_smoke_registrations'], + 'Patterns and categories already in the native registries must not register twice.' + ); + echo "Pattern registry smoke checks passed.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index f9147ea..ae24809 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -24,6 +24,8 @@ function run(label, command, args) { run('Validate Composer metadata', 'composer', ['validate', '--no-check-publish', '--strict']); run('Validate documented npm commands', 'npm', ['run', 'docs:check-commands']); +run('Protect blocking readiness coverage', 'node', ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); +run('Protect committed release inputs and reject symlinks', 'node', ['--test', '.github/scripts/build-dist.test.cjs']); run('Install Composer dependencies for Twig smoke coverage', 'composer', [ 'install', '--no-interaction', @@ -31,19 +33,29 @@ run('Install Composer dependencies for Twig smoke coverage', 'composer', [ '--prefer-dist', ]); run('Run Bootstrap loader smoke test', 'npm', ['run', 'smoke:bootstrap-loader']); +run('Run missing Timber native-route smoke test', 'npm', ['run', 'smoke:missing-timber']); run('Run ACF Local JSON smoke test', 'npm', ['run', 'smoke:acf-json']); run('Run asset manifest smoke test', 'npm', ['run', 'smoke:asset-manifest']); run('Run Twig attribute helper smoke test', 'npm', ['run', 'smoke:attributes']); run('Run block scoped asset smoke test', 'npm', ['run', 'smoke:block-assets']); run('Run child theme generator smoke test', 'npm', ['run', 'smoke:child-theme-generator']); run('Run component locator smoke test', 'npm', ['run', 'smoke:component-locator']); +run('Run filesystem discovery failure smoke test', 'npm', ['run', 'smoke:file-discovery']); run('Run core block Twig renderer smoke test', 'npm', ['run', 'smoke:core-block-twig']); run('Run editor enhancements smoke test', 'npm', ['run', 'smoke:editor-enhancements']); run('Run editor policy smoke test', 'npm', ['run', 'smoke:editor-policy']); run('Run pattern registry smoke test', 'npm', ['run', 'smoke:patterns']); run('Run parent theme filter smoke test', 'npm', ['run', 'smoke:theme-filters']); run('Run Twig autoescape smoke test', 'npm', ['run', 'smoke:twig-autoescape']); +run('Run parent template extension smoke test', 'npm', ['run', 'smoke:template-extension']); +run('Run fallback navigation smoke test', 'npm', ['run', 'smoke:navigation']); run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']); run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']); run('Install Whisk dependencies', 'npm', ['run', 'whisk:install']); +run('Compare PHP and JavaScript Twig helper contracts', 'npm', ['run', 'test:twig-helpers']); +run('Compare component resolution with Emulsify Core', 'npm', ['run', 'test:component-resolution']); +run('Count request-wide component discovery and verify cache lifecycle', 'npm', ['run', 'smoke:component-request-cache']); +run('Verify lint and format failure propagation', 'npm', ['run', 'test:lint-wrappers']); +run('Run generated project Jest smoke test', 'npm', ['run', 'smoke:jest-project']); +run('Run accessibility runner tests', 'npm', ['run', 'test:a11y-runner']); run('Run WordPress starter init smoke test', 'npm', ['run', 'smoke:starter-init']); diff --git a/.github/scripts/readiness-matrix-contract.cjs b/.github/scripts/readiness-matrix-contract.cjs new file mode 100644 index 0000000..f84a164 --- /dev/null +++ b/.github/scripts/readiness-matrix-contract.cjs @@ -0,0 +1,192 @@ +const assert = require('node:assert/strict'); +const yaml = require('js-yaml'); + +function validateReadinessMatrix(source, { minimumWordPress = '6.8', requiredPhpVersions = ['8.3', '8.4', '8.5'] } = {}) { + const workflow = yaml.load(source); + + function entriesFor(jobName, versionKey) { + const job = workflow.jobs?.[jobName]; + assert.ok(job, `theme-readiness.yml must keep the ${jobName} job.`); + assert.equal(job['continue-on-error'], '${{ matrix.experimental }}', `${jobName} must make only matrix.experimental legs advisory.`); + assert.ok(!job.strategy?.matrix?.exclude, `${jobName} must not exclude required readiness legs.`); + const entries = job.strategy?.matrix?.include; + assert.ok(Array.isArray(entries) && entries.length, `${jobName} must declare its readiness matrix with include.`); + const versions = new Set(); + for (const entry of entries) { + assert.equal(typeof entry[versionKey], 'string', `${jobName} must declare each ${versionKey} as a string.`); + assert.equal(typeof entry.experimental, 'boolean', `${jobName} must explicitly mark each leg's experimental status.`); + assert.ok(!versions.has(entry[versionKey]), `${jobName} must not duplicate ${entry[versionKey]} readiness legs.`); + versions.add(entry[versionKey]); + } + return { job, entries }; + } + + function requireLeg(entries, versionKey, version, experimental, jobName) { + assert.ok(entries.some((entry) => entry[versionKey] === version && entry.experimental === experimental), + `${jobName} must keep ${version} ${experimental ? 'advisory' : 'blocking'}.`); + } + + function requireCommand(job, command, jobName) { + const steps = (job.steps || []).filter((step) => step.run?.trim() === command); + assert.ok(steps.length, `${jobName} must run ${command}.`); + for (const step of steps) { + assert.ok(step['continue-on-error'] === undefined || step['continue-on-error'] === false, + `${jobName}: ${command} must not allow failures.`); + assert.equal(step.if, undefined, `${jobName}: ${command} must run on every matrix leg.`); + } + return steps; + } + + const practical = entriesFor('practical', 'php-version'); + for (const version of requiredPhpVersions) requireLeg(practical.entries, 'php-version', version, false, 'practical'); + requireCommand(practical.job, 'npm run pr:check', 'practical'); + requireCommand(practical.job, 'npm run release:check', 'practical'); + + const fixture = entriesFor('wordpress-fixture', 'wordpress-version'); + requireLeg(fixture.entries, 'wordpress-version', minimumWordPress, false, 'wordpress-fixture'); + requireLeg(fixture.entries, 'wordpress-version', 'latest', false, 'wordpress-fixture'); + requireLeg(fixture.entries, 'wordpress-version', 'nightly', true, 'wordpress-fixture'); + for (const step of requireCommand(fixture.job, 'npm run release:check', 'wordpress-fixture')) { + assert.equal(step.env?.WP_SMOKE_WORDPRESS_VERSION, '${{ matrix.wordpress-version }}', + 'WordPress fixture smoke must resolve its version from matrix.wordpress-version, not a hardcoded version.'); + assert.equal(step.env?.WP_SMOKE_REQUIRED, '1', 'WordPress fixture matrix must fail when smoke prerequisites are missing.'); + assert.equal(step.env?.WP_SMOKE_A11Y, '1', 'WordPress fixture matrix must audit rendered accessibility.'); + const env = { ...workflow.env, ...fixture.job.env, ...step.env }; + assert.notEqual(String(env.WP_SMOKE_SKIP), '1', 'WordPress fixture matrix must not skip its smoke test.'); + } + return { practical: practical.entries, wordpress: fixture.entries }; +} + +const extendedConditions = { + 'wordpress-fixture': "inputs.release || (github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'release-2.x')) || github.event_name == 'push' || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.wordpress_fixture)", + 'extended-whisk': "inputs.release || (github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'release-2.x')) || github.event_name == 'push' || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.extended_checks)", +}; + +function requireBlockingRun(job, command, label) { + const step = job.steps?.find((entry) => entry.run?.trim() === command); + assert.ok(step, `${label} must run ${command}.`); + assert.equal(step.if, undefined, `${label}: ${command} must not be conditional.`); + assert.ok(step['continue-on-error'] === undefined || step['continue-on-error'] === false, + `${label}: ${command} must not allow failures.`); + return step; +} + +function validateReleaseWorkflow(releaseSource, readinessSource) { + const release = yaml.load(releaseSource); + const readiness = yaml.load(readinessSource); + validateReadinessMatrix(readinessSource); + + assert.equal(readiness.on?.workflow_call?.inputs?.release?.type, 'boolean', 'Full readiness must be callable with a release input.'); + assert.equal(readiness.permissions?.contents, 'read', 'Readiness credentials must remain read-only.'); + assert.equal(readiness.concurrency?.group, 'theme-readiness-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}', 'Reusable release readiness must not share a concurrency group with standalone runs.'); + const gate = release.jobs?.['release-readiness']; + const publisher = release.jobs?.['semantic-release']; + const state = release.jobs?.['release-state']; + assert.ok(gate && publisher && state, 'Release must keep publication state, full readiness and publishing jobs.'); + assert.equal(gate.uses, './.github/workflows/theme-readiness.yml', 'Release must reuse the full readiness workflow.'); + assert.equal(gate.with?.release, true, 'Release must force all reusable readiness jobs.'); + assert.equal(gate.needs, 'release-state', 'Full readiness must follow the publication recovery check.'); + assert.equal(publisher.needs, 'release-readiness', 'Publishing must wait for full readiness success.'); + for (const [name, job] of Object.entries(release.jobs)) { + assert.equal(job.if, undefined, `${name} must use the default success dependency condition.`); + assert.ok(job['continue-on-error'] === undefined || job['continue-on-error'] === false, `${name} must not allow failures.`); + if (!job.uses) assert.ok(Number.isInteger(job['timeout-minutes']) && job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 30, `${name} must have a bounded timeout.`); + } + assert.equal(release.permissions?.contents, 'read', 'Release workflow credentials must default to read-only.'); + assert.equal(state.permissions?.contents, 'read', 'Publication recovery must use read-only credentials.'); + requireBlockingRun(state, 'node .github/scripts/semantic-release.cjs --check-publication', 'Publication recovery'); + for (const [name, job] of Object.entries(readiness.jobs)) { + assert.ok(Number.isInteger(job['timeout-minutes']) && job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 30, `${name} must have a bounded timeout.`); + if (extendedConditions[name]) { + assert.equal(job.if?.replace(/\s+/g, ' ').trim(), extendedConditions[name], `${name} must run for every reusable release invocation.`); + } else { + assert.equal(job.if, undefined, `${name} must not skip required readiness.`); + } + } + const lint = readiness.jobs['php-lint']; + const whisk = readiness.jobs['extended-whisk']; + assert.ok(lint && whisk, 'Release readiness must include PHP analysis and Whisk checks.'); + for (const [name, job] of [['php-lint', lint], ['extended-whisk', whisk]]) { + assert.ok(job['continue-on-error'] === undefined || job['continue-on-error'] === false, `${name} must not allow failures.`); + } + requireBlockingRun(lint, 'npm run lint:php', 'PHP analysis'); + requireBlockingRun(readiness.jobs.practical, 'npm run test:generated-theme', 'Practical readiness'); + assert.equal(requireBlockingRun(readiness.jobs.practical, 'npm run smoke:generation-parity', 'Practical readiness').env?.EMULSIFY_PARITY_REQUIRED, '1', 'Generation parity must not skip missing prerequisites.'); + requireBlockingRun(whisk, 'npm --prefix whisk run test -- --runInBand', 'Whisk readiness'); + requireBlockingRun(whisk, 'npm --prefix whisk run a11y', 'Whisk readiness'); + + const bundleName = 'emulsify-tested-${{ github.sha }}'; + const practical = readiness.jobs.practical; + const fixture = readiness.jobs['wordpress-fixture']; + assert.equal(fixture.needs, 'practical', 'WordPress fixtures must wait for the built archive.'); + const build = practical.steps.find((step) => step.run === 'npm run build:dist'); + const checksum = practical.steps.find((step) => step.run === 'sha256sum emulsify.zip > emulsify.zip.sha256'); + const upload = practical.steps.find((step) => step.name === 'Upload tested release bundle'); + for (const step of [build, checksum, upload]) { + assert.ok(step, 'Practical readiness must build and checksum the tested release bundle.'); + assert.equal(step.if, "matrix.php-version == '8.3'", 'Only the blocking baseline PHP lane must supply the release bundle.'); + assert.ok(step['continue-on-error'] === undefined || step['continue-on-error'] === false, 'The tested archive must not allow failures.'); + } + assert.equal(checksum['working-directory'], 'dist-artifact', 'Checksum must name the release ZIP.'); + assert.match(upload.uses, /^actions\/upload-artifact@[a-f0-9]{40}$/, 'Release bundle uploads must be pinned.'); + assert.equal(upload.with?.name, bundleName, 'Release bundle must be bound to this commit.'); + assert.equal(upload.with?.path?.trim(), 'dist-artifact/emulsify.zip\ndist-artifact/emulsify.zip.sha256', 'Bundle must contain the tested ZIP and checksum.'); + assert.equal(upload.with?.['if-no-files-found'], 'error', 'Missing release files must fail readiness.'); + assert.ok(practical.steps.indexOf(build) < practical.steps.indexOf(checksum) && practical.steps.indexOf(checksum) < practical.steps.indexOf(upload), 'Build, checksum and upload must run in order.'); + + function requireTestedArchive(job, name) { + const download = job.steps.find((step) => step.name === 'Download tested release bundle'); + assert.ok(download, `${name} must download the tested ZIP.`); + assert.match(download.uses, /^actions\/download-artifact@[a-f0-9]{40}$/, 'Artifact downloads must be pinned.'); + assert.equal(download.with?.name, bundleName, `${name} must use this commit's tested ZIP.`); + assert.equal(download.with?.path, 'dist-artifact', 'Downloaded ZIP must retain its release path.'); + assert.equal(download.with?.['digest-mismatch'], 'error', 'Artifact digest mismatches must fail.'); + assert.equal(download.if, undefined, 'Tested archive download must not be conditional.'); + assert.ok(download['continue-on-error'] === undefined || download['continue-on-error'] === false, 'Tested archive downloads must not allow failures.'); + const verify = requireBlockingRun(job, 'sha256sum --check emulsify.zip.sha256', name); + assert.equal(verify['working-directory'], 'dist-artifact', 'Archive checksum must be verified in its output directory.'); + assert.ok(job.steps.indexOf(download) < job.steps.indexOf(verify), 'Archive must be downloaded before verification.'); + assert.ok(!job.steps.some((step) => /build:dist/.test(step.run || '')), `${name} must reuse the tested ZIP without rebuilding.`); + if (name === 'Publishing') assert.ok(!job.steps.some((step) => /\bcomposer\b/.test(step.run || '') || step.uses?.startsWith('shivammathur/setup-php@')), 'Publishing must not configure PHP or Composer with release credentials.'); + return verify; + } + const fixtureVerify = requireTestedArchive(fixture, 'WordPress fixtures'); + const fixtureRun = requireBlockingRun(fixture, 'npm run release:check', 'WordPress fixtures'); + assert.ok(fixture.steps.indexOf(fixtureVerify) < fixture.steps.indexOf(fixtureRun), 'WordPress must test the verified ZIP.'); + const publishVerify = requireTestedArchive(publisher, 'Publishing'); + const dryRun = requireBlockingRun(publisher, 'npm run publish-test -- --no-ci', 'Publishing'); + const publish = requireBlockingRun(publisher, 'npm run publish', 'Publishing'); + requireBlockingRun(publisher, 'npm ci --ignore-scripts', 'Publishing'); + assert.ok(publisher.steps.indexOf(publishVerify) < publisher.steps.indexOf(dryRun) && publisher.steps.indexOf(dryRun) < publisher.steps.indexOf(publish), 'Verified ZIP and dry run must precede publishing.'); + assert.equal(publisher.permissions?.contents, 'write', 'Only publishing needs repository write permission.'); + for (const scope of [release.env, publisher.env, state.env]) { + for (const key of ['GITHUB_TOKEN', 'GH_TOKEN', 'COMPOSER_AUTH', 'NODE_AUTH_TOKEN']) assert.equal(scope?.[key], undefined, 'Release credentials must be scoped to their command.'); + } + for (const job of [publisher, state, ...Object.values(readiness.jobs)]) { + const checkout = job.steps?.find((step) => step.uses?.startsWith('actions/checkout@')); + assert.equal(checkout?.with?.['persist-credentials'], false, 'Checkouts must not persist credentials for dependency or build commands.'); + } + for (const step of publisher.steps) { + for (const key of ['GITHUB_TOKEN', 'GH_TOKEN', 'COMPOSER_AUTH', 'NODE_AUTH_TOKEN']) { + if (step !== dryRun && step !== publish) assert.equal(step.env?.[key], undefined, 'Dependency and build steps must not receive release credentials.'); + } + } + assert.equal(dryRun.env?.GITHUB_TOKEN, '${{ secrets.GITHUB_TOKEN }}', 'The dry run needs its scoped push-permission check.'); + assert.equal(publish.env?.GITHUB_TOKEN, '${{ secrets.GITHUB_TOKEN }}', 'Publishing needs its scoped GitHub token.'); + + for (const name of ['practical', 'wordpress-fixture']) requireBlockingRun(readiness.jobs[name], 'composer audit --locked --no-dev', name); + const whiskInstall = requireBlockingRun(whisk, 'npm run whisk:install -- --package-lock=true', 'Whisk readiness'); + const whiskAudit = whisk.steps.find((step) => step.run === 'npm --prefix whisk audit'); + assert.ok(whiskAudit && whiskAudit['continue-on-error'] === true && whiskAudit.if === undefined, 'Whisk audit must log findings without blocking frontend checks.'); + assert.ok(whisk.steps.indexOf(whiskInstall) < whisk.steps.indexOf(whiskAudit), 'Whisk audit requires the lock emitted by its dependency install.'); + return { release, readiness }; +} + +function validateProjectsWorkflow(source) { + const workflow = yaml.load(source); + assert.equal(workflow.jobs?.['add-to-project']?.if?.replace(/\s+/g, ' ').trim(), + "github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]')", + 'Project token must not be requested for fork or Dependabot pull requests.'); +} + +module.exports = { validateReadinessMatrix, validateReleaseWorkflow, validateProjectsWorkflow }; diff --git a/.github/scripts/readiness-matrix-contract.test.cjs b/.github/scripts/readiness-matrix-contract.test.cjs new file mode 100644 index 0000000..5cbfa5e --- /dev/null +++ b/.github/scripts/readiness-matrix-contract.test.cjs @@ -0,0 +1,257 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const yaml = require('js-yaml'); +const { validateReadinessMatrix, validateReleaseWorkflow, validateProjectsWorkflow } = require('./readiness-matrix-contract.cjs'); + +function fixture() { + return { + jobs: { + practical: { + strategy: { matrix: { include: [ + { 'php-version': '8.3', experimental: false }, + { 'php-version': '8.4', experimental: false }, + { 'php-version': '8.5', experimental: false }, + ] } }, + 'continue-on-error': '${{ matrix.experimental }}', + steps: [{ run: 'npm run pr:check' }, { run: 'npm run release:check' }], + }, + 'wordpress-fixture': { + strategy: { matrix: { include: [ + { 'wordpress-version': '6.8', experimental: false }, + { 'wordpress-version': 'latest', experimental: false }, + { 'wordpress-version': 'nightly', experimental: true }, + ] } }, + 'continue-on-error': '${{ matrix.experimental }}', + steps: [{ + run: 'npm run release:check', + env: { WP_SMOKE_WORDPRESS_VERSION: '${{ matrix.wordpress-version }}', WP_SMOKE_REQUIRED: '1', WP_SMOKE_A11Y: '1' }, + }], + }, + }, + }; +} + +function validate(workflow = fixture(), options) { + return validateReadinessMatrix(yaml.dump(workflow), options); +} + +test('accepts minimum and current stable blocking, with an advisory preview', () => { + assert.equal(validate().wordpress.length, 3); +}); + +test('validates the committed workflow and declared theme minimum', () => { + const repoRoot = path.resolve(__dirname, '../..'); + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/theme-readiness.yml'), 'utf8'); + const minimumWordPress = fs.readFileSync(path.join(repoRoot, 'style.css'), 'utf8').match(/Requires at least:\s*(\S+)/)[1]; + validateReadinessMatrix(workflow, { minimumWordPress }); +}); + +test('rejects removed or advisory minimum, latest, and baseline PHP legs', () => { + for (const [jobName, versionKey, version] of [ + ['wordpress-fixture', 'wordpress-version', '6.8'], + ['wordpress-fixture', 'wordpress-version', 'latest'], + ['practical', 'php-version', '8.3'], + ['practical', 'php-version', '8.4'], + ['practical', 'php-version', '8.5'], + ]) { + for (const remove of [false, true]) { + const workflow = fixture(); + const entries = workflow.jobs[jobName].strategy.matrix.include; + if (remove) entries.splice(entries.findIndex((entry) => entry[versionKey] === version), 1); + else entries.find((entry) => entry[versionKey] === version).experimental = true; + assert.throws(() => validate(workflow), /must keep .* blocking/); + } + } +}); + +test('requires the nightly preview to remain advisory', () => { + const workflow = fixture(); + workflow.jobs['wordpress-fixture'].strategy.matrix.include[2].experimental = false; + assert.throws(() => validate(workflow), /must keep nightly advisory/); +}); + +test('rejects job-wide failure bypasses and matrix exclusions', () => { + for (const jobName of ['practical', 'wordpress-fixture']) { + const bypass = fixture(); + bypass.jobs[jobName]['continue-on-error'] = true; + assert.throws(() => validate(bypass), /only matrix.experimental legs advisory/); + const excluded = fixture(); + excluded.jobs[jobName].strategy.matrix.exclude = [{ experimental: false }]; + assert.throws(() => validate(excluded), /must not exclude/); + } +}); + +test('rejects string experimental flags, numeric versions, and duplicate legs', () => { + const flag = fixture(); + flag.jobs.practical.strategy.matrix.include[0].experimental = 'false'; + assert.throws(() => validate(flag), /explicitly mark/); + const numeric = fixture(); + numeric.jobs.practical.strategy.matrix.include[0]['php-version'] = 8.3; + assert.throws(() => validate(numeric), /as a string/); + const duplicate = fixture(); + duplicate.jobs.practical.strategy.matrix.include.push({ 'php-version': '8.3', experimental: true }); + assert.throws(() => validate(duplicate), /must not duplicate/); +}); + +test('rejects missing, conditional, skipped, or failure-tolerant readiness commands', () => { + for (const jobName of ['practical', 'wordpress-fixture']) { + for (const mutation of ['remove', 'if', 'continue-on-error', 'skip-smoke']) { + const workflow = fixture(); + const step = workflow.jobs[jobName].steps.find((entry) => entry.run === 'npm run release:check'); + if (mutation === 'remove') workflow.jobs[jobName].steps = []; + else if (mutation === 'skip-smoke') step.run += ' -- --skip-smoke'; + else step[mutation] = true; + assert.throws(() => validate(workflow), /must (run|not allow failures)/); + } + } +}); + +test('rejects hardcoded fixture versions or disabled fixture checks', () => { + for (const [key, value] of [['WP_SMOKE_WORDPRESS_VERSION', '6.8'], ['WP_SMOKE_REQUIRED', '0'], ['WP_SMOKE_A11Y', '0'], ['WP_SMOKE_SKIP', '1']]) { + const workflow = fixture(); + workflow.jobs['wordpress-fixture'].steps[0].env[key] = value; + assert.throws(() => validate(workflow), /WordPress fixture/); + } + for (const scope of ['workflow', 'job']) { + const workflow = fixture(); + (scope === 'workflow' ? workflow : workflow.jobs['wordpress-fixture']).env = { WP_SMOKE_SKIP: '1' }; + assert.throws(() => validate(workflow), /must not skip/); + } +}); + +test('keeps the validated PHP 8.5 lane blocking', () => { + const workflow = fixture(); + workflow.jobs.practical.strategy.matrix.include[2].experimental = true; + assert.throws(() => validate(workflow), /must keep 8.5 blocking/); +}); + +function releaseFixture() { + const repoRoot = path.resolve(__dirname, '../..'); + return { + release: yaml.load(fs.readFileSync(path.join(repoRoot, '.github/workflows/semantic-release.yml'), 'utf8')), + readiness: yaml.load(fs.readFileSync(path.join(repoRoot, '.github/workflows/theme-readiness.yml'), 'utf8')), + }; +} + +function validateRelease({ release, readiness } = releaseFixture()) { + return validateReleaseWorkflow(yaml.dump(release), yaml.dump(readiness)); +} + +test('release publishing waits for the complete callable readiness workflow', () => { + validateRelease(); +}); + +test('rejects bypassed reusable readiness or publish dependencies', () => { + for (const mutation of [ + ({ release }) => { delete release.jobs['release-readiness']; }, + ({ release }) => { release.jobs['release-readiness'].uses = './.github/workflows/other.yml'; }, + ({ release }) => { release.jobs['release-readiness'].with.release = false; }, + ({ release }) => { release.jobs['semantic-release'].needs = []; }, + ({ release }) => { release.jobs['release-readiness'].if = 'always()'; }, + ({ release }) => { release.jobs['semantic-release'].if = 'always()'; }, + ({ release }) => { release.jobs['release-readiness']['continue-on-error'] = true; }, + ({ readiness }) => { delete readiness.on.workflow_call; }, + ({ readiness }) => { readiness.concurrency.group = 'theme-readiness-${{ github.ref }}'; }, + ({ readiness }) => { readiness.jobs['wordpress-fixture'].if = 'always()'; }, + ({ readiness }) => { readiness.jobs['extended-whisk'].if = 'false'; }, + ({ readiness }) => { readiness.jobs['php-lint']['continue-on-error'] = true; }, + ({ readiness }) => { readiness.jobs.practical.if = 'false'; }, + ]) { + const workflow = releaseFixture(); + mutation(workflow); + assert.throws(() => validateRelease(workflow)); + } +}); + +test('rejects skipped required fixture, PHP, parity and Whisk release gates', () => { + for (const [jobName, command] of [ + ['wordpress-fixture', 'npm run release:check'], + ['php-lint', 'npm run lint:php'], + ['practical', 'npm run smoke:generation-parity'], + ['practical', 'npm run pr:check'], + ['extended-whisk', 'npm --prefix whisk run test -- --runInBand'], + ['extended-whisk', 'npm --prefix whisk run a11y'], + ]) { + for (const bypass of ['remove', 'if', 'continue-on-error', 'skip-smoke']) { + const workflow = releaseFixture(); + const job = workflow.readiness.jobs[jobName]; + const step = job.steps.find((entry) => entry.run === command); + if (bypass === 'remove') job.steps.splice(job.steps.indexOf(step), 1); + else if (bypass === 'skip-smoke') step.run += ' -- --skip-smoke'; + else step[bypass] = true; + assert.throws(() => validateRelease(workflow)); + } + } + for (const envKey of ['WP_SMOKE_REQUIRED', 'WP_SMOKE_A11Y']) { + const workflow = releaseFixture(); + const step = workflow.readiness.jobs['wordpress-fixture'].steps.find((entry) => entry.run === 'npm run release:check'); + delete step.env[envKey]; + assert.throws(() => validateRelease(workflow)); + } +}); + +test('rejects release credential persistence and rebuilding instead of reusing the tested ZIP', () => { + for (const mutation of [ + ({ release }) => { release.jobs['semantic-release'].steps.find((step) => step.name === 'Checkout').with['persist-credentials'] = true; }, + ({ release }) => { release.jobs['semantic-release'].steps.push({ run: 'npm run build:dist' }); }, + ({ release }) => { release.jobs['semantic-release'].env = { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }; }, + ({ release }) => { delete release.jobs['semantic-release']['timeout-minutes']; }, + ({ release }) => { release.jobs['semantic-release'].steps.find((step) => step.name === 'Verify tested archive checksum').run = 'true'; }, + ({ release }) => { release.jobs['semantic-release'].steps.find((step) => step.name === 'Download tested release bundle').with.name = 'untested'; }, + ({ readiness }) => { readiness.jobs['wordpress-fixture'].needs = []; }, + ({ readiness }) => { readiness.jobs['wordpress-fixture'].steps.find((step) => step.name === 'Download tested release bundle').with.name = 'untested'; }, + ({ readiness }) => { delete readiness.jobs.practical['timeout-minutes']; }, + ]) { + const workflow = releaseFixture(); + mutation(workflow); + assert.throws(() => validateRelease(workflow)); + } +}); + +test('project integration skips fork and Dependabot pull requests without skipping issues or manual runs', () => { + const repoRoot = path.resolve(__dirname, '../..'); + const workflow = yaml.load(fs.readFileSync(path.join(repoRoot, '.github/workflows/addtoprojects.yml'), 'utf8')); + validateProjectsWorkflow(yaml.dump(workflow)); + for (const condition of [undefined, 'true', "github.actor != 'dependabot[bot]'"]) { + workflow.jobs['add-to-project'].if = condition; + assert.throws(() => validateProjectsWorkflow(yaml.dump(workflow))); + } +}); + +test('rejects missing or failure-tolerant shipped Composer audit and unlogged Whisk audit', () => { + for (const jobName of ['practical', 'wordpress-fixture']) { + for (const bypass of ['remove', 'continue-on-error']) { + const workflow = releaseFixture(); + const job = workflow.readiness.jobs[jobName]; + const step = job.steps.find((entry) => entry.run === 'composer audit --locked --no-dev'); + if (bypass === 'remove') job.steps.splice(job.steps.indexOf(step), 1); + else step[bypass] = true; + assert.throws(() => validateRelease(workflow)); + } + } + const workflow = releaseFixture(); + workflow.readiness.jobs['extended-whisk'].steps = workflow.readiness.jobs['extended-whisk'].steps.filter((step) => step.name !== 'Audit Whisk dependencies'); + assert.throws(() => validateRelease(workflow)); +}); + +test('Whisk audit uses the lock emitted by its existing dependency install', () => { + const job = releaseFixture().readiness.jobs['extended-whisk']; + assert.equal(job.steps.find((step) => step.name === 'Install Whisk dependencies').run, + 'npm run whisk:install -- --package-lock=true'); + assert.ok(!job.steps.some((step) => step.run?.includes('--package-lock-only')), + 'An informational audit must not add a separate dependency-resolution gate.'); +}); + +test('rejects Whisk audit without an installed lock or before its dependency install', () => { + const noLock = releaseFixture(); + noLock.readiness.jobs['extended-whisk'].steps.find((step) => step.name === 'Install Whisk dependencies').run = 'npm run whisk:install'; + assert.throws(() => validateRelease(noLock)); + + const reordered = releaseFixture(); + const steps = reordered.readiness.jobs['extended-whisk'].steps; + const auditIndex = steps.findIndex((step) => step.name === 'Audit Whisk dependencies'); + steps.unshift(...steps.splice(auditIndex, 1)); + assert.throws(() => validateRelease(reordered)); +}); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 4cf3b52..9156540 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -7,8 +7,13 @@ const fs = require('fs'); const path = require('path'); const childProcess = require('child_process'); +const yaml = require('js-yaml'); +const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); +const { validateReadinessMatrix, validateReleaseWorkflow, validateProjectsWorkflow } = require('./readiness-matrix-contract.cjs'); +const { validateThemeJsonStyles } = require('./theme-json-style-contract.cjs'); const repoRoot = path.resolve(__dirname, '../..'); +const skipSmoke = process.argv.slice(2).includes('--skip-smoke'); const results = []; function addResult(status, name, detail) { @@ -33,6 +38,16 @@ function semver(value) { return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(value)); } +function wordpressVersionAtLeast(version, minimum) { + const actualParts = version.split('.').map(Number); + const minimumParts = minimum.split('.').map(Number); + for (let index = 0; index < Math.max(actualParts.length, minimumParts.length); index += 1) { + const difference = (actualParts[index] || 0) - (minimumParts[index] || 0); + if (difference !== 0) return difference > 0; + } + return true; +} + function runStaticCheck(name, callback) { // Static checks intentionally inspect files as text. They catch packaging and // documentation regressions without needing a full WordPress install. @@ -46,8 +61,8 @@ function runStaticCheck(name, callback) { } function runCommandCheck(name, command, args) { - // Command checks are reserved for smoke paths that already know how to skip - // when local prerequisites such as WP-CLI or MySQL are absent. + // Focused contract tests always run. Runtime smoke commands can also report + // missing local prerequisites such as WP-CLI or MySQL as an explicit skip. const result = childProcess.spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', @@ -299,33 +314,21 @@ function runStaticChecks() { const license = readFile('LICENSE'); const issueTemplate = readFile('.github/ISSUE_TEMPLATE.md'); const pullRequestTemplate = readFile('.github/PULL_REQUEST_TEMPLATE.md'); - const releaseGuardRejectContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '1.1.0' }, - }; - const releaseGuardAcceptContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '2.0.0' }, - }; - const releaseGuardFutureContext = { - branch: { name: 'main' }, - lastRelease: { version: '2.0.0' }, - nextRelease: { version: '2.0.1' }, - }; - const releaseAnalyzerAlphaContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0-alpha.5' }, - }; - const releaseAnalyzerNoStableContext = { - branch: { name: 'main' }, - lastRelease: {}, - }; - runStaticCheck('Required files', () => { const requiredFiles = [ '.github/scripts/release-check.cjs', + '.github/scripts/release-version-contract.cjs', + '.github/scripts/release-version-contract.test.cjs', + '.github/scripts/readiness-matrix-contract.cjs', + '.github/scripts/readiness-matrix-contract.test.cjs', + '.github/scripts/theme-json-style-contract.cjs', + '.github/scripts/theme-json-style-contract.test.cjs', + '.github/scripts/semantic-release.test.cjs', + '.github/scripts/fallback-navigation-smoke.php', + '.github/scripts/rendered-a11y.cjs', + '.github/scripts/rendered-a11y.test.cjs', + '.github/scripts/media-embed-smoke.php', + '.github/scripts/media-embed-browser.cjs', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', @@ -336,10 +339,12 @@ function runStaticChecks() { '.github/workflows/theme-readiness.yml', '.gitignore', '.nvmrc', + 'AGENTS.md', 'README.md', 'UPGRADE.md', 'docs/README.md', 'docs/acf-local-json.md', + 'docs/accessibility.md', 'docs/acf-twig-blocks.md', 'docs/asset-loading.md', 'docs/block-patterns.md', @@ -402,9 +407,15 @@ function runStaticChecks() { 'templates/search.twig', 'templates/single-password.twig', 'templates/single.twig', + 'docs/template-extension.md', + '.github/scripts/template-extension-smoke.php', + '.github/fixtures/template-extension/README.md', '.github/scripts/acf-local-json-smoke.php', '.github/scripts/asset-manifest-smoke.php', '.github/scripts/attribute-helper-smoke.php', + '.github/scripts/twig-helper-contract.cjs', + '.github/scripts/twig-helper-contract.php', + '.github/fixtures/twig-helper-contract.json', '.github/scripts/block-scoped-assets-smoke.php', '.github/scripts/bootstrap-loader-smoke.php', '.github/scripts/child-theme-generator-smoke.php', @@ -413,6 +424,9 @@ function runStaticChecks() { '.github/scripts/editor-policy-smoke.php', '.github/scripts/pattern-registry-smoke.php', '.github/scripts/wordpress-starter-init-smoke.cjs', + '.github/scripts/lint-wrappers.test.cjs', + '.github/scripts/jest-project-smoke.cjs', + '.github/scripts/a11y-runner.test.cjs', '.github/scripts/theme-filters-smoke.php', '.github/scripts/twig-autoescape-smoke.php', '.github/scripts/twig-project-namespace-smoke.php', @@ -421,6 +435,9 @@ function runStaticChecks() { 'whisk/.cli/init.js', 'whisk/.gitignore', 'whisk/.nvmrc', + 'whisk/AGENTS.md', + 'whisk/docs/wordpress-integration.md', + 'whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md', 'whisk/config/emulsify-core/a11y.config.js', 'whisk/config/emulsify-core/run-a11y.js', 'whisk/config/jest.config.js', @@ -431,17 +448,23 @@ function runStaticChecks() { 'whisk/config/acf-json/.gitkeep', 'whisk/patterns/.gitkeep', 'whisk/src/components/.gitkeep', - 'whisk/templates/page.twig', ]; const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(repoRoot, file))); ensure(missingFiles.length === 0, `Missing required files: ${missingFiles.join(', ')}.`); return `Found ${requiredFiles.length} required release files.`; }); + runStaticCheck('Release version contract', () => { + const { errors, packageVersion, latestStableTag, releasePrepared } = validateReleaseVersionContract({ repoRoot }); + ensure(errors.length === 0, errors.join('\n')); + return releasePrepared + ? `Prepared version ${packageVersion} follows latest stable tag ${latestStableTag}.` + : `Package version ${packageVersion} matches latest stable tag ${latestStableTag}; no release is prepared.`; + }); + runStaticCheck('Root release metadata', () => { ensure(rootPackage.name === 'emulsify-wordpress', 'package.json name should be emulsify-wordpress.'); ensure(semver(rootPackage.version), 'package.json version must be a valid semver string.'); - ensure(rootPackage.version === releaseConfig.expectedStableRelease, `package.json version should prepare the ${releaseConfig.expectedStableRelease} release.`); ensure(rootPackage.description, 'package.json description is required.'); ensureParentThemeLanguage('package.json description', rootPackage.description); ensureNoTitleCaseBuildPhrase('package.json description', rootPackage.description); @@ -452,6 +475,9 @@ function runStaticChecks() { ensure(rootPackage.scripts['pr:check'] === 'node .github/scripts/pr-validation.cjs', 'package.json should expose npm run pr:check.'); ensure(rootPackage.scripts['release:check'] === 'node .github/scripts/release-check.cjs', 'package.json should expose npm run release:check.'); ensure(rootPackage.scripts['docs:check-commands'] === 'node .github/scripts/docs-command-check.cjs', 'package.json should expose npm run docs:check-commands.'); + ensure(rootPackage.scripts['test:lint-wrappers'] === 'node --test .github/scripts/lint-wrappers.test.cjs', 'package.json should expose the lint wrapper regression tests.'); + ensure(rootPackage.scripts['smoke:jest-project'] === 'node .github/scripts/jest-project-smoke.cjs', 'package.json should expose generated project ESM/coverage smoke coverage.'); + ensure(rootPackage.scripts['test:a11y-runner'] === 'node --test .github/scripts/a11y-runner.test.cjs', 'package.json should expose the Core accessibility wrapper tests.'); ensure(rootPackage.scripts['build:dist'] === 'bash scripts/build-dist.sh', 'package.json should expose npm run build:dist.'); ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpcs -q'), 'package.json lint:php should run PHPCS.'); ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpstan analyse --no-progress'), 'package.json lint:php should run PHPStan.'); @@ -470,6 +496,7 @@ function runStaticChecks() { ensure(rootPackage.scripts['smoke:theme-filters'] === 'php .github/scripts/theme-filters-smoke.php', 'package.json should expose npm run smoke:theme-filters.'); ensure(rootPackage.scripts['smoke:twig-switch'] === 'php .github/scripts/twig-switch-smoke.php', 'package.json should expose npm run smoke:twig-switch.'); ensure(rootPackage.scripts['smoke:twig-project-namespace'] === 'php .github/scripts/twig-project-namespace-smoke.php', 'package.json should expose npm run smoke:twig-project-namespace.'); + ensure(rootPackage.scripts['smoke:template-extension'] === 'php .github/scripts/template-extension-smoke.php', 'package.json should expose the exact-byte template extension smoke.'); ensure(rootPackage.scripts['whisk:install'], 'package.json should expose npm run whisk:install.'); ensure(rootPackage.scripts['whisk:build'] === 'npm --prefix whisk run build', 'package.json should expose npm run whisk:build.'); ensure(!rootPackage.devDependencies['@semantic-release/changelog'], 'package.json should not declare unused @semantic-release/changelog tooling.'); @@ -503,8 +530,6 @@ function runStaticChecks() { ensure(rootThemeHeader.Version === releaseVersion, `style.css Version should match the ${releaseVersion} release line.`); ensure(rootThemeHeader.License === 'GPL-2.0-only', 'style.css License should be GPL-2.0-only.'); ensure(rootThemeHeader['License URI'] === 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html', 'style.css License URI should point to GPLv2.'); - ensure(rootThemeHeader['Requires at least'] === '6.7', 'style.css Requires at least should stay aligned to the WordPress baseline.'); - ensure(rootThemeHeader['Tested up to'] === '6.7', 'style.css Tested up to should stay aligned to the WordPress baseline.'); ensure(rootThemeHeader['Requires PHP'] === '8.3', 'style.css Requires PHP should stay aligned to the release baseline.'); ensureParentThemeLanguage('style.css Description', rootThemeHeader.Description); ensure(whiskThemeHeader['Theme Name'] === 'Whisk', 'whisk/style.css Theme Name should be Whisk.'); @@ -518,6 +543,25 @@ function runStaticChecks() { return 'Parent and Whisk WordPress theme headers are coherent with package metadata.'; }); + runStaticCheck('WordPress minimum compatibility', () => { + const minimum = rootThemeHeader['Requires at least']; + ensure(minimum === '6.8', 'style.css Requires at least must be 6.8 for this release.'); + const timberPath = 'vendor/timber/timber/src/Timber.php'; + ensure(fs.existsSync(path.join(repoRoot, timberPath)), 'Installed Timber source is required to verify its WordPress minimum; run composer install.'); + const timberMinimum = readFile(timberPath).match(/\bMINIMUM_WP_VERSION\s*=\s*['"](\d+\.\d+(?:\.\d+)?)['"]/); + ensure(timberMinimum, 'Unable to read installed Timber MINIMUM_WP_VERSION; run composer install and review Timber compatibility.'); + ensure(wordpressVersionAtLeast(minimum, timberMinimum[1]), `style.css Requires at least (${minimum}) is below installed Timber MINIMUM_WP_VERSION (${timberMinimum[1]}); raise the theme minimum before releasing.`); + return `Theme minimum ${minimum} supports installed Timber's WordPress minimum ${timberMinimum[1]}.`; + }); + + runStaticCheck('WordPress tested compatibility', () => { + const tested = rootThemeHeader['Tested up to']; + const minimum = rootThemeHeader['Requires at least']; + ensure(/^\d+\.\d+(?:\.\d+)?$/.test(tested), 'style.css Tested up to must name a stable WordPress version verified by the blocking fixture.'); + ensure(wordpressVersionAtLeast(tested, minimum), 'style.css Tested up to must not be lower than Requires at least.'); + return `Tested up to ${tested} covers theme minimum ${minimum}; the current stable WordPress fixture is blocking.`; + }); + runStaticCheck('PHP analysis scope', () => { const phpcsConfig = readFile('phpcs.xml.dist'); const phpstanConfig = readFile('phpstan.neon.dist'); @@ -562,6 +606,16 @@ function runStaticChecks() { return 'Attribute helper runtime and smoke fixture are wired.'; }); + runStaticCheck('Twig helper characterization', () => { + ensure(rootPackage.scripts['test:twig-helpers'] === 'node .github/scripts/twig-helper-contract.cjs', 'Root package should expose the PHP/JavaScript Twig helper characterization.'); + const prValidation = readFile('.github/scripts/pr-validation.cjs'); + const install = prValidation.indexOf("run('Install Whisk dependencies'"); + const compare = prValidation.indexOf("['run', 'test:twig-helpers']"); + ensure(install >= 0 && compare > install, 'PR validation must compare both helper runtimes after installing Whisk/Core.'); + ensure(docs.twig.includes('## Helper characterization'), 'Twig authoring docs must explain the characterization and recorded differences.'); + return 'The PR gate compares exact PHP and installed Core helper output against one explicit-difference corpus.'; + }); + runStaticCheck('Twig HTML autoescaping', () => { const twig = readFile('includes/Runtime/Twig.php'); const smoke = readFile('.github/scripts/twig-autoescape-smoke.php'); @@ -570,7 +624,7 @@ function runStaticChecks() { ensure(twig.includes("$options['autoescape'] = 'html'"), 'Timber integration should enable HTML autoescaping.'); ensure(smoke.includes('{{ fields.heading }}'), 'Twig autoescape smoke should render a field value.'); ensure(smoke.includes(''), 'Twig autoescape smoke should use a hostile script fixture.'); - ensure(smoke.includes("$definition['is_safe']"), 'Twig autoescape smoke should register helper safety metadata.'); + ensure(smoke.includes("$definition['options']"), 'Twig autoescape smoke should register the same helper options as Timber.'); return 'Twig HTML autoescaping is registered and covered by a behavioral smoke test.'; }); @@ -616,12 +670,12 @@ function runStaticChecks() { ensure(cli.includes("replace_theme_header( $contents, 'Theme Name'"), 'Child theme generator should update Theme Name explicitly.'); ensure(cli.includes("replace_theme_header( $contents, 'Text Domain'"), 'Child theme generator should update Text Domain explicitly.'); ensure(cli.includes("replace_theme_header( $contents, 'Template'"), 'Child theme generator should update Template explicitly.'); - ensure(cli.includes("data['project']['name']"), 'Child theme generator should update project.emulsify.json project.name.'); - ensure(cli.includes("data['project']['machineName']"), 'Child theme generator should update project.emulsify.json project.machineName.'); - ensure(cli.includes("data['project']['generatedFrom']"), 'Child theme generator should update project.emulsify.json generatedFrom.'); - ensure(cli.includes("data['project']['generatedFromVersion']"), 'Child theme generator should update project.emulsify.json generatedFromVersion.'); + ensure(/\$data->project->name\s*=\s*\$theme_label/.test(cli), 'Child theme generator should update project.emulsify.json project.name.'); + ensure(/\$data->project->machineName\s*=\s*\$machine_name/.test(cli), 'Child theme generator should update project.emulsify.json project.machineName.'); + ensure(/\$data->project->generatedFrom\s*=\s*self::GENERATED_FROM/.test(cli), 'Child theme generator should update project.emulsify.json generatedFrom.'); + ensure(/\$data->project->generatedFromVersion\s*=\s*\$version/.test(cli), 'Child theme generator should update project.emulsify.json generatedFromVersion.'); ensure(cli.includes("$this->join_path( dirname( $source ), 'style.css' ), 'Version'"), 'Child theme generator should read release lineage from installed WordPress theme metadata.'); - ensure(cli.includes("data['name'] = $machine_name"), 'Child theme generator should update package.json name.'); + ensure(/\$data->name\s*=\s*\$machine_name/.test(cli), 'Child theme generator should update package.json name.'); ensure(cli.includes('collect_pattern_updates'), 'Child theme generator should update starter pattern namespaces.'); ensure(cli.includes('sanitize_label_for_source'), 'Child theme generator should sanitize human labels before writing source comments.'); ensure(cli.includes("replace_theme_header( $contents, 'Theme Name', $source_label )"), 'Child theme generator should use a source-safe label in style.css.'); @@ -671,6 +725,7 @@ function runStaticChecks() { const locator = readFile('includes/Blocks/ComponentLocator.php'); const fileDiscovery = readFile('includes/Support/FileDiscovery.php'); const registry = readFile('includes/Blocks/Registry.php'); + const bootstrap = readFile('includes/Bootstrap.php'); const acfBlocks = readFile('includes/Blocks/AcfBlocks.php'); const nativeBlocks = readFile('includes/Blocks/NativeBlocks.php'); const smoke = readFile('.github/scripts/component-locator-smoke.php'); @@ -691,7 +746,15 @@ function runStaticChecks() { ensure(locator.includes('clear_discovery_cache') && locator.includes('delete_transient'), 'Component locator should provide a clear method for the active discovery cache key.'); ensure(locator.includes('stylesheet_version') && locator.includes('template_version') && locator.includes('manifest_file_signature'), 'Component locator cache key should include theme versions and manifest filemtime.'); ensure(locator.includes('wp_get_environment_type') && locator.includes('WP_DEBUG'), 'Component locator should keep active development uncached unless explicitly enabled.'); - ensure(registry.includes('$components = new ComponentLocator()'), 'Block registry should share one ComponentLocator instance.'); + ensure( + /\$components\s*=\s*new Blocks\\ComponentLocator\(\)/.test(bootstrap) + && bootstrap.includes('new Assets( $asset_manifest, $components )') + && bootstrap.includes('new Blocks\\Registry( $asset_manifest, $components )') + && registry.includes('$this->components = $components ?? new ComponentLocator()') + && registry.includes('new AcfBlocks( $this->components, $this->manifest )') + && registry.includes('new NativeBlocks( $this->components )'), + 'Bootstrap should share one ComponentLocator across runtime assets and ACF/Twig/native block registration.' + ); ensure(acfBlocks.includes('$this->components->acf_components()'), 'ACF/Twig block discovery should use ComponentLocator.'); ensure(!acfBlocks.includes("$args['data']['twig_template'] ="), 'ACF/Twig block registration should not persist template paths in editor block data.'); ensure( @@ -812,6 +875,7 @@ function runStaticChecks() { ensure(editorPolicySmoke.includes('Editor policy smoke checks passed'), 'Editor policy smoke should have a clear success message.'); ensure(patternSmoke.includes('Pattern registry smoke checks passed'), 'Pattern registry smoke should have a clear success message.'); ensure(fileDiscovery.includes('function theme_roots'), 'File discovery helper should create child-first theme roots.'); + ensure(fileDiscovery.includes('function filtered_theme_roots') && fileDiscovery.includes('self::theme_roots( $directory, true )') && fileDiscovery.includes('apply_filters( $hook, $roots, $directory )'), 'Asset root discovery should preserve child-first roots and service-specific directory filters.'); ensure(fileDiscovery.includes('function normalize_roots'), 'File discovery helper should normalize readable unique roots.'); ensure(fileDiscovery.includes('function file_records'), 'File discovery helper should create file records for service-specific filtering.'); ensure(fileDiscovery.includes('function recursive_files'), 'File discovery helper should support recursive scans.'); @@ -821,10 +885,10 @@ function runStaticChecks() { ensure(assetManifest.includes('dist/emulsify-assets.json'), 'Asset manifest helper should default to dist/emulsify-assets.json.'); ensure(assetManifest.includes('emulsify_theme_asset_manifest_path') && assetManifest.includes('emulsify_theme_asset_manifest_data'), 'Asset manifest helper should expose path and parsed data filters.'); ensure(assets.includes("'global'") && editorEnhancements.includes("'editor'") && assetManifest.includes("'components'") && assetManifest.includes("'blocks'"), 'Asset manifest support should cover global, editor, component, and block-specific sections.'); - ensure(assets.includes('FileDiscovery::theme_roots') && assets.includes('FileDiscovery::file_records'), 'Runtime assets should use shared filesystem discovery helpers.'); + ensure(assets.includes("FileDiscovery::filtered_theme_roots( $directory, 'emulsify_theme_asset_directories' )") && assets.includes('FileDiscovery::file_records') && assets.includes('AssetRecord::from_discovered_files'), 'Runtime assets should use shared root discovery and asset record helpers.'); ensure(assets.includes('AssetManifest') && assets.includes('manifest_asset_files') && assets.includes("enqueue_scripts( 'emulsify-global', 'dist/global' )"), 'Runtime assets should support manifest-backed global and component assets.'); - ensure(assets.includes('is_scoped_component_asset') && assets.includes('*.component.json'), 'Runtime assets should skip component files declared as block-scoped metadata.'); - ensure(editorEnhancements.includes('FileDiscovery::theme_roots') && editorEnhancements.includes('FileDiscovery::file_records'), 'Editor assets should use shared filesystem discovery helpers.'); + ensure(assets.includes('is_scoped_component_asset') && assets.includes('$this->components->component_metadata_files()') && assets.includes('metadata_asset_paths'), 'Runtime assets should skip component files declared as block-scoped metadata.'); + ensure(editorEnhancements.includes("FileDiscovery::filtered_theme_roots( $directory, 'emulsify_theme_editor_asset_directories' )") && editorEnhancements.includes('FileDiscovery::file_records') && editorEnhancements.includes('AssetRecord::from_discovered_files'), 'Editor assets should use shared root discovery and asset record helpers.'); ensure(editorEnhancements.includes('AssetManifest') && editorEnhancements.includes("asset_records( 'editor'"), 'Editor assets should support manifest-backed editor assets.'); ensure(acfBlocks.includes('scoped_asset_records') && acfBlocks.includes('enqueue_assets') && acfBlocks.includes('emulsify_theme_acf_block_asset_records'), 'ACF/Twig blocks should support metadata-driven scoped assets.'); ensure(nativeBlocks.includes("register_block_type( (string) $component['path'] )"), 'Native blocks should delegate block.json asset fields to WordPress register_block_type().'); @@ -869,8 +933,10 @@ function runStaticChecks() { ensure(fs.existsSync(path.join(repoRoot, 'whisk/.nvmrc')), 'Whisk should use .nvmrc for Node version tooling.'); ensure(!fs.existsSync(path.join(repoRoot, 'whisk/.nvm')), 'Whisk should not use the legacy .nvm filename.'); ensure(fs.existsSync(path.join(repoRoot, 'whisk/config/jest.config.js')), 'Whisk should provide the Jest config referenced by package scripts.'); - ensure(scripts.test === 'jest --coverage --passWithNoTests --config ./config/jest.config.js', 'whisk/package.json test script should point at the checked-in Jest config.'); + ensure(scripts.test === 'node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --config ./config/jest.config.js', 'whisk/package.json test should execute native project ESM and fail when no tests exist.'); + ensure(scripts.twatch === 'node --experimental-vm-modules node_modules/jest/bin/jest.js --no-coverage --watch --verbose --config ./config/jest.config.js', 'whisk/package.json twatch should use the same native ESM runner.'); ensure(jestConfig.includes("testEnvironment: 'node'"), 'whisk/config/jest.config.js should define a node test environment.'); + ensure(jestConfig.includes('transform: {}') && jestConfig.includes("coverageProvider: 'v8'") && jestConfig.includes('collectCoverageFrom'), 'Whisk Jest config should execute ESM directly and collect project coverage.'); ensure(initHook.includes('sanitizeLabelForSource'), 'Whisk init hook should sanitize human labels before writing source comments.'); ensure(initHook.includes("replaceThemeHeader(contents, 'Theme Name', sourceLabel)"), 'Whisk init hook should update style.css with a source-safe Theme Name.'); ensure(initHook.includes("replaceThemeHeader(contents, 'Text Domain', machineName)"), 'Whisk init hook should update style.css Text Domain.'); @@ -889,7 +955,7 @@ function runStaticChecks() { ensure(starterInitSmoke.includes("style.Template === 'emulsify'"), 'Starter init smoke should validate Template: emulsify.'); ensure(starterInitSmoke.includes('*/ echo 1; /*'), 'Starter init smoke should cover hostile source-comment labels.'); ensure(starterInitSmoke.includes('node_modules') && starterInitSmoke.includes('dist'), 'Starter init smoke should validate copied build and dependency output is absent.'); - ensure(starterInitSmoke.includes("['--prefix', 'whisk', 'run', 'test']"), 'Starter init smoke should verify the starter npm test script works after Whisk dependencies are installed.'); + ensure(starterInitSmoke.includes('No tests found'), 'Starter init smoke should verify an empty starter fails its npm test script.'); ensure(starterInitSmoke.includes("['run', 'inspect:components', '--', '--json']"), 'Starter init smoke should run the component inspector JSON command from the generated theme root.'); ensure(starterInitSmoke.includes('JSON.parse') && starterInitSmoke.includes('Array.isArray(report.components)'), 'Starter init smoke should validate the component inspector JSON report, including an empty components array.'); ensure(fs.existsSync(path.join(repoRoot, 'whisk/src/components/.gitkeep')), 'whisk/src/components should remain as an empty optional component placeholder.'); @@ -907,14 +973,14 @@ function runStaticChecks() { ensure(!fs.existsSync(path.join(repoRoot, 'whisk/whisk.info.yml')), 'Whisk should not add Drupal-style .info.yml metadata.'); ensure(!fs.existsSync(path.join(repoRoot, 'whisk/scripts/vite-if-inputs.mjs')), 'Whisk should not wrap Emulsify Core Vite build errors.'); ensure(whiskPackage.dependencies && whiskPackage.dependencies['@emulsify/core'], 'whisk/package.json must declare @emulsify/core.'); - // Accept any caret range on Core 4 at or above the component-inspector - // minimum, so a patch or minor Core bump does not require editing this file. + // Accept any caret range at or above Core 4.5, so a patch or minor Core + // bump does not require editing this file. const coreRange = whiskPackage.dependencies['@emulsify/core']; const coreRangeMatch = /^\^4\.(\d+)\.(\d+)$/.exec(coreRange); ensure(coreRangeMatch, `whisk/package.json should target a caret range within Emulsify Core 4; found ${coreRange}.`); ensure( - Number(coreRangeMatch[1]) > 3 || (Number(coreRangeMatch[1]) === 3 && Number(coreRangeMatch[2]) >= 0), - `whisk/package.json should target Emulsify Core ^4.3.0 or newer within Core 4; found ${coreRange}.`, + Number(coreRangeMatch[1]) >= 5, + `whisk/package.json should target Emulsify Core ^4.5.0 or newer within Core 4; found ${coreRange}.`, ); ensure(scripts['inspect:components'] === 'emulsify-inspect-components', 'whisk/package.json should expose the published Emulsify Core component inspector binary.'); ensure(!scripts['inspect:components'].includes('node_modules/'), 'whisk/package.json should not deep-link to the component inspector implementation.'); @@ -924,9 +990,10 @@ function runStaticChecks() { ensure(whiskA11yConfig.includes("storybookBuildDir: '.out'"), 'Whisk accessibility config should target the generated theme Storybook output relative to the consuming project root.'); ensure(whiskA11yConfig.includes('wait: 1000'), 'Whisk accessibility config should wait for the selected Storybook story to mount.'); ensure(whiskA11yRunner.includes('resolvePa11yStoryIds') && whiskA11yRunner.includes('storyIds.length === 0'), 'Whisk accessibility runner should discover stories and reject an empty audit.'); - ensure(whiskA11yRunner.includes('http.createServer') && whiskA11yRunner.includes('await pa11y') && whiskA11yRunner.includes('logReport'), 'Whisk accessibility runner should serve the static build over HTTP and report real Pa11y results.'); - ensure(whiskA11yRunner.includes("process.env.CI === 'true' && process.platform === 'linux'") && whiskA11yRunner.includes('pa11yOptions.chromeLaunchConfig') && whiskA11yRunner.includes("'--no-sandbox'") && whiskA11yRunner.includes("'--disable-setuid-sandbox'"), 'Whisk accessibility runner should launch Chrome without its unavailable sandbox on isolated Linux CI runners.'); - ensure(whiskA11yRunner.includes('if (hasIssues)') && whiskA11yRunner.includes('process.exitCode = 1'), 'Whisk accessibility runner should fail when Pa11y reports a non-ignored violation.'); + ensure(whiskA11yRunner.includes('resolveProjectA11yConfig') && whiskA11yRunner.includes('applyProjectA11yConfig'), 'Whisk accessibility wrapper should load the consuming project config through Core.'); + ensure(whiskA11yRunner.includes('startStorybookServer') && whiskA11yRunner.includes('lintReportAndExit') && whiskA11yRunner.includes('finally'), 'Whisk accessibility wrapper should delegate HTTP serving, bounded checks, failure reporting, and cleanup to Core.'); + ensure(!whiskA11yRunner.includes('http.createServer') && !whiskA11yRunner.includes('await pa11y'), 'Whisk should not ship a copied accessibility engine.'); + ensure(!whiskA11yRunner.includes('--no-sandbox') && !whiskA11yRunner.includes('--disable-setuid-sandbox'), 'Whisk accessibility code must keep Chrome sandboxing enabled.'); ensure(!scriptText.includes('vite-if-inputs'), 'whisk/package.json scripts should not wrap missing-input build errors.'); ensure(scripts.develop && scripts.develop.includes('npm:vite'), 'whisk/package.json develop script should run the Vite watcher.'); ensure(!scripts.webpack, 'whisk/package.json should not expose a webpack script.'); @@ -946,7 +1013,6 @@ function runStaticChecks() { const projectComponentLoader = readFile('includes/Twig/ProjectComponentLoader.php'); const twigNamespaceSmoke = readFile('.github/scripts/twig-project-namespace-smoke.php'); const childFunctions = readFile('whisk/functions.php'); - const childPageTemplate = readFile('whisk/templates/page.twig'); const parentTemplateFiles = listFilesRecursive('templates', (file) => file.endsWith('.twig')).sort(); const childTemplateFiles = listFilesRecursive('whisk/templates', (file) => file.endsWith('.twig')).sort(); const expectedParentFallbacks = [ @@ -959,26 +1025,18 @@ function runStaticChecks() { 'templates/single-password.twig', 'templates/single.twig', ]; - const unexpectedChildTemplates = childTemplateFiles.filter((file) => file !== 'whisk/templates/page.twig'); - const duplicateChildTemplates = childTemplateFiles.filter((file) => { - const parentFile = file.replace(/^whisk\//, ''); - const parentPath = path.join(repoRoot, parentFile); - return fs.existsSync(parentPath) && readFile(file) === readFile(parentFile); - }); const childTemplatePathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/templates'"); const parentTemplatePathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/templates'"); - const childComponentSrcPathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/src/components'"); - const childComponentLegacyPathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/components'"); - const parentComponentSrcPathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/src/components'"); - const parentComponentLegacyPathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/components'"); + const childComponentSrcPathIndex = twigIntegration.indexOf("get_stylesheet_directory() . '/src/components'"); + const childComponentLegacyPathIndex = twigIntegration.indexOf("get_stylesheet_directory() . '/components'"); + const parentComponentSrcPathIndex = twigIntegration.indexOf("get_template_directory() . '/src/components'"); + const parentComponentLegacyPathIndex = twigIntegration.indexOf("get_template_directory() . '/components'"); for (const fallback of expectedParentFallbacks) { ensure(parentTemplateFiles.includes(fallback), `${fallback} should exist as a parent fallback.`); } - ensure(unexpectedChildTemplates.length === 0, `Whisk should not duplicate parent fallback templates: ${unexpectedChildTemplates.join(', ')}.`); - ensure(duplicateChildTemplates.length === 0, `Whisk templates should not be byte-identical parent copies: ${duplicateChildTemplates.join(', ')}.`); - ensure(childPageTemplate.includes("{% extends '@emulsify-tpl/page.twig' %}"), 'whisk/templates/page.twig should extend the parent-only page fallback.'); - ensure(childPageTemplate.includes('{{ parent() }}'), 'whisk/templates/page.twig should demonstrate wrapping parent fallback output.'); + ensure(childTemplateFiles.length === 0, `Whisk ships no Twig templates; remove ${childTemplateFiles.join(', ')} and see docs/template-extension.md for intentional child overrides.`); + ensure(!fs.existsSync(path.join(repoRoot, 'whisk/templates')), 'Whisk should omit the templates directory; see docs/template-extension.md before adding project overrides.'); ensure(childTemplatePathIndex !== -1, 'Twig integration should register child @templates path.'); ensure(parentTemplatePathIndex !== -1, 'Twig integration should register parent @templates path.'); ensure(childTemplatePathIndex < parentTemplatePathIndex, 'Twig integration should register child @templates before parent @templates.'); @@ -994,8 +1052,8 @@ function runStaticChecks() { ensure(twigIntegration.includes('project_structure_namespaces') && twigIntegration.includes('structureImplementations'), 'Twig integration should honor Emulsify Core structureImplementations for configured namespaces.'); ensure(twigIntegration.includes('emulsify_theme_project_component_roots'), 'Twig integration should expose a focused project component roots filter.'); ensure(twigIntegration.includes('new ProjectComponentLoader') && projectComponentLoader.includes('implements \\Twig\\Loader\\LoaderInterface'), 'Twig integration should wrap the loader for machineName:component references.'); - ensure(twigIntegration.includes('machineName:component'), 'Twig integration should document the project component reference intent in code comments.'); - ensure(twigNamespaceSmoke.includes('@custom/teaser.twig') && twigNamespaceSmoke.includes('variant.structureImplementations'), 'Twig namespace smoke should verify configured Core structure namespaces.'); + ensure(twigIntegration.includes('component_root_records') && twigIntegration.includes("$loader->getPaths( 'components' )"), 'Twig component aliases should use the selected, filtered component namespace roots.'); + ensure(twigNamespaceSmoke.includes('@custom/teaser.twig') && twigNamespaceSmoke.includes('structureImplementations'), 'Twig namespace smoke should verify configured Core structure namespaces.'); ensure(!fs.existsSync(path.join(repoRoot, 'whisk/includes/twig-namespaces.php')), 'Whisk should rely on the parent Twig namespace integration by default.'); ensure(!childFunctions.includes('twig-namespaces.php'), 'whisk/functions.php should not require a duplicate Twig namespace file.'); ensure(childFunctions.includes('project_machine_name:component_name') && childFunctions.includes('@components for compatible component libraries'), 'whisk/functions.php should document generic project machine-name component references while preserving @components compatibility.'); @@ -1016,13 +1074,45 @@ function runStaticChecks() { ensure(wordpressFixtureSmoke.includes('assertGeneratedChildTheme'), 'WordPress fixture smoke should validate generated child theme metadata and copied Whisk files.'); ensure(wordpressFixtureSmoke.includes("project.project?.generatedFrom !== 'emulsify-wordpress'"), 'WordPress fixture smoke should validate generatedFrom metadata.'); ensure(wordpressFixtureSmoke.includes('project.project?.generatedFromVersion !== expectedGeneratedFromVersion'), 'WordPress fixture smoke should derive the expected generatedFromVersion from root package metadata instead of a literal.'); - ensure(wordpressFixtureSmoke.includes('${themeSlug}-page'), 'WordPress fixture smoke should prove the generated child page template renders through Timber.'); + ensure(wordpressFixtureSmoke.includes('Generated child theme should not include Twig templates by default.') && wordpressFixtureSmoke.includes('entry__content'), 'WordPress fixture smoke should prove parent page rendering without a generated child Twig override.'); + ensure(wordpressFixtureSmoke.includes('Child block extension must preserve every surrounding byte.') && wordpressFixtureSmoke.includes("{% extends '@emulsify-tpl/page.twig' %}") && wordpressFixtureSmoke.includes('finally'), 'WordPress fixture smoke should prove one-block child inheritance and restore the temporary override; see docs/template-extension.md.'); ensure(wordpressFixtureSmoke.includes('checkGeneratedAssets'), 'WordPress fixture smoke should fetch generated child theme built assets.'); ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithoutAcf'), 'WordPress fixture smoke should check ACF/Twig discovery when ACF is absent.'); ensure(wordpressFixtureSmoke.includes('installAcfStub'), 'WordPress fixture smoke should provide a fixture-only ACF stub.'); ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithStub'), 'WordPress fixture smoke should check ACF/Twig registration with the ACF stub.'); ensure(wordpressFixtureSmoke.includes('emulsify/smoke-native'), 'WordPress fixture smoke should check native block.json discovery and registration.'); - return 'Parent owns route fallbacks and default Twig namespaces; Whisk ships only the page override example.'; + return 'Parent owns route fallbacks and default Twig namespaces; Whisk ships no Twig templates.'; + }); + + runStaticCheck('Template extension contract', () => { + const base = readFile('templates/layouts/base.twig'); + const extensionDocs = readFile('docs/template-extension.md'); + for (const block of ['head', 'header', 'content', 'footer', 'site_header', 'main', 'page_title']) { + ensure(base.includes(`{% block ${block} %}`) && extensionDocs.includes('`' + block + '`'), `Preserve the public ${block} block and boundary documented in docs/template-extension.md.`); + } + const goldens = listFilesRecursive('.github/fixtures/template-extension', (file) => file.endsWith('.html')); + ensure(goldens.length >= 10, 'Keep the pre-extension output captures; see docs/template-extension.md.'); + ensure(readFile('.github/scripts/pr-validation.cjs').includes('smoke:template-extension'), 'PR validation should compare exact template bytes and child extension boundaries.'); + for (const file of ['README.md', 'docs/README.md', 'docs/timber-and-twig-authoring.md']) { + ensure(readFile(file).includes('template-extension.md'), `${file} should link to the public template extension contract.`); + } + return 'Seven public layout blocks retain ten golden outputs, tested child inheritance, and documented boundaries.'; + }); + + runStaticCheck('Rendered WordPress accessibility', () => { + const { wcagTags, disabledRules } = require('./rendered-a11y.cjs'); + ensure(JSON.stringify(wcagTags) === JSON.stringify(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']), 'Rendered WordPress axe audit must select all five WCAG tags.'); + ensure(JSON.stringify(disabledRules) === JSON.stringify(['target-size']), 'Only target-size may be disabled for the unstyled parent fixture.'); + const fixtureJob = themeReadinessWorkflow.slice(themeReadinessWorkflow.indexOf(' wordpress-fixture:'), themeReadinessWorkflow.indexOf(' extended-whisk:')); + ensure(fixtureJob.includes('Select and verify runner Chrome') && fixtureJob.includes('--headless --dump-dom about:blank'), 'WordPress fixture job must verify sandboxed runner Chrome.'); + ensure(fixtureJob.includes("WP_SMOKE_A11Y: '1'") && fixtureJob.includes('WP_SMOKE_A11Y_OUTPUT:'), 'WordPress fixture job must enable the rendered browser audit and retain its reports.'); + ensure(fixtureJob.includes('wordpress-rendered-a11y-') && fixtureJob.includes('if: always()'), 'WordPress accessibility reports must upload on success and failure.'); + ensure(wordpressFixtureSmoke.includes('Verify rendered accessibility failure gates'), 'WordPress fixture must exercise the browser audit negative controls.'); + ensure(wordpressFixtureSmoke.includes('archiveCount < 12') && wordpressFixtureSmoke.includes("['option', 'update', 'posts_per_page', '1']"), 'Rendered archive audit needs at least 12 posts and one post per page.'); + const guide = readFile('docs/accessibility.md'); + ensure(guide.includes('target-size') && guide.includes('screen-reader-text') && guide.includes('focus'), 'Accessibility guide must document the sole rule exception and project styling responsibilities.'); + ensure(wordpressFixtureSmoke.includes("name: 'media-embeds'") && wordpressFixtureSmoke.includes('media-embed-smoke.php') && wordpressFixtureSmoke.includes('verifyMediaEmbeds'), 'The fixture must test media transforms and audit the rendered YouTube/Vimeo posters.'); + return 'Seven rendered routes use sandboxed Chrome, full WCAG tags, strict navigation/media assertions, and retained HTML/axe reports; only target-size is excluded.'; }); runStaticCheck('Generated child theme documentation', () => { @@ -1139,6 +1229,30 @@ function runStaticChecks() { return 'Generated docs cover project ownership, frontend workflows, upgrades, and sanitized support collection.'; }); + runStaticCheck('Generated AI guidance', () => { + const guidancePaths = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', + ]; + for (const relativePath of guidancePaths) { + const sourcePath = `whisk/${relativePath}`; + const contents = readFile(sourcePath); + ensure(!/%%EMULSIFY_[A-Z_]+%%|whisk/i.test(contents), `${sourcePath} must be token-free and contain no starter identity so generators copy it unchanged.`); + ensure(generatedThemeContract.includes(`'${relativePath}'`), `Generated theme contract should require ${relativePath}.`); + ensure(docsCommandCheck.includes(`'${relativePath}'`), `Documentation command checks should cover ${relativePath}.`); + ensure(distBuilder.includes(`"${sourcePath}"`), `Distribution build should require staged ${sourcePath}.`); + } + + const attributes = childProcess.spawnSync('git', [ + 'check-attr', 'export-ignore', '--', + 'AGENTS.md', ...guidancePaths.map((file) => `whisk/${file}`), + ], { cwd: repoRoot, encoding: 'utf8' }); + ensure(attributes.status === 0, `Unable to check guidance Git distribution attributes: ${attributes.stderr}`); + ensure(!/: export-ignore: (?:set|true)\s*$/m.test(attributes.stdout), 'Git distributions must retain contributor and generated AI guidance, including hidden skills.'); + return 'Portable guidance is required, copied without replacement, command-checked, and retained in ZIP/Git distributions.'; + }); + runStaticCheck('Generated child theme contract', () => { ensure(rootPackage.scripts['test:generated-theme'] === 'node --test .github/scripts/generated-theme-contract.test.cjs', 'package.json should expose the focused generated child theme contract tests.'); ensure(rootPackage.scripts['smoke:generation-parity'] === 'node .github/scripts/generation-parity-smoke.cjs', 'package.json should expose the generation parity smoke.'); @@ -1208,65 +1322,26 @@ function runStaticChecks() { const analyzerOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/commit-analyzer'); const notesOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/release-notes-generator'); const githubOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/github'); - const releaseAnalyzer = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.analyzeCommits === 'function'); const releaseGuard = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.verifyRelease === 'function'); - const readinessJobStart = semanticReleaseWorkflow.indexOf(' release-readiness:'); - const releaseJobStart = semanticReleaseWorkflow.indexOf(' semantic-release:'); - const readinessJob = semanticReleaseWorkflow.slice(readinessJobStart, releaseJobStart); - const releaseJob = semanticReleaseWorkflow.slice(releaseJobStart); - const buildStep = releaseJob.indexOf('npm run build:dist'); - const publishStep = releaseJob.indexOf('npm run publish'); const releaseAsset = Array.isArray(githubOptions.assets) ? githubOptions.assets.find((asset) => asset.path === 'dist-artifact/emulsify.zip') : null; - ensure(releaseConfig.expectedStableRelease === '2.0.0', 'release.config.js should declare 2.0.0 as the expected stable release.'); - ensure(releaseConfig.initialStableBaseline === '1.0.0', 'release.config.js should declare the temporary 1.0.0 stable baseline.'); ensure(releaseConfig.tagFormat === '${version}', 'release.config.js should emit non-prefixed semver tags.'); ensure(releaseConfig.repositoryUrl === 'git@github.com:emulsify-ds/emulsify-wordpress.git', 'release.config.js should publish against emulsify-wordpress.'); ensure(Array.isArray(releaseConfig.branches), 'release.config.js branches must be an array.'); ensure(releaseConfig.branches.length === 1 && releaseConfig.branches[0] === 'main', 'release.config.js should publish only from main.'); - ensure(releaseAnalyzer, 'release.config.js should force the first stable release to a major release type.'); - ensure(releaseGuard, 'release.config.js should guard the first stable release version.'); + ensure(!releaseConfig.plugins.some((plugin) => ['@semantic-release/npm', '@semantic-release/git'].includes(Array.isArray(plugin) ? plugin[0] : plugin)), 'release.config.js must not rewrite committed versions through @semantic-release/npm or @semantic-release/git.'); + ensure(releaseGuard, 'release.config.js should require the computed release version to match the prepared package version.'); ensure(rootPackage.scripts.publish.startsWith('node .github/scripts/semantic-release.cjs '), 'The publish script should prepare semantic-release through the guarded runner.'); ensure(rootPackage.scripts['publish-test'].startsWith('node .github/scripts/semantic-release.cjs '), 'The publish-test script should use the same guarded semantic-release runner.'); - ensure(semanticReleaseRunner.includes("git(['tag', initialStableBaseline, sourceSha])"), 'The semantic-release runner should expose the latest prerelease commit as a temporary stable baseline.'); - ensure(semanticReleaseRunner.includes('finally') && semanticReleaseRunner.includes('removeStableBaseline(temporaryBaseline)'), 'The semantic-release runner should always clean up its temporary baseline tag.'); - ensure(releaseGuard.verifyRelease.toString().includes('removeSyntheticBaseline'), 'The stable release guard should remove the temporary baseline before semantic-release pushes tags.'); - ensure(typeof notesOptions.writerOpts?.finalizeContext === 'function', 'Release notes should suppress the inaugural compare link to the temporary baseline tag.'); ensure(releaseAsset, 'release.config.js should attach dist-artifact/emulsify.zip to GitHub releases.'); ensure(releaseAsset.label === 'Emulsify WordPress theme (with dependencies)', 'release.config.js should give the installable ZIP a clear release label.'); ensureBreakingParser('@semantic-release/commit-analyzer', analyzerOptions.parserOpts); ensureBreakingParser('@semantic-release/release-notes-generator', notesOptions.parserOpts); - ensure(readinessJobStart >= 0, 'semantic-release.yml should run release-readiness before publishing.'); - ensure(semanticReleaseWorkflow.includes('needs: release-readiness'), 'semantic-release.yml release job should wait for release readiness.'); - ensure(semanticReleaseWorkflow.includes('contents: write'), 'semantic-release.yml should grant GitHub release permissions explicitly.'); - ensure(semanticReleaseWorkflow.includes('id: semantic'), 'semantic-release.yml should expose the semantic-release step as steps.semantic.'); - ensure(semanticReleaseWorkflow.includes('npm run release:check'), 'semantic-release.yml should run local release readiness checks.'); - ensure(semanticReleaseWorkflow.includes('wp-cli'), 'semantic-release.yml should install WP-CLI for the WordPress smoke fixture.'); - ensure(semanticReleaseWorkflow.includes('mysql:'), 'semantic-release.yml should provide a MySQL service for the WordPress smoke fixture.'); - ensure(semanticReleaseWorkflow.includes('WP_SMOKE_DB_HOST'), 'semantic-release.yml should pass WordPress smoke database settings.'); - ensure(readinessJob.includes('- name: Build tested installable theme archive') && readinessJob.includes('run: npm run build:dist'), 'Release readiness should build the archive before the WordPress fixture.'); - ensure(readinessJob.includes('WP_SMOKE_THEME_ARCHIVE: dist-artifact/emulsify.zip'), 'Release readiness should test the built archive in WordPress.'); - ensure(releaseJobStart >= 0, 'semantic-release.yml should define the semantic-release job.'); - ensure(releaseJob.includes("php-version: '8.3'"), 'semantic-release.yml release job should set up PHP 8.3.'); - ensure(releaseJob.includes('tools: composer:v2'), 'semantic-release.yml release job should install Composer.'); - ensure(buildStep >= 0, 'semantic-release.yml release job should build the installable theme archive.'); - ensure(publishStep >= 0 && buildStep < publishStep, 'semantic-release.yml should build the installable theme archive before publishing.'); - try { - releaseGuard.verifyRelease({}, releaseGuardRejectContext); - throw new Error('release.config.js release guard should reject pre-2.0.0 releases.'); - } - catch (error) { - const expectedMessage = error.message.includes('Expected semantic-release to prepare 2.0.0'); - ensure(expectedMessage, 'release.config.js release guard should explain the expected 2.0.0 release.'); - } - releaseGuard.verifyRelease({}, releaseGuardAcceptContext); - releaseGuard.verifyRelease({}, releaseGuardFutureContext); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerAlphaContext) === 'major', 'release.config.js should force a major release from the latest alpha tag.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerNoStableContext) === 'major', 'release.config.js should force a major release when semantic-release finds no previous stable release.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardRejectContext) === 'major', 'release.config.js should force a major release before 2.0.0 exists.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardFutureContext) === null, 'release.config.js should use normal commit analysis after 2.0.0 exists.'); - return 'Semantic release is configured for non-prefixed tags, main-only publishing, a forced 2.0.0 stable release, and the installable ZIP asset.'; + validateReleaseWorkflow(semanticReleaseWorkflow, themeReadinessWorkflow); + validateProjectsWorkflow(readFile('.github/workflows/addtoprojects.yml')); + ensure(semanticReleaseRunner.includes('Refusing to publish local-only stable tag(s)'), 'The guarded runner should refuse to push unpublished local stable tags.'); + return 'Semantic release requires a prepared package version, non-prefixed tags, main-only publishing, and the tested installable ZIP asset.'; }); runStaticCheck('Installable release artifact', () => { @@ -1279,7 +1354,7 @@ function runStaticChecks() { ensure(distBuilder.includes('"docs"') && distBuilder.includes('"includes"') && distBuilder.includes('"templates"') && distBuilder.includes('"src"'), 'Distribution build should include user documentation and parent runtime directories.'); ensure(distBuilder.includes('"UPGRADE.md"') && distBuilder.includes('"docs/wp-cli-child-theme-generation.md"'), 'Distribution build should retain the documentation linked from its README.'); ensure(distBuilder.includes('"functions.php"') && distBuilder.includes('"style.css"') && distBuilder.includes('"theme.json"'), 'Distribution build should include required WordPress theme files.'); - ensure(distBuilder.includes('copy_tracked_directory "whisk"'), 'Distribution build should include the tracked Whisk generator payload.'); + ensure(distBuilder.includes('copy_committed_directory "whisk"'), 'Distribution build should include the committed Whisk generator payload.'); ensure(distBuilder.includes('"whisk/project.emulsify.json"') && distBuilder.includes('"whisk/style.css"'), 'Distribution build should verify required Whisk generator files.'); ensure(distBuilder.includes('"whisk/.cache"') && distBuilder.includes('"whisk/.cli"') && distBuilder.includes('"whisk/node_modules"'), 'Distribution build should reject Whisk caches, generation tooling, and dependencies.'); ensure(distBuilder.includes('find emulsify -type f -print') && distBuilder.includes('zip -X'), 'Distribution build should archive the staged emulsify/ tree reproducibly.'); @@ -1321,8 +1396,11 @@ function runStaticChecks() { ensure(themeReadinessWorkflow.includes('npm run lint:php'), 'theme-readiness.yml should run PHP lint.'); ensure(themeReadinessWorkflow.includes('npm run pr:check'), 'theme-readiness.yml should delegate project smoke checks to npm run pr:check.'); ensure(themeReadinessWorkflow.includes('npm run release:check'), 'theme-readiness.yml should run release readiness checks.'); + ensure(practicalJob.includes('fetch-depth: 0') && wordpressFixtureJob.includes('fetch-depth: 0'), 'Readiness jobs running the release version contract should fetch full history and tags.'); ensure(practicalJob.includes("- name: Build installable theme archive\n if: matrix.php-version == '8.3'\n run: npm run build:dist"), 'The PHP 8.3 practical lane should build the client-testable release archive.'); - ensure(practicalJob.includes("- name: Upload installable theme archive\n if: matrix.php-version == '8.3'\n uses: actions/upload-artifact@v7"), 'The PHP 8.3 practical lane should use direct-file artifact upload.'); + const readinessJobs = yaml.load(themeReadinessWorkflow).jobs; + const archiveUpload = readinessJobs.practical.steps.find((step) => step.name === 'Upload installable theme archive'); + ensure(archiveUpload?.if === "matrix.php-version == '8.3'" && /^actions\/upload-artifact@[a-f0-9]{40}$/.test(archiveUpload.uses), 'The PHP 8.3 practical lane should use a pinned artifact upload.'); ensure(practicalJob.includes('path: dist-artifact/emulsify.zip') && practicalJob.includes('archive: false') && practicalJob.includes('overwrite: true'), 'Theme readiness should upload the installable ZIP directly and support workflow reruns.'); ensure(practicalJob.includes('if-no-files-found: error') && practicalJob.includes('retention-days: 14'), 'The client-testable artifact should be required and retained for 14 days.'); ensure(practicalStart >= 0 && phpLintStart > practicalStart && wordpressFixtureStart > phpLintStart && extendedWhiskStart > wordpressFixtureStart, 'theme-readiness.yml should define practical, PHP, WordPress fixture, and Whisk accessibility jobs.'); @@ -1335,22 +1413,29 @@ function runStaticChecks() { ensure(wordpressFixtureJob.includes('mysql:'), 'WordPress fixture job should provide MySQL.'); ensure(wordpressFixtureJob.includes('wp-cli'), 'WordPress fixture job should install WP-CLI.'); ensure(wordpressFixtureJob.includes("WP_SMOKE_REQUIRED: '1'"), 'WordPress fixture job should require the fixture instead of allowing a skip.'); - ensure(wordpressFixtureJob.includes('- name: Build installable theme archive') && wordpressFixtureJob.includes('run: npm run build:dist'), 'WordPress fixture job should build the installable archive.'); + ensure(wordpressFixtureJob.includes('- name: Download tested release bundle') && !wordpressFixtureJob.includes('run: npm run build:dist'), 'WordPress fixture job should use the archive built by practical readiness.'); ensure(wordpressFixtureJob.includes('WP_SMOKE_THEME_ARCHIVE: dist-artifact/emulsify.zip'), 'WordPress fixture job should test the built archive instead of the source tree.'); + ensure(wordpressFixtureSmoke.includes("['core', 'version']") && wordpressFixtureSmoke.includes('WordPress version: requested') && wordpressFixtureSmoke.includes('resolved ${resolvedWordpressVersion}'), 'WordPress fixture smoke should log the actual downloaded core version, including latest and nightly resolution.'); + ensure(wordpressFixtureSmoke.includes('log(`WordPress ${resolvedWordpressVersion} (requested ${wordpressVersion}): Generated'), 'The fixture success summary must expose resolved WordPress versions in captured CI output.'); ensure(themeReadinessWorkflow.includes('extended_checks'), 'theme-readiness.yml should expose optional extended checks.'); ensure(extendedWhiskJob.includes(releaseTargetedPrGate), 'Whisk accessibility job should run on release-targeted pull requests.'); ensure(extendedWhiskJob.includes("github.event_name == 'schedule'"), 'Whisk accessibility job should run on the schedule.'); ensure(extendedWhiskJob.includes("(github.event_name == 'workflow_dispatch' && inputs.extended_checks)"), 'Whisk accessibility job should honor its manual input.'); ensure(extendedWhiskJob.includes('cp -R .github/fixtures/whisk-a11y/. whisk/src/components/'), 'Whisk accessibility job should seed the CI-only component fixture.'); - ensure(extendedWhiskJob.includes('id: setup-chrome') && extendedWhiskJob.includes('uses: browser-actions/setup-chrome@v2'), 'Whisk accessibility job should install Chrome explicitly.'); - ensure(extendedWhiskJob.includes('PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}'), 'Whisk accessibility job should use the Chrome path provided by setup-chrome.'); + ensure(extendedWhiskJob.includes('Select and verify runner Chrome') && extendedWhiskJob.includes('command -v google-chrome'), 'Whisk accessibility job should use the runner-installed Chrome.'); + ensure(extendedWhiskJob.includes('"$chrome_path" --version') && extendedWhiskJob.includes('"$chrome_path" --headless --dump-dom about:blank') && extendedWhiskJob.includes('PUPPETEER_EXECUTABLE_PATH=%s') && extendedWhiskJob.includes('"$GITHUB_ENV"'), 'Whisk accessibility job should verify a sandboxed Chrome launch and export its path.'); + ensure(!extendedWhiskJob.includes('browser-actions/setup-chrome') && !extendedWhiskJob.includes('--no-sandbox') && !extendedWhiskJob.includes('--disable-setuid-sandbox'), 'Whisk accessibility CI must preserve the installed browser sandbox.'); + ensure(extendedWhiskJob.includes("PUPPETEER_SKIP_DOWNLOAD: 'true'"), 'Whisk CI should skip redundant Puppeteer browser downloads.'); + ensure(extendedWhiskJob.includes('npm --prefix whisk run test -- --runInBand'), 'Whisk CI should execute real seeded project tests before the accessibility audit.'); ensure(extendedWhiskJob.includes('npm --prefix whisk run a11y'), 'Whisk accessibility job should build Storybook and run the real audit.'); - ensure(!extendedWhiskJob.includes('continue-on-error: true'), 'Whisk accessibility violations should fail the job.'); ensure(whiskA11yEntry.includes('ciReadinessFixture'), 'CI-only Whisk fixture should provide a Vite entry.'); ensure(whiskA11yStory.includes("title: 'CI/Readiness fixture'") && whiskA11yStory.includes("import template from './ci-readiness.twig'"), 'CI-only Whisk fixture should provide a discoverable Twig story.'); ensure(whiskA11yTemplate.includes('
') && whiskA11yTemplate.includes(' { + const entries = validateReadinessMatrix(themeReadinessWorkflow, { + minimumWordPress: rootThemeHeader['Requires at least'], + }); + return `Blocking WordPress minimum and latest, advisory nightly, and ${entries.practical.filter((entry) => !entry.experimental).length} blocking PHP legs are required.`; + }); + + runStaticCheck('Pinned workflow actions and validation credentials', () => { + const files = listFilesRecursive('.github/workflows', (file) => /\.ya?ml$/.test(file)); + for (const file of files) { + const source = readFile(file); + for (const line of source.split(/\r?\n/)) { + if (!/^\s*(?:-\s*)?uses:\s*/.test(line)) continue; + // A local reusable workflow runs from the caller's commit, not a tag. + if (/^\s*uses:\s*\.\/\.github\/workflows\/theme-readiness\.yml\s*$/.test(line)) continue; + ensure(/^\s*(?:-\s*)?uses:\s*[\w.-]+\/[\w./-]+@[a-f0-9]{40}\s+#\s*v\d+\.\d+\.\d+(?:[\w.+-]*)?\s*$/.test(line), + `${file} must pin every action to a full commit SHA with its release version comment: ${line.trim()}`); + if (line.includes('actions/upload-artifact@')) ensure(/#\s*v7\./.test(line), `${file} must use the Node 24 upload-artifact v7 release.`); + } + const workflow = yaml.load(source); + ensure(workflow.permissions?.contents === 'read' && Object.keys(workflow.permissions).length === 1, + `${file} must default to exactly contents: read.`); + ensure(!Object.hasOwn(workflow.on || {}, 'pull_request_target'), `${file} must not run untrusted pull requests with pull_request_target credentials.`); + for (const [jobName, job] of Object.entries(workflow.jobs || {})) { + const publishingJob = file === '.github/workflows/semantic-release.yml' && jobName === 'semantic-release'; + if (publishingJob) { + ensure(job.permissions?.contents === 'write' && job.permissions?.issues === 'write' + && job.permissions?.['pull-requests'] === 'write' && Object.keys(job.permissions).length === 3, + 'The semantic-release publishing job must request exactly contents, issues, and pull-requests write permissions.'); + } + else { + ensure(job.permissions === undefined || (typeof job.permissions === 'object' + && Object.values(job.permissions).every((value) => value === 'read' || value === 'none')), + `${file} ${jobName} validation permissions must remain read-only.`); + } + for (const step of job.steps || []) { + if (!step.uses?.startsWith('actions/checkout@')) continue; + ensure(step.with?.['persist-credentials'] === false, `${file} ${jobName} checkout must disable persisted credentials.`); + } + } + } + return 'All workflow actions use full SHA pins with version comments, and validation checkouts do not retain credentials.'; + }); + runStaticCheck('Release documentation', () => { const expectedDocLinks = [ 'docs/README.md', @@ -1396,7 +1525,7 @@ function runStaticChecks() { 'docs/post-2x-optimization-roadmap.md', ]; - ensure(readme.includes('Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme'), 'README.md should describe the 2.0.0 Timber-first parent theme.'); + ensure(readme.includes('Emulsify WordPress is a Timber-first WordPress parent theme'), 'README.md should describe the Timber-first parent theme.'); ensure(readme.includes('Emulsify WordPress is licensed under GPL-2.0-only'), 'README.md should document the GPL-2.0-only license.'); ensure(readme.includes('[LICENSE](LICENSE)'), 'README.md should link to the repository license file.'); ensure(readme.includes('## Requirements'), 'README.md should keep requirements visible.'); @@ -1534,7 +1663,7 @@ function runStaticChecks() { ensure(docs.cli.includes('Force replacement safety') && docs.cli.includes('Emulsify-generated child theme markers'), 'WP-CLI doc should document force replacement safety.'); ensure(docs.cli.includes('Upgrade and support diagnostics') && docs.cli.includes('generatedFromVersion'), 'WP-CLI doc should document generated child theme lineage diagnostics.'); ensure(docs.cli.includes('Ignored dependency, cache, Storybook, and Vite output directories are not copied'), 'WP-CLI doc should explain that generated themes do not inherit build output.'); - ensure(docs.release.includes('release-2.x') && docs.release.includes('2.0.0'), 'Release process doc should document the release-2.x target release.'); + ensure(docs.release.includes('## Release PR procedure') && docs.release.includes('prepared version'), 'Release process doc should explain preparing a version in a release PR.'); ensure(docs.release.includes('Packagist lists `emulsify-ds/emulsify-wordpress-theme` as maintained'), 'Release process doc should require the published Composer package to be un-abandoned.'); ensure(docs.release.includes('WordPress Theme Readiness workflow'), 'Release process doc should document the theme readiness workflow.'); ensure(docs.index.includes('## Continuous integration') && docs.index.includes('dedicated PHPCS/PHPStan job') && docs.index.includes('Pull requests targeting `main` or `release-2.x` additionally run'), 'Docs index should summarize fast and release-targeted pull-request readiness coverage.'); @@ -1611,7 +1740,7 @@ function runStaticChecks() { ensure(issueTemplate.includes('emulsify-wordpress/releases'), 'Issue template should link to WordPress theme releases.'); ensure(pullRequestTemplate.includes('emulsify-wordpress/issues/1'), 'Pull request template should link to WordPress theme issues.'); ensure(!/emulsify-drupal/.test(`${issueTemplate}\n${pullRequestTemplate}`), 'GitHub templates should not link to the Drupal repository.'); - return 'README, docs, and GitHub templates match the WordPress 2.0.0 release story.'; + return 'README, docs, and GitHub templates match the WordPress parent theme release process.'; }); runStaticCheck('License metadata', () => { @@ -1654,7 +1783,23 @@ function runStaticChecks() { return 'No .DS_Store files were found outside .git.'; }); - runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + runStaticCheck('Theme JSON styles', () => { + const errors = validateThemeJsonStyles(readJson('theme.json')); + ensure(errors.length === 0, errors.join('\n')); + return 'Style values are scalar where required and WordPress preset references resolve to settings.'; + }); + + runCommandCheck('Theme JSON style contract tests', process.execPath, ['--test', '.github/scripts/theme-json-style-contract.test.cjs']); + runCommandCheck('Release version contract tests', process.execPath, ['--test', '.github/scripts/release-version-contract.test.cjs']); + runCommandCheck('Readiness matrix contract tests', process.execPath, ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); + runCommandCheck('Semantic release guard tests', process.execPath, ['--test', '.github/scripts/semantic-release.test.cjs', '.github/scripts/release-config.test.cjs']); + + if (skipSmoke) { + addResult('SKIP', 'WordPress fixture smoke', 'Skipped by --skip-smoke.'); + } + else { + runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + } } function printSummary() { diff --git a/.github/scripts/release-config.test.cjs b/.github/scripts/release-config.test.cjs new file mode 100644 index 0000000..505489a --- /dev/null +++ b/.github/scripts/release-config.test.cjs @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const releaseConfig = require('../../release.config.js'); + +test('release conditions require a nonempty installable ZIP before tagging', (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-release-zip-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const guard = releaseConfig.plugins.find((plugin) => typeof plugin.verifyConditions === 'function'); + assert.ok(guard, 'A configured verifyConditions hook must require the release ZIP.'); + const verify = () => guard.verifyConditions({}, { cwd }); + assert.throws(verify, /dist-artifact\/emulsify\.zip.*npm run build:dist/); + const archive = path.join(cwd, 'dist-artifact/emulsify.zip'); + fs.mkdirSync(archive, { recursive: true }); + assert.throws(verify, /dist-artifact\/emulsify\.zip/); + fs.rmdirSync(archive); + fs.writeFileSync(archive, ''); + assert.throws(verify, /dist-artifact\/emulsify\.zip/); + fs.writeFileSync(archive, 'tested archive fixture'); + assert.doesNotThrow(verify); +}); + +test('maintainer npm commands cannot bypass the guarded release runner', () => { + const pkg = require('../../package.json'); + assert.equal(pkg.private, true); + assert.equal(pkg.scripts['semantic-release'], undefined); + assert.match(pkg.scripts.publish, /^node \.github\/scripts\/semantic-release\.cjs\b/); + assert.match(pkg.scripts['publish-test'], /semantic-release\.cjs.*--dry-run/); +}); diff --git a/.github/scripts/release-version-contract.cjs b/.github/scripts/release-version-contract.cjs new file mode 100644 index 0000000..b70a33f --- /dev/null +++ b/.github/scripts/release-version-contract.cjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +function stableVersionParts(version) { + if (typeof version !== 'string') return null; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match || match[0] !== version) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function compareVersions(left, right) { + const leftParts = stableVersionParts(left); + const rightParts = stableVersionParts(right); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return rightParts[index] - leftParts[index]; + } + return 0; +} + +function validateReleaseVersions({ tags = [], latestStableTag, packageVersion, documents = {} }) { + const errors = []; + const stableTags = tags.filter((tag) => stableVersionParts(tag)).sort(compareVersions); + latestStableTag ??= stableTags[0]; + const result = { errors, packageVersion, latestStableTag, stableTags, releasePrepared: false }; + + if (!stableVersionParts(latestStableTag)) { + errors.push('No stable release tag is available; fetch the full Git history and tags (fetch-depth: 0).'); + return result; + } + + if (!stableVersionParts(packageVersion)) { + errors.push('package.json version must use a canonical stable x.y.z version.'); + } else { + const [major, minor, patch] = stableVersionParts(latestStableTag); + const nextVersions = [`${major}.${minor}.${patch + 1}`, `${major}.${minor + 1}.0`, `${major + 1}.0.0`]; + if (packageVersion !== latestStableTag && (tags.includes(packageVersion) || !nextVersions.includes(packageVersion))) { + errors.push(`package.json version must equal the latest stable tag ${latestStableTag} or an untagged next patch, minor, or major version (${nextVersions.join(', ')}).`); + } + } + + const releasedVersions = new Set(tags); + const draft = documents['docs/release-notes-next.md'] || ''; + for (const line of draft.split(/\r?\n/)) { + // Draft headings and release titles claim a version; historical prose and + // compatibility requirements elsewhere in the draft do not. + if (!/^\s*(?:#{1,6}\s|Title\s*:)/i.test(line)) continue; + for (const match of line.matchAll(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/g)) { + if (releasedVersions.has(match[1])) { + errors.push(`The next-release draft names already-tagged release ${match[1]}; keep the draft unreleased.`); + } + } + } + + for (const [file, text] of Object.entries(documents)) { + for (const match of text.matchAll(/(?:latest|current)\s+(?:(?:published|stable)\s+)?(?:release|tag|version)\s*:\s*[`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/gi)) { + if (match[1] !== latestStableTag) { + errors.push(`${file} names a stale current release (${match[1]}); use the latest stable tag ${latestStableTag} or remove the claim.`); + } + } + } + + result.releasePrepared = errors.length === 0 && packageVersion !== latestStableTag; + return result; +} + +function validateReleaseVersionContract({ repoRoot = path.resolve(__dirname, '../..') } = {}) { + try { + const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('rev-parse', '--is-shallow-repository') !== 'false') { + return { errors: ['Release version checks require full Git history and tags (fetch-depth: 0).'], releasePrepared: false }; + } + const tags = git('tag', '--list').split(/\r?\n/).filter(Boolean); + if (!tags.some((tag) => stableVersionParts(tag))) return validateReleaseVersions({ tags }); + const read = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); + const documents = Object.fromEntries([ + 'README.md', + 'docs/release-notes-next.md', + 'docs/release-process.md', + ].map((file) => [file, read(file)])); + return validateReleaseVersions({ tags, packageVersion: JSON.parse(read('package.json')).version, documents }); + } catch (error) { + return { errors: [`Unable to validate the release version contract: ${error.message}`], releasePrepared: false }; + } +} + +if (require.main === module) { + const result = validateReleaseVersionContract(); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`FAIL ${error}`); + process.exitCode = 1; + } else { + console.log(`PASS Release version policy and unreleased draft (latest tag: ${result.latestStableTag}; package: ${result.packageVersion}).`); + } +} + +module.exports = { validateReleaseVersionContract, validateReleaseVersions }; diff --git a/.github/scripts/release-version-contract.test.cjs b/.github/scripts/release-version-contract.test.cjs new file mode 100644 index 0000000..6788b33 --- /dev/null +++ b/.github/scripts/release-version-contract.test.cjs @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const test = require('node:test'); +const { validateReleaseVersionContract, validateReleaseVersions } = require('./release-version-contract.cjs'); + +function fixture(overrides = {}) { + return { + tags: ['1.0.0-alpha.4', '2.0.0', '1.9.9'], + packageVersion: '2.0.0', + documents: { + 'README.md': 'Emulsify WordPress is the parent theme. Current release: `2.0.0`.', + 'docs/release-notes-next.md': '# 2.1.0 Release Notes Draft\n\nRequires Core 4.3.2. Latest published tag: 2.0.0.', + 'docs/release-process.md': 'Review commits since the latest tag.', + }, + ...overrides, + }; +} + +function validate(overrides) { + return validateReleaseVersions(fixture(overrides)); +} + +test('accepts the latest tag and exact untagged next patch, minor, or major', () => { + for (const packageVersion of ['2.0.0', '2.0.1', '2.1.0', '3.0.0']) { + const result = validate({ packageVersion }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.releasePrepared, packageVersion !== '2.0.0'); + } +}); + +test('rejects old versions and skipped increments', () => { + for (const packageVersion of ['1.9.9', '1.9.8', '2.0.2', '2.1.1', '2.2.0', '3.0.1', '4.0.0']) { + const result = validate({ packageVersion }); + assert.match(result.errors.join('\n'), /untagged next patch, minor, or major/); + assert.equal(result.releasePrepared, false); + } +}); + +test('rejects a proposed next version that is already tagged', () => { + for (const packageVersion of ['2.0.1', '2.1.0', '3.0.0']) { + assert.match(validate({ + tags: ['2.0.0', packageVersion], + latestStableTag: '2.0.0', + packageVersion, + }).errors.join('\n'), /untagged next patch, minor, or major/); + } +}); + +test('rejects prereleases and noncanonical package versions', () => { + for (const packageVersion of [ + undefined, null, 2.1, '', '2', '2.1', 'v2.1.0', '2.1.0-beta.1', + '2.1.0+build.1', '2.1.0.1', '02.1.0', '2.01.0', '2.1.00', ' 2.1.0', '2.1.0\n', + '9007199254740992.0.0', + ]) { + assert.match(validate({ packageVersion }).errors.join('\n'), /canonical stable x.y.z/); + } +}); + +test('finds the latest stable unprefixed tag using numeric version order', () => { + const result = validate({ + tags: ['2.9.0', 'v10.0.0', '2.10.0-beta.1', '2.10.0', '02.20.0'], + packageVersion: '2.10.0', + documents: {}, + }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.10.0'); + assert.deepEqual(result.stableTags, ['2.10.0', '2.9.0']); +}); + +test('fails clearly when full tags contain no stable release', () => { + for (const tags of [[], ['v2.0.0', '2.1.0-beta.1']]) { + assert.match(validate({ tags }).errors.join('\n'), /No stable release tag.*full Git history and tags/); + } +}); + +test('rejects already-shipped draft headings and release titles, including prereleases', () => { + for (const draft of [ + '# 2.0.0 Release Notes Draft', + '## 1.9.9', + '# Unreleased\nTitle: `emulsify 2.0.0`', + '# Unreleased\nTitle: `v1.9.9`', + '# 1.0.0-alpha.4 Release Notes', + ]) { + assert.match(validate({ documents: { 'docs/release-notes-next.md': draft } }).errors.join('\n'), /already-tagged release/); + } +}); + +test('accepts historical upgrade headings, compatibility text, and current published-tag labels', () => { + assert.deepEqual(validate({ documents: { + 'UPGRADE.md': '## Upgrading from 1.9.9 to 2.0.0', + 'docs/release-notes-next.md': '# Unreleased\nRequires Core 4.3.2. Follow the upgrade notes from 2.0.0.\nLatest published tag: `2.0.0`.', + } }).errors, []); +}); + +test('rejects stale latest/current release claims in each release document', () => { + for (const file of ['README.md', 'docs/release-notes-next.md', 'docs/release-process.md']) { + for (const claim of [ + 'Latest release: 1.9.9', 'Current release: **1.9.9**', 'Latest stable tag: 1.9.9', + 'Latest published tag: `1.9.9`', 'current version: v1.9.9', + 'Latest release: 2.0.0-beta.1', + ]) { + assert.match(validate({ documents: { [file]: claim } }).errors.join('\n'), /stale current release/); + } + } +}); + +test('keeps a prepared package valid after its tag is published', () => { + assert.deepEqual(validate({ packageVersion: '2.1.0', documents: {} }).errors, []); + const result = validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.1.0', documents: {} }); + assert.deepEqual(result.errors, []); + assert.equal(result.releasePrepared, false); + assert.match(validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.0.0', documents: {} }).errors.join('\n'), /latest stable tag 2.1.0/); +}); + +function git(repoRoot, ...args) { + return execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'tag.gpgSign=false', ...args], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function repositoryFixture(t) { + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-release-version-')); + t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); + const repoRoot = path.join(workRoot, 'repository'); + fs.mkdirSync(path.join(repoRoot, 'docs'), { recursive: true }); + const { documents, packageVersion } = fixture(); + for (const [file, contents] of Object.entries(documents)) fs.writeFileSync(path.join(repoRoot, file), contents); + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ version: packageVersion })); + git(repoRoot, 'init', '--quiet'); + git(repoRoot, 'add', '.'); + git(repoRoot, '-c', 'user.name=Release Test', '-c', 'user.email=release@example.test', 'commit', '--quiet', '-m', 'test: create release fixture'); + return { workRoot, repoRoot }; +} + +test('reads package metadata, real Git tags, and release documents from a repository', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + let result = validateReleaseVersionContract({ repoRoot }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.packageVersion, '2.0.0'); + assert.equal(result.releasePrepared, false); + + fs.writeFileSync(path.join(repoRoot, 'README.md'), 'Latest release: 1.9.9'); + result = validateReleaseVersionContract({ repoRoot }); + assert.match(result.errors.join('\n'), /README.md names a stale current release/); +}); + +test('rejects a real Git repository with no stable tag', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0-beta.1'); + fs.rmSync(path.join(repoRoot, 'docs'), { recursive: true }); + assert.match(validateReleaseVersionContract({ repoRoot }).errors.join('\n'), /No stable release tag/); +}); + +test('rejects a shallow clone even when its stable tag is available', (t) => { + const { repoRoot, workRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + const shallowRoot = path.join(workRoot, 'shallow'); + git(workRoot, 'clone', '--quiet', '--depth=1', pathToFileURL(repoRoot).href, shallowRoot); + assert.equal(git(shallowRoot, 'rev-parse', '--is-shallow-repository'), 'true'); + assert.match(validateReleaseVersionContract({ repoRoot: shallowRoot }).errors.join('\n'), /require full Git history and tags \(fetch-depth: 0\)/); +}); diff --git a/.github/scripts/rendered-a11y.cjs b/.github/scripts/rendered-a11y.cjs new file mode 100644 index 0000000..3ebfcdb --- /dev/null +++ b/.github/scripts/rendered-a11y.cjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +// Audit rendered WordPress pages with the generated theme's existing Core +// dependencies. Project styling owns target size; every other tagged rule runs. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); + +const wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; +const disabledRules = ['target-size']; + +function tooling(themeDir) { + const requireTheme = createRequire(path.join(path.resolve(themeDir), 'package.json')); + const requireCore = createRequire(requireTheme.resolve('@emulsify/core/package.json')); + // Puppeteer is Pa11y's dependency, including when npm nests it under Pa11y. + const requirePa11y = createRequire(requireCore.resolve('pa11y')); + return { + puppeteer: requirePa11y('puppeteer'), + axePath: requireCore.resolve('axe-core/axe.min.js'), + }; +} + +function validateRoutes(routes) { + assert(Array.isArray(routes) && routes.length > 0, 'Provide at least one rendered accessibility route.'); + const names = new Set(); + for (const route of routes) { + assert(/^[a-z0-9][a-z0-9_-]*$/i.test(route.name), 'Each route needs a filename-safe name.'); + assert(!names.has(route.name), `Duplicate accessibility route name: ${route.name}`); + names.add(route.name); + assert(['http:', 'https:'].includes(new URL(route.url).protocol), `${route.name}: expected an HTTP URL.`); + const texts = Array.isArray(route.expectedText) ? route.expectedText : [route.expectedText]; + assert(texts.length > 0 && texts.every((text) => typeof text === 'string' && text.trim()), `${route.name}: provide expected rendered content.`); + } +} + +async function renderedEvidence(page, selector) { + return page.evaluate((contentSelector) => { + const currentItems = (container) => Array.from(container?.querySelectorAll('[aria-current="page"]') || [], (element) => ({ + text: element.textContent.trim(), + href: element.href || null, + tag: element.tagName.toLowerCase(), + })); + const styles = (element) => { + const computed = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + text: element.textContent.trim(), + className: element.className, + position: computed.position, + display: computed.display, + visibility: computed.visibility, + opacity: computed.opacity, + width: computed.width, + height: computed.height, + overflow: computed.overflow, + clip: computed.clip, + clipPath: computed.clipPath, + whiteSpace: computed.whiteSpace, + rect: { width: rect.width, height: rect.height }, + }; + }; + const pagination = document.querySelector('nav[aria-label="Pagination"]'); + return { + selector: contentSelector, + content: document.querySelector(contentSelector)?.textContent ?? null, + pagination: { + exists: Boolean(pagination), + current: currentItems(pagination), + dots: Array.from(pagination?.querySelectorAll('.dots') || [], (element) => ({ + text: element.textContent.trim(), + interactive: element.matches('a, button, input, select, textarea, [href], [role="button"], [role="link"], [tabindex]') || Boolean(element.closest('a, button')), + ariaCurrent: element.getAttribute('aria-current'), + })), + }, + primaryMenuCurrent: currentItems(document.querySelector('nav[aria-label="Primary navigation"]')), + screenReaderText: Array.from(document.querySelectorAll('.screen-reader-text'), styles), + skipLinks: Array.from(document.querySelectorAll('a[href="#content"]'), styles), + stylesheets: Array.from(document.styleSheets, (sheet) => { + let ruleCount = null; + try { ruleCount = sheet.cssRules.length; } catch {} + return { href: sheet.href, disabled: sheet.disabled, media: sheet.media.mediaText, ruleCount }; + }), + }; + }, selector); +} + +function assertEvidence(route, report) { + const evidence = report.rendered; + assert.notEqual(evidence.content, null, `${route.name}: missing content selector ${evidence.selector}.`); + const normalized = evidence.content.replace(/\s+/g, ' '); + for (const text of Array.isArray(route.expectedText) ? route.expectedText : [route.expectedText]) { + assert(normalized.includes(text.replace(/\s+/g, ' ')), `${route.name}: missing expected rendered content ${JSON.stringify(text)}.`); + } + if (route.paginationPage !== undefined) { + assert(evidence.pagination.exists, `${route.name}: missing pagination navigation.`); + assert.equal(evidence.pagination.current.length, 1, `${route.name}: expected exactly one current pagination item.`); + assert.equal(evidence.pagination.current[0].text, String(route.paginationPage), `${route.name}: pagination must mark page ${route.paginationPage}.`); + assert(evidence.pagination.dots.length > 0, `${route.name}: long pagination must contain an ellipsis.`); + for (const dot of evidence.pagination.dots) { + assert(dot.text === '…' && !dot.interactive && dot.ariaCurrent === null, `${route.name}: pagination ellipsis must be literal, noninteractive text without aria-current.`); + } + } + if (route.currentMenuHref) { + assert.equal(evidence.primaryMenuCurrent.length, 1, `${route.name}: expected exactly one current primary-menu item.`); + assert.equal(evidence.primaryMenuCurrent[0].tag, 'a', `${route.name}: current primary-menu item must be a link.`); + assert.equal(evidence.primaryMenuCurrent[0].href, new URL(route.currentMenuHref, route.url).href, `${route.name}: primary menu must mark the expected item.`); + } + assert.equal(report.browserErrors.length, 0, `${route.name}: browser errors: ${report.browserErrors.join('; ')}`); + assert.equal(report.axe.violations.length, 0, `${route.name}: accessibility violations: ${report.axe.violations.map(({ id }) => id).join(', ')}`); +} + +async function auditRenderedPages({ themeDir, routes, outputDir }) { + validateRoutes(routes); + assert(outputDir, 'Provide an accessibility artifact output directory.'); + fs.mkdirSync(outputDir, { recursive: true }); + const { puppeteer, axePath } = tooling(themeDir); + const browser = await puppeteer.launch({ headless: true }); + const reports = []; + const failures = []; + try { + for (const route of routes) { + const page = await browser.newPage(); + const report = { name: route.name, requestedUrl: route.url, wcagTags, disabledRules, browserErrors: [], externalRequests: [], axe: null }; + let html = ''; + reports.push(report); + page.on('pageerror', (error) => report.browserErrors.push(error.message)); + page.on('request', (request) => { + const url = new URL(request.url()); + if (['http:', 'https:'].includes(url.protocol) && url.origin !== new URL(route.url).origin) report.externalRequests.push(url.href); + }); + try { + await page.setViewport({ width: 1280, height: 900 }); + const response = await page.goto(route.url, { waitUntil: 'networkidle0', timeout: 30000 }); + html = await page.content(); + report.status = response?.status() ?? null; + report.finalUrl = page.url(); + assert(response?.ok(), `${route.name}: navigation returned HTTP ${report.status ?? 'no response'}.`); + report.rendered = await renderedEvidence(page, route.selector || 'main'); + if (route.mediaEmbeds !== undefined) { + assert.equal(await page.$$eval('iframe[srcdoc]', (frames) => frames.length), route.mediaEmbeds, `${route.name}: expected poster documents for every provider.`); + assert.equal(report.externalRequests.length, 0, `${route.name}: neutral posters must make no third-party requests.`); + } + // Same-origin srcdoc posters need axe in their documents as well. + for (const frame of page.frames()) await frame.addScriptTag({ path: axePath }); + report.axe = await page.evaluate((tags) => axe.run(document, { + runOnly: { type: 'tag', values: tags }, + rules: { 'target-size': { enabled: false } }, + }), wcagTags); + for (const violation of report.axe.violations) { + console.error(`${route.name}: ${violation.id} (${violation.impact}) ${violation.helpUrl}`); + for (const node of violation.nodes) console.error(` ${node.target.join(' ')}: ${node.failureSummary}`); + } + assertEvidence(route, report); + console.log(`${route.name}: ${report.axe.passes.length} rules passed, 0 violations, ${report.axe.incomplete.length} manual review items.`); + } catch (cause) { + report.error = cause.message; + failures.push(new Error(`${route.name}: ${cause.message}`, { cause })); + } finally { + if (!html) { + try { html = await page.content(); } catch (error) { report.snapshotError = error.message; } + } + fs.writeFileSync(path.join(outputDir, `${route.name}.html`), html); + fs.writeFileSync(path.join(outputDir, `${route.name}.axe.json`), `${JSON.stringify(report, null, 2)}\n`); + await page.close(); + } + } + } finally { + await browser.close(); + } + if (failures.length) throw new AggregateError(failures, `Rendered accessibility checks failed for ${failures.length} route(s). See ${outputDir}.`); + return reports; +} + +if (require.main === module) { + const [themeDir, routesFile, outputDir] = process.argv.slice(2); + if (!themeDir || !routesFile || !outputDir) { + console.error('Usage: rendered-a11y.cjs '); + process.exitCode = 1; + } else { + auditRenderedPages({ themeDir, routes: JSON.parse(fs.readFileSync(routesFile, 'utf8')), outputDir }).catch((error) => { + console.error(error); + process.exitCode = 1; + }); + } +} + +module.exports = { auditRenderedPages, wcagTags, disabledRules, tooling }; diff --git a/.github/scripts/rendered-a11y.test.cjs b/.github/scripts/rendered-a11y.test.cjs new file mode 100644 index 0000000..944d38f --- /dev/null +++ b/.github/scripts/rendered-a11y.test.cjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +// Browser regressions use the same installed consumer tooling and Chrome as +// the WordPress audit. Every fixture below is unstyled HTML served locally. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { auditRenderedPages, wcagTags, disabledRules } = require('./rendered-a11y.cjs'); + +test('rendered accessibility gate rejects HTTP, content, axe, pagination, and menu regressions', { timeout: 60000 }, async () => { + const themeDir = process.env.EMULSIFY_A11Y_THEME || path.resolve(__dirname, '../../whisk'); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-rendered-a11y-test-')); + const clean = `Accessibility fixture + +
+

Fixture ready

Expected content

+ +
`; + const pages = { + '/clean': clean, + '/http-error': clean, + '/missing-content': clean.replace('Expected content', 'Different content'), + '/axe-violation': clean.replace('
', ''), + '/bad-pagination': clean.replace('', ''), + '/bad-menu': clean.replace('