CI Stabilization Patterns for Storybook Play Functions
This catalog documents testing issues discovered during CI stabilization of the Business Affiliate UX project. Each pattern describes a class of failure that appeared during Playwright-driven Storybook testing, the symptom that surfaced it, the root cause, and the fix. Apply these patterns proactively when writing play functions for future projects.
Pattern 1: AG Grid Virtualization
Section titled “Pattern 1: AG Grid Virtualization”Problem: Row queries fail even though the data is present in the DOM.
Symptom: findByText('SomeRowValue') succeeds, but adding .toBeVisible() on the result causes the assertion to fail intermittently or consistently in CI.
Root Cause: AG Grid virtualizes rows — rows that are outside the current scroll viewport are rendered in the DOM but are clipped by the grid container’s overflow. @testing-library’s toBeVisible() walks the CSS visibility chain and considers clipped elements non-visible.
Fix: Do not assert toBeVisible() on AG Grid row content. The fact that findByText succeeds is sufficient evidence that the row is present. If you need to assert the row is interactable, click it and assert on the resulting action instead.
Pattern 2: AG Grid Buffer Rows
Section titled “Pattern 2: AG Grid Buffer Rows”Problem: Exact row count assertions fail.
Symptom: An assertion like expect(rows).toHaveLength(20) fails with received 21 or received 22.
Root Cause: AG Grid renders 1-2 extra buffer rows outside the visible area as part of its virtualization strategy. The number of buffer rows can vary based on container height and grid configuration.
Fix: Never assert exact row counts in AG Grid stories. Assert on the presence of specific row values, or use toBeGreaterThanOrEqual / toBeLessThanOrEqual with a range that accommodates buffer rows. If you need to verify page size, assert on the pagination toolbar label (e.g., “Showing 20 of 60”) rather than counting DOM rows.
Pattern 3: AG Grid Re-renders After Mutations
Section titled “Pattern 3: AG Grid Re-renders After Mutations”Problem: Assertions on row content immediately after a mutation (save, delete, page change) fail intermittently.
Symptom: getByText('UpdatedValue') throws Unable to find element or finds a stale element reference after the play function triggers a save or navigation action.
Root Cause: After a mutation, AG Grid processes the updated data by temporarily removing existing row nodes and re-inserting them. There is a window between removal and re-insertion during which DOM queries return nothing or stale elements.
Fix: Wrap all post-mutation row assertions in waitFor. Do not chain .then() on the mutation action and immediately query; always introduce an explicit wait boundary.
await userEvent.click(saveButton);await waitFor(() => { expect(canvas.getByText('UpdatedValue')).toBeInTheDocument();});Pattern 4: Sonner Toast Animation
Section titled “Pattern 4: Sonner Toast Animation”Problem: Toast notifications are present in the DOM but toBeVisible() fails immediately after they appear.
Symptom: findByText(/success/i) resolves, but expect(element).toBeVisible() fails with opacity-related computed style failures.
Root Cause: Sonner animates toasts in using a CSS opacity transition starting at 0. The element is mounted in the DOM (so findByText finds it) before the animation completes (so toBeVisible fails during the transition window). Additionally, Sonner portals its toast container to document.body, outside the story canvas element.
Fix: Use a two-step query: find the element first using screen (not canvas, because the portal is outside the canvas), then wait for visibility separately.
const toastText = await screen.findByText(/success/i, {}, { timeout: 10000 });await waitFor(() => { expect(toastText).toBeVisible(); }, { timeout: 10000 });Always use screen (not within(canvasElement)) for Sonner toasts.
Pattern 5: Radix Dropdown Portals
Section titled “Pattern 5: Radix Dropdown Portals”Problem: Dropdown menu items are not found when queried with canvas.findByRole.
Symptom: canvas.findByRole('menuitem', { name: 'Edit' }) throws a timeout error even though the dropdown visibly opened in the browser.
Root Cause: Radix UI’s DropdownMenuContent portals to document.body by default, outside the story’s canvas subtree. Queries scoped to canvasElement will not find portal content.
Fix: Use screen.findByRole (unscoped, searching the full document) for any Radix dropdown menu items, dialog content, popover content, or other components that use Radix’s portal primitive.
// Wrong — portal is outside canvasawait canvas.findByRole('menuitem', { name: 'Edit' });
// Correctawait screen.findByRole('menuitem', { name: 'Edit' });Pattern 6: Duplicate Element Matches After Edit/Save
Section titled “Pattern 6: Duplicate Element Matches After Edit/Save”Problem: Queries match multiple elements after an edit-and-save cycle, causing getByText to throw “Found multiple elements”.
Symptom: After saving an edit, the updated value appears in both the data grid row and the open detail drawer. getByText('UpdatedName') finds two elements and throws.
Root Cause: The grid and the drawer both display the same field value simultaneously. After a save, both are updated, so the DOM contains the text in two places.
Fix: Re-query the drawer element after the save completes and scope subsequent assertions with within(drawer). Do not hold a reference to the drawer from before the mutation — re-query it to get the post-save instance.
await userEvent.click(saveButton);await waitFor(() => screen.getByText('UpdatedName')); // wait for save to propagateconst drawer = screen.getByRole('complementary', { name: /business affiliate/i });expect(within(drawer).getByText('UpdatedName')).toBeInTheDocument();Pattern 7: userEvent.tripleClick Does Not Exist
Section titled “Pattern 7: userEvent.tripleClick Does Not Exist”Problem: Attempt to use userEvent.tripleClick to select all text in an input field throws a runtime error.
Symptom: TypeError: userEvent.tripleClick is not a function at story runtime.
Root Cause: The @storybook/test re-export of userEvent does not include tripleClick. The method exists in the full @testing-library/user-event package but is not available in the Storybook testing environment.
Fix: Use userEvent.clear() to clear an input field before typing a replacement value. Do not attempt to simulate a triple-click to select-all.
// Wrong — tripleClick is not availableawait userEvent.tripleClick(input);await userEvent.type(input, 'NewValue');
// Correctawait userEvent.clear(input);await userEvent.type(input, 'NewValue');Pattern 8: Step Delays in CI
Section titled “Pattern 8: Step Delays in CI”Problem: Play functions that call a visual step-delay utility hang or run slowly in CI.
Symptom: Stories that run correctly locally take 10-30x longer in CI, or the Playwright test runner times out waiting for the play function to complete.
Root Cause: Story step delays (used to make stepwise stories human-readable during demos) are implemented with a setTimeout-based delay, typically 2 seconds per step. In CI and Playwright-driven runs, these delays accumulate across all steps and dramatically increase test duration.
Fix: Import the step delay from the shared utility at _shared/story-step-delay.ts. This utility checks navigator.webdriver at runtime: when true (CI, Playwright), the delay is skipped entirely; when false (interactive Storybook), the full delay is applied. Do not inline setTimeout delays in play functions.
import { storyStepDelay } from '../_shared/story-step-delay';
// In the play function:await storyStepDelay(); // 2s in browser, 0ms in CICopyright: © Arda Systems 2025-2026, All rights reserved