From 8ba8f21381d7a48de2d76796f4e7967aa55b24db Mon Sep 17 00:00:00 2001 From: Prajjwal Poudel Date: Thu, 24 Sep 2026 18:11:53 +0545 Subject: [PATCH 1/3] Add - ClaudeGrill Playwright QA suite Playwright e2e suite for Flash Free: @fresh specs for verified theme behaviour (runs on a bare Playground install and the demo site), @demo specs for the imported demo and WooCommerce, and quarantined fixme scenarios for open flash-pro issues #30, #84, #90 and #92. Also adds the claudegrill manifest and knowledge file, the QA workflow, .distignore, Playwright ignores, QA exclusions in the Grunt and gulp zip tasks, and an .npmrc for the existing stylelint peer conflict. --- .distignore | 39 ++ .github/workflows/qa-suite.yml | 59 ++ .gitignore | 13 +- .npmrc | 4 + .themegrill-qa/docs/flash.md | 52 ++ .themegrill-qa/knowledge.md | 546 ++++++++++++++++++ .themegrill-qa/suite.json | 198 +++++++ Gruntfile.js | 9 + gulpfile.js | 11 +- package.json | 4 +- playwright.config.ts | 26 + tests/e2e/auth.setup.ts | 61 ++ tests/e2e/fixtures.ts | 35 ++ tests/e2e/specs/blog/blog-index.spec.ts | 40 ++ .../demo-homepage/demo-front-page.spec.ts | 46 ++ tests/e2e/specs/footer/scroll-to-top.spec.ts | 29 + tests/e2e/specs/header/header-search.spec.ts | 73 +++ .../specs/homepage/front-page-health.spec.ts | 67 +++ .../mobile-menu/mobile-menu-toggle.spec.ts | 75 +++ tests/e2e/specs/not-found/not-found.spec.ts | 59 ++ tests/e2e/specs/search/search-results.spec.ts | 59 ++ .../e2e/specs/single-post/single-post.spec.ts | 39 ++ .../e2e/specs/woocommerce/header-cart.spec.ts | 72 +++ tests/e2e/utils/login.ts | 14 + tests/e2e/utils/page.ts | 36 ++ 25 files changed, 1663 insertions(+), 3 deletions(-) create mode 100644 .distignore create mode 100644 .github/workflows/qa-suite.yml create mode 100644 .npmrc create mode 100644 .themegrill-qa/docs/flash.md create mode 100644 .themegrill-qa/knowledge.md create mode 100644 .themegrill-qa/suite.json create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/specs/blog/blog-index.spec.ts create mode 100644 tests/e2e/specs/demo-homepage/demo-front-page.spec.ts create mode 100644 tests/e2e/specs/footer/scroll-to-top.spec.ts create mode 100644 tests/e2e/specs/header/header-search.spec.ts create mode 100644 tests/e2e/specs/homepage/front-page-health.spec.ts create mode 100644 tests/e2e/specs/mobile-menu/mobile-menu-toggle.spec.ts create mode 100644 tests/e2e/specs/not-found/not-found.spec.ts create mode 100644 tests/e2e/specs/search/search-results.spec.ts create mode 100644 tests/e2e/specs/single-post/single-post.spec.ts create mode 100644 tests/e2e/specs/woocommerce/header-cart.spec.ts create mode 100644 tests/e2e/utils/login.ts create mode 100644 tests/e2e/utils/page.ts diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..f9ac519 --- /dev/null +++ b/.distignore @@ -0,0 +1,39 @@ +# Development and VCS +/.git +/.github +/.gitignore +/.distignore +/.editorconfig +/.eslintrc.json +/.jscsrc +/.jshintignore +/.jshintrc +/.stylelintrc.json +/.DS_Store +Thumbs.db + +# Build tooling +/node_modules +/vendor +/package.json +/package-lock.json +/.npmrc +/composer.json +/composer.lock +/Gruntfile.js +/gulpfile.js +/phpcs.xml +/assets/sass +/.sass-cache +/dist +/deploy +*.zip + +# QA (claudegrill + Playwright) +/.themegrill-qa +/tests +/playwright.config.ts +/test-results +/playwright-report +/blob-report +/playwright diff --git a/.github/workflows/qa-suite.yml b/.github/workflows/qa-suite.yml new file mode 100644 index 0000000..dfdbeb5 --- /dev/null +++ b/.github/workflows/qa-suite.yml @@ -0,0 +1,59 @@ +# Written by `/claudegrill:setup`. Safe to edit β€” it is a plain caller. +# +# THIS IS THE ONLY PR CHECK MOST PRODUCTS NEED. No ANTHROPIC_API_KEY, no agent, +# runner minutes only. +# +# It works without AI because the developer already wrote the spec: they ran +# /claudegrill:verify-fix locally, write-spec added a @fresh spec to their +# branch, and they committed it with the fix. By the time CI sees the PR the +# regression is already a deterministic assertion. +# +# It runs once when a PR opens, and after that ON REQUEST. Pushing a commit does +# NOT re-run it. To run it against the PR's current head, comment: +# +# @claudegrill suite +# +# πŸ‘€ means it started; πŸš€ or πŸ˜• means it finished, and the result comment on the +# PR is edited in place. Only owners, members and collaborators can trigger it, +# and never on a PR from a fork: close and reopen that PR instead. +# +# A comment does nothing until THIS FILE IS ON THE DEFAULT BRANCH, because +# GitHub reads `issue_comment` workflows from there and never from the PR. +# +# Do not make it a required check. A comment-triggered run is not attached to +# the PR's head commit, so after a push the check would wait forever. +# +# Nothing runs on a schedule: the team's model is a local +# /claudegrill:verify-fix while the fix is being written, and the e2e suite on +# the PR. No cron, no nightly, no WordPress-core watcher. +# +# `scope: changed` narrows a run to the areas the diff maps to. A changed file +# matching no `area_paths` pattern in .themegrill-qa/suite.json runs nothing, +# and is listed in the run summary so the gap is visible. +# +# Worth knowing, since nothing else runs the suite now: a spec whose area is +# never touched by any PR will not execute. Set `scope: full` below if you would +# rather every run cover the whole @fresh tier. + +name: QA suite + +on: + pull_request: + # No `synchronize`: a push does not spend runner minutes. + types: [opened, reopened, ready_for_review] + issue_comment: + types: [created] + workflow_dispatch: + +jobs: + pr: + # Every comment on every issue and PR fires `issue_comment`. Anything that + # is not the command stops here and never starts the reusable workflow, + # which checks the command again and also who wrote it. + if: github.event_name != 'issue_comment' || startsWith(github.event.comment.body, '@claudegrill suite') + uses: ThemeGrill/claudegrill/.github/workflows/suite.yml@v1.7.1 + with: + product_slug: flash + product_type: theme + scope: changed + diff --git a/.gitignore b/.gitignore index f39945b..ffeb497 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,15 @@ Thumbs.db flash.zip # Package Json file -/package-lock.json \ No newline at end of file +/package-lock.json + +# claudegrill β€” local credentials, never commit +.themegrill-qa/.env.local + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +# Saved admin session (a live auth cookie) +/tests/e2e/.auth/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..88b84ad --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# stylelint-config-wordpress@12 declares a peer of stylelint ^7 while the build +# pins stylelint ^9; without this `npm install` (used by the QA suite in CI) +# refuses to resolve the tree. +legacy-peer-deps=true diff --git a/.themegrill-qa/docs/flash.md b/.themegrill-qa/docs/flash.md new file mode 100644 index 0000000..eb43eaf --- /dev/null +++ b/.themegrill-qa/docs/flash.md @@ -0,0 +1,52 @@ +# Flash β€” documentation (secondary source) + +**Not an ingest.** `ingest-docs.mjs` could not read docs.themegrill.com on +2026-09-24. Every route (REST `doc_category`, `/sitemap.xml`, +`/wp-sitemap.xml`, `/sitemap_index.xml`, the page itself) returned HTTP 403 from +the site's BlogVault firewall ("Blocked because of Malicious Activities", +reference 5752720196ab511afc1a50) to curl, headless and headed Chromium, and an +external fetcher alike. What follows is what the 2026-09 documentation audit +(`test-radiate/themegrill-documentation-audit.html`, section "Flash & Flash +Pro", findings FLA-F1…F12) and the Flash QA report recorded about each page. +Quotes are the auditors' quotes of the docs, not a fresh read. + +Re-run `ingest-docs.mjs --rest https://docs.themegrill.com/flash` from an +unblocked network (or after the firewall entry is cleared) to replace this file +with a real ingest and a `docs-index.json`. + +## Pages the audit read + +| Doc page | What it says / covers | Audit verdict | +|---|---|---| +| https://docs.themegrill.com/flash/ | Index of categorised articles (Global, Header, Content, Footer, Widgets, WooCommerce, Pricing & License) | Customizer articles "genuinely good, Flash-specific, and accurate" | +| …/flash/docs-category/header/ | Header Customizer articles | accurate | +| …/flash/docs-category/content/ | Content Customizer articles | accurate | +| …/flash/docs/customize-primary-header/ | Sticky Header "keeps the header in the same place"; QA quotes it as making "the header section sticky when the user scrolls down the page content". Also documents Header β€Ί Primary Header β€Ί Search | Silent on whether the top bar is part of the sticky block (FLA-F9) | +| …/flash/docs/customize-primary-menu/ | Mobile Menu section: colours only | Does not mention that the Free header search is unreachable on phones (FLA-F10) | +| …/flash/docs/optimization/ | Preloader, Scrollbar, Animation | No troubleshooting for the slow Kirki first load (FLA-F3) | +| …/flash/docs/customize-woocommerce-shop-page/ | Products Per Page, Products Per Row | Nothing on the "Shop" h1 or Free's blog sidebar on shop pages (FLA-F7) | +| …/flash/docs-category/widgets/ | 30 Flash Toolkit widget pages, fields only | No warning about repeater widgets in the block Widgets screen (FLA-F6) | +| …/flash/docs/upgrading-flash-to-flash-pro/ | Install Pro, then Appearance β€Ί Themes β€Ί Activate. "All your content theme settings will remain as it is even after switching to the Pro theme." | **Contradicted by the product** (FLA-F1, P0) | +| …/flash/docs/add-portfolio-to-wordpress-site/ | Portfolio setup | Archive layout not described; body not fully read (FLA-F11) | + +Generic pages the audit tied to Flash: …/docs/will-i-lose-my-work-if-i-update-from-the-free-version-to-the-pro-version/ +(same retention claim), …/docs/importing-demo-content/ (one sentence), +…/docs/how-to-import-demo-content-in-themegrill-themes/ (no builder +prerequisites or media handling), …/docs/how-to-check-plugin-conflict/, +…/docs/translate-wordpress-themes-and-plugins-using-polylang/ (generic), and +…/docs/wordpress-site-too-slow-causes-and-solutions/. + +## Stated outcomes usable as assertions + +- Upgrade: settings "will remain as it is" after switching to Pro (false after + a second Pro activation; see knowledge.md Doc drift). +- Primary Header β€Ί Sticky Header: header stays pinned while scrolling. +- Primary Header β€Ί Search: the header search icon can be enabled/disabled. +- WooCommerce shop page: Products Per Page and Products Per Row control the grid. + +## Gaps the audit found (no page exists) + +Flash-specific demo import (FLA-F2); Elementor / SiteOrigin usage and the +1200 px Full Width clamp (FLA-F5); a widget overview index; troubleshooting +(the sitewide category holds one unrelated article; two Flash knowledgebase +category URLs return 404). diff --git a/.themegrill-qa/knowledge.md b/.themegrill-qa/knowledge.md new file mode 100644 index 0000000..7ac9e48 --- /dev/null +++ b/.themegrill-qa/knowledge.md @@ -0,0 +1,546 @@ +# Flash β€” QA Knowledge + +Draft, not final (generated 2026-09-24 by `knowledge-init`, then updated the +same day with the 2026-09 QA reports from test-radiate, the docs audit and the +first suite runs). See "Needs a human" at the bottom before trusting the +critical-flow ordering or anything marked TODO. + +## Product + +- Flash (free) 1.4.12, text domain `flash`, entry `style.css`. Pro sibling: + `flash-pro` 2.4.17 in the same install β€” a separate codebase. Verify + shared-looking code on each tier independently. +- `style.css`: `Tested up to: 6.8`, `Requires PHP: 5.6`. +- Classic PHP theme built on Underscores. No REST routes, no CPTs, no + shortcodes, no custom tables, no `woocommerce/` template overrides. +- Customizer is built on a **bundled Kirki** (`inc/kirki/`, required at + `functions.php:327`). `inc/customizer.php:17` does a bare `exit;` if the + `Kirki` class is missing, and all panels/sections/fields are registered + inside an `init` closure (`inc/customizer.php:22`). +- Front-page content is not the theme's own: the demos build it from + **Flash Toolkit** widgets in **SiteOrigin Page Builder** layouts + (`page-templates/pagebuilder.php`, `inc/siteorigin-panels.php`). The theme + owns the styling and JS (`js/flash.js`, Swiper, Isotope, Waypoints, + CounterUp registered in `functions.php:289-305`). +- Assets on every front-end view (`flash_scripts`, `functions.php:238`): + Font Awesome 6 `all` + `v4-shims` + solid/regular/brands (templates still + use FA4 names such as `fa-clock-o`, resolved via the shims), `style.css`, + `css/responsive.css`, `jquery.nav`, `flash.js`, `navigation.js`, + skip-link fix. `jquery.sticky` only when the sticky header is on + (`functions.php:293`). `.min` unless `SCRIPT_DEBUG`. + +## Settings surfaces (source-derived) + +All Kirki config `flash_config`, `option_type => theme_mod`, +`capability => edit_theme_options` (`inc/customizer.php:23-29`). Line +numbers are the `add_field` call in `inc/customizer.php`. + +**Beware the inverted checkboxes:** most "Disable"/"Remove" checkboxes are +off by default, and the templates show the feature when the mod is `!= '1'`. +The setting ID and the label say "remove/disable"; a truthy value hides the thing. + +| Panel β€Ί Section (id) | Setting | Control / label | Default | Consumed at | +|---|---|---|---|---| +| Global β€Ί Colors β€Ί Base Colors (`flash_base_colors`) | `color_scheme` | select: default/dark/gray/red/yellow | `default` | `:1419`, `:1507` | +| | `link_color` "Primary Color" | color | scheme[1] `#30AFB8` | `flash_link_color_css` `:1679` | +| | `secondary_text_color` "Text Color" | color | scheme[3] `#666666` | `:1833` | +| Global β€Ί Colors β€Ί Heading Colors (`flash_heading_colors`) | `main_text_color` "Headings" | color | scheme[2] `#313b48` | `:1784` | +| Global β€Ί Background (`flash_background`) | core `background_color`, `background_image` (moved here, `:1108-1111`) | β€” | scheme[0] | core | +| Global β€Ί Layout β€Ί Site Layout | `flash_site_layout` | radio-buttonset wide/boxed | `wide` | body class `boxed` (`inc/extras.php:29`) | +| Global β€Ί Layout β€Ί Sidebar Layout | `flash_page_layout`, `flash_post_layout`, `flash_archive_layout` | radio-image right-sidebar/left-sidebar/full-width/full-width-center | `right-sidebar` | body class (`inc/extras.php:45-67`) | +| Global β€Ί Typography β€Ί Base | `flash_body_font` | Kirki typography, output on `body` | Montserrat regular | Kirki | +| Header β€Ί Site Identity (`title_tagline`) | `flash_transparent_logo` | image | `''` | `header.php:91` | +| | `flash_retina_logo` / `flash_retina_logo_upload` | checkbox + image | 0 / `''` | `inc/extras.php:635` (srcset on logo) | +| Header β€Ί Header Media (`header_image`) | core header image/video | β€” | β€” | `template-parts/header-media.php` | +| Header β€Ί Header Top Bar | `flash_top_header` "Enable" | toggle | **`1`** | `header.php:69` | +| | `flash_top_header_left` / `_right` | select social-menu/header-text/disable | `disable` | `header.php:73,76` | +| | `flash_top_header_text` | editor, `postMessage` | `''` | `flash_top_header_content` | +| Header β€Ί Primary Header | `flash_logo_position` | radio-image left-logo-right-menu / right-logo-left-menu / center-logo-below-menu | `left-logo-right-menu` | body class | +| | `flash_header_search` "Disable" | checkbox (inverted) | `''` | `header.php:143,173` | +| | `flash_header_cart` "Disable" | checkbox (inverted), needs WooCommerce | `''` | `header.php:128,158` | +| | `flash_sticky_header` "Enable" | toggle | `''` | body class `header-sticky`, enqueues `jquery.sticky` | +| Content β€Ί Page Header | `flash_pageheader_background_image` | image, `postMessage` | `''` | `flash_frontend_css` `:1861` | +| | `flash_remove_breadcrumbs` "Disable" | checkbox (inverted) | `''` | `inc/extras.php:161` | +| Content β€Ί Blog/Archive | `flash_blog_style` | radio-image classic-layout / full-width-archive / grid-view | `classic-layout` | body class | +| | `flash_remove_meta_date/_author/_comment_count/_category/_tag` | 5 checkboxes (inverted) | `''` | `inc/template-tags.php:31-65` | +| Content β€Ί Single Post | `flash_related_post_option` "Enable" | switch | `0` | `single.php:31` | +| | `flash_related_post_option_display` | radio categories/tags | `categories` | `inc/extras.php:586-592` | +| | `flash_remove_single_bio` "Disable" | checkbox (inverted) | `''` | `template-parts/author-bio.php:9` | +| | `flash_remove_single_nav` "Disable" | checkbox (inverted) | `''` | `single.php:27` | +| Footer β€Ί Footer Widgets Area | `flash_footer_widgets` | radio-image 1–4 columns | `4` | `sidebar-footer.php:33` | +| Footer β€Ί Scroll to Top | `flash_disable_back_to_top` "Disable" | checkbox (inverted) | `''` | `footer.php:49` | +| Additional β€Ί Integration | `flash_custom_css` | code β€” **only when `wp_update_custom_css_post()` is missing (WP < 4.7)** (`:965`) | `''` | `flash_frontend_css` | +| Additional β€Ί Optimization | `flash_disable_preloader` "Disable" | checkbox (inverted) | `''` | `header.php:38` | +| (top) `flash_upsell_section` | "View Pro version" link section | β€” | β€” | `inc/admin/class-flash-upsell-section.php` | + +Core tweaks in `flash_customize_register` (`inc/customizer.php:1094`): +`blogname`, `blogdescription` and `header_textcolor` become `postMessage`, the +`header_textcolor` control is removed (it shares the heading colour), and the +core `background_image` section is removed. The four colour settings are also +`postMessage`, previewed by the JS templates at `:1655`. + +Colour CSS is emitted inline only when a value **differs from the active +scheme's default** (`inc/customizer.php:1685`, strict `===` compare, so case +matters: TODO confirm that `#30afb8` saved lower-case does not emit a +redundant block). + +Other theme features (`functions.php`): `custom-logo`, `custom-background`, +custom header (+ video), nav menus `primary`, `social`, `footer`; sidebars +`flash_right_sidebar`, `flash_left_sidebar`, `flash_footer_sidebar1`–`4`; +image sizes `flash-square` 300Γ—300, `flash-big` 800Γ—400, `flash-grid` +370Γ—270; WooCommerce + gallery zoom/lightbox/slider; Jetpack infinite scroll +(`inc/jetpack.php`); `align-wide`, `wp-block-styles`, `responsive-embeds`, +selective-refresh widgets; editor styles (`style-editor-block.css`). +Page templates: `full-width.php`, `pagebuilder.php`. + +Per-post meta boxes (`inc/meta-boxes.php`): +- "Select Layout" on posts and pages β†’ post meta `flash_page_layout`: + `default-layout` / right-sidebar / left-sidebar / full-width / + full-width-center. `default-layout` or empty falls back to the Customizer + value (`inc/extras.php:55-67`). +- "Header Transparency" on pages only β†’ post meta `flash_transparency`: + `transparent` / `non-transparent`, added verbatim as a body class + (`inc/extras.php:34`). + +Admin surfaces: +- Appearance β†’ "Flash Options" (`flash-options`, + `inc/admin/class-flash-dashboard.php:35`, `edit_theme_options`). +- Welcome notice with a demo-import button β†’ AJAX `wp_ajax_import_button` + (`inc/admin/class-flash-welcome-notice.php:10,103`). +- Upgrade notice and theme-review notice, both dismissible via nonced GET. + +## Persistence + +- Theme mods: everything in the table above (option `theme_mods_flash`), + plus core `custom_logo`, `header_image*`, `background_*`, + `nav_menu_locations`. +- Options: `flash_theme_installed_time`, `flash_upgrade_notice_start_time`, + `flash_admin_notice_welcome` (+ `flash_admin_notice_*` on dismiss), + migration flags `flash_page_header_bg_customize_migrate` and + `flash_typography_transfer_free`. +- Post meta: `flash_page_layout`, `flash_transparency` (saved in + `flash_save_custom_meta`, `inc/meta-boxes.php:121`, `sanitize_text_field`; + an empty value deletes the meta). +- Transient: category count cache flushed on `edit_category` / `save_post` + (`inc/template-tags.php:119-127`). +- No custom tables. + +## Capability boundaries + +| Surface | Gate | Source | +|---|---|---| +| All Customizer settings | `edit_theme_options` (Kirki config) | `inc/customizer.php:26` | +| Flash Options page | `edit_theme_options` | `class-flash-dashboard.php:38` | +| Layout / transparency meta save | nonce `flash_meta_nonce` + `edit_page` / `edit_post` | `inc/meta-boxes.php:124-138` | +| Welcome notice, demo-import AJAX | `manage_options` + `check_ajax_referer('flash_demo_import_nonce')` | `class-flash-welcome-notice.php:36,104-106` | +| Plugin install/activate from notice | `activate_plugin` | `class-flash-welcome-notice.php:128,169` | +| Upgrade notice dismiss | nonce + `publish_posts` | `class-flash-notice.php:58-81`, `class-flash-upgrade-notice.php:8` | +| Theme-review notice dismiss | nonce only | `class-flash-theme-review-notice.php:152,170` | +| Admin menu entries | `manage_options` | `class-flash-admin.php:42` | +| "Ready to publish" prompt on empty blog | `publish_posts` | `template-parts/content-none.php:19` | + +## Migrations / upgrade handling + +Three routines, all on `after_setup_theme`, none versioned: +1. `flash_font_family_change` (`functions.php:363`, since 1.2.8): turns the + old string `flash_body_font` (`Montserrat:400,700` / `Raleway:…` / + `Ruda:…`) into Kirki's array shape. It runs once, guarded by option + `flash_typography_transfer_free`, and only if `theme_mods_flash` exists. + TODO: a value that isn't one of the three strings falls back to Montserrat. + Confirm with a human whether that is intended. +2. `flash_page_header_bg_customize_migrate` (`inc/migration.php:20`, since + 1.3.8): moves `flash_pageheader_background` β†’ + `flash_pageheader_background_image`. It runs once, guarded by an option + (changelog 1.3.8 "Page header background image issue"). +3. `flash_custom_css_migrate` (`inc/extras.php:549`): appends + `flash_custom_css` to core Additional CSS, then removes the mod. It has no + done-flag and re-checks on every load until the mod is gone. + +## Fragile areas (git history, 653 commits, last 2026-09-03) + +- **Primary colour propagation**: the most repeated fix. Changelog 1.4.5 + ("not affecting mobile cart icon colour"), 1.4.6 ("not affecting + Preloader"), commit 276c7a7 (mobile caret icon background), and branch + `fix/primary-color` (1615537). New UI elements keep missing from the + hand-maintained selector list in `flash_link_color_css` + (`inc/customizer.php:1679-1775`). Any new element needs a colour check. +- **Customizer loading / Kirki**: 1d9c261 ("customizer global not + displaying"), changelog 1.4.11 ("Customizer section issue"), e78afe8 + (priority of theme options), 913bf80 (radio label click). + `inc/customizer.php` modified in 40 commits. +- **Sliders / Flash Toolkit sections (theme JS)**: c736dfe (TT-3667, + double-escaped slider nav arrows), 4db95f7 (double arrow), fecb10a + (responsive autoplay), 58ad717 (Firefox image overlap), 214b857 + (testimonial bullets), dd48489 / 1.4.0.1 (`flash.min.js` out of sync with + `flash.js`). Files: `js/flash.js` (19), `js/flash.min.js` (10). + **The min file is committed and has been out of sync with the source + before.** +- **Header cart / WooCommerce**: e485518 (fatal: `get_cart_contents_count()` + on null; now guarded by `isset( WC()->cart )` at `header.php:158`, but + `header.php:128` checks only `class_exists`), 1.4.5 ("Option to enable + header cart icon not appearing"). +- **Sidebar layout**: df02685 / 1.3.9 ("Blog/Post sidebar issue in Full + width"). +- **Admin notices / dashboard**: `class-flash-dashboard.php` (15), + `class-flash-theme-review-notice.php` (14), fef3d75 ("array offset on + value of type bool"), fc22932 (child-theme name/version). +- Most-modified files, excluding README and build files: `style.css` (96), + `style-rtl.css` (45), `inc/customizer.php` (40), `functions.php` (35), + `assets/sass/_theme-style.scss` (35), `inc/extras.php` (24). + `style.css` and `style-rtl.css` are compiled from `assets/sass/`, so RTL + can drift from LTR. + +## Audit history (what the live evidence below comes from) + +- **QA pass, 2026-09-18, test-radiate.local** (`flash-qa-report.html`, + `flash-Theme-Research-and-Improvement-Report.html`, + `flash-research/hands-on-notes.md`): WP 7.1.1, Flash 1.4.12, Flash Pro + 2.4.17, Flash Toolkit 1.2.6, SiteOrigin, Everest Forms, Starter Templates + 2.1.3; WooCommerce 11.1.0 added in a follow-up. Demo: Flash Food, plus a + Flash Construction homepage. Chromium at 1440, 768 and 375 px. The site is + not clean: it has Radiate leftovers (17 pages, 6 posts from 2014), and the + admin password was reset. +- **Senior-dev audit, 2026-09-21, sandbox 127.0.0.1:8811** + (`flash-free-pro-senior-dev-audit.html`, finding IDs FLASH-0xx, evidence in + `flash-audit-evidence/`): fresh WP 7.1.1, PHP 8.2/8.3/8.5, WP_DEBUG on, + WooCommerce 11.1.0. No demo; content seeded instead (14 posts, 8 products + 1 + variable, a Home/About/Blog menu). 8 third-party plugins were exercised one + at a time. +- **Docs audit** (`themegrill-documentation-audit.html`, FLA-F1…F12): + summarised in `.themegrill-qa/docs/flash.md`. +- **This suite, 2026-09-24**: all green on test-radiate.local (Flash active, + construction demo, WooCommerce in coming-soon mode) and on a fresh + WordPress Playground boot. + +All report files live in the local `test-radiate` site directory (the Local site root, beside `app/`). +Issue numbers below are in **themegrill/flash-pro** (private), where the +audits filed Free and Pro bugs alike. + +## Critical flows β€” TODO: confirm ordering with a human + +Ordered by the senior-dev audit's release-risk ranking ("finalSummary"), with +areas named after the suite's `@area` tags. `suite-index.mjs` derives +`areas_uncovered` from this list, so a wrong list misdirects every later +effort. Specs exist for the areas marked (spec). + +1. **homepage** β€” front page serves, flash.js bootstrap runs, preloader clears, one h1 (spec) +2. **woocommerce** β€” header cart and fragments, shop/product/cart, the page-type helper behind the "Shop" title (spec, @demo only) +3. **free-to-pro-migration** β€” theme_mods_flash copied into Pro on activation; `flash_pro_active` gate (#33) +4. **mobile-menu** β€” hamburger at ≀768 px, submenu toggles, keyboard access (#90) (spec) +5. **header** β€” search toggle, cart icon, 3 logo positions, sticky header, top bar, transparent header per page (spec) +6. **breadcrumbs** β€” the trail builder in inc/extras.php: date, category and author crumbs (#93) +7. **search** β€” results and zero-result pages (spec) +8. **not-found** β€” 404 status, heading, search form (spec) +9. **single-post** β€” title h1, breadcrumbs, post navigation, author bio, comments (spec) +10. **blog** β€” posts index, archive styles, post-meta toggles (spec) +11. **related-posts** β€” on/off, categories vs tags, cards without thumbnails (#32) +12. **customizer-colors** β€” scheme and primary/text/heading colours reaching every element (the most-repeated fix in git history) +13. **layouts** β€” wide/boxed; page/post/archive sidebar layouts; per-post "Select Layout" meta +14. **footer** β€” widget columns, copyright, scroll-to-top (spec) +15. **demo-homepage** β€” imported SiteOrigin + Flash Toolkit front page (spec, @demo only) +16. **demo-import** β€” welcome notice β†’ Starter Templates (SiteOrigin tab, Free filter) β†’ import +17. **admin-notices** β€” welcome/upgrade/review notices by role, dismiss persists + +## Expected behaviour + +### Live-verified (reports and this suite) + +Each item was observed working, and those marked (spec) are now asserted. +- **Front page** serves 200 with no console or page errors. `#preloader-background` + goes `display:none` about 0.6 s after ready. Exactly one h1, `.site-title`, + on `/` (DOMJ; spec). +- **Skip link.** "Skip to content" is the first Tab stop and targets + `#content` (MOBJ Tab order; spec). +- **Desktop header search.** `.search-icon` toggles `.search-box.active`, the + field gets focus, Enter searches, and Escape closes it. The box is + `visibility:hidden` while closed, so there's no invisible focus trap + (FLASH-014 notes; spec). +- **Mobile menu.** `.menu-toggle` is `display:none` at 1366 px and visible at + 768 and 375 px. The menu starts `display:none` and slideToggles open and + closed on tap. `aria-expanded="true"` appears only after the first tap. + Submenus expand and collapse on the demo (DOMJ, MOBJ, QA; spec covers open + and close). +- **Search.** `h1.trail-title` "Search Results for: ", breadcrumbs, results + listed. Zero results show "Nothing Found" plus a new search form (DOMJ; spec). +- **404** serves HTTP 404 with "Oops! That page can’t be found." and a search + form. Without WooCommerce the header h1 reads "Page NOT Found" (FLASH-003 + expected; spec). +- **Single post.** The title is the only h1 (`h1.trail-title`), the breadcrumb + opens with `a.trail-home` β†’ home URL, and content and post navigation + render. On the demo, meta, categories and comments render too (DOMJ, QA; spec). +- **Posts index** lists linked post titles. As a separate posts page, it's + titled after that page ("Blog") (DOMJ; spec). +- **Scroll-to-top.** `a#scroll-up` is hidden at the top, fades in past 1000 px + of scroll, and animates back to 0 on click (QA; spec). +- **WooCommerce.** AJAX add-to-cart on /shop/ bumps the header `.cart-value` + immediately. Sale products show "Sale!" and del/ins prices. Cart and + checkout render. A long product title wraps without overflow, and a missing + product image falls back to the placeholder. The cart icon works at desktop + and 375 px (QA; spec covers the count and the sale badge). +- **Demo front page.** A SiteOrigin layout of Flash Toolkit widgets + (about, blog, counter, CTA, heading, portfolio) renders with no page errors. + The top bar shows the demo's contact text (QA; spec). +- **Header styles.** All 3 Free logo positions render with no leftovers when + switching (QA). There's no spec yet; it needs a theme-mod fixture. +- **Related posts** are off by default. When on, "You May Also Like" shows 3 + `.tg-column-3` cards drawn at random from posts sharing a category + (`orderby rand`). A card without an image omits `.post-thumbnails` (QA). +- **Customizer** loads with 0 errors in about 1.5 s. It has 163 settings + (91 postMessage). `flash_site_layout` and `flash_body_font` refresh the + preview (FLASH-041). +- **Robustness.** No `img` without `alt`. 0 px horizontal overflow at 1366 + and 768 px, and at 375 px without WooCommerce. No theme PHP messages on + 8.2/8.3/8.5 except FLASH-023. 2–9 theme queries per page with no N+1. + No cron events. No third-party front-end requests (Kirki self-hosts the + fonts). A child theme can override `flash_footer_copyright()` and + `content-none.php` cleanly (DOMJ, perf, FLASH-035). +- **Plugin compatibility (sandbox).** CF7 and Everest Forms submit and + validate. With Elementor, the default template is 786 px beside the sidebar + and Canvas is full-bleed. Yoast, Rank Math, LiteSpeed and Polylang (`/fr/` β†’ + `lang="fr-FR"`) produce 0 PHP messages. + +### From source +- On a fresh install the header top bar renders (`flash_top_header` + defaults to `1`), but both halves default to `disable`, so it is present + and empty. The sandbox headings fit this; the demo fills it. TODO (human): + is that intended, or should it default off? +- Search icon, cart icon, breadcrumbs, author bio, post navigation, + scroll-to-top, preloader and all five post-meta items show by default. + Each one is hidden by its "Disable" checkbox. +- The header cart icon appears only with WooCommerce active + (`header.php:128,158`). +- Hiding the header text (`display_header_text` off) visually hides the site + title and tagline with clip CSS. They stay in the DOM (`inc/customizer.php:1891`). +- Excerpt length is 20 words (`inc/extras.php:393`). +- `woocommerce_breadcrumb` is removed and WooCommerce page titles are hidden + (`inc/woocommerce.php:21,24`). The theme's page header shows the shop title + instead (`flash_page_title`, `inc/extras.php:377`). This is by design. +- TODO (human): with `tags` selected for related posts and an untagged post, + confirm the result is empty rather than unfiltered. + +## Known issues + +Quarantined scenarios (`test.fixme`, each carrying an `@guards` tag) exist +for the ones marked (fixme). Each was confirmed to fail on test-radiate.local +on 2026-09-24. Drop the `.fixme` in the PR that fixes it. + +**Free, or shared by Free and Pro** +- **#82 (Major, FLASH-001) β€” preloader never hides without jQuery Migrate.** + `jQuery( window ).load(fn)` at `js/flash.js:121` throws + `e.indexOf is not a function` on jQuery 3. The overlay stays up, and sticky + header, counter, scroll-to-top and slider fixes never run. Any + performance plugin that drops Migrate triggers it. Free only. +- **#30 (High, QA BUG-01) β€” header search unreachable below ~576 px.** + `.header-action-container` is 0Γ—0, and the hamburger panel has no search + form. This affects all 3 Free header styles; Pro is fine (fixme, + `header-search.spec.ts`). +- **#90 (Medium, FLASH-014) β€” hamburger and search toggles are `
`s** + with no role, name or tabindex (`header.php:117-119, 145-147`), so they + aren't keyboard-operable (fixme, `mobile-menu-toggle.spec.ts`). +- **#84 (Medium, FLASH-003/017/035) β€” page-header title reads "Shop" and + headings duplicate.** With WooCommerce active, `flash_is_woocommerce_page()` + (`inc/extras.php:511`) loosely compares `get_the_ID()` to the shop page ID. + The 404, an empty search, `/?author=999`, Cart, Checkout and My Account + all get WooCommerce's title. Seen live on 2026-09-24: the 404 shows "Shop", + and a zero-result search shows "Search results: β€œβ€¦β€". `/blog/` has two + h1s (the bar plus index.php's screen-reader h1). The helper can't be + overridden in a child theme (fatal) (fixme Γ—3: not-found, search, blog). +- **#83 (Medium, FLASH-002) β€” header cart fatal when `WC()->cart` is null** + (REST/headless renders). Master guards only the default layout + (`header.php:158`); `center-logo-below-menu` (`header.php:128-136`) still + fatals, and so does Pro's `cart-icon.php:9`. The released 1.4.12 zip + fatals on both. +- **#92 (Low, FLASH-019/020) β€” sideways scroll on phones.** With + WooCommerce, the hidden mini-cart adds 2–4 px at 320–412 px. The 404's + 300 px `fa-exclamation-circle` pushes scrollWidth to 493 at 375 px (fixme, + `not-found.spec.ts`). +- **#91 (Medium, FLASH-015/018) β€” contrast and landmarks.** Post meta is + `#8e8e8e` on white (3.27:1). The Home crumb is `#a1a1a1` on `#fafafa` + (2.47:1). axe finds 37 contrast failures on /blog/ and 19 on a single post. + The nav landmarks are unlabelled, and the breadcrumb has a redundant role. +- **#93 (Low, FLASH-021/022/023) β€” breadcrumbs.** The day-archive month link + is `/2026/00/`. A category name with a comma splits into two crumbs and + leaves a tag open (`inc/extras.php:222-223`). `/author/nobody/` logs + `Attempt to read property "display_name" on bool` (8.2) or `on false` + (8.3+) (`inc/extras.php:329`). +- **#32 (Medium, QA BUG-03) β€” related-posts grid misaligns** when a related + post has no featured image. It's intermittent because selection is random. +- **#88 (Medium, FLASH-011/034) β€” Kirki downloads Google Fonts inside the + visitor request.** Cold TTFB is 2.39 s vs 0.40 s warm. With an unwritable + `wp-content/fonts`, every request takes 6.2–6.4 s and logs 27 `copy()` + warnings. The editor loads fonts from Google's CDN. +- **#21 comment (FLASH-012) β€” 5 Font Awesome handles, 406 KB** (46 % of the + Free home page). `fa-opencart` pulls in the brands font. +- **#101 (Low, FLASH-042) β€” font-swap CLS 0.186** on desktop home. +- **#96 (Low, FLASH-025) β€” shop, category and product pages show the blog + sidebar.** There's no `sidebar-shop.php` (open since themegrill/flash#26). + QA saw a full-width shop because of the demo's layout setting. +- **#86 (Medium, FLASH-005) β€” the welcome installer bypasses + `DISALLOW_FILE_MODS`.** `wp_ajax_import_button` checks only + `manage_options`. FLASH-006 (Flash Toolkit): anonymous updater triggers + write `flash_toolkit_admin_notices`. +- **#87 (Low, FLASH-008/009) β€” related-posts `title=""`** + is exploitable only with wptexturize off. The meta-box save reads + `$_POST['flash_page_layout']` without `isset` (`inc/meta-boxes.php:144`) + and stores any string as a body class. +- **#95 (FLASH-024/030/031).** `$content_width` is computed too early (780 + on full width). There's dead code: `Flash_Site_Library` is never loaded and + `inc/customizer/class-flash-upsell-custom-control.php` is 0 bytes. The + `flash_categories` transient never expires. +- **#94 (FLASH-026) β€” footer credit markup.** `href="…"target=` has no space, + and the sentence is built from concatenated translatable strings. +- **#97 (FLASH-033).** The released 1.4.12 zip lacks master's fixes. + `Tested up to: 6.8` and `Requires PHP: 5.6` are stale. #24 (FLASH-029): + `flash.pot` dates from 2022. +- **#99 (FLASH-039).** There's no `wpml-config.xml`, so + `flash_top_header_text` can't be translated. +- **#40 (FLASH-040) β€” Elementor Full Width is clamped to 1200 px** by + `.tg-container`. +- **#33 (High, QA BUG-04 / FLASH-036) β€” Freeβ†’Pro settings aren't carried + over** after the first Pro activation. `flash_pro_active` is set before the + copy and is never cleared. +- **#31 (High, Flash Toolkit 1.2.6) β€” repeater widgets** (Slider, + Testimonial, Pricing Table) return 500 in the block Widgets screen + (`get_current_screen()` undefined). +- **Starter Templates / demo (TDI).** Demo media is hotlinked from + themegrilldemos.com (#15). The Elementor tab's Free filter is empty (#16). + The import dialog has a11y errors (#19). Notices appear on every admin + screen (#23). The Flash Food gallery section is blank (not filed). + +**Source-only, not in any report** +- `flash_scroll_to_top_fixed_header` (a heading-only `custom` control) is + registered twice (`inc/customizer.php:897` and `:920`). The first one sits + in the Footer Widgets block. TODO: check for a duplicate "Fixed" heading in + Footer β€Ί Scroll to Top. + +**Pro only (for context):** #34 (split menu renders 3 navs), #85 (author +social XSS), #89 (Freemius on every request), #14 (empty shop sidebar column), +#18, #20, #22. + +## Doc drift + +Docs could not be fetched live (see `.themegrill-qa/docs/flash.md`). These +drift items come from the 2026-09 docs audit and QA pass: +- `DOC DRIFT: https://docs.themegrill.com/flash/docs/upgrading-flash-to-flash-pro/` + says "All your content theme settings will remain as it is even after + switching to the Pro theme". The product copies settings only on Pro's + first-ever activation (#33). The same claim appears at + …/docs/will-i-lose-my-work-if-i-update-from-the-free-version-to-the-pro-version/ + (FLA-F1, P0). +- `DOC DRIFT: …/flash/docs/customize-primary-header/` documents the header + Search option with no caveat. The product's Free header search is + unreachable below ~576 px (#30), and …/customize-primary-menu/ covers only + mobile colours (FLA-F10). +- `DOC DRIFT: …/flash/docs/customize-primary-header/` says Sticky "keeps the + header in the same place". The product pins the top bar too, about 190 px + at desktop. The docs don't say whether that's intended (FLA-F9; + QA INVESTIGATION-01). +- `DOC DRIFT: https://themegrill.com/flash-free-vs-pro/` lists sticky + header, header layouts and the preloader as Pro-gated. Free actually ships + a Sticky toggle, 3 logo positions and the preloader. +- Undocumented behaviour: Kirki's slow first load (FLA-F3), the "Shop" h1 and + the shop blog sidebar (FLA-F7), repeater widgets in the block Widgets screen + (FLA-F6), and Elementor/SiteOrigin usage (FLA-F5). There's no Flash-specific + demo-import page (FLA-F2). + +## Test harness notes + +- **Sites.** The local suite runs against test-radiate.local (Flash active, + construction demo, WooCommerce, admin creds in `.env.local`). CI and + `run-suite.mjs --boot playground` run the `@fresh` tier on a bare Playground + install with no WooCommerce, no Flash Toolkit and no demo. Every `@fresh` + spec must hold on both. +- **Posts index location differs.** Playground creates a "Blog" page but + leaves `show_on_front = posts`, so its posts index is `/`. On the demo site + it's `/blog/`. Use `visitPostsIndex()` (`tests/e2e/utils/page.ts`), which + finds `body.blog`. +- **Preloader.** It covers the page for about 600 ms. Call `visit()` / + `waitForPreloader()` after every navigation. +- **Misleading selectors.** + - The desktop `#primary-menu` `
    ` has zero height (floated items), so + assert on its links. + - `.menu-toggle` and `.search-icon` are divs, so `getByRole('button')` + finds nothing. + - `.search-icon` reads `display:block` at 375 px inside a 0Γ—0 container, + so use the bounding box. + - The breadcrumb Home link is `a.trail-home` with no `rel`. + - The hidden mini-cart's `

    Cart

    ` pollutes heading counts. +- **WooCommerce coming-soon mode** is on for anonymous visitors on + test-radiate.local, and WooCommerce switches it back on whenever it's + toggled. Shop specs run as the saved admin. The admin's cart persists + across runs, so assert relative counts and clean up (see + `header-cart.spec.ts`). +- **Header cart after removal.** The header `.cart-value` wasn't refreshed + within 5 s after removing a line on the classic cart page. Reload before + asserting. Whether it should update live is unverified: TODO confirm + whether the cart page is meant to fire `wc_fragment_refresh`. +- **Font swap** shifts the desktop nav by about 21 px at 0.5–0.6 s. Wait for + `document.fonts.ready` before asserting geometry. The first request after + activation or a font change is slow (Kirki downloads the fonts). +- **Related posts are random.** Seed candidates that all have, or all lack, + images. +- **Toolkit repeater widgets** can only be seeded through SiteOrigin (#31). +- Never log in per spec; `auth.setup.ts` saves one session. wp-login's + post-load scripts can race `fill()`, so the setup verifies both fields + without printing them. +- Theme mods are per stylesheet (`theme_mods_flash` vs `theme_mods_flash-pro`). + `flash_pro_active` persists, so reset it between migration tests. Check the + active theme before probing. + +## Known non-issues β€” TODO: confirm with a human + +- No WooCommerce breadcrumb and no WooCommerce page title in the content + area: deliberate (`inc/woocommerce.php`). +- The Custom CSS field is absent from Additional β€Ί Integration on WP β‰₯ 4.7. + This is deliberate; old values are migrated to Additional CSS. +- Classic Editor opening for new pages comes from SiteOrigin, not the theme. +- WPForms failing to submit is identical on Twenty Twenty-Five, so it's not + Flash. +- Everest Forms desktop failure was harness timing. The "image 404" and + "959 KB image" findings were a dev-router artefact (`%20` not decoded). +- The Freemius fatal on Pro came from a git clone without submodules, not + from the product. +- `flash_font_family_change()` does not overwrite a later body-font choice. + Revisions store no `flash_page_layout` meta. The footer blogname is escaped. + The preloader does not slow LCP. +- Needs a product ruling rather than a bug report: sticky header including + the top bar (about 190 px), and the `/portfolio/` archive being a 1-column + list. + +## What must survive an upgrade β€” TODO: unknown, no human input yet + +From source, the candidates are the ~35 theme mods above, the two post-meta +keys, the three migrations (`flash_body_font` shape, page-header background +key, custom CSS β†’ Additional CSS), menu assignments to `primary` / `social` / +`footer`, and the contents of the six widget areas. Live evidence: sidebar +widgets survive the Free β†’ Pro switch, and theme mods do only on Pro's +first-ever activation (#33). A maintainer needs to say which ones are +promised. + +## Sources + +- Source: this checkout at 1.4.12 (b517fee, 2026-09-03), branch + `add/claudegrill-qa-setup`. +- Reports: `test-radiate/flash-qa-report.html`, + `flash-free-pro-senior-dev-audit.html` (+ `flash-audit-evidence/`), + `flash-Theme-Research-and-Improvement-Report.html`, `flash-research/*.md`, + `themegrill-documentation-audit.html`. +- Docs: not ingested. docs.themegrill.com blocks this machine's IP (BlogVault + firewall, 403, reference 5752720196ab511afc1a50). The secondary summary is + in `.themegrill-qa/docs/flash.md`. +- Git: `git log` over 653 commits, `README.md` changelog 1.3.2–1.4.12. +- Suite runs: 2026-09-24. `@fresh` 15 passed + 6 fixme on test-radiate.local + and on Playground. `@demo` 4 passed. Repeat Γ—3 with no flakes. + +## Needs a human + +- Critical-flow list and ordering: now ordered by the audit's risk ranking, + still unconfirmed. +- Rulings: the empty top bar on a fresh install; sticky pinning the top bar; + the related-posts tags fallback; whether the classic cart page should + refresh the header count live. +- Known non-issues: seeded from the audits, unconfirmed. +- What must survive an upgrade: unknown. +- Docs: re-run `ingest-docs.mjs` from an unblocked network, and ask whoever + runs docs.themegrill.com to clear the BlogVault block (ref above). +- `area_paths` in suite.json: drafted from source, needs review. diff --git a/.themegrill-qa/suite.json b/.themegrill-qa/suite.json new file mode 100644 index 0000000..81e5416 --- /dev/null +++ b/.themegrill-qa/suite.json @@ -0,0 +1,198 @@ +{ + "runner": "playwright", + "package_manager": "npm", + "install": "npm install --ignore-scripts --no-audit --no-fund", + "command": "npx playwright test", + "config": "playwright.config.ts", + "spec_dir": "tests/e2e/specs", + "spec_extension": ".spec.ts", + "json_report": "test-results/results.json", + "env": { + "base_url": "FLASH_BASE_URL", + "admin_user": "FLASH_ADMIN_USER", + "admin_pass": "FLASH_ADMIN_PASS" + }, + "tiers": { + "fresh": "@fresh", + "demo": "@demo" + }, + "area_paths": { + "homepage": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "index.php", + "page.php", + "page-templates/**", + "template-parts/content-page.php", + "js/skip-link-focus-fix.js", + "css/fontawesome*.css", + "css/all*.css", + "css/v4-shims*.css", + "fonts/**" + ], + "header": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "searchform.php", + "template-parts/header-media.php", + "js/jquery.sticky*.js", + "inc/woocommerce.php" + ], + "mobile-menu": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "js/navigation*.js", + "js/jquery.nav*.js" + ], + "search": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "search.php", + "searchform.php", + "template-parts/content-search.php", + "template-parts/content-none.php", + "inc/template-tags.php", + "inc/woocommerce.php" + ], + "blog": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "index.php", + "archive.php", + "template-parts/content.php", + "inc/template-tags.php", + "sidebar.php", + "sidebar-left.php" + ], + "single-post": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "single.php", + "comments.php", + "template-parts/content.php", + "template-parts/post-navigation.php", + "template-parts/author-bio.php", + "inc/related-posts.php", + "inc/template-tags.php", + "inc/meta-boxes.php" + ], + "footer": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "sidebar-footer.php" + ], + "not-found": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "404.php", + "searchform.php", + "inc/woocommerce.php" + ], + "woocommerce": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "inc/woocommerce.php" + ], + "demo-homepage": [ + "style.css", + "style-rtl.css", + "css/responsive.css", + "functions.php", + "header.php", + "footer.php", + "inc/extras.php", + "inc/customizer.php", + "inc/kirki/**", + "js/flash.js", + "js/flash.min.js", + "page-templates/pagebuilder.php", + "inc/siteorigin-panels.php", + "js/swiper*.js", + "css/swiper*.css", + "js/isotope*.js", + "js/waypoints*.js", + "js/jquery.counterup*.js" + ] + } +} diff --git a/Gruntfile.js b/Gruntfile.js index b42096a..076e77e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -157,6 +157,15 @@ module.exports = function( grunt ){ '!node_modules/**', '!sass/**', '!phpcs.xml', + '!tests', + '!tests/**', + '!playwright.config.ts', + '!test-results', + '!test-results/**', + '!playwright-report', + '!playwright-report/**', + '!blob-report', + '!blob-report/**', 'README.md' ], dest: 'flash', diff --git a/gulpfile.js b/gulpfile.js index 03521d8..7c371fb 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -125,7 +125,16 @@ var paths = { '!*.md', '!gulpfile.js', '!composer.lock', - '!phpcs.xml' + '!phpcs.xml', + '!tests', + '!tests/**', + '!playwright.config.ts', + '!test-results', + '!test-results/**', + '!playwright-report', + '!playwright-report/**', + '!blob-report', + '!blob-report/**' ], dest: './dist' } diff --git a/package.json b/package.json index f23b86d..888891c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "license": "GPL-3.0+", "main": "Gruntfile.js", "devDependencies": { + "@playwright/test": "^1.63.0", "autoprefixer": "^8.5.1", "browser-sync": "^2.24.4", "eslint": "^4.19.1", @@ -50,7 +51,8 @@ "uglify": "gulp minifyJs", "makepot": "grunt makepot", "compress": "grunt compress", - "build": "npm run styles && npm run uglify && npm run makepot && npm run compress" + "build": "npm run styles && npm run uglify && npm run makepot && npm run compress", + "test:e2e": "playwright test" }, "dependencies": { "gulp-line-ending-corrector": "^1.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..db04b69 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * TGQA_BASE_URL / TGQA_ADMIN_USER / TGQA_ADMIN_PASS are set by claudegrill's + * run-suite.mjs before invoking this config. See .themegrill-qa/knowledge.md + * for what Flash's specs assume about the site under test. + */ +export default defineConfig({ + testDir: "./tests/e2e/specs", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]], + use: { + baseURL: process.env.TGQA_BASE_URL, + trace: "on-first-retry", + }, + projects: [ + { name: "setup", testDir: "./tests/e2e", testMatch: /auth\.setup\.ts/ }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + dependencies: ["setup"], + }, + ], +}); diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..7d8bc7e --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type Page } from "@playwright/test"; +import { test as setup, expect } from "./fixtures"; +import { ADMIN_STATE, hasAdminCredentials } from "./utils/login"; + +/** + * Fills the login form and confirms both fields hold what was typed. + * wp-login.php runs focus and show-password scripts after `load`, and a fill + * racing them has been seen to land in the wrong field (2026-09-24). The + * check compares values without printing them β€” a toHaveValue() failure + * would write the password into the report. + */ +async function fillLoginForm(page: Page, user: string, pass: string) { + const userField = page.locator("#user_login"); + const passField = page.locator("#user_pass"); + for (let attempt = 1; attempt <= 3; attempt++) { + await userField.fill(user); + await passField.fill(pass); + if ( + (await userField.inputValue()) === user && + (await passField.inputValue()) === pass + ) { + return; + } + } + throw new Error("login form did not keep the typed credentials after 3 attempts"); +} + +/** + * Logs in once through core's wp-login.php form and saves the session for + * every spec that needs one (see utils/login.ts for why not per spec). + * Tagged with both tiers because run-suite.mjs filters every project by tier, + * this one included. Without credentials it writes an empty state, and the + * specs that need a session skip themselves. + */ +setup("authenticate as admin @fresh @demo", async ({ page }) => { + fs.mkdirSync(path.dirname(ADMIN_STATE), { recursive: true }); + if (!hasAdminCredentials()) { + fs.writeFileSync(ADMIN_STATE, JSON.stringify({ cookies: [], origins: [] })); + return; + } + + await page.goto("/wp-login.php"); + await page.waitForLoadState("networkidle"); + await fillLoginForm( + page, + process.env.TGQA_ADMIN_USER!, + process.env.TGQA_ADMIN_PASS!, + ); + + // Wait on the path, not a regex over the URL: wp-login.php's own + // `redirect_to=…/wp-admin/` query would match before login completes. + await Promise.all([ + page.waitForURL((url) => url.pathname.includes("/wp-admin/")), + page.locator("#wp-submit").click(), + ]); + await expect(page.locator("#wpadminbar")).toBeAttached(); + + await page.context().storageState({ path: ADMIN_STATE }); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..d42e76b --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,35 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +/** + * Every spec imports `test` from here, not from @playwright/test. + * + * CI boots WordPress Playground with `--login`, whose mu-plugin logs in any + * visitor that does not carry the `playground_auto_login_already_happened` + * cookie. Without it every "logged-out" spec actually runs as admin, with the + * admin bar over the page (seen on PR #56). Setting the cookie on each context + * makes a fresh context anonymous on Playground and is ignored everywhere + * else. Specs that need a session still load ADMIN_STATE on top of it. + */ +export const test = base.extend({ + context: async ({ context, baseURL }, use) => { + await context.addCookies([ + { + name: "playground_auto_login_already_happened", + value: "1", + domain: new URL(baseURL!).hostname, + path: "/", + }, + ]); + await use(context); + }, +}); + +/** Fails legibly, instead of timing out later, if the visitor is logged in. */ +export async function expectLoggedOut(page: Page): Promise { + await expect( + page.locator("#wpadminbar"), + "this spec expects an anonymous visitor, but the admin bar is present", + ).toHaveCount(0); +} + +export { expect }; diff --git a/tests/e2e/specs/blog/blog-index.spec.ts b/tests/e2e/specs/blog/blog-index.spec.ts new file mode 100644 index 0000000..04e765d --- /dev/null +++ b/tests/e2e/specs/blog/blog-index.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "../../fixtures"; +import { visitPostsIndex } from "../../utils/page"; + +/** + * @area blog + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ) 2026-09-21 + * @why The posts index lists linked post titles. When it is a separate posts + * page (not the front page), the page-header bar is titled with that + * page's name (flash_page_title, is_home branch, inc/extras.php:369-371). + */ +test("the posts index lists linked posts, titled after the posts page @fresh @blog", async ({ + page, +}) => { + const { isFrontPage } = await visitPostsIndex(page); + + if (!isFrontPage) { + await expect(page.locator("h1.trail-title")).toHaveText("Blog"); + } + + const articles = page.getByRole("main").locator("article"); + expect(await articles.count()).toBeGreaterThan(1); + await expect(articles.first().locator(".entry-title a")).toHaveAttribute("href", /\S/); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area blog + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html FLASH-017, 2026-09-21 + * @guards themegrill/flash-pro#84 + * @why index.php adds a screen-reader h1 "Blog" on top of the page-header + * bar's h1, so /blog/ has two h1s. Confirmed live on test-radiate.local + * 2026-09-24 (`h1.trail-title` + `h1.page-title.screen-reader-text`). + */ +test.fixme("the posts page has exactly one h1 @fresh @blog", async ({ page }) => { + const { isFrontPage } = await visitPostsIndex(page); + test.skip(isFrontPage, "only a separate posts page gets the extra h1"); + await expect(page.locator("h1")).toHaveCount(1); +}); diff --git a/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts b/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts new file mode 100644 index 0000000..5368091 --- /dev/null +++ b/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "../../fixtures"; +import { visit } from "../../utils/page"; + +/** + * @area demo-homepage + * @tier demo + * @source flash-qa-report.html "Tested β€” No Issue Found (Flash Free)" 2026-09-18 + * @why After a Starter Templates import the front page is a SiteOrigin layout + * (page-templates/pagebuilder.php) of Flash Toolkit widgets, and the QA pass + * found it rendering sliders, portfolio, testimonials and the blog section + * with no console errors. Needs Flash Toolkit, SiteOrigin and an imported + * demo; skips on a site without them. + */ +test("the imported demo front page renders its Flash Toolkit sections @demo @demo-homepage", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await visit(page, "/"); + const layout = page.locator(".panel-layout"); + test.skip((await layout.count()) === 0, "front page is not a SiteOrigin layout (no demo imported)"); + + await expect(layout.locator(".panel-grid").first()).toBeVisible(); + expect(await layout.locator("[class*='widget_themegrill_flash_']").count()).toBeGreaterThan(0); + expect(errors, `page errors: ${errors.join("; ")}`).toEqual([]); +}); + +/** + * @area demo-homepage + * @tier demo + * @source flash-qa-report.html INVESTIGATION-01; header.php:69-80 + * @why The demo configures the header top bar (flash_top_header on, with + * left/right content); it renders above the primary header with that + * content visible. + */ +test("the header top bar shows the demo's contact content @demo @demo-homepage", async ({ + page, +}) => { + await visit(page, "/"); + const top = page.locator("#masthead .header-top"); + test.skip((await top.count()) === 0, "top bar disabled on this site"); + + await expect(top).toBeVisible(); + await expect(top).not.toHaveText(/^\s*$/); +}); diff --git a/tests/e2e/specs/footer/scroll-to-top.spec.ts b/tests/e2e/specs/footer/scroll-to-top.spec.ts new file mode 100644 index 0000000..a6e12ff --- /dev/null +++ b/tests/e2e/specs/footer/scroll-to-top.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "../../fixtures"; +import { visitPostsIndex } from "../../utils/page"; + +/** + * @area footer + * @tier fresh + * @source flash-qa-report.html "Tested β€” No Issue Found (Flash Free)" 2026-09-18; + * js/flash.js:160-185 + * @why footer.php:49 renders `a#scroll-up` unless flash_disable_back_to_top. + * flash.js hides it on ready, fades it in past 1000 px of scroll, and + * animates back to the top on click. The QA pass verified all three. + */ +test("scroll-to-top appears after scrolling and returns to the top @fresh @footer", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await visitPostsIndex(page); + + const button = page.locator("a#scroll-up"); + await expect(button).toBeHidden(); + + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + const scrolled = await page.evaluate(() => window.scrollY); + test.skip(scrolled <= 1000, `page is only ${scrolled}px scrollable; needs > 1000 px of content`); + + await expect(button).toBeVisible(); + await button.click(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); +}); diff --git a/tests/e2e/specs/header/header-search.spec.ts b/tests/e2e/specs/header/header-search.spec.ts new file mode 100644 index 0000000..e16e761 --- /dev/null +++ b/tests/e2e/specs/header/header-search.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "../../fixtures"; +import { visit } from "../../utils/page"; + +/** + * @area header + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ, FLASH-014) 2026-09-21 + * @why header.php:173 renders the search icon unless flash_header_search is + * set (default ''). js/flash.js:39-49 toggles .search-box.active on click + * and focuses the field; the box is visibility:hidden until then, so it is + * not an invisible focus trap. Guards the default-on desktop search end to + * end. Does not cover the Disable setting (needs a theme-mod fixture). + */ +test("the header search icon opens a focused search form that returns results @fresh @header", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await visit(page, "/"); + + const wrap = page.locator("#masthead .header-action-container .search-wrap"); + const box = wrap.locator(".search-box"); + const field = box.getByRole("searchbox", { name: "Search for:" }); + await expect(field).toBeHidden(); + + await wrap.locator(".search-icon").click(); + await expect(box).toHaveClass(/\bactive\b/); + await expect(field).toBeVisible(); + await expect(field).toBeFocused(); + + await field.fill("a"); + await field.press("Enter"); + await expect(page).toHaveURL(/[?&]s=a\b/); + await expect(page.getByRole("main").locator("article").first()).toBeVisible(); +}); + +/** + * @area header + * @tier fresh + * @source js/flash.js:52-64 + * @why Escape closes an open header search box (keyup 27 β†’ hideSearchForm). + */ +test("Escape closes the open header search box @fresh @header", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await visit(page, "/"); + + const wrap = page.locator("#masthead .header-action-container .search-wrap"); + await wrap.locator(".search-icon").click(); + await expect(wrap.locator(".search-box")).toHaveClass(/\bactive\b/); + + await page.keyboard.press("Escape"); + await expect(wrap.locator(".search-box")).not.toHaveClass(/\bactive\b/); + await expect(wrap.getByRole("searchbox")).toBeHidden(); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area header + * @tier fresh + * @source flash-qa-report.html BUG-01, 2026-09-18 + * @guards themegrill/flash-pro#30 + * @why Below ~576 px `.header-action-container` collapses to a 0Γ—0 box, so + * the header search cannot be reached at all on phones, on all three Free + * header styles, and the hamburger panel holds no search form either. + * `.search-icon` still computes display:block, so assert on the box size. + */ +test.fixme("header search is reachable at 375 px @fresh @header", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await visit(page, "/"); + + const box = await page.locator("#masthead .header-action-container .search-icon").boundingBox(); + expect(box?.width ?? 0).toBeGreaterThan(0); + expect(box?.height ?? 0).toBeGreaterThan(0); +}); diff --git a/tests/e2e/specs/homepage/front-page-health.spec.ts b/tests/e2e/specs/homepage/front-page-health.spec.ts new file mode 100644 index 0000000..55b4e20 --- /dev/null +++ b/tests/e2e/specs/homepage/front-page-health.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, expectLoggedOut } from "../../fixtures"; +import { waitForPreloader } from "../../utils/page"; + +/** + * @area homepage + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ, PHP runtime matrix) 2026-09-21 + * @why The baseline every other spec assumes: the front page serves 200, logs + * no console or page errors, and the preloader overlay that header.php:38 + * renders by default is hidden by js/flash.js:192-197 (~600 ms after + * ready). If flash.js throws before that line the overlay stays over the + * whole site, which is how FLASH-001 presents. Does not assert layout. + */ +test("front page serves with no console errors and the preloader clears @fresh @homepage", async ({ + page, +}) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`)); + + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + await waitForPreloader(page); + + expect(errors, `unexpected console errors: ${errors.join("; ")}`).toEqual([]); +}); + +/** + * @area homepage + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ headings) 2026-09-21 + * @why header.php:100-102 renders the site title as the page's h1 only on the + * front page (a

    elsewhere). The audit counted exactly one h1 on `/`. + * Guards against a second h1 creeping in from the page header bar. + */ +test("front page has the site title as its only h1 @fresh @homepage", async ({ page }) => { + await page.goto("/"); + + const h1 = page.locator("h1"); + await expect(h1).toHaveCount(1); + await expect(h1).toHaveClass(/site-title/); + await expect(h1.getByRole("link")).toHaveAttribute("rel", "home"); +}); + +/** + * @area homepage + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (mobile Tab order, MOBJ) 2026-09-21 + * @why header.php:59 renders "Skip to content" as the first focusable element, + * targeting

    (header.php:211). The audit recorded it as + * the first Tab stop. Does not assert its styling. + */ +test("skip link is the first Tab stop and targets the content region @fresh @homepage", async ({ + page, +}) => { + await page.goto("/"); + await expectLoggedOut(page); + await waitForPreloader(page); + await page.keyboard.press("Tab"); + + const skip = page.getByRole("link", { name: "Skip to content" }); + await expect(skip).toBeFocused(); + await expect(skip).toHaveAttribute("href", "#content"); + await expect(page.locator("#content")).toHaveCount(1); +}); diff --git a/tests/e2e/specs/mobile-menu/mobile-menu-toggle.spec.ts b/tests/e2e/specs/mobile-menu/mobile-menu-toggle.spec.ts new file mode 100644 index 0000000..9240a39 --- /dev/null +++ b/tests/e2e/specs/mobile-menu/mobile-menu-toggle.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../../fixtures"; +import { visit } from "../../utils/page"; + +for (const width of [768, 375]) { + /** + * @area mobile-menu + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ, MOBJ) 2026-09-21 + * @why The audit measured `.menu-toggle` as display:none at 1366 px and + * visible at 768 and 375 px, with the menu collapsed until it is tapped; + * js/flash.js:92-96 slideToggles `.main-navigation .menu`. Guards open and + * close at the two widths the audit verified. Does not cover submenus. + */ + test(`hamburger opens and closes the primary menu at ${width} px @fresh @mobile-menu`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await visit(page, "/"); + + const nav = page.locator("#site-navigation"); + const toggle = nav.locator(".menu-toggle"); + const menu = nav.locator(".menu").first(); + + await expect(toggle).toBeVisible(); + await expect(menu).toBeHidden(); + await expect(menu).toHaveCSS("display", "none"); + + await toggle.click(); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("link").first()).toBeVisible(); + + await toggle.click(); + await expect(menu).toBeHidden(); + }); +} + +/** + * @area mobile-menu + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ) 2026-09-21 + * @why At desktop width the hamburger is hidden and the menu is inline. + */ +test("desktop shows the primary menu inline with no hamburger @fresh @mobile-menu", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await visit(page, "/"); + + const nav = page.locator("#site-navigation"); + await expect(nav.locator(".menu-toggle")).toBeHidden(); + // The
      has zero height (floated items), so Playwright calls the list + // itself hidden. Assert on its links instead. + await expect(nav.locator(".menu").first().getByRole("link").first()).toBeVisible(); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area mobile-menu + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html FLASH-014, 2026-09-21 + * @guards themegrill/flash-pro#90 + * @why `.menu-toggle` is a
      with no role, name or tabindex + * (header.php:117-119), so keyboard users cannot open the menu. + */ +test.fixme("the hamburger is a named, keyboard-operable button @fresh @mobile-menu", async ({ + page, +}) => { + await page.setViewportSize({ width: 768, height: 900 }); + await visit(page, "/"); + + const toggle = page.locator("#site-navigation").getByRole("button", { name: /menu/i }); + await toggle.focus(); + await page.keyboard.press("Enter"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +}); diff --git a/tests/e2e/specs/not-found/not-found.spec.ts b/tests/e2e/specs/not-found/not-found.spec.ts new file mode 100644 index 0000000..946417a --- /dev/null +++ b/tests/e2e/specs/not-found/not-found.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../fixtures"; +import { visit } from "../../utils/page"; + +/** + * @area not-found + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ, runtime matrix) and + * flash-qa-report.html "Tested β€” No Issue Found" 2026-09-18/21 + * @why An unknown URL serves HTTP 404 with 404.php's "Oops! That page can’t be + * found." heading and a search form, inside the normal header and + * footer. Does not assert the page-header bar title (see the quarantined + * scenario below). + */ +test("an unknown URL serves a 404 with the not-found heading and a search form @fresh @not-found", async ({ + page, +}) => { + const response = await visit(page, "/no-such-page-claudegrill/"); + expect(response?.status()).toBe(404); + + const main = page.getByRole("main"); + await expect(main.getByRole("heading", { name: "Oops! That page can’t be found." })).toBeVisible(); + await expect(main.getByRole("searchbox")).toBeVisible(); + await expect(page.locator("#masthead")).toBeVisible(); + await expect(page.locator("#colophon")).toBeVisible(); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area not-found + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html FLASH-003, 2026-09-21 + * @guards themegrill/flash-pro#84 + * @why With WooCommerce active the 404's page-header h1 reads "Shop" + * (flash_is_woocommerce_page() loose ID compare, inc/extras.php:511). + * Seen live on test-radiate.local 2026-09-24. Without WooCommerce it + * correctly reads "Page NOT Found" (inc/extras.php:364). + */ +test.fixme("the 404 page header reads Page NOT Found @fresh @not-found", async ({ page }) => { + await visit(page, "/no-such-page-claudegrill/"); + await expect(page.locator("h1.trail-title")).toHaveText("Page NOT Found"); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area not-found + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html FLASH-020, 2026-09-21 + * @guards themegrill/flash-pro#92 + * @why The 404's decorative `i.fa.fa-exclamation-circle` is 300 px wide and + * pushes the page to scrollWidth 493 at 375 px (118 px of sideways scroll). + */ +test.fixme("the 404 page has no horizontal scroll at 375 px @fresh @not-found", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await visit(page, "/no-such-page-claudegrill/"); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +}); diff --git a/tests/e2e/specs/search/search-results.spec.ts b/tests/e2e/specs/search/search-results.spec.ts new file mode 100644 index 0000000..388101b --- /dev/null +++ b/tests/e2e/specs/search/search-results.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../fixtures"; +import { visit } from "../../utils/page"; + +/** + * @area search + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ headings) 2026-09-21 + * @why flash_page_title() (inc/extras.php:366) titles a results page + * "Search Results for: " in the page-header bar, with the + * breadcrumb trail beside it, and search.php lists the matches. The audit + * recorded exactly that h1 on `/?s=audit`. + */ +test("a search with matches shows the query in the page header and lists results @fresh @search", async ({ + page, +}) => { + const response = await visit(page, "/?s=a"); + expect(response?.ok()).toBeTruthy(); + + await expect(page.locator("h1.trail-title")).toHaveText("Search Results for: a"); + await expect(page.locator("nav#flash-breadcrumbs")).toBeVisible(); + await expect(page.getByRole("main").locator("article").first()).toBeVisible(); +}); + +/** + * @area search + * @tier fresh + * @source template-parts/content-none.php; live on test-radiate.local 2026-09-24 + * @why With no matches content-none.php renders "Nothing Found" and a fresh + * search form, so the visitor can retry. + */ +test("a search with no matches says Nothing Found and offers a new search @fresh @search", async ({ + page, +}) => { + await visit(page, "/?s=zzqqxxnomatch"); + + const main = page.getByRole("main"); + await expect(main.getByRole("heading", { name: "Nothing Found" })).toBeVisible(); + await expect(main.locator("article")).toHaveCount(0); + await expect(main.getByRole("searchbox")).toBeVisible(); +}); + +// Quarantined: open bug. Drop `.fixme` in the PR that fixes it. +/** + * @area search + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html FLASH-003, 2026-09-21 + * @guards themegrill/flash-pro#84 + * @why With WooCommerce active, flash_is_woocommerce_page() (inc/extras.php:511) + * loosely compares get_the_ID() to the shop page ID, so a zero-result + * search takes WooCommerce's title ("Search results: β€œβ€¦β€") instead of + * the theme's. Seen live on test-radiate.local 2026-09-24. Without + * WooCommerce this passes. + */ +test.fixme("a zero-result search keeps the theme's page-header title @fresh @search", async ({ + page, +}) => { + await visit(page, "/?s=zzqqxxnomatch"); + await expect(page.locator("h1.trail-title")).toHaveText("Search Results for: zzqqxxnomatch"); +}); diff --git a/tests/e2e/specs/single-post/single-post.spec.ts b/tests/e2e/specs/single-post/single-post.spec.ts new file mode 100644 index 0000000..ce65484 --- /dev/null +++ b/tests/e2e/specs/single-post/single-post.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "../../fixtures"; +import { visitPostsIndex, waitForPreloader } from "../../utils/page"; + +/** + * @area single-post + * @tier fresh + * @source flash-free-pro-senior-dev-audit.html (DOMJ headings, navs) and + * flash-qa-report.html "Tested β€” No Issue Found" 2026-09-18/21 + * @why A single post shows its title as the page's only h1 (the page-header + * bar, `h1.trail-title`), a breadcrumb trail starting at Home, the post + * content, and post navigation (single.php:27, on unless + * flash_remove_single_nav). Reached from the posts index so it works on any + * site's content. + */ +test("a single post shows its title as the only h1, a Home breadcrumb and post navigation @fresh @single-post", async ({ + page, +}) => { + await visitPostsIndex(page); + const firstLink = page.getByRole("main").locator("article .entry-title a").first(); + const title = (await firstLink.innerText()).trim(); + + await firstLink.click(); + await waitForPreloader(page); + + const h1 = page.locator("h1"); + await expect(h1).toHaveCount(1); + await expect(h1).toHaveClass(/trail-title/); + await expect(h1).toHaveText(title); + + // The trail opens with a.trail-home β†’ get_home_url() (inc/extras.php:179). + const home = page.locator("nav#flash-breadcrumbs .trail-begin a.trail-home"); + await expect(home).toHaveText("Home"); + expect(new URL((await home.getAttribute("href"))!, page.url()).pathname).toBe( + new URL("/", page.url()).pathname, + ); + + await expect(page.getByRole("main").locator("article .entry-content")).toBeVisible(); + await expect(page.locator(".navigation.post-navigation")).toBeVisible(); +}); diff --git a/tests/e2e/specs/woocommerce/header-cart.spec.ts b/tests/e2e/specs/woocommerce/header-cart.spec.ts new file mode 100644 index 0000000..4a9b5a4 --- /dev/null +++ b/tests/e2e/specs/woocommerce/header-cart.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "../../fixtures"; +import { ADMIN_STATE, hasAdminCredentials } from "../../utils/login"; +import { visit } from "../../utils/page"; + +/** + * WooCommerce's "coming soon" store mode hides the shop from visitors and + * switches itself back on whenever WooCommerce is toggled (audit NOTES, + * 2026-09-21), so these run as the saved admin, who always sees the store. + */ +test.use({ storageState: ADMIN_STATE }); + +test.beforeEach(async ({ page }) => { + test.skip(!hasAdminCredentials(), "needs TGQA_ADMIN_USER / TGQA_ADMIN_PASS"); + await visit(page, "/shop/"); + test.skip( + (await page.locator("#masthead .cart-wrap").count()) === 0, + "WooCommerce is not active (header.php:158 renders no cart)", + ); +}); + +/** + * @area woocommerce + * @tier demo + * @source flash-qa-report.html "Tested β€” No Issue Found (WooCommerce)" 2026-09-18 + * @why AJAX add-to-cart on /shop/ immediately updates the header cart count + * through flash_woocommerce_header_add_to_cart_fragment + * (inc/woocommerce.php:37-58). Asserts the count goes up by one rather + * than an absolute value, because the admin's cart persists, and removes + * the added line afterwards. + */ +test("adding a product from the shop bumps the header cart count @demo @woocommerce", async ({ + page, +}) => { + const count = page.locator("#masthead .header-action-container .cart-value"); + const before = Number((await count.innerText()).trim()); + + const product = page.locator("ul.products li.product").filter({ + has: page.locator(".ajax_add_to_cart"), + }).first(); + const name = (await product.locator(".woocommerce-loop-product__title").innerText()).trim(); + await product.locator(".ajax_add_to_cart").click(); + + await expect(count).toHaveText(String(before + 1)); + + // Leave the persistent cart as it was. + await visit(page, "/cart/"); + const line = page.locator("tr.cart_item, .wc-block-cart-items__row").filter({ hasText: name }).first(); + await line.locator("a.remove, .wc-block-cart-item__remove-link").first().click(); + await expect(line).toHaveCount(0); + // The header count is server-rendered; reload before comparing. + await visit(page, "/cart/"); + await expect(page.locator("#masthead .header-action-container .cart-value")).toHaveText(String(before)); +}); + +/** + * @area woocommerce + * @tier demo + * @source flash-qa-report.html "Tested β€” No Issue Found (WooCommerce)" 2026-09-18 + * @why A sale product shows WooCommerce's "Sale!" badge and a struck-through + * regular price next to the sale price in the Flash shop grid. Skips if the + * store has no product on sale. + */ +test("a sale product shows the Sale badge and a struck-through price @demo @woocommerce", async ({ + page, +}) => { + const sale = page.locator("ul.products li.product.sale").first(); + test.skip((await sale.count()) === 0, "no product on sale in this store"); + + await expect(sale.locator(".onsale")).toHaveText("Sale!"); + await expect(sale.locator(".price del")).toBeVisible(); + await expect(sale.locator(".price ins")).toBeVisible(); +}); diff --git a/tests/e2e/utils/login.ts b/tests/e2e/utils/login.ts new file mode 100644 index 0000000..0321454 --- /dev/null +++ b/tests/e2e/utils/login.ts @@ -0,0 +1,14 @@ +import path from "node:path"; + +/** + * Storage state for a logged-in admin, written once per run by + * tests/e2e/auth.setup.ts. Specs that need a session use + * `test.use({ storageState: ADMIN_STATE })` instead of logging in themselves: + * WordPress keeps a user's sessions in one `session_tokens` user meta, and + * parallel logins as the same user race on it β€” the loser's token is + * dropped and its next request is silently logged out. + */ +export const ADMIN_STATE = path.join(__dirname, "..", ".auth", "admin.json"); + +export const hasAdminCredentials = (): boolean => + !!process.env.TGQA_ADMIN_USER && !!process.env.TGQA_ADMIN_PASS; diff --git a/tests/e2e/utils/page.ts b/tests/e2e/utils/page.ts new file mode 100644 index 0000000..2710351 --- /dev/null +++ b/tests/e2e/utils/page.ts @@ -0,0 +1,36 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Flash covers every page with #preloader-background and hides it 600 ms + * after document.ready (js/flash.js:192-197). Anything clicked before then + * lands on the overlay, so specs call this after every navigation. Resolves + * immediately when the preloader is disabled (flash_disable_preloader) and the + * element is not rendered at all (header.php:38). + */ +export async function waitForPreloader(page: Page): Promise { + await expect(page.locator("#preloader-background")).toBeHidden({ timeout: 10_000 }); +} + +/** Navigates and waits until the page is usable, returning the response. */ +export async function visit(page: Page, url: string) { + const response = await page.goto(url); + await waitForPreloader(page); + return response; +} + +/** + * Opens the posts index, wherever this site keeps it. A site with a static + * front page lists posts on its posts page (/blog/ on the demo sites); a fresh + * Playground boot creates a "Blog" page but leaves show_on_front = posts, so + * the index is the front page and /blog/ is an ordinary empty page. WordPress + * marks the index with body class `blog` (is_home), so try /blog/ first and + * fall back to /. Returns whether the index is also the front page. + */ +export async function visitPostsIndex(page: Page): Promise<{ isFrontPage: boolean }> { + await visit(page, "/blog/"); + if (await page.locator("body.blog").count()) return { isFrontPage: false }; + + await visit(page, "/"); + await expect(page.locator("body.blog"), "no posts index at /blog/ or /").toHaveCount(1); + return { isFrontPage: true }; +} From 51c7134376462c921519e4879597d1c600cdd1d7 Mon Sep 17 00:00:00 2001 From: Prajjwal Poudel Date: Thu, 24 Sep 2026 18:25:37 +0545 Subject: [PATCH 2/3] Fix - Address Copilot review on QA specs Demo front-page spec now fails on console errors as well as page errors. The header-cart spec adds a product that is not already in the admin's persistent cart, so its cleanup can no longer remove a pre-existing line. --- .../demo-homepage/demo-front-page.spec.ts | 7 +++- .../e2e/specs/woocommerce/header-cart.spec.ts | 38 +++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts b/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts index 5368091..48e6524 100644 --- a/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts +++ b/tests/e2e/specs/demo-homepage/demo-front-page.spec.ts @@ -15,7 +15,10 @@ test("the imported demo front page renders its Flash Toolkit sections @demo @dem page, }) => { const errors: string[] = []; - page.on("pageerror", (err) => errors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`)); await visit(page, "/"); const layout = page.locator(".panel-layout"); @@ -23,7 +26,7 @@ test("the imported demo front page renders its Flash Toolkit sections @demo @dem await expect(layout.locator(".panel-grid").first()).toBeVisible(); expect(await layout.locator("[class*='widget_themegrill_flash_']").count()).toBeGreaterThan(0); - expect(errors, `page errors: ${errors.join("; ")}`).toEqual([]); + expect(errors, `console errors: ${errors.join("; ")}`).toEqual([]); }); /** diff --git a/tests/e2e/specs/woocommerce/header-cart.spec.ts b/tests/e2e/specs/woocommerce/header-cart.spec.ts index 4a9b5a4..999f6b8 100644 --- a/tests/e2e/specs/woocommerce/header-cart.spec.ts +++ b/tests/e2e/specs/woocommerce/header-cart.spec.ts @@ -24,25 +24,47 @@ test.beforeEach(async ({ page }) => { * @source flash-qa-report.html "Tested β€” No Issue Found (WooCommerce)" 2026-09-18 * @why AJAX add-to-cart on /shop/ immediately updates the header cart count * through flash_woocommerce_header_add_to_cart_fragment - * (inc/woocommerce.php:37-58). Asserts the count goes up by one rather - * than an absolute value, because the admin's cart persists, and removes - * the added line afterwards. + * (inc/woocommerce.php:37-58). The admin's cart persists between runs, so + * the spec adds a product that is not already in it, asserts the count + * goes up by one, and removes that new line afterwards, leaving the cart + * exactly as it found it. */ test("adding a product from the shop bumps the header cart count @demo @woocommerce", async ({ page, }) => { + await visit(page, "/cart/"); + const inCart = new Set( + ( + await page + .locator("tr.cart_item .product-name a, .wc-block-components-product-name") + .allInnerTexts() + ).map((t) => t.trim()), + ); + + await visit(page, "/shop/"); const count = page.locator("#masthead .header-action-container .cart-value"); const before = Number((await count.innerText()).trim()); - const product = page.locator("ul.products li.product").filter({ + const candidates = page.locator("ul.products li.product").filter({ has: page.locator(".ajax_add_to_cart"), - }).first(); - const name = (await product.locator(".woocommerce-loop-product__title").innerText()).trim(); - await product.locator(".ajax_add_to_cart").click(); + }); + let product = null; + let name = ""; + for (const item of await candidates.all()) { + const title = (await item.locator(".woocommerce-loop-product__title").innerText()).trim(); + if (!inCart.has(title)) { + product = item; + name = title; + break; + } + } + test.skip(product === null, "every purchasable shop product is already in the cart"); + await product!.locator(".ajax_add_to_cart").click(); await expect(count).toHaveText(String(before + 1)); - // Leave the persistent cart as it was. + // Leave the persistent cart as it was: the line is new, so removing it + // restores the original contents. await visit(page, "/cart/"); const line = page.locator("tr.cart_item, .wc-block-cart-items__row").filter({ hasText: name }).first(); await line.locator("a.remove, .wc-block-cart-item__remove-link").first().click(); From 4498f2a5632a13c4cd1c0fc9cc0f934540917552 Mon Sep 17 00:00:00 2001 From: Prajjwal Poudel Date: Thu, 24 Sep 2026 18:33:24 +0545 Subject: [PATCH 3/3] Fix - Clean up the header-cart spec's cart line in a finally block A failed count assertion or navigation no longer leaves the added product in the saved admin account's persistent cart. --- .../e2e/specs/woocommerce/header-cart.spec.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/e2e/specs/woocommerce/header-cart.spec.ts b/tests/e2e/specs/woocommerce/header-cart.spec.ts index 999f6b8..a8fbe6f 100644 --- a/tests/e2e/specs/woocommerce/header-cart.spec.ts +++ b/tests/e2e/specs/woocommerce/header-cart.spec.ts @@ -60,15 +60,20 @@ test("adding a product from the shop bumps the header cart count @demo @woocomme } test.skip(product === null, "every purchasable shop product is already in the cart"); - await product!.locator(".ajax_add_to_cart").click(); - await expect(count).toHaveText(String(before + 1)); + try { + await product!.locator(".ajax_add_to_cart").click(); + await expect(count).toHaveText(String(before + 1)); + } finally { + // Leave the persistent cart as it was, even when the assertion above + // fails: the line is new, so removing it restores the original contents. + await visit(page, "/cart/"); + const line = page.locator("tr.cart_item, .wc-block-cart-items__row").filter({ hasText: name }).first(); + if (await line.count()) { + await line.locator("a.remove, .wc-block-cart-item__remove-link").first().click(); + await expect(line).toHaveCount(0); + } + } - // Leave the persistent cart as it was: the line is new, so removing it - // restores the original contents. - await visit(page, "/cart/"); - const line = page.locator("tr.cart_item, .wc-block-cart-items__row").filter({ hasText: name }).first(); - await line.locator("a.remove, .wc-block-cart-item__remove-link").first().click(); - await expect(line).toHaveCount(0); // The header count is server-rendered; reload before comparing. await visit(page, "/cart/"); await expect(page.locator("#masthead .header-action-container .cart-value")).toHaveText(String(before));