From 91cb15addb1c5dd499f25aeb76e03aacf38dcd08 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 24 Sep 2026 15:53:30 +0200 Subject: [PATCH 01/10] chore: point the submodule at documentation's merged main --- documentation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation b/documentation index 4e1f23d8..f9a68a17 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit 4e1f23d87f15c1382118c03c8be2a1d989c01868 +Subproject commit f9a68a17856784d202bbaf53f569b48736cb1861 From 05ef28448ce22db1124d083033d19108029daee8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 07:33:15 +0200 Subject: [PATCH 02/10] test: expect theme-default's hero preview to match the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit theme-default v1.4.5 gives hero an edit.jsx that mirrors save.jsx, in place of the scaffold placeholder "Hero – Block Editor". The preview test no longer needs to be marked as a known failure, and previewGap, which only existed for it, goes. --- tests/e2e/specs/blocks.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/e2e/specs/blocks.spec.ts b/tests/e2e/specs/blocks.spec.ts index 40e85818..4bf95963 100644 --- a/tests/e2e/specs/blocks.spec.ts +++ b/tests/e2e/specs/blocks.spec.ts @@ -17,8 +17,6 @@ type BlockCase = { attributes?: Record; /** Text the block renders on the front end */ text: string; - /** Why the editor preview is known not to match the page yet */ - previewGap?: string; }; const pluginBlocks: BlockCase[] = [ @@ -31,7 +29,7 @@ const moduleBlocks: BlockCase[] = [{ name: 'e2e-module/module-card', text: 'Modu const generatedThemeBlocks: BlockCase[] = [{ name: 'default/theme-card', text: 'Theme Card' }]; const themeDefaultBlocks: BlockCase[] = [ - { name: 'default/hero', text: 'Hero', previewGap: 'theme-default ships the placeholder edit.jsx ("Hero – Block Editor")' }, + { name: 'default/hero', text: 'Hero' }, { name: 'default/call-to-action', attributes: { heading: `Call to action ${runId}` }, text: `Call to action ${runId}` }, ]; @@ -91,8 +89,6 @@ for (const { host, blocks, skip } of blockCases()) { }); test(`${block.name} previews in the editor what the page shows`, async ({ admin, editor }) => { - test.fail(block.previewGap !== undefined, block.previewGap); - await admin.createNewPost({ title: `E2E preview ${block.name} ${runId}` }); await editor.insertBlock({ name: block.name, attributes: block.attributes }); From 50966d15437678a8bf2f0963751b0808388082aa Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 14:29:24 +0200 Subject: [PATCH 03/10] test: cover the template hierarchy in the browser tests Two fixture themes: e2e-full has a template for every case of the hierarchy, e2e-index has index.blade.php alone. The spec activates each in turn, reads the template in the framework's marker and in a data-e2e-view attribute every fixture template writes, and checks the status: - front-page, home, page-{slug}, page-{id}, a custom page template, page, single, single-{type}, archive-{type}, archive, taxonomy-{tax}-{term}, taxonomy-{tax}, category-{slug}, tag, author-{nicename}, date, search, and 404 with a 404 status; - a Blade view wins over a PHP template of the same name; - a Route::wp() route and a Laravel route win over the hierarchy (no marker: the hierarchy never ran); - with index alone, every case falls back to index, and an unknown URL answers 404 rather than index. The content has fixed slugs, since the templates are named after them. The theme and reading settings the spec changes are recorded before it changes them and restored by its own teardown, or by the global one if the run was interrupted. Replayed on a site: without the 404 view the 404 case fails; with the marker disabled every case fails. --- tests/e2e/bin/fixtures.sh | 11 ++ tests/e2e/fixtures/hierarchy/cleanup.php | 37 ++++++ tests/e2e/fixtures/hierarchy/seed.php | 96 ++++++++++++++ .../fixtures/themes/e2e-full/app/Cms/Book.php | 17 +++ .../themes/e2e-full/app/Cms/Genre.php | 16 +++ .../fixtures/themes/e2e-full/app/Cms/Note.php | 17 +++ .../fixtures/themes/e2e-full/functions.php | 7 ++ tests/e2e/fixtures/themes/e2e-full/index.php | 4 + .../e2e-full/page-e2e-hierarchy-php.php | 7 ++ .../e2e-full/resources/views/404.blade.php | 2 + .../views/archive-e2e_book.blade.php | 2 + .../resources/views/archive.blade.php | 2 + .../author-e2e-hierarchy-author.blade.php | 2 + .../category-e2e-hierarchy-cat.blade.php | 2 + .../e2e-full/resources/views/date.blade.php | 2 + .../resources/views/front-page.blade.php | 2 + .../e2e-full/resources/views/home.blade.php | 2 + .../e2e-full/resources/views/index.blade.php | 2 + .../resources/views/layouts/e2e.blade.php | 12 ++ .../views/page-e2e-hierarchy-about.blade.php | 2 + .../views/page-e2e-hierarchy-php.blade.php | 2 + .../views/page-e2e-hierarchy-routed.blade.php | 2 + .../e2e-full/resources/views/page.blade.php | 2 + .../e2e-full/resources/views/search.blade.php | 2 + .../resources/views/single-e2e_book.blade.php | 2 + .../e2e-full/resources/views/single.blade.php | 2 + .../e2e-full/resources/views/tag.blade.php | 2 + .../taxonomy-e2e_genre-e2e-rock.blade.php | 2 + .../views/taxonomy-e2e_genre.blade.php | 2 + .../views/templates/landing.blade.php | 3 + .../fixtures/themes/e2e-full/routes/web.php | 18 +++ tests/e2e/fixtures/themes/e2e-full/style.css | 6 + .../fixtures/themes/e2e-index/functions.php | 7 ++ tests/e2e/fixtures/themes/e2e-index/index.php | 4 + .../e2e-index/resources/views/index.blade.php | 12 ++ tests/e2e/fixtures/themes/e2e-index/style.css | 6 + tests/e2e/global-teardown.ts | 4 + tests/e2e/specs/hierarchy.spec.ts | 119 ++++++++++++++++++ tests/e2e/support/hierarchy.ts | 74 +++++++++++ 39 files changed, 515 insertions(+) create mode 100644 tests/e2e/fixtures/hierarchy/cleanup.php create mode 100644 tests/e2e/fixtures/hierarchy/seed.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/app/Cms/Book.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/app/Cms/Genre.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/app/Cms/Note.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/functions.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/index.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/page-e2e-hierarchy-php.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/404.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/archive-e2e_book.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/archive.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/author-e2e-hierarchy-author.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/category-e2e-hierarchy-cat.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/date.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/front-page.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/home.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/index.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-about.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-php.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-routed.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/page.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/search.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/single-e2e_book.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/single.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/tag.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre-e2e-rock.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/resources/views/templates/landing.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/routes/web.php create mode 100644 tests/e2e/fixtures/themes/e2e-full/style.css create mode 100644 tests/e2e/fixtures/themes/e2e-index/functions.php create mode 100644 tests/e2e/fixtures/themes/e2e-index/index.php create mode 100644 tests/e2e/fixtures/themes/e2e-index/resources/views/index.blade.php create mode 100644 tests/e2e/fixtures/themes/e2e-index/style.css create mode 100644 tests/e2e/specs/hierarchy.spec.ts create mode 100644 tests/e2e/support/hierarchy.ts diff --git a/tests/e2e/bin/fixtures.sh b/tests/e2e/bin/fixtures.sh index 5784d71c..df0bbbcb 100755 --- a/tests/e2e/bin/fixtures.sh +++ b/tests/e2e/bin/fixtures.sh @@ -11,6 +11,9 @@ # - when theme-default is the active theme, a dynamic block made in it # by pollora:make:block. Any other theme is left alone: it may be # someone's work in progress. +# - the template hierarchy themes, e2e-full and e2e-index, copied into +# themes/. They are not activated here: the hierarchy spec activates +# each in turn and gives the site its own theme back. # down remove all of it, and give modules_statuses.json back as it was set -euo pipefail @@ -69,6 +72,12 @@ case "${1:-}" in build "$MODULE_DIR" php artisan module:enable "$MODULE" --no-interaction + # Template hierarchy themes + for theme in e2e-full e2e-index; do + rm -rf "themes/$theme" + cp -r "$FIXTURES/themes/$theme" "themes/$theme" + done + # Theme, only when it is theme-default if [ "$(wp theme list --status=active --field=name)" = "$THEME" ]; then php artisan pollora:make:block "$THEME_BLOCK" --theme="$THEME" --title="Theme Card" --force --no-interaction @@ -87,6 +96,8 @@ case "${1:-}" in rm -f "$STATUSES_BACKUP" fi + rm -rf themes/e2e-full themes/e2e-index + # The theme block is only ever made in theme-default, and removed from it rm -rf "$THEME_DIR/resources/views/blocks/$THEME_BLOCK" ;; diff --git a/tests/e2e/fixtures/hierarchy/cleanup.php b/tests/e2e/fixtures/hierarchy/cleanup.php new file mode 100644 index 00000000..c19a4103 --- /dev/null +++ b/tests/e2e/fixtures/hierarchy/cleanup.php @@ -0,0 +1,37 @@ + 'any', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'post_name__in' => [ + 'e2e-hierarchy-front', 'e2e-hierarchy-blog', 'e2e-hierarchy-about', 'e2e-hierarchy-by-id', + 'e2e-hierarchy-plain', 'e2e-hierarchy-landing', 'e2e-hierarchy-routed', 'e2e-hierarchy-laravel', + 'e2e-hierarchy-php', 'e2e-hierarchy-post', 'e2e-hierarchy-book', 'e2e-hierarchy-note', + ], + 'fields' => 'ids', +]); + +foreach ($posts as $id) { + wp_delete_post($id, true); +} + +foreach ([['category', 'e2e-hierarchy-cat'], ['post_tag', 'e2e-hierarchy-tag'], ['e2e_genre', 'e2e-rock'], ['e2e_genre', 'e2e-jazz']] as [$taxonomy, $slug]) { + $term = get_term_by('slug', $slug, $taxonomy); + + if ($term) { + wp_delete_term($term->term_id, $taxonomy); + } +} + +$author = get_user_by('login', 'e2e-hierarchy-author'); + +if ($author) { + require_once ABSPATH.'wp-admin/includes/user.php'; + wp_delete_user($author->ID); +} diff --git a/tests/e2e/fixtures/hierarchy/seed.php b/tests/e2e/fixtures/hierarchy/seed.php new file mode 100644 index 00000000..7c50d3ab --- /dev/null +++ b/tests/e2e/fixtures/hierarchy/seed.php @@ -0,0 +1,96 @@ + wp_insert_post([ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => $slug, + 'post_name' => $slug, + ...$extra, +], true); + +$authorId = wp_insert_user([ + 'user_login' => 'e2e-hierarchy-author', + 'user_nicename' => 'e2e-hierarchy-author', + 'user_email' => 'e2e-hierarchy-author@example.test', + 'user_pass' => wp_generate_password(24), + 'role' => 'author', +]); + +$ids = [ + 'front' => $page('e2e-hierarchy-front'), + 'blog' => $page('e2e-hierarchy-blog'), + 'about' => $page('e2e-hierarchy-about'), + 'byId' => $page('e2e-hierarchy-by-id'), + 'plain' => $page('e2e-hierarchy-plain'), + 'landing' => $page('e2e-hierarchy-landing', ['meta_input' => ['_wp_page_template' => 'templates/landing.blade.php']]), + 'routed' => $page('e2e-hierarchy-routed'), + 'laravel' => $page('e2e-hierarchy-laravel'), + 'php' => $page('e2e-hierarchy-php'), +]; + +$category = wp_insert_term('E2E hierarchy category', 'category', ['slug' => 'e2e-hierarchy-cat']); +$rock = wp_insert_term('E2E rock', 'e2e_genre', ['slug' => 'e2e-rock']); +$jazz = wp_insert_term('E2E jazz', 'e2e_genre', ['slug' => 'e2e-jazz']); + +$ids['post'] = wp_insert_post([ + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'e2e-hierarchy-post', + 'post_name' => 'e2e-hierarchy-post', + 'post_author' => $authorId, + 'post_date' => '2020-01-15 10:00:00', + 'post_category' => [$category['term_id']], + 'tags_input' => ['e2e-hierarchy-tag'], +], true); + +$ids['book'] = wp_insert_post(['post_type' => 'e2e_book', 'post_status' => 'publish', 'post_title' => 'e2e-hierarchy-book', 'post_name' => 'e2e-hierarchy-book'], true); +wp_set_object_terms($ids['book'], [$rock['term_id'], $jazz['term_id']], 'e2e_genre'); +$ids['note'] = wp_insert_post(['post_type' => 'e2e_note', 'post_status' => 'publish', 'post_title' => 'e2e-hierarchy-note', 'post_name' => 'e2e-hierarchy-note'], true); + +foreach ([$authorId, $category, $rock, $jazz, ...array_values($ids)] as $created) { + if (is_wp_error($created)) { + fwrite(STDERR, $created->get_error_message()."\n"); + exit(1); + } +} + +// A static front page and a posts page, so front-page and home are both reachable. +update_option('show_on_front', 'page'); +update_option('page_on_front', $ids['front']); +update_option('page_for_posts', $ids['blog']); + +echo json_encode([ + 'ids' => $ids, + 'urls' => [ + 'front' => home_url('/'), + 'blog' => get_permalink($ids['blog']), + 'about' => get_permalink($ids['about']), + 'byId' => get_permalink($ids['byId']), + 'plain' => get_permalink($ids['plain']), + 'landing' => get_permalink($ids['landing']), + 'routed' => get_permalink($ids['routed']), + 'laravel' => get_permalink($ids['laravel']), + 'php' => get_permalink($ids['php']), + 'post' => get_permalink($ids['post']), + 'book' => get_permalink($ids['book']), + 'bookArchive' => get_post_type_archive_link('e2e_book'), + 'note' => get_permalink($ids['note']), + 'noteArchive' => get_post_type_archive_link('e2e_note'), + 'rock' => get_term_link('e2e-rock', 'e2e_genre'), + 'jazz' => get_term_link('e2e-jazz', 'e2e_genre'), + 'category' => get_category_link($category['term_id']), + 'tag' => get_term_link('e2e-hierarchy-tag', 'post_tag'), + 'author' => get_author_posts_url($authorId), + 'date' => get_month_link(2020, 1), + 'search' => home_url('/?s=e2e-hierarchy'), + 'notFound' => home_url('/e2e-hierarchy-nothing-here'), + ], +]); diff --git a/tests/e2e/fixtures/themes/e2e-full/app/Cms/Book.php b/tests/e2e/fixtures/themes/e2e-full/app/Cms/Book.php new file mode 100644 index 00000000..46924205 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/app/Cms/Book.php @@ -0,0 +1,17 @@ +
php
'; diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/404.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/404.blade.php new file mode 100644 index 00000000..b15383a0 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/404.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', '404') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/archive-e2e_book.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/archive-e2e_book.blade.php new file mode 100644 index 00000000..97a91ae6 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/archive-e2e_book.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'archive-e2e_book') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/archive.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/archive.blade.php new file mode 100644 index 00000000..4d69dc63 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/archive.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'archive') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/author-e2e-hierarchy-author.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/author-e2e-hierarchy-author.blade.php new file mode 100644 index 00000000..8bc2691b --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/author-e2e-hierarchy-author.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'author-e2e-hierarchy-author') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/category-e2e-hierarchy-cat.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/category-e2e-hierarchy-cat.blade.php new file mode 100644 index 00000000..316be9a3 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/category-e2e-hierarchy-cat.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'category-e2e-hierarchy-cat') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/date.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/date.blade.php new file mode 100644 index 00000000..9ac5a4e4 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/date.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'date') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/front-page.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/front-page.blade.php new file mode 100644 index 00000000..70eaf282 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/front-page.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'front-page') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/home.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/home.blade.php new file mode 100644 index 00000000..228d8394 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/home.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'home') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/index.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/index.blade.php new file mode 100644 index 00000000..8db1f75a --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/index.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'index') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php new file mode 100644 index 00000000..884cca5b --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php @@ -0,0 +1,12 @@ +{{-- Every template names itself, so a test can tell which one rendered without the marker. --}} + + + + + @php(wp_head()) + + +
@yield('view')
+ @php(wp_footer()) + + diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-about.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-about.blade.php new file mode 100644 index 00000000..53187a27 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-about.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'page-e2e-hierarchy-about') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-php.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-php.blade.php new file mode 100644 index 00000000..eb5ce59b --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-php.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'page-e2e-hierarchy-php') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-routed.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-routed.blade.php new file mode 100644 index 00000000..e9708ec2 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/page-e2e-hierarchy-routed.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'page-e2e-hierarchy-routed') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/page.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/page.blade.php new file mode 100644 index 00000000..130e370b --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/page.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'page') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/search.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/search.blade.php new file mode 100644 index 00000000..18cc348c --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/search.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'search') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/single-e2e_book.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/single-e2e_book.blade.php new file mode 100644 index 00000000..fb7d079e --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/single-e2e_book.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'single-e2e_book') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/single.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/single.blade.php new file mode 100644 index 00000000..7c150cb0 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/single.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'single') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/tag.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/tag.blade.php new file mode 100644 index 00000000..6dd8374a --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/tag.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'tag') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre-e2e-rock.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre-e2e-rock.blade.php new file mode 100644 index 00000000..27b3e5e1 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre-e2e-rock.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'taxonomy-e2e_genre-e2e-rock') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre.blade.php new file mode 100644 index 00000000..c2ea766b --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/taxonomy-e2e_genre.blade.php @@ -0,0 +1,2 @@ +@extends('layouts.e2e') +@section('view', 'taxonomy-e2e_genre') diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/templates/landing.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/templates/landing.blade.php new file mode 100644 index 00000000..1202c46a --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/templates/landing.blade.php @@ -0,0 +1,3 @@ +{{-- Template Name: E2E Landing --}} +@extends('layouts.e2e') +@section('view', 'templates/landing') diff --git a/tests/e2e/fixtures/themes/e2e-full/routes/web.php b/tests/e2e/fixtures/themes/e2e-full/routes/web.php new file mode 100644 index 00000000..a9d9bb85 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/routes/web.php @@ -0,0 +1,18 @@ + '' + ."
{$by}
"; + +Route::wp('page', 'e2e-hierarchy-routed', static fn () => response($page('wp'))); + +Route::get('/e2e-hierarchy-laravel', static fn () => response($page('laravel'))); diff --git a/tests/e2e/fixtures/themes/e2e-full/style.css b/tests/e2e/fixtures/themes/e2e-full/style.css new file mode 100644 index 00000000..a7b095f1 --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/style.css @@ -0,0 +1,6 @@ +/* +Theme Name: E2E Full +Description: Pollora browser test fixture — template hierarchy. Never activate it on a real site. +Version: 1.0.0 +License: MIT +*/ diff --git a/tests/e2e/fixtures/themes/e2e-index/functions.php b/tests/e2e/fixtures/themes/e2e-index/functions.php new file mode 100644 index 00000000..789adc0d --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-index/functions.php @@ -0,0 +1,7 @@ + + + + + @php(wp_head()) + + +
index
+ @php(wp_footer()) + + diff --git a/tests/e2e/fixtures/themes/e2e-index/style.css b/tests/e2e/fixtures/themes/e2e-index/style.css new file mode 100644 index 00000000..1c85e8cb --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-index/style.css @@ -0,0 +1,6 @@ +/* +Theme Name: E2E Index +Description: Pollora browser test fixture — template hierarchy. Never activate it on a real site. +Version: 1.0.0 +License: MIT +*/ diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 6285b3e7..203ebdbc 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -1,7 +1,11 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { restoreSite } from './support/hierarchy'; import { wp } from './support/site'; export default async function globalTeardown(): Promise { + // The hierarchy spec puts the site back itself; this covers a run interrupted before it could. + restoreSite(); + if (! existsSync('./.auth/user.json')) { return; } diff --git a/tests/e2e/specs/hierarchy.spec.ts b/tests/e2e/specs/hierarchy.spec.ts new file mode 100644 index 00000000..7a42cf14 --- /dev/null +++ b/tests/e2e/specs/hierarchy.spec.ts @@ -0,0 +1,119 @@ +import { rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { activateTheme, restoreSite, seed, type Seeded, themeDir } from '../support/hierarchy'; +import { renderedTemplate } from '../support/site'; + +/** + * The WordPress template hierarchy, resolved to Blade by the framework, read in the + * marker it writes under WP_DEBUG (``). + * + * Two fixture themes: e2e-full has a template for every case, so each URL must reach + * the most specific one; e2e-index has index.blade.php alone, so each must fall back to + * it. Every fixture template also names itself in a data-e2e-view attribute, which is + * what tells a route's response apart from the hierarchy's: routes write no marker. + */ + +type Case = { + /** Key of the URL in the seed's output */ + url: string; + /** Marker the page must carry, or null when a route answered and the hierarchy never ran */ + template: string | null; + view: string; + status?: number; +}; + +const fullTheme: Record = { + 'a static front page renders front-page': { url: 'front', template: 'front-page', view: 'front-page' }, + 'the posts page renders home': { url: 'blog', template: 'home', view: 'home' }, + 'a page with a template for its slug renders page-{slug}': { url: 'about', template: 'page-e2e-hierarchy-about', view: 'page-e2e-hierarchy-about' }, + 'a page with a template for its id renders page-{id}': { url: 'byId', template: 'page-{id}', view: 'page-{id}' }, + 'a page with a custom template renders it': { url: 'landing', template: 'landing', view: 'templates/landing' }, + 'any other page renders page': { url: 'plain', template: 'page', view: 'page' }, + 'a post renders single': { url: 'post', template: 'single', view: 'single' }, + 'a custom post type with a template renders single-{type}': { url: 'book', template: 'single-e2e_book', view: 'single-e2e_book' }, + 'a custom post type without one renders single': { url: 'note', template: 'single', view: 'single' }, + 'a post type archive with a template renders archive-{type}': { url: 'bookArchive', template: 'archive-e2e_book', view: 'archive-e2e_book' }, + 'a post type archive without one renders archive': { url: 'noteArchive', template: 'archive', view: 'archive' }, + 'a term with a template renders taxonomy-{tax}-{term}': { url: 'rock', template: 'taxonomy-e2e_genre-e2e-rock', view: 'taxonomy-e2e_genre-e2e-rock' }, + 'any other term renders taxonomy-{tax}': { url: 'jazz', template: 'taxonomy-e2e_genre', view: 'taxonomy-e2e_genre' }, + 'a category with a template renders category-{slug}': { url: 'category', template: 'category-e2e-hierarchy-cat', view: 'category-e2e-hierarchy-cat' }, + 'a tag renders tag': { url: 'tag', template: 'tag', view: 'tag' }, + 'an author with a template renders author-{nicename}': { url: 'author', template: 'author-e2e-hierarchy-author', view: 'author-e2e-hierarchy-author' }, + 'a date archive renders date': { url: 'date', template: 'date', view: 'date' }, + 'a search renders search': { url: 'search', template: 'search', view: 'search' }, + 'an unknown URL renders 404, with a 404 status': { url: 'notFound', template: '404', view: '404', status: 404 }, + 'a Blade view wins over a PHP template of the same name': { url: 'php', template: 'page-e2e-hierarchy-php', view: 'page-e2e-hierarchy-php' }, + 'a Route::wp() route wins over the page template': { url: 'routed', template: null, view: 'route:wp' }, + 'a Laravel route wins over the WordPress page at its URL': { url: 'laravel', template: null, view: 'route:laravel' }, +}; + +// Everything WordPress itself resolves; the post types, the taxonomy and the routes +// belong to e2e-full and do not exist under this theme. +const indexTheme: Record = Object.fromEntries( + ['front', 'blog', 'about', 'byId', 'landing', 'plain', 'post', 'category', 'tag', 'author', 'date', 'search', 'routed', 'laravel', 'php'].map((url) => [ + url, + { url, template: 'index', view: 'index' }, + ]), +); + +let seeded: Seeded; + +test.beforeAll(() => { + activateTheme('e2e-full'); + seeded = seed(); + + const byId = seeded.ids.byId; + fullTheme['a page with a template for its id renders page-{id}'] = { url: 'byId', template: `page-${byId}`, view: `page-${byId}` }; + writeFileSync(join(themeDir('e2e-full'), 'resources', 'views', `page-${byId}.blade.php`), `@extends('layouts.e2e')\n@section('view', 'page-${byId}')\n`); +}); + +test.afterAll(() => { + if (seeded) { + rmSync(join(themeDir('e2e-full'), 'resources', 'views', `page-${seeded.ids.byId}.blade.php`), { force: true }); + } + + restoreSite(); +}); + +async function expectRendered(page: import('@playwright/test').Page, url: string, expected: Case): Promise { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + const response = await page.goto(url); + const html = await page.content(); + + expect(response?.status(), `status of ${url}`).toBe(expected.status ?? 200); + expect(renderedTemplate(html)?.template ?? null, `marker of ${url}`).toBe(expected.template); + await expect(page.locator('main[data-e2e-view]')).toHaveAttribute('data-e2e-view', expected.view); + expect(errors, 'no uncaught page error').toEqual([]); +} + +test.describe('Template hierarchy, with a template for every case', () => { + test.beforeAll(() => activateTheme('e2e-full')); + + for (const [name, expected] of Object.entries(fullTheme)) { + test(name, async ({ page }) => { + // Read at run time: the page-{id} case is only known once the content exists. + await expectRendered(page, seeded.urls[expected.url], fullTheme[name]); + }); + } +}); + +test.describe('Template hierarchy, with index alone', () => { + test.beforeAll(() => activateTheme('e2e-index')); + + for (const [key, expected] of Object.entries(indexTheme)) { + test(`${key} falls back to index`, async ({ page }) => { + await expectRendered(page, seeded.urls[key], expected); + }); + } + + test('an unknown URL answers 404, not index', async ({ page }) => { + const response = await page.goto(seeded.urls.notFound); + + expect(response?.status()).toBe(404); + expect(renderedTemplate(await page.content())).toBeNull(); + await expect(page.locator('main[data-e2e-view="index"]')).toHaveCount(0); + }); +}); diff --git a/tests/e2e/support/hierarchy.ts b/tests/e2e/support/hierarchy.ts new file mode 100644 index 00000000..40a9e437 --- /dev/null +++ b/tests/e2e/support/hierarchy.ts @@ -0,0 +1,74 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { wp } from './site'; + +/** + * Site state for the template hierarchy spec, which has to switch the active theme and the + * reading settings. What it changes is written to .auth/hierarchy.json before it changes + * it, so the spec's own teardown — or the global teardown, if the run was interrupted — + * can put the site back. + */ +const statePath = './.auth/hierarchy.json'; +const fixtures = join(import.meta.dirname, '..', 'fixtures', 'hierarchy'); + +type SiteState = { theme: string; options: Record }; + +export type Seeded = { ids: Record; urls: Record }; + +const readingOptions = ['show_on_front', 'page_on_front', 'page_for_posts']; + +/** The PHP of a fixture script, for `wp eval` (which takes code without its opening tag). */ +const script = (name: string): string => readFileSync(join(fixtures, name), 'utf8').replace(/^<\?php\s*/, ''); + +/** Where the site's copy of a fixture theme lives. */ +export const themeDir = (theme: string): string => join(process.env.E2E_SITE_DIR ?? process.cwd(), 'themes', theme); + +function rememberSite(): void { + if (existsSync(statePath)) { + return; // An interrupted run already recorded the site as it was before any test touched it. + } + + const state: SiteState = { + theme: wp('theme', 'list', '--status=active', '--field=name'), + options: Object.fromEntries(readingOptions.map((name) => [name, wp('option', 'get', name)])), + }; + + writeFileSync(statePath, JSON.stringify(state)); +} + +export function activateTheme(theme: string): void { + rememberSite(); + wp('theme', 'activate', theme); + // Post type and taxonomy URLs change with the theme that registers them. + wp('rewrite', 'flush'); +} + +/** Clear anything an earlier run left, then create the content. Needs e2e-full active. */ +export function seed(): Seeded { + wp('eval', script('cleanup.php')); + + return JSON.parse(wp('eval', script('seed.php'))) as Seeded; +} + +/** Remove the content and give the site back its theme and reading settings. Safe to run twice. */ +export function restoreSite(): void { + if (! existsSync(statePath)) { + return; + } + + const state = JSON.parse(readFileSync(statePath, 'utf8')) as SiteState; + + // The e2e_genre terms can only be deleted while the theme registering them is active. + if (existsSync(themeDir('e2e-full'))) { + wp('theme', 'activate', 'e2e-full'); + wp('eval', script('cleanup.php')); + } + + for (const [name, value] of Object.entries(state.options)) { + wp('option', 'update', name, value); + } + + wp('theme', 'activate', state.theme); + wp('rewrite', 'flush'); + rmSync(statePath); +} From 75e8f5c330a47289b9ab1dd63896d451438b3704 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 14:37:44 +0200 Subject: [PATCH 04/10] style: declare strict types in the hierarchy fixture scripts --- tests/e2e/fixtures/hierarchy/cleanup.php | 2 ++ tests/e2e/fixtures/hierarchy/seed.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/e2e/fixtures/hierarchy/cleanup.php b/tests/e2e/fixtures/hierarchy/cleanup.php index c19a4103..3db94604 100644 --- a/tests/e2e/fixtures/hierarchy/cleanup.php +++ b/tests/e2e/fixtures/hierarchy/cleanup.php @@ -1,5 +1,7 @@ Date: Fri, 25 Sep 2026 14:47:52 +0200 Subject: [PATCH 05/10] refactor: type the fixture routes' closures --- tests/e2e/fixtures/themes/e2e-full/routes/web.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/fixtures/themes/e2e-full/routes/web.php b/tests/e2e/fixtures/themes/e2e-full/routes/web.php index a9d9bb85..05c7243c 100644 --- a/tests/e2e/fixtures/themes/e2e-full/routes/web.php +++ b/tests/e2e/fixtures/themes/e2e-full/routes/web.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; /* @@ -13,6 +14,6 @@ $page = static fn (string $by): string => '' ."
{$by}
"; -Route::wp('page', 'e2e-hierarchy-routed', static fn () => response($page('wp'))); +Route::wp('page', 'e2e-hierarchy-routed', static fn (): Response => response($page('wp'))); -Route::get('/e2e-hierarchy-laravel', static fn () => response($page('laravel'))); +Route::get('/e2e-hierarchy-laravel', static fn (): Response => response($page('laravel'))); From cc9f68beeb795039756ada88fafde59172f800c2 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 15:09:24 +0200 Subject: [PATCH 06/10] test: cover the framework's features in the browser tests The e2e-features fixture plugin declares, by attribute, a filter and an action that leave markers, a post type, two REST routes (public, and IsAdmin with a route parameter) and an Ajax action, and enqueues a plain script through the Asset facade. features.spec checks each by the effect a visitor or an editor sees: - the_content is filtered on a published post, wp_footer prints; - the plugin's post type has its REST type, archive and admin menu; - the public route answers anyone, the IsAdmin one refuses a visitor and answers an administrator; - the Ajax action answers a visitor and a logged-in user; - the Asset facade's script runs; every script and stylesheet of the home page loads, none over plain http, no mixed content, no PHP warning; - get_theme_file_uri() gives the active theme's built entry a URL that answers, and the page a visitor gets carries no server path; - the active theme's menu locations and, when its config/login.php asks for it, its login screen. The theme checks read what the active theme declares rather than assuming one. Replayed on a site: with the theme URI fix of v13.32.0-beta.6 undone, the get_theme_file_uri() and server path checks fail. --- tests/e2e/bin/fixtures.sh | 11 + .../app/Endpoints/AdminStatus.php | 23 ++ .../app/Endpoints/PublicStatus.php | 22 ++ .../plugins/e2e-features/app/Event.php | 19 ++ .../plugins/e2e-features/app/Markers.php | 66 ++++++ .../plugins/e2e-features/app/Ping.php | 20 ++ .../e2e-features/assets/e2e-features.js | 2 + .../plugins/e2e-features/e2e-features.php | 18 ++ tests/e2e/specs/features.spec.ts | 196 ++++++++++++++++++ 9 files changed, 377 insertions(+) create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/AdminStatus.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/PublicStatus.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Event.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Markers.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Ping.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/assets/e2e-features.js create mode 100644 tests/e2e/fixtures/plugins/e2e-features/e2e-features.php create mode 100644 tests/e2e/specs/features.spec.ts diff --git a/tests/e2e/bin/fixtures.sh b/tests/e2e/bin/fixtures.sh index df0bbbcb..dc385970 100755 --- a/tests/e2e/bin/fixtures.sh +++ b/tests/e2e/bin/fixtures.sh @@ -11,6 +11,9 @@ # - when theme-default is the active theme, a dynamic block made in it # by pollora:make:block. Any other theme is left alone: it may be # someone's work in progress. +# - the e2e-features plugin, committed in fixtures/plugins: framework +# features declared by attribute (hooks, a post type, REST routes, +# an Ajax action) and a script enqueued through the Asset facade; # - the template hierarchy themes, e2e-full and e2e-index, copied into # themes/. They are not activated here: the hierarchy spec activates # each in turn and gives the site its own theme back. @@ -72,6 +75,11 @@ case "${1:-}" in build "$MODULE_DIR" php artisan module:enable "$MODULE" --no-interaction + # Framework features plugin + rm -rf "public/content/plugins/e2e-features" + cp -r "$FIXTURES/plugins/e2e-features" "public/content/plugins/e2e-features" + wp plugin activate e2e-features + # Template hierarchy themes for theme in e2e-full e2e-index; do rm -rf "themes/$theme" @@ -96,6 +104,9 @@ case "${1:-}" in rm -f "$STATUSES_BACKUP" fi + wp plugin deactivate e2e-features 2>/dev/null || true + rm -rf public/content/plugins/e2e-features + rm -rf themes/e2e-full themes/e2e-index # The theme block is only ever made in theme-default, and removed from it diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/AdminStatus.php b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/AdminStatus.php new file mode 100644 index 00000000..bfb10a88 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/AdminStatus.php @@ -0,0 +1,23 @@ +\d+)', permissionCallback: IsAdmin::class)] +class AdminStatus +{ + /** @return array{route: string, id: int} */ + #[Method('GET')] + public function show(string $id): array + { + return ['route' => 'admin', 'id' => (int) $id]; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/PublicStatus.php b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/PublicStatus.php new file mode 100644 index 00000000..22bccf60 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/PublicStatus.php @@ -0,0 +1,22 @@ + 'public']; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Event.php b/tests/e2e/fixtures/plugins/e2e-features/app/Event.php new file mode 100644 index 00000000..830c5ef9 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Event.php @@ -0,0 +1,19 @@ +filtered

'; + } + + /** + * Also hands the test what only a web request can answer: the URL the active theme's + * built entry resolves to through get_theme_file_uri(). + */ + #[Action('wp_footer')] + public function markFooter(): void + { + $entry = $this->themeEntry(); + + printf( + '', + wp_json_encode([ + 'action' => 'wp_footer', + 'themeEntry' => $entry, + 'themeFileUri' => $entry === null ? null : get_theme_file_uri($entry), + ]) + ); + } + + /** A file enqueued through the Asset facade with a plain URL, no Vite: it proves it ran. */ + #[Action('init')] + public function enqueueScript(): void + { + Asset::add('e2e-features/script', plugins_url('e2e-features/assets/e2e-features.js')) + ->toFrontend(); + } + + /** The first entry of the active theme's Vite manifest, as the theme names it. */ + private function themeEntry(): ?string + { + $manifest = public_path('build/theme/'.get_stylesheet().'/manifest.json'); + + if (! is_file($manifest)) { + return null; + } + + foreach ((array) json_decode((string) file_get_contents($manifest), true) as $source => $chunk) { + if (($chunk['isEntry'] ?? false) === true) { + return (string) $source; + } + } + + return null; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Ping.php b/tests/e2e/fixtures/plugins/e2e-features/app/Ping.php new file mode 100644 index 00000000..60680ab1 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Ping.php @@ -0,0 +1,20 @@ + true, 'loggedIn' => is_user_logged_in()]); + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/assets/e2e-features.js b/tests/e2e/fixtures/plugins/e2e-features/assets/e2e-features.js new file mode 100644 index 00000000..ac933f52 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/assets/e2e-features.js @@ -0,0 +1,2 @@ +// Enqueued by the e2e-features fixture through the Asset facade; the test reads this attribute. +document.documentElement.dataset.e2eFeaturesScript = 'ran'; diff --git a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php new file mode 100644 index 00000000..abcee5d3 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php @@ -0,0 +1,18 @@ + wp('theme', 'list', '--status=active', '--field=name'); + +async function footerData(page: Page): Promise { + return JSON.parse((await page.locator('script#e2e-features').textContent()) ?? 'null') as FooterData; +} + +/** A request context with no session, as a visitor. */ +async function visitor(): Promise { + return playwrightRequest.newContext({ ignoreHTTPSErrors: true, storageState: { cookies: [], origins: [] } }); +} + +test.describe('Hooks declared by attribute', () => { + test('an #[Action] on wp_footer prints its marker', async ({ page }) => { + await page.goto(homeUrl('/')); + + expect((await footerData(page)).action).toBe('wp_footer'); + }); + + test('a #[Filter] on the_content changes a post', async ({ page, requestUtils }) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { title: `E2E filter ${runId}`, content: '

Body.

', status: 'publish' }, + }); + + try { + await page.goto(post.link); + await expect(page.locator('[data-e2e-filter="the_content"]')).toHaveCount(1); + } finally { + await requestUtils.rest({ method: 'DELETE', path: `/wp/v2/posts/${post.id}`, params: { force: true } }); + } + }); +}); + +test.describe('A post type declared by a plugin', () => { + test('is registered, with its archive and its admin menu', async ({ page, requestUtils }) => { + const type = await requestUtils.rest({ path: '/wp/v2/types/e2e_event' }); + expect(type.slug).toBe('e2e_event'); + + const archive = await page.goto(wp('eval', 'echo get_post_type_archive_link("e2e_event");')); + expect(archive?.status()).toBe(200); + + await page.goto(new URL('wp-admin/', process.env.WP_BASE_URL).toString()); + await expect(page.locator('#adminmenu a[href="edit.php?post_type=e2e_event"]').first()).toBeAttached(); + }); +}); + +test.describe('REST routes declared by #[WpRestRoute]', () => { + test('a route with no permission is public', async () => { + const context = await visitor(); + const response = await context.get(homeUrl('/wp-json/e2e/v1/public')); + + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ route: 'public' }); + await context.dispose(); + }); + + test('an IsAdmin route refuses a visitor', async () => { + const context = await visitor(); + const response = await context.get(homeUrl('/wp-json/e2e/v1/admin/7')); + + expect(response.status()).toBe(403); + await context.dispose(); + }); + + test('an IsAdmin route answers an administrator, with its route parameter', async ({ requestUtils }) => { + expect(await requestUtils.rest({ path: '/e2e/v1/admin/7' })).toEqual({ route: 'admin', id: 7 }); + }); +}); + +test.describe('Ajax actions declared by #[Ajax]', () => { + const ajaxUrl = (): string => new URL('wp-admin/admin-ajax.php?action=e2e_ping', process.env.WP_BASE_URL).toString(); + + test('a visitor gets an answer', async () => { + const context = await visitor(); + const response = await context.get(ajaxUrl()); + + expect(await response.json()).toEqual({ success: true, data: { pong: true, loggedIn: false } }); + await context.dispose(); + }); + + test('a logged-in user gets an answer too', async ({ page }) => { + const response = await page.request.get(ajaxUrl()); + + expect(await response.json()).toEqual({ success: true, data: { pong: true, loggedIn: true } }); + }); +}); + +test.describe('Assets', () => { + test('a script enqueued through the Asset facade runs', async ({ page }) => { + await page.goto(homeUrl('/')); + + await expect(page.locator('html')).toHaveAttribute('data-e2e-features-script', 'ran'); + }); + + test('every script and stylesheet of the home page loads, none over plain http', async ({ page }) => { + const failed: string[] = []; + const insecure: string[] = []; + const mixed: string[] = []; + + page.on('response', (response) => { + const type = response.request().resourceType(); + + if ((type === 'script' || type === 'stylesheet') && response.status() >= 400) { + failed.push(`${response.status()} ${response.url()}`); + } + }); + page.on('request', (request) => { + if (request.url().startsWith('http://')) { + insecure.push(request.url()); + } + }); + page.on('console', (message) => { + if (message.text().includes('Mixed Content')) { + mixed.push(message.text()); + } + }); + + const response = await page.goto(homeUrl('/'), { waitUntil: 'load' }); + const html = await page.content(); + + expect(response?.status()).toBe(200); + expect(failed, 'scripts and stylesheets that failed').toEqual([]); + expect(insecure, 'requests over plain http').toEqual([]); + expect(mixed, 'mixed content').toEqual([]); + expect(html, 'no PHP warning in the page').not.toMatch(/(Warning|Notice|Deprecated)<\/b>:/); + }); + + test("the active theme's built entry has a URL through get_theme_file_uri()", async ({ page }) => { + await page.goto(homeUrl('/')); + const data = await footerData(page); + + expect(data.themeEntry, `the active theme (${activeTheme()}) has no Vite build`).not.toBeNull(); + expect(data.themeFileUri).toContain(`/build/theme/${activeTheme()}/`); + expect((await page.request.get(data.themeFileUri as string)).status()).toBe(200); + }); + + // As a visitor: a debugging plugin such as Query Monitor shows paths to administrators, on purpose. + test('no server path leaks into the page a visitor gets', async () => { + const basePath = wp('eval', 'echo base_path();'); + const context = await visitor(); + const html = await (await context.get(homeUrl('/'))).text(); + await context.dispose(); + + expect(html.includes(basePath), `the page contains the server path ${basePath}`).toBe(false); + }); +}); + +test.describe('The active theme', () => { + test('registers the menu locations its config/menus.php declares', async ({ requestUtils }) => { + const configFile = join(siteDir, 'themes', activeTheme(), 'config', 'menus.php'); + test.skip(! existsSync(configFile), 'the active theme declares no menus'); + + const declared = JSON.parse(wp('eval', 'echo json_encode(array_keys((array) include get_stylesheet_directory()."/config/menus.php"));')) as string[]; + const registered = Object.keys(await requestUtils.rest({ path: '/wp/v2/menu-locations' })); + + expect(declared.length).toBeGreaterThan(0); + expect(registered).toEqual(expect.arrayContaining(declared)); + }); + + test('dresses the login screen when its config/login.php asks for it', async ({ browser }) => { + const dressed = existsSync(join(siteDir, 'themes', activeTheme(), 'config', 'login.php')); + const context = await browser.newContext({ ignoreHTTPSErrors: true, storageState: { cookies: [], origins: [] } }); + const page = await context.newPage(); + + const response = await page.goto(new URL('wp-login.php', process.env.WP_BASE_URL).toString()); + + expect(response?.status()).toBe(200); + await expect(page.locator('#loginform')).toBeVisible(); + await expect(page.locator('style#pollora-login')).toHaveCount(dressed ? 1 : 0); + + if (dressed) { + await expect(page.locator('body')).toHaveClass(/pollora-login/); + expect(await page.locator('style#pollora-login').textContent()).toMatch(/--pollora-login-primary:\s*[^;]+;/); + } + + await context.close(); + }); +}); From 8030b6d1407869cf90e2869ab24b6743450236b2 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 16:02:29 +0200 Subject: [PATCH 07/10] test: check that __() sends text domains to WordPress in the browser tests WordPress's __() is renamed __wp() by the framework's patch, and pollora/helper-overrider's __() routes a call with a text domain to WordPress and a call with replacements to Laravel. If Laravel reclaims the name, nothing fails: WordPress catalogues just stop resolving. The e2e-features plugin ships a fr_FR catalogue and a REST route that loads it and answers what __() and __wp() return. Replayed on a site: with the WordPress side of the resolver disabled, the string comes back untranslated and the test fails. --- .../app/Endpoints/Translations.php | 34 ++++++++++++++++++ .../languages/e2e-features-fr_FR.mo | Bin 0 -> 453 bytes .../languages/e2e-features-fr_FR.po | 13 +++++++ tests/e2e/specs/features.spec.ts | 15 ++++++++ 4 files changed, 62 insertions(+) create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/languages/e2e-features-fr_FR.mo create mode 100644 tests/e2e/fixtures/plugins/e2e-features/languages/e2e-features-fr_FR.po diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php new file mode 100644 index 00000000..6d6aacfb --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php @@ -0,0 +1,34 @@ + $domain === 'e2e-features' ? 'fr_FR' : $locale, 10, 2); + unload_textdomain('e2e-features'); + load_plugin_textdomain('e2e-features', false, 'e2e-features/languages'); + + return [ + 'wordpress' => __('E2E greeting', 'e2e-features'), + 'untranslated' => __('E2E string outside the catalogue', 'e2e-features'), + 'laravel' => __('Shipping :brand', ['brand' => 'Example']), + 'wpNative' => __wp('E2E greeting', 'e2e-features'), + ]; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/languages/e2e-features-fr_FR.mo b/tests/e2e/fixtures/plugins/e2e-features/languages/e2e-features-fr_FR.mo new file mode 100644 index 0000000000000000000000000000000000000000..60a64038bda141568deb980951dd0afa44bd38a8 GIT binary patch literal 453 zcmah_!AiqG5RKv~c=D=;IVw7u2CH>zJhV-q(qbu5JSl53jVbAF*xiVJh#%ok`7KUb z#G?a`_ulemnAz{WollQzn{mK6X${x)hSt2xI$}I+dEPDaKI4nE7l$zvhRBtw@McCY zDRVf^@qvsjwGz+<5|XYlWc@kS+BlrrqRes76c$r0z@JF#Ff~%yN;+-UFREx{L70+U zv7e5o@!!nOY2XjFa-_KJqb324-rZSU$x`(pdzHqLi|*3|dw;oh{DKU|D$_Y{E1>sM zI)8ST;?VGdye*ET<6&VCUSM#AVK?n`MfXyi2ggAuf`G#~r?;}b?BDD}ZcY%eFwe={ zpH+>K6(-uOtN>N7t&PGyY-Dt(;08KJeg6?hdc~&*ppbC!`+43#tEFxX 1);\n" +"X-Domain: e2e-features\n" + +msgid "E2E greeting" +msgstr "Bonjour depuis le catalogue" diff --git a/tests/e2e/specs/features.spec.ts b/tests/e2e/specs/features.spec.ts index d16eed5c..da4cd76c 100644 --- a/tests/e2e/specs/features.spec.ts +++ b/tests/e2e/specs/features.spec.ts @@ -163,6 +163,21 @@ test.describe('Assets', () => { }); }); +test.describe('Translations through __()', () => { + test("a text domain goes to WordPress's catalogues, replacements to Laravel", async () => { + const context = await visitor(); + const translations = await (await context.get(homeUrl('/wp-json/e2e/v1/translations'))).json(); + await context.dispose(); + + expect(translations).toEqual({ + wordpress: 'Bonjour depuis le catalogue', + untranslated: 'E2E string outside the catalogue', + laravel: 'Shipping Example', + wpNative: 'Bonjour depuis le catalogue', + }); + }); +}); + test.describe('The active theme', () => { test('registers the menu locations its config/menus.php declares', async ({ requestUtils }) => { const configFile = join(siteDir, 'themes', activeTheme(), 'config', 'menus.php'); From 83715ef27dcf0bb3e425de530709a098c7b5307a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 16:02:30 +0200 Subject: [PATCH 08/10] ci: run the browser tests in Firefox and WebKit every night E2E_BROWSERS selects the Playwright projects (chromium by default). Pull requests and pushes keep Chromium alone; the nightly schedule and a manual run use all three engines. Measured locally in Firefox: the suite passes but for the the_content check, which fails on pollora-test for a theme defect already fixed in theme-apiary. --- .github/workflows/ci.yml | 12 +++++++++++- tests/e2e/playwright.config.ts | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a802c6..66d1add4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main, develop, 'feature/**', 'hotfix/**', 'release/**'] pull_request: branches: [main, develop] + # Nightly, on the default branch: the browser tests in Firefox and WebKit as well as + # Chromium, which pull requests run alone to stay fast. + schedule: + - cron: '30 3 * * *' + workflow_dispatch: jobs: tests: @@ -238,6 +243,11 @@ jobs: # authoritative: a deployment dumped with --classmap-authoritative answers # from the classmap alone. Theme and plugin classes are autoloaded at # runtime, and every front-end page once answered 404 there (#320). + # Chromium on pushes and pull requests; all three engines on the nightly and on a + # manual run. + env: + E2E_BROWSERS: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'chromium,firefox,webkit' || 'chromium' }} + strategy: fail-fast: false matrix: @@ -328,7 +338,7 @@ jobs: working-directory: packages/framework/tests/e2e run: | npm ci - npx playwright install --with-deps chromium + npx playwright install --with-deps ${E2E_BROWSERS//,/ } # DDEV serves HTTPS with a mkcert certificate when mkcert is present. # Say which case the runner is in rather than guessing. diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 9ee9ee1f..f0b3451e 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -9,6 +9,7 @@ import { defineConfig, devices } from '@playwright/test'; * WP_BASE_URL WordPress itself (`siteurl`), e.g. https://pollora-test.ddev.site/cms/ — * read by @wordpress/e2e-test-utils-playwright for wp-admin, login and REST * E2E_WP_CLI how to run WP-CLI against that site, e.g. "ddev wp" (default) + * E2E_BROWSERS comma-separated: chromium (default), firefox, webkit — the nightly runs all three */ const homeUrl = process.env.E2E_HOME_URL ?? 'https://pollora-test.ddev.site'; // Trailing slash: relative paths such as wp-login.php must resolve inside /cms. @@ -29,6 +30,20 @@ if (! process.env.NODE_EXTRA_CA_CERTS) { } } +const browsers: Record = { + chromium: 'Desktop Chrome', + firefox: 'Desktop Firefox', + webkit: 'Desktop Safari', +}; + +const projects = (process.env.E2E_BROWSERS ?? 'chromium').split(',').map((name) => name.trim()).map((name) => { + if (! (name in browsers)) { + throw new Error(`E2E_BROWSERS: unknown browser "${name}" (known: ${Object.keys(browsers).join(', ')})`); + } + + return { name, use: { ...devices[browsers[name]] } }; +}); + export default defineConfig({ testDir: './specs', globalSetup: './global-setup.ts', @@ -46,5 +61,5 @@ export default defineConfig({ screenshot: 'only-on-failure', video: 'retain-on-failure', }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + projects, }); From 1ea8e96c0d5abb488a8c8d59bda4f63d92f1bb79 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 16:18:54 +0200 Subject: [PATCH 09/10] test: load the fr_FR catalogue as the current locale's Since WordPress 6.5 translations are kept per locale. The route loaded the plugin's catalogue as fr_FR through plugin_locale, which only worked on a site whose language is fr_FR (pollora-test); on the CI site, in en_US, __() found nothing. Measured with the locale forced to en_US: the former loading leaves the string untranslated, this one translates it. --- .../e2e-features/app/Endpoints/Translations.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php index 6d6aacfb..67ff1f04 100644 --- a/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php @@ -10,8 +10,8 @@ /** * What `__()` answers, on each side of the helper that shares the name: WordPress keeps * its own as `__wp()`, and `__()` sends a call with a text domain to WordPress and a call - * with replacements to Laravel. This plugin's catalogue is loaded in fr_FR whatever the - * site's language. + * with replacements to Laravel. This plugin's fr_FR catalogue is what WordPress finds, + * whatever the site's language. */ #[WpRestRoute('e2e/v1', '/translations')] class Translations @@ -20,9 +20,11 @@ class Translations #[Method('GET')] public function show(): array { - add_filter('plugin_locale', static fn (string $locale, string $domain): string => $domain === 'e2e-features' ? 'fr_FR' : $locale, 10, 2); + // Loaded as the catalogue of the current locale, whatever the site's language: + // since WordPress 6.5 translations are kept per locale, so loading it as fr_FR + // on an en_US site would leave __() nothing to find. unload_textdomain('e2e-features'); - load_plugin_textdomain('e2e-features', false, 'e2e-features/languages'); + load_textdomain('e2e-features', WP_PLUGIN_DIR.'/e2e-features/languages/e2e-features-fr_FR.mo', determine_locale()); return [ 'wordpress' => __('E2E greeting', 'e2e-features'), From 9bba9beaa25820afa2b38a63147e090b366c11cd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 25 Sep 2026 16:40:49 +0200 Subject: [PATCH 10/10] chore: gather the v13.32.0-beta.8 entries under the version being tagged --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22770df..060a100c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.7...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...develop) + +## [v13.32.0-beta.8](https://github.com/Pollora/framework/compare/v13.32.0-beta.7...v13.32.0-beta.8) - 2026-09-25 + +No change to the framework's code: this beta ships the browser test suite that now guards it, 73 tests per browser. + +### Added +- Browser tests of the template hierarchy. Two fixture themes: one has a template for every case, the other `index.blade.php` alone. Each URL is read in the framework's template marker, in the template's own output and in the HTTP status: `front-page`, `home`, `page-{slug}`, `page-{id}`, a custom page template, `page`, `single`, `single-{type}`, `archive-{type}`, `archive`, `taxonomy-{tax}-{term}`, `taxonomy-{tax}`, `category-{slug}`, `tag`, `author-{nicename}`, `date`, `search`, and `404` with a 404 status; a Blade view over a PHP template of the same name; `Route::wp()` and Laravel routes over the hierarchy. With `index` alone every case falls back to it, and an unknown URL still answers 404. Replayed: without the 404 view, or with the marker disabled, the suite fails +- Browser tests of the framework's features, through a fixture plugin that declares each by attribute and checks it by its effect: `#[Filter]` and `#[Action]`, a `#[PostType]` (REST type, archive, admin menu), `#[WpRestRoute]` (a public route; an `IsAdmin` one that refuses a visitor and answers an administrator), `#[Ajax]` for visitors and logged-in users, a script enqueued through the `Asset` facade, every script and stylesheet of the home page loading with nothing over plain `http://`, `get_theme_file_uri()` giving the active theme's built entry a URL that answers, no server path in the page a visitor gets, the theme's menu locations and login screen, and `__()` sending a text domain to WordPress's catalogues and replacements to Laravel. Replayed: with the v13.32.0-beta.6 theme URI fix undone, or with WordPress's side of `__()` disabled, the suite fails +- The browser tests run in Firefox and WebKit as well as Chromium every night, and on a manual run of the workflow (`E2E_BROWSERS`). Pull requests keep Chromium alone. The schedule runs from `main`, so it starts with this release ## [v13.32.0-beta.7](https://github.com/Pollora/framework/compare/v13.32.0-beta.6...v13.32.0-beta.7) - 2026-09-24