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 @@ +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