Skip to content

feat: add withStreamingNetwork for HTTP streaming test support#371

Open
PabloFuentesSanz wants to merge 9 commits into
masterfrom
feat/streaming-network
Open

feat: add withStreamingNetwork for HTTP streaming test support#371
PabloFuentesSanz wants to merge 9 commits into
masterfrom
feat/streaming-network

Conversation

@PabloFuentesSanz

@PabloFuentesSanz PabloFuentesSanz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Motivation

As HTTP streaming becomes increasingly common — particularly in chat and LLM-powered applications that use SSE or chunked transfer encoding — testing streaming responses with wrapito previously required workarounds that diverged significantly from production behavior.

The typical approach was to use a vi.spyOn(global, 'fetch') and return a Promise that never resolves (to simulate a pending stream) or to mock response.json() on a fake response object. This approach has two problems:

  1. It does not reflect how streaming clients actually work. Real streaming clients call response.body.getReader() and loop over chunks — not response.json(). A spy-based mock cannot exercise this code path at all.
  2. It couples the test to implementation details. Mocking fetch at the spy level means your test needs to know how the component calls fetch internally, rather than testing the observable behavior (text appearing chunk by chunk, a loading indicator while the stream is open, etc.).

withStreamingNetwork solves both problems by providing a ReadableStream-backed response through wrapito's existing mock infrastructure, so tests can verify real streaming behavior without changing how fetch is intercepted.

What's new: withStreamingNetwork

A new built-in method on the wrap() chain — no registration required, works exactly like withNetwork.

wrap(ChatComponent)
  .withStreamingNetwork({
    path: '/assistant/message/',
    method: 'POST',
    chunks: ['Hello', ', ', 'world!'],
  })
  .mount()

Parameters

Parameter Type Default Description
path string API path to intercept
host string Optional host, same as withNetwork
method HttpMethod 'GET' HTTP method to match
chunks StreamChunk[] Array of strings or { text, delay? } objects
delayBetweenChunks number 0 Global delay in ms applied between all chunks
keepOpen boolean false When true, the stream never closes — useful for testing in-progress states

StreamChunk is either a plain string (no delay) or { text: string; delay?: number } (per-chunk delay that overrides delayBetweenChunks).

Usage in a real project

Register nothing — just use it:

// Test: chunks arrive and render progressively
wrap(ChatApp)
  .withStreamingNetwork({
    path: '/chat/',
    method: 'POST',
    chunks: [
      { text: 'Sure, ' },
      { text: 'here is your answer.', delay: 80 },
    ],
  })
  .mount()

expect(await screen.findByText('Sure, ')).toBeInTheDocument()
expect(await screen.findByText('here is your answer.')).toBeInTheDocument()
// Test: UI shows a loading/streaming indicator while the stream is open
wrap(ChatApp)
  .withStreamingNetwork({
    path: '/chat/',
    method: 'POST',
    chunks: ['Generating...'],
    keepOpen: true,
  })
  .mount()

expect(await screen.findByText('Generating...')).toBeInTheDocument()
expect(screen.getByRole('progressbar')).toBeInTheDocument() // or whatever your indicator is
expect(screen.getByRole('button', { name: /stop/i })).not.toBeDisabled()

Test cases you can now cover

  • ✅ Each chunk renders as it arrives (progressive rendering)
  • ✅ Per-chunk or global delays to test timing-dependent UI (throttled display, debounce)
  • ✅ Stream that never closes → "streaming in progress" state (disabled submit, spinner, stop button)
  • ✅ Empty stream → component handles zero chunks gracefully
  • ✅ Chaining with other withNetwork calls for endpoints that mix streaming and JSON responses

Implementation details

Core change: createResponse now supports body: ReadableStream

WrapResponse already extended Partial<Response>, which includes body?: ReadableStream<Uint8Array> | null from the DOM type. The only change to mockNetwork.ts is that createResponse now checks for a body property and, when present, returns a response-like object with .body set to the stream and Content-Type: text/event-stream — instead of the default JSON shape with .json().

No breaking changes

  • All existing tests pass untouched.
  • withNetwork, withInteraction, atPath, and all other built-in methods are unaffected.
  • The extension system (configure({ extend: ... })) is unchanged.
  • Components that call response.json() continue to work exactly as before.

Also fixed

  • Extension type was previously <T>(api, args: T) => Wrap — a generic that made it impossible to write typed user-defined extensions without TypeScript errors. Changed to (api, args: any) => unknown, which is what the runtime already assumed.
  • tsconfig.test.json now includes @testing-library/jest-dom in types, so toBeInTheDocument and other jest-dom matchers are correctly typed in test files.

Tests

New test file: tests/lib/streamingNetwork.test.ts covering:

  1. All chunks arrive and the stream closes — streaming indicator disappears
  2. keepOpen: true — streaming indicator stays visible after chunks are received
  3. Per-chunk delay — chunks arrive in order after their individual delays
  4. Global delayBetweenChunks — uniform delay between all chunks

🤖 Generated with Claude Code


Update: multiple sequential responses & controllable streams

Two follow-up additions, driven by migrating a real chat test suite (sequential assistant messages + step-by-step loading/progress assertions without timers).

Array form — sequential responses to the same endpoint

withStreamingNetwork now also accepts an array (like withNetwork). Each entry is a separate response served in order to successive requests to the same endpoint (consume-once):

wrap(ChatApp)
  .withStreamingNetwork([
    { path: '/chat/message/', method: 'POST', chunks: ['First answer'] },
    { path: '/chat/message/', method: 'POST', chunks: ['Second answer'] },
  ])
  .mount()
// first request → "First answer", second request → "Second answer"

Note the two axes: chunks are progressive pieces of one response (concatenated into a single message), while the array registers separate responses for separate requests. They compose: [{ chunks: ['Hel', 'lo'] }, { chunks: ['Bye'] }].

createStreamController — drive a stream from the test

For step-by-step assertions (loading → partial → closed) without relying on timers, pass a controllable stream instead of chunks:

import { wrap, createStreamController } from 'wrapito'

const chat = createStreamController()
wrap(ChatApp)
  .withStreamingNetwork({ path: '/chat/message/', method: 'POST', stream: chat.stream })
  .mount()

// loading state visible here

chat.sendChunk('partial...')
// assert in-progress UI (chunk visible, stream still open)

chat.close()
// assert final UI (stream closed)

StreamingNetworkConfig now accepts stream?: ReadableStream as an alternative to chunks. createStreamController() returns { stream, sendChunk, close }. sendChunk/close are synchronous; in React tests flush with await waitFor(...) / findBy* after each emission.

Added tests

  • Sequential streaming responses to the same endpoint are served in order
  • Controllable stream emits chunks on demand (createStreamController)

Adds a first-class withStreamingNetwork method to the wrap() API,
enabling realistic testing of HTTP streaming (SSE, NDJSON, chunked
responses) without resorting to spies or manual fetch mocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@PabloFuentesSanz PabloFuentesSanz requested a review from a team as a code owner June 18, 2026 09:56
PabloFuentesSanz and others added 2 commits June 18, 2026 12:00
The Extension type change was introduced as a side effect when
withStreamingNetwork was briefly implemented as a user extension.
Since it is now a built-in method, there is no reason to alter the
existing extension type contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread src/mockNetwork.ts Outdated
Using the inherited DOM body field as an implicit signal for streaming
was confusing. streamBody makes the intent unambiguous both at the
WrapResponse definition and at the createResponse dispatch point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread tests/lib/streamingNetwork.test.ts
@PabloFuentesSanz

PabloFuentesSanz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

PR probando la v13.5.0-beta9 en Calidad360 --> https://github.com/mercadona/mo.calidad360.web/pull/266

Comment thread tests/lib/streamingNetwork.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants