
Hyva Playwright Test
- 780 installs
- 78 repo stars
- Updated July 31, 2026
- hyva-themes/hyva-ai-tools
hyva-playwright-test is a Claude Code skill that writes Playwright end-to-end tests for Hyvä Magento themes with Alpine.js-aware selectors for developers testing storefront UI behavior.
About
hyva-playwright-test is a Playwright testing skill tailored to Hyvä Magento storefronts that replaced Luma KnockoutJS, RequireJS, and jQuery with Alpine.js and Tailwind CSS. Playwright strict mode rejects ambiguous locators, which clashes with Alpine.js DOM patterns where hidden elements persist on the page. The skill triggers on phrases like write playwright test, playwright alpine, test hyva page, e2e test, and playwright selector. Developers use it when creating page objects, debugging flaky locators, or adding E2E coverage for Hyvä checkout and component flows.
- Handles Alpine.js hidden x-show elements that trigger Playwright strict mode failures
- Provides scoped selectors targeting the #messages container for success and error messages
- Documents common selector pitfalls specific to Hyvä + Tailwind storefronts
- Includes patterns for writing page objects and debugging flaky Alpine tests
- Trigger phrases: "write playwright test", "playwright alpine", "test hyva page"
Hyva Playwright Test by the numbers
- 780 all-time installs (skills.sh)
- +31 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #561 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hyva-themes/hyva-ai-tools --skill hyva-playwright-testAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 780 |
|---|---|
| repo stars | ★ 78 |
| Last updated | July 31, 2026 |
| Repository | hyva-themes/hyva-ai-tools ↗ |
How do you write Playwright tests for Hyvä Alpine.js themes?
Generate reliable Playwright end-to-end tests for Hyvä Magento themes that correctly handle Alpine.js component behavior.
Who is it for?
Magento developers on Hyvä themes who need Playwright E2E tests that handle Alpine.js DOM and strict-mode locator rules.
Skip if: Luma-only Magento shops, non-Magento React apps, or teams not using Playwright for browser testing.
When should I use this skill?
User asks to write Playwright tests, debug Hyvä selectors, or mentions Alpine.js locators on a Hyvä Magento storefront.
What you get
Playwright spec files, Hyvä page objects, and Alpine.js-safe locator strategies for Magento storefronts.
- Playwright spec files
- Page object definitions
- Alpine.js-safe locator recipes
Files
Writing Playwright Tests for Hyvä + Alpine.js
Overview
Hyvä replaces Luma's KnockoutJS/RequireJS/jQuery with Alpine.js + Tailwind CSS. Playwright's strict mode (rejects locators matching multiple elements) conflicts with Alpine.js DOM patterns where hidden elements exist throughout the page. This skill documents pitfalls and solutions discovered while writing Playwright tests for Hyvä storefronts.
The #1 Rule: Hidden Alpine Elements
Hyvä templates scatter elements like <div x-show="displayErrorMessage" class="message error"> throughout the DOM. These are invisible but present, so a bare selector like .message.error matches both hidden and visible instances, causing Playwright strict mode violations.
Always scope page-level messages to the `#messages` container:
// WRONG — matches hidden Alpine x-show elements throughout DOM
await expect(page.locator('.message.success')).toContainText('Added to cart');
await expect(page.locator('.message-error')).toContainText('Error');
// RIGHT — scoped to the visible messages container
await expect(page.locator('#messages .message.success')).toContainText('Added to cart');
await expect(page.locator('#messages .message-error, #messages .message.error')).toContainText('Error');Never use: bare .message, .message.error, .message.success, or div.message as selectors.
Exception — inline page messages: Not all .message elements are flash messages. The search results "no results" notice (.message.notice) renders as static inline content inside #maincontent, not inside the #messages container. For these inline messages, the bare class selector is correct.
Selector Strategy
Follow Playwright's recommended locator priority:
1. `getByRole()` — always prefer — closest to how users perceive the page. Avoids text ambiguity where the same text appears in headings, links, breadcrumbs, and sr-only spans. 2. `getByLabel()` — for form controls (checkboxes, inputs with associated labels). 3. `getByText()` — for non-interactive elements, scoped to a container (e.g., page.locator('#maincontent').getByText(...)). 4. `getByPlaceholder()`, `getByAltText()` — for inputs and images respectively. 5. `getByTestId()` — when Hyvä provides data-testid attributes or when adding custom test IDs. 6. CSS selectors — last resort, only when user-facing locators aren't available. Prefer aria-* attribute selectors (e.g., [aria-label="pagination"], [aria-current="page"]) over class-based selectors. When CSS is necessary, scope to a unique container (e.g., #messages .message.success).
Avoid: :visible pseudo-selector — per Playwright docs, "it's usually better to find a more reliable way to uniquely identify the element." Scope to a container or use role/attribute selectors instead. Only use :visible as an absolute last resort when the DOM provides no other way to distinguish elements.
Alpine.js Interaction Patterns
| Pattern | Problem | Solution |
|---|---|---|
x-show hidden elements | Strict mode: multiple matches | Scope to unique container (#messages), use role/attribute selectors |
x-defer="intersect" | Element not initialized until visible | scrollIntoViewIfNeeded() before interacting |
x-if (template) | Elements don't exist in DOM until condition true | Click the trigger first, then query children |
x-model on inputs | Alpine clears value after form submit | Don't assert input value post-submit; verify via success message |
x-text / x-html async | Cart badge updates asynchronously | Use web-first assertions with timeout: not.toHaveText('0', { timeout: 15_000 }) |
x-show submenus | Hidden until hover | hover() on parent before clicking child |
| Alpine form reveal | Fields hidden until checkbox checked | waitFor({ state: 'visible' }) after checking the checkbox |
press('Enter') on input | May submit Alpine-bound form unexpectedly | Prefer explicit .click() on submit button |
Assertions
Always use web-first assertions that auto-wait and retry:
// DO — auto-retries // DON'T — no retry
await expect(loc).toBeVisible(); // expect(await loc.isVisible()).toBe(true);
await expect(loc).toContainText('X'); // expect(await loc.textContent()).toContain('X');For async Alpine.js updates (cart counts, prices), use extended timeouts on the assertion — never waitForTimeout():
// Cart count updates asynchronously via Alpine x-text
await expect(page.locator('#menu-cart-icon span[x-text="summaryCount"]'))
.not.toHaveText('0', { timeout: 15_000 });Hyvä vs Luma Selector Differences
| Element | Hyvä Selector | Luma Selector |
|---|---|---|
| Pagination nav | getByRole('navigation', { name: 'pagination' }) | ul.pages-items |
| Page link | getByRole('link', { name: 'Page 2' }) | .pages-items li a |
| Active page | [aria-current="page"] | <strong> element |
| Filter button | getByRole('button', { name: 'Color filter' }) | .filter-options-title |
| Cart icon badge | #menu-cart-icon > span[x-text="summaryCount"] | .counter-number |
| Account menu | #customer-menu + nav | .customer-menu |
| Success message | #messages .message.success | .message-success |
| Error message | #messages .message-error, #messages .message.error | .message-error |
| Main menu | getByRole('navigation', { name: 'Main menu' }) | nav.navigation |
| Footer nav | getByRole('navigation', { name: 'Company Menu' }).getByRole('link', { name }) | nav ul li:nth-child(N) a |
| Product image | #gallery img[itemprop="image"] | #gallery img:visible |
| Add to Cart (card) | getByRole('button', { name: /Add to Cart/ }).first() | button.btn-primary:visible |
References
See references/ for code examples. Load files relevant to the current task:
Always useful:
- [page-object-patterns.md](references/page-object-patterns.md) — Page object structure, navigation, form submits, redirects
- [selector-patterns.md](references/selector-patterns.md) — Before/after selector fixes (messages, text ambiguity, forms)
Page-specific (load when testing that page):
- [cart-patterns.md](references/cart-patterns.md) — Cart spinner wait, quantity changes, mini cart
- [product-patterns.md](references/product-patterns.md) — Bundle quantities, gallery images
- [account-patterns.md](references/account-patterns.md) — Password change (Alpine checkbox reveal)
- [category-patterns.md](references/category-patterns.md) — Filters (x-defer scroll), pagination (ARIA)
<!-- Copyright © Hyvä Themes https://hyva.io. All rights reserved. Licensed under OSL 3.0 -->
Account Patterns
Patterns for customer account page interactions in Hyvä's Alpine.js storefront.
waitFor() After Alpine Checkbox Reveal
The account edit page uses Alpine.js x-show to reveal password fields after checking a checkbox. Fill actions race against Alpine's state update without an explicit wait.
// BROKEN — fills password field before Alpine reveals it
async changePassword(currentPw: string, newPw: string) {
const form = this.page.locator(selectors.changePasswordForm);
await form.getByLabel('Change Password').check();
await form.locator(selectors.currentPasswordInput).fill(currentPw);
// ❌ newPasswordInput may not be visible yet
}
// FIXED — wait for Alpine x-show to reveal the field
async changePassword(currentPw: string, newPw: string) {
const form = this.page.locator(selectors.changePasswordForm);
await form.getByLabel('Change Password').check();
// Wait for Alpine to process x-show toggle
await form.locator(selectors.newPasswordInput).waitFor({ state: 'visible' });
await form.locator(selectors.currentPasswordInput).fill(currentPw);
await form.locator(selectors.newPasswordInput).fill(newPw);
await form.locator(selectors.newPasswordConfirmationInput).fill(newPw);
await form.locator(selectors.newPasswordConfirmationInput).press('Enter');
await this.page.waitForLoadState('domcontentloaded');
}Cart Patterns
Patterns for cart page and mini cart interactions in Hyvä's Alpine.js storefront.
Cart Spinner Wait Pattern
The cart totals recalculate asynchronously. A Tailwind animate-spin SVG spinner appears and disappears. The .catch(() => {}) on the visible wait is critical — the spinner may appear and disappear too quickly to catch.
async waitForCartUpdate() {
// Wait for spinner to appear (may already be gone — catch silently)
await this.page.locator(selectors.cartSpinner)
.waitFor({ state: 'visible', timeout: 5_000 })
.catch(() => {});
// Wait for spinner to disappear (longer timeout for server calculation)
await this.page.locator(selectors.cartSpinner)
.waitFor({ state: 'hidden', timeout: 15_000 });
}Usage:
await cartPage.changeQuantity(0, 3);
await cartPage.waitForCartUpdate();
const newTotal = await cartPage.getGrandTotal();changeQuantity() — Cart Page vs Mini Cart
Cart page — fill + Enter (stays on same page):
async changeQuantity(index: number, newQty: number) {
const input = this.page.locator(selectors.qtyInputField).nth(index);
await input.clear();
await input.fill(String(newQty));
await input.press('Enter');
}Mini cart — navigates to product page via edit button, changes qty, clicks Add to Cart:
async changeQuantity(newQty: string): Promise<void> {
await this.slider.locator(selectors.miniCartEditProductButton).click();
await this.page.waitForLoadState('domcontentloaded');
await this.qtyInputField.clear();
await this.qtyInputField.fill(newQty);
await this.addToCartButton.click();
await this.page.waitForLoadState('domcontentloaded');
}Category Patterns
Patterns for category listing page interactions in Hyvä's Alpine.js storefront.
scrollIntoViewIfNeeded() for x-defer="intersect"
Hyvä's category filters use x-defer="intersect" — the Alpine.js component only initializes when scrolled into the viewport. You must scroll the element into view before interacting.
// BROKEN — element not initialized because never scrolled into viewport
async filterByColorRed() {
await this.page.locator('.filter-options-title').filter({ hasText: 'Color' }).click();
await this.selectColorRed.click();
}
// FIXED — scroll triggers x-defer="intersect" initialization
async filterByColorRed() {
const colorButton = this.page.getByRole('button', { name: 'Color filter' });
await colorButton.scrollIntoViewIfNeeded();
await colorButton.click();
await this.selectColorRed.click();
await this.page.waitForLoadState('domcontentloaded');
}Filter Buttons
Hyvä renders category filters as buttons inside headings with x-defer="intersect".
// BEFORE (Luma CSS selectors)
await page.locator('.filter-options-title').filter({ hasText: 'Color' }).click();
// AFTER — scroll into view (triggers x-defer), then click role-based selector
const colorButton = page.getByRole('button', { name: 'Color filter' });
await colorButton.scrollIntoViewIfNeeded();
await colorButton.click();Pagination
Hyvä uses semantic ARIA markup for pagination instead of Luma's ul.pages-items.
// BEFORE (Luma CSS selectors)
await page.locator('ul.pages-items li a').first().click();
const activePage = page.locator('ul.pages-items li strong');
// AFTER (Hyvä ARIA roles)
const paginationNav = page.getByRole('navigation', { name: 'pagination' });
await paginationNav.getByRole('link', { name: 'Page 2' }).click();
const activePage = paginationNav.locator('[aria-current="page"]');Page Object Patterns
Code patterns for Playwright page objects interacting with Hyvä's Alpine.js components.
Page Object Structure
Every page object follows this pattern:
import type { Page, Locator } from '@playwright/test';
// 1. Selectors object at module top level
const selectors = {
pageTitle: '#maincontent h1.page-title',
successMessages: '#messages .message.success',
qtyInputField: 'input[data-role="cart-item-qty"]',
cartSpinner: '#cart-totals svg.animate-spin',
// ... more selectors
};
// 2. Class with readonly page
export class CartPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// 3. Navigation methods
async goto() {
await this.page.goto('/checkout/cart');
await this.page.waitForLoadState('domcontentloaded');
}
// 4. Action methods (async, return void)
async changeQuantity(index: number, newQty: number) {
const input = this.page.locator(selectors.qtyInputField).nth(index);
await input.clear();
await input.fill(String(newQty));
await input.press('Enter');
}
// 5. Getter properties returning Locator
get pageTitle(): Locator {
return this.page.locator(selectors.pageTitle);
}
get successMessages(): Locator {
return this.page.locator(selectors.successMessages);
}
// 6. Helper methods for reading values
async getGrandTotal(): Promise<string> {
return await this.page.locator(selectors.grandTotal).textContent() ?? '';
}
}Key conventions:
- Selectors as a
constobject at module scope (not in a separate file) readonly page: Pagein constructor- Action methods are
asyncand returnvoid - Getters return
Locator(notPromise<Locator>) - Text-reading helpers return
Promise<string>
hover() Parent then Click Child for Submenus
Hyvä's navigation uses Alpine.js x-show on submenus, triggered by mouse hover. You must hover to reveal the element before clicking.
async openSubcategory(categoryName: string, subcategoryName: string) {
await this.page.getByRole('link', { name: categoryName }).hover();
await this.page.getByRole('navigation', { name: 'Main menu' })
.getByRole('link', { name: subcategoryName }).click();
}waitForLoadState() After Form Submits
After navigating or submitting forms, wait for the new page to load:
async goToProfile() {
await this.page.getByRole('link', { name: 'Account Information' }).click();
await this.page.waitForLoadState('domcontentloaded');
}
async login(email: string, password: string) {
await this.page.locator('#email').fill(email);
await this.page.locator('#pass').fill(password);
await this.page.locator('#pass').press('Enter');
await this.page.waitForLoadState('domcontentloaded');
}waitForURL() When Actions Redirect
async logout() {
await this.page.goto('/customer/account/logout');
await this.page.waitForURL('**/logoutSuccess');
}
async addProductToCart(productUrl: string) {
await this.page.goto(productUrl);
await this.page.locator(selectors.addToCartButton).click();
await this.page.waitForURL('**/checkout/cart');
}Product Patterns
Patterns for product page interactions in Hyvä's Alpine.js storefront.
Bundle Product Quantities
Fill + blur (no submit, Alpine recalculates on blur):
async function setBundleQuantities(
page: Page,
qtyOrFn: number | ((index: number) => number),
) {
const inputs = page.locator('input.qty.bundle-option-qty');
const count = await inputs.count();
for (let idx = 0; idx < count; idx++) {
const input = inputs.nth(idx);
const qty = typeof qtyOrFn === 'function' ? qtyOrFn(idx) : qtyOrFn;
await input.clear();
await input.fill(String(qty));
await input.blur(); // blur() triggers Alpine recalculation
}
// Callers should use web-first assertions to wait for Alpine to settle
}Gallery Images
Hyvä's product gallery contains multiple <img> elements: a hidden placeholder (with itemprop="image"), the active visible image (with x-show), and thumbnails. Use the itemprop attribute to uniquely target the main product image.
// BEFORE — matches 3 images (placeholder, active, thumbnail), strict mode error
await page.locator('#gallery img').getAttribute('src');
// BEFORE — :visible works but is discouraged
await page.locator('#gallery img:visible').getAttribute('src');
// AFTER — itemprop uniquely identifies the main product image
await page.locator('#gallery img[itemprop="image"]').getAttribute('src');
// ALTERNATIVE — alt text when product name is known
await page.getByRole('img', { name: 'Didi Sport Watch' }).click();Selector Patterns — Before / After
Concrete examples of selector patterns for Hyvä's Alpine.js DOM. Each pattern prefers user-facing locators per Playwright best practices.
Hidden Message Elements
Bare .message selectors match hidden Alpine x-show elements — scope to #messages.
// BEFORE — matches hidden Alpine x-show elements (strict mode error)
await expect(page.locator('.message.success')).toContainText('Item added');
await expect(page.locator('.message-error')).toContainText('Error');
// AFTER — scoped to #messages container
await expect(page.locator('#messages .message.success')).toContainText('Item added');
await expect(page.locator('#messages .message-error, #messages .message.error')).toContainText('Error');Text Ambiguity — getByText() to getByRole()
In Magento, the same text appears in headings, links, breadcrumbs, and screen-reader spans. getByText() matches all of them.
// BEFORE — "Account Information" appears in heading, sidebar link, and breadcrumb
await page.getByText('Account Information').click();
// AFTER — targets specifically the link
await page.getByRole('link', { name: 'Account Information' }).click();Scoped Text Searches
Product names and other text appear in multiple page regions (recently viewed, cross-sells, main content).
// BEFORE — may match product name in sidebar/footer/cross-sells
await expect(page.getByText(productName)).toBeVisible();
// AFTER — scoped to main content area
await expect(page.locator('#maincontent').getByText(productName)).toBeVisible();Checkbox Labels — getByText() to getByLabel()
Form checkboxes have associated <label> elements. getByText() may match other instances of the same text.
// BEFORE — "Change Password" text exists in multiple DOM locations
await page.getByText('Change Password').click();
await expect(page.getByText('Change Password')).not.toBeChecked();
// AFTER — targets the checkbox input via its label
await page.getByLabel('Change Password').check();
await expect(page.getByLabel('Change Password')).not.toBeChecked();Submenu Navigation — hover() Before Click
Hidden x-show submenus must be revealed by hovering before clicking. Using force: true or complex CSS selectors bypasses this and leads to flaky tests.
// WRONG — force-clicks a hidden submenu element
const subCategory = page.locator(
'div.lg\\:block > nav > ul li:nth-child(2) > ul > li:nth-child(1) > a'
);
await subCategory.click({ force: true });
// RIGHT — hover parent to reveal Alpine x-show submenu, then click
await page.getByRole('link', { name: 'Women' }).hover();
await page.getByRole('navigation', { name: 'Main menu' })
.getByRole('link', { name: 'Tops' }).click();Related skills
How it compares
Pick hyva-playwright-test over generic Playwright skills when the storefront runs Hyvä with Alpine.js rather than Luma KnockoutJS patterns.
FAQ
Why do Hyvä storefronts break standard Playwright locators?
hyva-playwright-test addresses Hyvä's Alpine.js DOM, where hidden elements remain in the tree. Playwright strict mode then rejects locators matching multiple nodes—a pattern uncommon in Luma KnockoutJS storefronts.
What trigger phrases activate hyva-playwright-test?
hyva-playwright-test activates on write playwright test, playwright alpine, test hyva page, e2e test, and playwright selector requests while authoring or debugging Hyvä Magento Playwright coverage.