internal/analysis/rsc-fouc-shakaperf-artifacts/setup/generated-shakaperf-skills/discover-abtests/references/patterns.md
Each pattern below corresponds to a scenario you confirmed during probing. Only use a pattern if the corresponding behavior was actually observed.
See ../../assess-abtest-quality/SKILL.md — the canonical list (no error swallowing, no loops, no if-branching on page state, wait for conditions, prefer user-facing locators, deterministic inputs, each test independent). Read it before writing or grading any test.
CSS selectors (preferred) — selectors: ['.section-class'] captures the element's bounding box thanks to useBoundingBoxViewportForSelectors: true in visreg.config.ts. The engine automatically calls scrollIntoViewIfNeeded() before capture, so manual scroll calls are only needed to trigger lazy loading, not for positioning.
Viewport + scroll (fallback) — only use selectors: ['viewport'] if no CSS selector can target the section. See scroll-to-section pattern below.
Short pages (<2000px) — a single 'document' capture is enough.
Tall pages (>2000px) — run scripts/probe-sections.js to find scored candidates, then apply AI visual heuristics to pick the best selectors.
Use a two-strategy approach:
Strategy 1 — Algorithmic probe (scripts/probe-sections.js):
Run via javascript_tool after the page loads. It walks the DOM, scores elements by size, width, depth, semantic name, heading inclusion, content density, and uniqueness. Elements >1000px tall are penalized so their children get picked. Returns up to 15 non-overlapping candidates.
Strategy 2 — AI visual analysis: Scroll through the page and identify natural visual sections a user would recognize. For each, find the closest DOM element that wraps it. Evaluate: "If I capture just this element, will the screenshot show recognizable, self-contained UI?"
hero-slider, review-list, not _a3f2b)querySelectorAll returns exactly 1 element<h2> sits above, try the parentwhitePixelPercent > 90% after capture — mostly empty spaceclip.width = 0 engine error-container wrappers)viewports: [desktop] (sidebars typically hidden/repositioned on mobile)position: absolute/sticky/fixed narrower than 50% page widthAfter running each test, check parse-report.py output:
whitePixelPercent > 90 → selector captures too much empty space. Try child or sibling.isBottomSeventyPercentWhite = true → content concentrated at tophadEngineError with clip.width = 0 → add viewport restrictionsWhen a locator might match multiple elements, use .first():
await page.locator('.section-class').first().scrollIntoViewIfNeeded();
Use for any page with no meaningful dynamic content.
import { abTest } from 'shaka-shared';
import { waitUntilPageSettled } from 'shaka-perf/visreg/helpers';
abTest(
'Page Name',
{
startingPath: '/path',
options: { visreg: { delay: 50, misMatchThreshold: 0.05 } },
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
Use when the page has notable named sections worth capturing individually, or when document would be too tall (>3000px).
abTest(
'Page Name',
{
startingPath: '/path',
options: { visreg: { selectors: ['[data-cy="hero"]', 'document'], delay: 50, misMatchThreshold: 0.01 } },
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
Only use if you clicked the element during probing and saw a visible effect.
import { abTest, TestType } from 'shaka-shared';
import { waitUntilPageSettled } from 'shaka-perf/visreg/helpers';
abTest(
'Click [Button] on [Page]',
{
startingPath: '/start',
options: { visreg: { misMatchThreshold: 0.05, maxNumDiffPixels: 5 } },
},
async ({ page, testType, annotate }) => {
annotate('waiting for element to appear');
await page.waitForSelector('[data-cy="element"]');
annotate('clicking button');
await page.click('text=Button Text');
annotate('waiting for navigation to expected path');
await page.waitForURL('**/expected-path');
annotate('waiting for page to settle after navigation');
await waitUntilPageSettled(page);
},
);
Only use if scripts/probe-lazy-load.js (or manual scroll probing) confirmed new content appeared after scrolling.
NEVER use while (!atBottom) scroll loops with page.mouse.wheel() — they go infinite in shaka-perf visreg because window.scrollY doesn't update in the Playwright context. Instead, use scrollIntoViewIfNeeded() on a known bottom element (footer, last section) to trigger lazy loading.
abTest(
'Page Name',
{
startingPath: '/path',
options: { visreg: { delay: 50, misMatchThreshold: 0.05 } },
},
async ({ page, annotate }) => {
annotate('scrolling to bottom to trigger lazy load');
await page.locator('footer').scrollIntoViewIfNeeded();
annotate('waiting for lazy-loaded content to finish loading');
await page.waitForLoadState('networkidle');
annotate('scrolling back to top');
await page.evaluate(() => window.scrollTo(0, 0));
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
If there's no footer, use the last visible section or page.locator('body > *:last-child'). The key is to avoid scroll loops entirely.
Use when a selector only exists on certain viewports (e.g. display: none on mobile). The viewport labels come from visreg.config.ts (e.g. 'mobile', 'tablet', 'desktop').
viewports overrideWrite separate abTest() calls scoped to specific viewports via the viewports option, so each test only runs where its selector exists. No branching logic, clear test names, failures easy to trace.
import { abTest } from 'shaka-shared';
import { waitUntilPageSettled } from 'shaka-perf/visreg/helpers';
// Desktop/tablet only — .map-container is display:none on mobile
abTest(
'Homepage Map Section',
{
startingPath: '/',
options: {
visreg: {
selectors: ['.map-container'],
misMatchThreshold: 0.05,
viewports: [
{ label: 'tablet', width: 768, height: 1024 },
{ label: 'desktop', width: 1280, height: 800 },
],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
// mobile only — .mobile-featured-grid replaces the desktop grid
abTest(
'Homepage Mobile Featured',
{
startingPath: '/',
options: {
visreg: {
selectors: ['.mobile-featured-grid'],
misMatchThreshold: 0.05,
viewports: [{ label: 'mobile', width: 375, height: 667 }],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
When the viewport difference is test logic (different waits, clicks, or scroll behaviour), not just which selector to capture, it's tempting to branch on viewport.label inside one callback. Don't — that violates the "no if" rule and hides which path actually ran. Write one test per viewport, each with its own linear body and a viewports override:
// mobile — results render as a list
abTest(
'Search Results (mobile)',
{
startingPath: '/search',
options: {
visreg: {
misMatchThreshold: 0.05,
delay: 100,
viewports: [{ label: 'mobile', width: 375, height: 667 }],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for mobile results list');
await page.waitForSelector('.mobile-results', { state: 'visible' });
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
// desktop/tablet — results render in a split panel
abTest(
'Search Results (desktop)',
{
startingPath: '/search',
options: {
visreg: {
misMatchThreshold: 0.05,
delay: 100,
viewports: [{ label: 'desktop', width: 1280, height: 800 }],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for split-panel search layout');
await page.waitForSelector('.search-split-panel', { state: 'visible' });
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
Only use if injecting the CSS override during probing visually froze the animation.
import { abTest } from 'shaka-shared';
import { waitUntilPageSettled, overrideCSS } from 'shaka-perf/visreg/helpers';
const PAUSE_CSS = `
[data-cy="carousel-track"] { animation: none !important; transform: translateX(0) !important; }
`;
abTest(
'Carousel on [Page]',
{
startingPath: '/path',
options: { visreg: { delay: 50, misMatchThreshold: 0.05 } },
},
async ({ page, annotate }) => {
annotate('waiting for carousel to appear');
await page.waitForSelector('[data-cy="carousel-track"]', { state: 'visible' });
annotate('overriding CSS to freeze carousel animation');
await overrideCSS(page);
await page.addStyleTag({ content: PAUSE_CSS });
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
},
);
Only use when a CSS selector can't target the section you need AND the test screenshot shows mostly whitespace in the bottom 60% of the page. Read the test images to verify before switching to this pattern. The preferred approach is always a CSS selector (see "Selectors strategy" above).
abTest(
'Below-fold Section on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['viewport'],
misMatchThreshold: 0.05,
delay: 50,
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('scrolling target section into view');
await page.locator('.target-section').first().scrollIntoViewIfNeeded();
},
);
Click a button to open a modal, drawer, or expanded panel, then capture the result. Also probe and test interactions INSIDE the modal — if the modal contains forms, buttons, or links, write separate tests for those (see form filling and chained interaction patterns below).
abTest(
'Open [Modal Name] on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['viewport'],
misMatchThreshold: 0.05,
viewports: [{ label: 'desktop', width: 1280, height: 800 }],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('clicking button to open modal');
await page.locator('[data-cy="open-modal"]').click();
annotate('waiting for modal to appear');
await page.waitForTimeout(500);
},
);
Fill form inputs and capture the filled state. Use page.fill() for text inputs and textareas. For type="number" inputs, use numeric-only strings (no dashes or special characters). Use page.getByLabel() when inputs have associated labels.
abTest(
'Fill [Form Name] on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['.form-container'],
misMatchThreshold: 0.05,
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('filling name field');
await page.fill('input[name="name"]', 'Jane Smith');
annotate('filling email field');
await page.fill('input[name="email"]', '[email protected]');
// For type="number" inputs, use numeric-only strings:
annotate('filling phone field');
await page.fill('input[name="phone"]', '5551234567');
// For textareas:
annotate('filling message field');
await page.fill('textarea[name="message"]', 'Test message text');
},
);
Date pickers vary widely — probe the specific implementation during A6 to determine the right approach. Common patterns:
// Pattern 1: Native date input
await page.fill('input[type="date"]', '2026-06-15');
// Pattern 2: Click-to-open calendar widget (e.g., DayPicker, react-dates)
// Click the input/button to open the calendar, then click a specific date
annotate('clicking Check In to open calendar');
await page.locator('.check-in-input').click();
await page.waitForTimeout(300);
annotate('selecting a date');
await page.locator('td[aria-label="June 15, 2026"]').click(); // or similar
await page.waitForTimeout(300);
// Pattern 3: Calendar already visible on page (inline calendar)
// Just click dates directly
annotate('selecting start date on calendar');
await page.locator('.CalendarDay:has-text("15")').first().click();
await page.waitForTimeout(200);
annotate('selecting end date on calendar');
await page.locator('.CalendarDay:has-text("20")').first().click();
When probing calendars, check: does clicking a date input open a popup? What are the day cell selectors? Are dates clickable <td> elements or <button> elements? Use aria-label attributes when available — they're the most reliable selectors for specific dates.
For inputs with +/- buttons (guest counters, quantity selectors), click the increment button rather than trying to type into the field. The display value is often read-only.
annotate('incrementing adult count');
// Click the "+" button next to Adults — find it by proximity to the label
await page.locator('.adult-counter .increment-btn').click(); // or:
await page.locator('button:has-text("+")').first().click();
await page.waitForTimeout(200);
// Repeat for desired count
await page.locator('button:has-text("+")').first().click();
await page.waitForTimeout(200);
When probing +/- inputs, note: what selector targets the + button? Does clicking it update a visible number? Is there a max limit? Record all of this so the test can be written without guessing.
When a form has multiple inputs AND a submit button, the test should fill everything first, then submit. This captures both the filled-form state and any validation/error UI that appears after submission.
abTest(
'Fill and Submit Booking on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['viewport'],
misMatchThreshold: 0.05,
viewports: [{ label: 'desktop', width: 1280, height: 800 }],
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
// Fill all inputs first
annotate('selecting check-in date');
await page.locator('.check-in-input').click();
await page.waitForTimeout(300);
await page.locator('td[aria-label="June 15, 2026"]').click();
await page.waitForTimeout(300);
annotate('selecting check-out date');
await page.locator('td[aria-label="June 20, 2026"]').click();
await page.waitForTimeout(300);
annotate('opening guests dropdown');
await page.locator('.guest-input-btn').click();
await page.waitForTimeout(300);
annotate('incrementing adult count');
await page.locator('.increment-btn').first().click();
await page.waitForTimeout(200);
// Now click the submit button with all fields populated
annotate('clicking Book Now');
await page.locator('button:has-text("Book Now")').click();
await page.waitForTimeout(500);
},
);
Use page.getByLabel() for checkboxes and radio buttons. Add { exact: true } when the label text could partially match other labels.
abTest(
'Apply Filters on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['.results-container'],
misMatchThreshold: 0.1,
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('checking first filter option');
await page.getByLabel('Option A').check();
await page.waitForTimeout(200);
annotate('checking second filter option');
await page.getByLabel('Option B', { exact: true }).check();
await page.waitForTimeout(300);
},
);
Click something → new UI appears → interact with the new UI. Each link in the chain should have been confirmed during probing. If the new UI itself reveals more interactions (e.g., a filter panel with checkboxes), chain further.
abTest(
'Open Filters and Apply on [Page]',
{
startingPath: '/path',
options: {
visreg: {
selectors: ['.results-container'],
misMatchThreshold: 0.1,
},
},
},
async ({ page, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('clicking to open filter panel');
await page.locator('[aria-expanded]').first().click();
await page.waitForTimeout(300);
annotate('checking filter option');
await page.getByLabel('Category A').check();
await page.waitForTimeout(200);
annotate('clicking apply button');
await page.locator('button:has-text("Apply")').click();
await page.waitForTimeout(500);
},
);
Click a CTA or link that navigates to another page, then capture the destination. Use { waitUntil: 'commit' } for pages with slow server responses (>30s).
import { abTest, TestType } from 'shaka-shared';
import { waitUntilPageSettled } from 'shaka-perf/visreg/helpers';
abTest(
'Click [CTA] on [Page]',
{
startingPath: '/start-page',
options: {
visreg: {
selectors: ['viewport'],
misMatchThreshold: 0.05,
viewports: [{ label: 'desktop', width: 1280, height: 800 }],
},
},
},
async ({ page, testType, annotate }) => {
annotate('waiting for page to settle');
await waitUntilPageSettled(page);
annotate('clicking CTA link');
await page.locator('a[href="/destination"]').click();
annotate('waiting for navigation');
await page.waitForURL('**/destination');
// For slow pages: await page.waitForURL('**/destination', { waitUntil: 'commit' });
annotate('waiting for destination page to settle');
await waitUntilPageSettled(page);
},
);