Imported from VilnaCRM-Org/claude-plugins (
plugins/react-frontend-sdlc/skills/playwright-mobile-device-lane/SKILL.md). Install upstream withnpx skills add VilnaCRM-Org/claude-plugins --skill playwright-mobile-device-lane. Copyright stays with the author.
Playwright mobile device lane
Profile keys consumed
make.test_e2emake.test_visualmake.start_prodcapabilities.visual_testing
Every suite invocation goes through the profile's make target map. Skip the dependent step with a
recorded note when make.test_e2e, make.test_visual, or make.start_prod maps to null, and
record the visual half SKIPPED when capabilities.visual_testing is false.
Overview
Shrinking a desktop window is not mobile coverage: the context still reports isMobile: false,
hasTouch: false, DPR 1 and a desktop user agent. Real coverage means extra Playwright projects
built from stock device descriptors, scoped in both directions so mobile specs run only on
mobile projects and desktop projects never collect them.
When to use
- Adding touch interactions, tap flows, or a mobile viewport lane to an existing suite.
locator.tap()fails with a "hasTouch" error on a desktop project.- A visual run suddenly wants baselines named for projects that should never have run the spec.
- Mobile visual diffs look soft or miss asset regressions (CSS-downsampled captures).
- Not for: responsive layout assertions that only need a viewport size — those stay desktop specs.
Applicability by repository shape
- React SPA shape (feature modules under the source root, a bootable app, an aggregate CI
target): yes —
mobile-chrome(Pixel 7, chromium) andmobile-safari(iPhone 14, webkit), specs intests/e2e/mobile/andtests/visual/mobile/, recorded by the repository's visual-baseline update target. - Next.js app shape (routed pages, no aggregate duplication gate): partial — one
mobile-chrome(Pixel 7) project scoped bytestDir(src/test/e2e/mobile) plustestIgnoreon the desktop projects; no mobile visual lane. - Component-library shape (Storybook-first, no bootable app, published package): no — Storybook-driven desktop-only visual suite.
Core pattern
Scope every project, both ways. Take the descriptor whole — never hand-set deviceScaleFactor,
which desynchronises from the descriptor on upgrade.
const MOBILE_LANE = '**/mobile/**/*.spec.ts';
const DESKTOP_LANE_IGNORE = '**/mobile/**';
{ name: 'chromium', testIgnore: DESKTOP_LANE_IGNORE, use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-safari', testMatch: MOBILE_LANE, use: { ...devices['iPhone 14'] } },
Without testIgnore, every desktop project re-records the whole mobile screen set under its own
project name — a 13-screen suite crossed with three desktop projects is a few hundred unwanted
baselines. Without testMatch, the mobile projects re-record the desktop suite at 2.6x and 3x.
Playwright interpolates the project name into the snapshot path, so each omission multiplies
baselines rather than failing loudly.
Capture mobile visuals at the emulated ratio, never by resizing a desktop helper:
await expect(page).toHaveScreenshot(`${language}_${name}.png`, {
fullPage: true,
animations: 'disabled',
scale: 'device',
});
What a touch lane must assert
Cover what a resized desktop window cannot: sign-in and sign-up by tap() only, switcher
navigation, empty-form validation that fires no request, the password-visibility toggle, a
touch-target floor on the primary controls, no horizontal overflow, and submit reachability at a
keyboard-height viewport. Playwright cannot open a native on-screen keyboard, so that last one
shrinks the layout viewport as a proxy — name the spec for what it measures.
Pin the lane with a unit test
Config scoping decays silently. Assert, from the config and lane files as text: both descriptors are
the stock ones, the lane globs exist, every desktop project carries testIgnore, every mobile
project carries testMatch, every lane spec contains .tap(), no lane file (helpers included)
contains .click(, the visual helper uses scale: 'device' and never setViewportSize, and the
snapshot directory holds exactly one baseline per page per mobile project.
Gotchas
-
Playwright rejects
isMobileon Firefox, so a touch project must be Chromium- or WebKit-based. -
page.route()holding a request open makeswaitForLoadState('networkidle')hang until timeout. Wait for'load'or for the specificwaitForResponseinstead. -
Auto-generated MUI field ids can break
getByLabelText; locate by placeholder text,aria-label, or a stablenameattribute. -
eslint-plugin-testing-librarymatches every*.ts, andprefer-screen-queriesmisreads the destructured Playwrightpagefixture as a render result. The fix is a scoped config entry turning off that one rule for the e2e and visual spec globs — never an inline directive. -
A runtime
test.skip(...)device guard is redundant under project scoping and tripsplaywright/no-skipped-test(whoseallowConditionaldefault isfalse). -
The target mapped by
make.start_prodreuses an already-running stack, so baselines can be recorded from stale code; use the clean prod-restart target before recording (React SPA and Next.js shapes).make start-prod-clean make test-visual-update -
A recording run scoped to one lane refreshes only that lane, leaving the other's baselines stale. Re-record both in the same visual-baseline update run (React SPA and Next.js shapes).
Cost of the lane
Measured in the React SPA shape on its production stack against the pinned Playwright image:
| Suite | Before | After | Delta |
|---|---|---|---|
| E2E | 87 tests | 115 tests | +28 tests, ~+31 s |
| Visual | 240 | 244 | +4 tests, ~+17 s |
Common mistakes
- Adding mobile projects without
testIgnoreon the desktop ones — declare both globs as shared constants so the directions cannot drift. - Hand-setting DPR or user agent instead of spreading the device descriptor.
- Recording mobile baselines with the desktop
setViewportSizehelper — give the lane its own helper that passesscale: 'device'. - Enforcing a 44 CSS px touch floor on secondary controls whose height is a design decision; keep the floor on primary controls and file the rest as design work.