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/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 diff --git a/documentation b/documentation index 4e1f23d8..f9a68a17 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit 4e1f23d87f15c1382118c03c8be2a1d989c01868 +Subproject commit f9a68a17856784d202bbaf53f569b48736cb1861 diff --git a/tests/e2e/bin/fixtures.sh b/tests/e2e/bin/fixtures.sh index 5784d71c..dc385970 100755 --- a/tests/e2e/bin/fixtures.sh +++ b/tests/e2e/bin/fixtures.sh @@ -11,6 +11,12 @@ # - 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. # down remove all of it, and give modules_statuses.json back as it was set -euo pipefail @@ -69,6 +75,17 @@ 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" + 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 +104,11 @@ 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 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..3db94604 --- /dev/null +++ b/tests/e2e/fixtures/hierarchy/cleanup.php @@ -0,0 +1,39 @@ + '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..cb677278 --- /dev/null +++ b/tests/e2e/fixtures/hierarchy/seed.php @@ -0,0 +1,98 @@ + 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/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/Endpoints/Translations.php b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php new file mode 100644 index 00000000..67ff1f04 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/Endpoints/Translations.php @@ -0,0 +1,36 @@ + __('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/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 @@ + 1);\n" +"X-Domain: e2e-features\n" + +msgid "E2E greeting" +msgstr "Bonjour depuis le catalogue" 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..05c7243c --- /dev/null +++ b/tests/e2e/fixtures/themes/e2e-full/routes/web.php @@ -0,0 +1,19 @@ + '' + ."
{$by}
"; + +Route::wp('page', 'e2e-hierarchy-routed', static fn (): Response => response($page('wp'))); + +Route::get('/e2e-hierarchy-laravel', static fn (): Response => 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/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, }); 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 }); diff --git a/tests/e2e/specs/features.spec.ts b/tests/e2e/specs/features.spec.ts new file mode 100644 index 00000000..da4cd76c --- /dev/null +++ b/tests/e2e/specs/features.spec.ts @@ -0,0 +1,211 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { request as playwrightRequest, type APIRequestContext, type Page } from '@playwright/test'; +import { homeUrl, runId, wp } from '../support/site'; + +/** + * Framework features, each declared by the e2e-features fixture plugin or by the active + * theme, and each checked by the effect a visitor or an editor would see. + * + * The plugin works whatever the active theme; the theme checks (built assets, menus, + * login screen) read what the active theme declares instead of assuming one. + */ + +type FooterData = { action: string; themeEntry: string | null; themeFileUri: string | null }; + +const siteDir = process.env.E2E_SITE_DIR ?? process.cwd(); +const activeTheme = (): string => 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('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'); + 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(); + }); +}); 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); +}