How can I fix async Storybook tests that use `waitFor` failing in CI pipelines?

The following test passes locally, and the Chromatic build step passes, but in the Bitbucket pipeline it fails. I suspect it has to do with the async behavior and usage of waitFor.

const statuses = ['Open', 'In Progress', 'Cancelled', 'Closed'];
export const Default: PlayableStory = {
  args: {
    title: 'Filter by status',
    options: statuses.map(status => ({ value: status, label: status })),
  },
  play: async () => {
    const { getByText } = screen;
    statuses.forEach(status => expect(getByText(status)).toBeInTheDocument());

    const unSelectedBackgroundColor = 'rgba(0, 0, 0, 0)';
    const selectedBackgroundColor = 'rgba(0, 0, 0, 0.08)';

    async function expectChipSelected(text: string, selected = true) {
      await waitFor(() => {
        const chip = screen.getByRole('button', { name: text });
        expect(getComputedStyle(chip).backgroundColor).toEqual(
          selected ? selectedBackgroundColor : unSelectedBackgroundColor,
        );
      });
    }

    // select one
    await expectChipSelected(statuses[0], false);
    fireEvent.click(getByText(statuses[0]));
    await expectChipSelected(statuses[0]);
    
    // and more similar tests
  },
};

The relevant pipeline step:

- step:
    name: 'Run storybook tests'
    caches:
      - node
    script:
      - npm install --legacy-peer-deps
      - npx playwright install --with-deps
      - npm run build-storybook --silent
      - npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
          "npx http-server storybook-static --port 6006 --silent" \
          "npx wait-on tcp:6006 && npm run test-storybook"

I ran into a similar CI-only failure issue. The problem was twofold: using forEach with synchronous expectations for async behavior, and waitFor having default timeout too short. Changing forEach to a for…of loop and increasing waitFor timeout helped:

for (const status of statuses) {
  await waitFor(() => expect(screen.getByText(status)).toBeInTheDocument(), { timeout: 3000 });
}

In CI, rendering can be slightly slower than locally, so increasing the timeout ensures the elements are actually in the DOM before assertions.

I also noticed that waitFor sometimes fails in headless CI runs because the component renders asynchronously. Switching to findBy queries made the tests more reliable:

for (const status of statuses) {
  const element = await screen.findByText(status, {}, { timeout: 3000 });
  expect(element).toBeInTheDocument();
}

findBy internally waits for the element to appear, which avoids flaky waitFor usage. It’s cleaner and often fixes CI-only failures.

Another tricky point I ran into is that fireEvent.click can run before the async state settles. Wrapping the click and subsequent check in waitFor guarantees proper sequencing:

await waitFor(async () => {
  fireEvent.click(screen.getByText(statuses[0]));
  const chip = screen.getByRole('button', { name: statuses[0] });
  expect(getComputedStyle(chip).backgroundColor).toEqual(selectedBackgroundColor);
}, { timeout: 4000 });

Sometimes adding await new Promise(r => setTimeout(r, 50)) after the click helps in CI to let styles and DOM updates settle. I rarely need it locally, but headless environments are slower.