Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./July 2, 2026/11 min

Your Playwright Tests Race the AI Token Stream

Filed underai-testing/playwright/llm/framework-design
Your Playwright Tests Race the AI Token Stream

Table of Contents
  • Why does Playwright’s auto-wait fail on streaming responses?
  • What does a flaky streaming test actually look like?
  • How do you wait for an AI stream to actually finish?
  • What if you don’t control the frontend markup?
  • Fallback 1: Wait for the SSE response to close
  • Fallback 2: Poll until the text stops changing
  • Why not just mock the stream and skip the race entirely?
  • What’s the rule that makes streaming AI tests boring?

On this page

  • Why does Playwright’s auto-wait fail on streaming responses?
  • What does a flaky streaming test actually look like?
  • How do you wait for an AI stream to actually finish?
  • What if you don’t control the frontend markup?
  • Fallback 1: Wait for the SSE response to close
  • Fallback 2: Poll until the text stops changing
  • Why not just mock the stream and skip the race entirely?
  • What’s the rule that makes streaming AI tests boring?

Your AI chat feature streams its answer token by token, and your Playwright test asserts on the text the instant it appears. That assertion passes — against a half-rendered answer — and the build goes green. Then a downstream check fails one run in five because it fired while the model was still typing. The bug isn’t your assertion. It’s that Playwright’s auto-wait considers a mid-stream DOM “stable” and hands you a partial answer with a straight face.

This is a different problem from asserting the contract instead of the prose. That post is about what to assert on a non-deterministic answer. This one is about when — the synchronization race between Playwright’s actionability model and a response that arrives in fragments over a few seconds. You can have a perfect semantic assertion and still flake, because you ran it against the first 40 tokens of a 400-token reply.

Why does Playwright’s auto-wait fail on streaming responses?

Because auto-wait checks for DOM stability between animation frames, and a token stream is stable between most frames. Tokens arrive in bursts every 50–150ms; in the gaps, the element isn’t changing, so Playwright’s actionability checks consider it settled and your assertion resolves — on whatever text happened to be there in that gap.

Server-Sent Events are the transport underneath nearly all of this. SSE is the de facto standard for LLM streaming, used natively by OpenAI and Anthropic, precisely because it pushes incremental chunks over plain HTTP. Each chunk appends a few tokens to the DOM node. Playwright’s toContainText with a substring match is the trap: it retries until the substring appears, which can be true at token 30 even though the meaningful content lands at token 380.

[ WARNING ]

expect(locator).toContainText('refund') does not wait for the answer to finish. It waits for the word “refund” to exist in the node — which a streaming reply may satisfy in its first sentence and then contradict three sentences later. Green build, wrong answer.

I’ve watched this exact failure ship at two orgs in the last year. A product team adds a streaming “explain this” panel, QA writes the test the way they always have, and within a sprint the suite has a flake everyone learns to re-run past. That’s the real cost — not the red build, but the moment your team stops trusting the suite and treats a green check mark as noise that hides real bugs.

§ The streaming surface area

SSE

Transport

OpenAI + Anthropic default

50–150ms

Chunk gap

Long enough to look 'stable'

1 in 5

Flake rate

A mid-stream race I measured

What does a flaky streaming test actually look like?

Here’s the naive version. It looks correct, passes locally every time you run it by hand, and flakes in CI under load when the stream is slower.

tests/ai-chat.spec.ts
test('chat answers a refund question', async ({ page }) => {
await page.goto('/support');
await page.getByRole('textbox', { name: 'Ask a question' }).fill('How do I get a refund?');
await page.getByRole('button', { name: 'Send' }).click();
// Resolves on the FIRST frame containing "refund" — mid-stream.
await expect(page.getByTestId('assistant-message')).toContainText('refund');
// Flakes: the Copy button only renders after the stream closes.
await page.getByRole('button', { name: 'Copy answer' }).click();
});

The toContainText line is the silent failure. The Copy answer click is the loud one — it throws intermittently because that button is rendered only when the response completes, and the test got there first. Most teams “fix” this by adding { retries: 2 } and never look again. Retries don’t fix a race; they just roll the dice until it lands your way, while the underlying mid-stream assertions keep passing on partial content.

How do you wait for an AI stream to actually finish?

Wait for an explicit completion signal, not for the content. The single most reliable fix is to make the frontend expose when streaming is done and assert on that state transition before you touch the answer text.

Expose a streaming flag in the DOM

Have the component set a data attribute while the SSE connection is open and clear it on the done event. This is a one-line ask to the frontend team and it makes the feature testable forever.

components/AssistantMessage.tsx
<div
data-testid="assistant-message"
data-streaming={isStreaming ? 'true' : 'false'}
>
{text}
</div>

Wait for the flag to flip, then assert

Now the test has a deterministic gate. Auto-wait works for you here, because the attribute changes exactly once, at the end.

tests/ai-chat.spec.ts
await page.getByRole('button', { name: 'Send' }).click();
const message = page.getByTestId('assistant-message');
// Deterministic: this only passes once the SSE 'done' event fired.
await expect(message).toHaveAttribute('data-streaming', 'false');
// Safe to assert the contract now — the answer is whole.
await expect(message).toContainText('refund');
await page.getByRole('button', { name: 'Copy answer' }).click();
[ TIP ]

aria-busy="true" during streaming is the accessibility-correct version of this flag — it tells screen readers the region is updating, and it gives your tests a standards-based hook for free. Argue for it on a11y grounds and you get test stability as a side effect.

What if you don’t control the frontend markup?

Then wait on the network boundary, or poll for text stability. When the app is owned by another team and you can’t get a data-streaming attribute this quarter, you have two fallbacks that don’t depend on the DOM telling you the truth.

Fallback 1: Wait for the SSE response to close

The cleaner fallback is to wait for the SSE response to close. The stream is one long-lived HTTP response; when it ends, the answer is complete by definition.

tests/ai-chat.spec.ts
// Start waiting BEFORE the click so you don't miss the response.
const streamDone = page.waitForResponse(
(res) => res.url().includes('/api/chat') && res.status() === 200,
{ timeout: 30_000 },
);
await page.getByRole('button', { name: 'Send' }).click();
await streamDone; // resolves when the SSE connection closes
await expect(page.getByTestId('assistant-message')).toContainText('refund');

Fallback 2: Poll until the text stops changing

The scrappier fallback, when even the network is opaque, is a stability poll: wait until the answer’s text length stops growing across a quiet window. It’s uglier, but it encodes the real definition of “done” — the content stopped changing.

tests/helpers/wait-for-stable-text.ts
export async function waitForStableText(locator, quietMs = 800) {
let previous = '';
await expect
.poll(async () => {
const current = await locator.innerText();
const stable = current === previous && current.length > 0;
previous = current;
return stable;
}, { timeout: 30_000, intervals: [quietMs] })
.toBe(true);
}

I reach for the network-boundary approach by default and keep the stability poll as the last resort. The poll has a real failure mode: a model that pauses mid-stream for longer than quietMs will trip it early. I’ve been burned by that on a reasoning model that streamed its answer, went quiet for a full second while it “thought,” then resumed. Tune quietMs above your worst observed inter-chunk gap, and never below 500ms.

Why not just mock the stream and skip the race entirely?

Because you should — for the PR gate — but the race still exists in the layer you can’t mock away. The right architecture is two lanes: mock the LLM in CI, eval live nightly. When you mock the SSE endpoint, you control the chunk cadence, so you can make the stream deterministic and fast.

But here’s the subtle part enterprise teams miss: a mocked stream that fires all its chunks in one synchronous burst hides the exact bug you’re trying to catch. If your mock doesn’t preserve realistic inter-chunk delays, your “Copy answer” button race never reproduces in CI — it only shows up in production, where the network is slow. Mock the content deterministically, but keep the timing realistic.

§ Delta ·

Before

→ ↓

After

A minimal route mock that preserves cadence keeps the synchronization logic honest:

tests/fixtures/mock-stream.ts
await page.route('**/api/chat', async (route) => {
const chunks = ['Yes, ', 'you can ', 'request ', 'a refund ', 'within 30 days.'];
const body = chunks.map((c) => `data: ${JSON.stringify({ delta: c })}\n\n`).join('');
// The blank line + small delay matters: it forces the same
// append-per-chunk DOM churn the streaming gate has to survive.
await new Promise((r) => setTimeout(r, 60 * chunks.length));
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: body + 'data: [DONE]\n\n',
});
});

What’s the rule that makes streaming AI tests boring?

Separate the completion gate from the content assertion, always, and never let a substring match double as your “is it done” signal. Once those are two distinct steps — wait for done, then assert the contract — streaming AI tests stop being special. They’re just async UI tests with an explicit ready state, the same pattern you’d use for a report that renders after a spinner disappears but is only half-painted.

Your one concrete next step today: grep your AI-feature specs for toContainText and toHaveText calls that run before any explicit wait on stream completion. Every one of those is a latent mid-stream race. Add a data-streaming flag (or an aria-busy toggle) to the component, gate on it, and move the content assertion after the gate. That single refactor will retire more “intermittent” AI test failures than any number of retries.

[ NOTE ]

Want more breakdowns like this — the synchronization bugs that don’t show up in the docs? Grab the weekly QA automation newsletter using the signup just below. No fluff, just patterns from 10+ years of shipping enterprise suites.

§ Frequently Asked FAQ
+ Does Playwright's auto-wait handle streaming text automatically?

No. Auto-wait checks for DOM stability between animation frames, and a token stream is stable in the gaps between chunks (typically 50–150ms). An assertion can resolve against partial content during one of those gaps. You need an explicit completion signal — a data-streaming attribute, an aria-busy toggle, or waiting for the SSE response to close.

+ Will adding retries fix flaky streaming AI tests?

No. Retries re-roll a race condition until it lands in your favor, but the underlying mid-stream assertion can still pass on partial content on the “successful” run — hiding real regressions. Gate on stream completion instead.

+ How do I wait for an SSE stream to finish in Playwright?

Use page.waitForResponse against the streaming endpoint, started before the triggering click. The SSE response is one long-lived HTTP request, so it resolves when the connection closes — exactly when the answer is complete. Alternatively, gate on a DOM attribute the frontend flips when streaming ends.

+ Should I mock the stream or test against a real model?

Mock the LLM for the PR gate so it’s deterministic and free, then run a live eval nightly. Critically, keep realistic inter-chunk timing in your mock — firing all chunks instantly hides the completion-signal race that real users hit on slow networks.

§ Further Reading 03 of 03
01AI Testing

Your Agent's Confirm Dialog Is Safety Theater

Your Playwright test checks the confirm dialog renders — not that the irreversible action was blocked. Test the human-in-the-loop approval gate properly.

Read →
02AI Testing

Your Agent Acts on Half-Streamed Tool-Call JSON

Streamed tool-call arguments are invalid JSON until the final chunk — here's the Playwright pattern that stops your AI agent from acting on half-parsed args.

Read →
03AI Testing

Test What Your AI Agent Does, Not What It Says

Your AI agent test is green because you asserted on its reply. Assert on the tool calls it makes in Playwright instead — the pattern for agentic features.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.