Mert Özen Further With Every Line
Artificial Intelligence AI Interfaces with Angular — #08

Testing a Streaming AI UI: Fake Streams, Vitest Fake Timers, MSW and Playwright

Mert Özen Nov 14, 2026 16 min 2 views
Testing a Streaming AI UI: Fake Streams, Vitest Fake Timers, MSW and Playwright

A streaming AI UI is tested deterministically with fake AsyncGenerators that take the model out of the loop, fake timers, MSW responses with ReadableStream bodies and Playwright tests using page.route. In this post we show instanceof DOMException turning a cancellation into an error in the test environment, MSW's sse() handler not fitting a fetch stream, and a ping-less fake stream hitting the 15 s silence timer before the 30 s timeout; 18 tests finish in under 2 seconds.

Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0, Node.js 24) and .NET 10; unit tests use Angular's unit-test builder with Vitest 5.1 and jsdom 30.1, network-level tests MSW 3.0.5, and E2E tests @playwright/test 1.65 with Chromium. The recorded stream was produced with local Ollama 0.34.4 (qwen3:4b); none of the tests calls the model.

Guess First

Since post 3 the store of the Bookstore assistant (Bookstore, the online bookstore used as the shared example across this site's series) recognizes cancellation like this: err instanceof DOMException && err.name === 'AbortError'. In the browser the Stop button works with it; in post 7 we even tried it with Escape. If you exercise the same code with Vitest in a jsdom environment, with a test that really aborts an AbortController, does the test pass? Second question: MSW has a dedicated sse() handler for Server-Sent Events. Can we use it to fake the stream we read with fetch and POST?

Both answers turned out to be "no", and the first one led to a change in production code. In this post we test the streaming UI on three layers: unit tests for the parser and the state machine, MSW for the network level, and Playwright for the browser. No test calls the model. The general side of Angular test setup is in the Frontend Testing series; evaluating answer quality is in the AI with .NET series post "Testing Code That Uses IChatClient: Fake Clients, Deterministic Tests and Microsoft.Extensions.AI.Evaluation".

Why the Model Must Stay Out of the Tests

In the runs from the last two posts of this series, three of the answers to the same question ("Which is longer, Dune or The Hobbit? Answer in one sentence.") were: "Dune is longer than The Hobbit.", "Dune is longer than The Hobbit [1] [2]." and a two-line sentence quoting the catalog notes. The time varied between 3.6 and 7.6 seconds; the three-sentence question in post 7 took 38 seconds. An assertion like toContainText('Dune is longer than The Hobbit.') passes for one of these answers and fails for another. With a cloud provider every test run also costs money. The UI's job is not what the model says but handling the incoming events correctly, so the tests use three kinds of fake sources instead of the model:

  • A fake AsyncGenerator: to test the store without a network, with control over time.
  • A fake HTTP response with MSW: to test fetch, ReadableStream and the parser together, with real chunk boundaries.
  • Playwright page.route: to test real components in a real browser, without the API.

All three share the same material: a stream recorded once from a real model answer.

Testing the Parser: Split UTF-8 and CRLF

In post 2 we wrote readSse against two network problems: a multi-byte UTF-8 character split at a chunk boundary and a CRLF split across two chunks. Back then we tried them by hand with a script; now they become permanent tests:

// A ReadableStream that yields the given parts as separate chunks: mimics splitting on the network
function streamOf(chunks: (string | Uint8Array<ArrayBuffer>)[]): ReadableStream<Uint8Array<ArrayBuffer>> {
  const encoder = new TextEncoder();
  return new ReadableStream({
    start(controller) {
      for (const c of chunks) {
        controller.enqueue(typeof c === 'string' ? encoder.encode(c) : c);
      }
      controller.close();
    },
  });
}

describe('readSse', () => {
  it('joins a UTF-8 character split across two chunks', async () => {
    const bytes = new TextEncoder().encode('event: delta\ndata: {"text":"Doğu"}\n\n');
    const at = bytes.indexOf(0xc4); // "ğ" = C4 9F
    const messages = await collect(streamOf([bytes.slice(0, at + 1), bytes.slice(at + 1)]));
    expect(messages).toEqual([{ event: 'delta', data: '{"text":"Doğu"}' }]);
  });

  it('does not invent an empty line when CRLF is split between chunks', async () => {
    const messages = await collect(streamOf(['event: delta\r', '\ndata: {"text":"a"}\r\n\r\n']));
    expect(messages).toEqual([{ event: 'delta', data: '{"text":"a"}' }]);
  });
});

The Uint8Array<ArrayBuffer> type comes from TypeScript 6.0: typed arrays are now generic over their buffer type, and the plain Uint8Array of the first version did not compile when passed to readSse, which expects a ReadableStream<BufferSource>, because of a SharedArrayBuffer mismatch. To see that the test really protects something, I temporarily removed the CRLF fix from readSse:

$ npx ng test --watch=false --include src/app/assistant/sse.spec.ts
 FAIL  src/app/assistant/sse.spec.ts > readSse > does not invent an empty line when CRLF is split between chunks
AssertionError: expected [ { event: 'message', …(1) } ] to deeply equal [ { event: 'delta', …(1) } ]
-     "event": "delta",
+     "event": "message",

The split CRLF inserts a fake empty line, the event: delta line closes as an event of its own, and the data arrives under the name message. On screen this means part of the answer silently disappears.

The State Machine with a Fake Stream

To test the store we put a fake stream in place of ChatApi. Each step is either an event or a wait in milliseconds:

// Steps of the fake stream: an event or a wait in milliseconds
export type Step = ChatEvent | number;

// Takes the model out of the loop: yields the steps in order, waits with setTimeout (advanced by fake timers)
// and, like a real fetch, rejects with signal.reason when aborted
export function scripted(steps: Step[]): (signal: AbortSignal) => AsyncGenerator<ChatEvent> {
  return async function* (signal: AbortSignal) {
    for (const step of steps) {
      if (typeof step === 'number') {
        await new Promise<void>((resolve, reject) => {
          const timer = setTimeout(resolve, step);
          signal.addEventListener('abort', () => {
            clearTimeout(timer);
            reject(signal.reason);
          }, { once: true });
        });
      } else {
        signal.throwIfAborted();
        yield step;
      }
    }
  };
}
beforeEach(() => {
  sessionStorage.clear();
  // A fake stream instead of ChatApi: no model and no network in the test
  const fakeApi: Partial<ChatApi> = { stream: (_request, signal) => next(signal) };
  TestBed.configureTestingModule({ providers: [{ provide: ChatApi, useValue: fakeApi }] });
  store = TestBed.inject(ChatStore);
});

it('treats stop as aborted, not as an error', async () => {
  vi.useFakeTimers();
  play([{ type: 'delta', text: 'Dune' }, 10_000, { type: 'done' }]);
  const done = store.send('Which is longer?');
  await vi.advanceTimersByTimeAsync(100);
  store.stop();
  await done;
  expect(store.status()).toBe('aborted');
  expect(store.error()).toBeNull();
  expect(store.messages()[1]).toMatchObject({ text: 'Dune', ending: 'stopped' });
});

This test failed on the first run:

 FAIL  src/app/assistant/chat-store.spec.ts > ChatStore > treats stop as aborted, not as an error
AssertionError: expected 'error' to be 'aborted' // Object.is equality

The store took the cancellation for an error. A small test showed why: in the same environment, the reason of a signal aborted through new AbortController() was this:

+ [
+   "[object DOMException]",
+   "AbortError",
+   false,
+   true,

The value is a DOMException, its name is AbortError, it is an instance of Error, but instanceof DOMException is false. In the jsdom environment the global DOMException is jsdom's class while AbortController is Node.js's; two realms with two different DOMExceptions. In the browser there is a single realm, so the problem never showed, but the same thing happens with an error coming from an iframe or a worker. I changed the code, not the test:

// Checks the name instead of instanceof DOMException: the abort reason may come from another realm (test environment, iframe, worker)
function hasName(err: unknown, name: string): boolean {
  return typeof err === 'object' && err !== null && (err as { name?: unknown }).name === name;
}

handleError now uses hasName(err, 'AbortError') and hasName(err, 'TimeoutError'). The other store tests pin down the rules of the state machine from post 3: a partial answer is not deleted and is marked interrupted, a second message is ignored while streaming, and a 429 counts down for Retry-After.

The 30 and 15 Seconds with Fake Timers

The store has two timers: the 30-second first-token timeout and the 15-second silence timer that relies on the server's 5-second ping. Testing them in real time would add 30 seconds to every run; with Vitest's fake timers we advance time ourselves:

// A wait that sends a ping every 5 seconds, like the server
export function pings(totalMs: number): Step[] {
  const steps: Step[] = [];
  for (let t = 5000; t <= totalMs; t += 5000) {
    steps.push(5000, { type: 'ping' });
  }
  return steps;
}
it('times out after 30 seconds without a first token while pings keep the connection alive', async () => {
  vi.useFakeTimers();
  play([{ type: 'conversation', id: 'c1' }, ...pings(45_000), { type: 'delta', text: 'too late' }]);
  const done = store.send('Which is longer?');
  await vi.advanceTimersByTimeAsync(29_999);
  expect(store.status()).toBe('submitted');
  await vi.advanceTimersByTimeAsync(1);
  await done;
  expect(store.error()?.kind).toBe('timeout');
  expect(store.messages()[1].text).toBe('');
});

it('treats 15 seconds of silence as a lost connection', async () => {
  vi.useFakeTimers();
  play([{ type: 'conversation', id: 'c1' }, { type: 'thinking' }, 60_000, { type: 'done' }]);
  const done = store.send('Which is longer?');
  await vi.advanceTimersByTimeAsync(15_000);
  await done;
  expect(store.status()).toBe('error');
  expect(store.error()?.kind).toBe('network');
});

If you forget the pings while writing the first test, it ends at second 15 with a network error instead of a timeout at second 30: without pings the silence timer fires before the first-token timer. So the "first-token timeout" only means something while the server keeps the connection alive with pings; that relationship between the two timers is not written in the code, it is written in the test. The Async variant of advanceTimersByTimeAsync is essential here: after a timer fires, the async generator's and the store's promise chains must also move forward.

At the Network Level: an Event Stream with MSW

The fake generator skips fetch, the response body and the parser. We test that layer with MSW. My first attempt was MSW's sse() handler:

 FAIL  src/app/assistant/msw-probe.spec.ts
Invariant Violation: Failed to construct a Server-Sent Event handler for path "/chat/sse":
the EventSource API is not supported in this environment

The sse() handler exists to fake the EventSource API, and its official documentation only describes it in terms of EventSource. Our client does not use EventSource; in post 2 we chose fetch to send a POST body. The right tool is a plain http.post handler and a response whose body is a ReadableStream:

// An event-stream response that splits the body into the given parts with a wait between them
function eventStream(chunks: string[], delayMs = 5, onCancel?: () => void): HttpResponse<ReadableStream> {
  const encoder = new TextEncoder();
  let timer: ReturnType<typeof setTimeout> | undefined;
  const body = new ReadableStream({
    async start(controller) {
      for (const [i, chunk] of chunks.entries()) {
        if (i > 0) {
          await new Promise((resolve) => (timer = setTimeout(resolve, delayMs)));
        }
        controller.enqueue(encoder.encode(chunk));
      }
      controller.close();
    },
    cancel() {
      clearTimeout(timer);
      onCancel?.();
    },
  });
  return new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } });
}

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

In MSW 3 the listen option has a new name: onUnhandledRequest from MSW 2 is now onUnhandledFrame; the old name did not compile in TypeScript. The 'error' value makes a request without a handler break the test, so a request that accidentally goes to the real API or the model cannot slip through unnoticed. Here we can also verify that cancellation reaches the response body:

it('cancels the response body when the caller aborts', async () => {
  let cancelled = false;
  server.use(http.post('/chat/sse', () => eventStream(['event: thinking\ndata: {}\n\n', 'event: delta\ndata: {"text":"never"}\n\n'], 10_000, () => (cancelled = true))));
  const controller = new AbortController();
  const events = new ChatApi().stream(request, controller.signal);
  const first = await events.next();
  controller.abort();
  const rest = await collect(events).catch((e: unknown) => e);
  expect(first.value).toEqual({ type: 'thinking' });
  expect(rest).toMatchObject({ name: 'AbortError' });
  expect(cancelled).toBe(true);
});

The first version of this test hit Vitest's 5-second timeout: eventStream also waited before the first part, and events.next() got no answer for 10 seconds. The wait now only goes between parts. The rest assertion checks the name instead of instanceof, for the same realm reason.

Recorded Real Streams

Writing fake events by hand is easy but misses the details of a real stream: the sources event being a JSON array, thinking arriving before the first delta, deltas splitting mid-word. I recorded one answer from the real API with curl and stored it as a fixture; the start of the file:

// Recorded from a real /chat/sse response (qwen3:4b, 2026-11-14); tests replay this stream without calling the model
// Question: "Which is longer, Dune or The Hobbit? Answer in one sentence."
export const DUNE_SHORT = "event: conversation\ndata: {\"id\":\"f38c7df6-ac75-45a7-ac34-d3ec5be2e852\"}\n\nevent: sources\ndata: [{\"id\":1,\"tit...

The recording is 1998 bytes and 42 events: conversation, sources, thinking, 37 deltas, usage and done. In the test I split this stream into 7-character parts and replay it with MSW, so event boundaries are cut at arbitrary places as on the network:

// Splits a string into n-character parts: mimics arbitrary chunk boundaries on the network
function split(text: string, size: number): string[] {
  return Array.from({ length: Math.ceil(text.length / size) }, (_, i) => text.slice(i * size, (i + 1) * size));
}

it('replays a recorded stream split into 7-character chunks', async () => {
  server.use(http.post('/chat/sse', () => eventStream(split(DUNE_SHORT, 7), 1)));
  const events = await collect(new ChatApi().stream(request, new AbortController().signal));
  const text = events.map((e) => (e.type === 'delta' ? e.text : '')).join('');
  expect(events.map((e) => e.type).filter((t) => t !== 'delta')).toEqual(['conversation', 'sources', 'thinking', 'usage', 'done']);
  expect(text).toMatch(/^Dune is longer than The Hobbit/);
});

When the server changes the event vocabulary (a new event, a renamed field) the fixture has to be recorded again. Keep that a deliberate step: write at the top of the fixture which question, which model and which date it was recorded with; a changed fixture is then visible in code review. All unit and network tests:

$ npx ng test --watch=false --reporters=verbose
 ✓ sse.spec.ts > readSse > joins a UTF-8 character split across two chunks 3ms
 ✓ sse.spec.ts > readSse > does not invent an empty line when CRLF is split between chunks 0ms
 ✓ sse.spec.ts > readSse > skips comments, joins multi-line data and drops an unterminated last event 0ms
 ✓ chat-store.spec.ts > ChatStore > streams deltas into one assistant message and ends in done 7ms
 ✓ chat-store.spec.ts > ChatStore > keeps a partial answer and marks it interrupted on an error event 1ms
 ✓ chat-store.spec.ts > ChatStore > times out after 30 seconds without a first token while pings keep the connection alive 2ms
 ✓ chat-store.spec.ts > ChatStore > treats 15 seconds of silence as a lost connection 1ms
 ✓ chat-store.spec.ts > ChatStore > treats stop as aborted, not as an error 1ms
 ✓ chat-store.spec.ts > ChatStore > ignores a second message while the first one is streaming 1ms
 ✓ chat-store.spec.ts > ChatStore > counts down Retry-After on 429 before allowing the next message 1ms
 ✓ chat-api.spec.ts > ChatApi over the network (MSW) > replays a recorded stream split into 7-character chunks 384ms
 ✓ chat-api.spec.ts > ChatApi over the network (MSW) > turns 429 into ChatHttpError with Retry-After 3ms
 ✓ chat-api.spec.ts > ChatApi over the network (MSW) > cancels the response body when the caller aborts 2ms
 ✓ chat-api.spec.ts > ChatApi over the network (MSW) > drives the store end to end without a model 57ms
 Test Files  3 passed (3)
      Tests  14 passed (14)
   Duration  827ms (environment 42%, tests 33%, setup 16%, transform 8%, import 1%)

The 14 tests, the 30-second timeout included, finish in under a second.

Playwright: page.route, Cancellation and Half Markdown

Unit tests do not see the components, the sanitizing pipeline or the accessibility behavior. In the Playwright tests the dev server runs but there is no API and no model; every test answers /chat/sse with page.route:

// E2E tests run without a model or API: every test answers /chat/* requests with page.route
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  reporter: 'list',
  use: {
    baseURL: 'http://localhost:4260',
    trace: 'retain-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'npm start',
    url: 'http://localhost:4260',
    reuseExistingServer: !process.env['CI'],
  },
});
test('replays a recorded stream and announces the answer once', async ({ page }) => {
  await page.route('**/chat/sse', (route) =>
    route.fulfill({ status: 200, contentType: 'text/event-stream', body: DUNE_SHORT }),
  );
  await ask(page);
  const answer = page.locator('article').last();
  await expect(answer).toContainText('Dune is longer than The Hobbit');
  await expect(answer).toContainText('AI-generated, may contain mistakes.');
  // The accessibility rules from post 7 are now tests: focus in the input, a single announcement
  await expect(page.getByLabel('Message to the Bookstore assistant')).toBeFocused();
  await expect(page.locator('.cdk-live-announcer-element')).toHaveText(/^Assistant: Dune is longer than The Hobbit/);
});

test('Stop cancels the request in the browser', async ({ page }) => {
  // We do not answer: the model is still "thinking"
  let held: Route | undefined;
  await page.route('**/chat/sse', (route) => {
    held = route;
  });
  const failed = page.waitForEvent('requestfailed', (r) => r.url().endsWith('/chat/sse'));
  await ask(page);
  await page.getByRole('button', { name: 'Stop' }).click();
  expect((await failed).failure()?.errorText).toBe('net::ERR_ABORTED');
  await expect(page.locator('article').last()).toContainText('Stopped.');
  expect(held).toBeDefined();
});

route.fulfill delivers the whole body at once; it is not suited to spreading events over time. That is why the cancellation test never answers at all: the request hangs, and when Stop is pressed the browser cuts it with net::ERR_ABORTED. This proves that cancellation happens in the browser; that the server also stops the model we measured in the server log in post 2, and that check belongs in the backend's own tests.

For half markdown I set up the case from post 4: a code block opens and an error event arrives before it closes. Instead of comparing screenshots (images change with the operating system and fonts, and a baseline recorded on Windows does not match on macOS), I compare the accessibility tree:

test('a half code block cut by an error still renders as code', async ({ page }) => {
  await page.route('**/chat/sse', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'text/event-stream',
      body: sse([
        ['delta', { text: 'Add the ISBN check:\n\n```csharp\n' }],
        ['delta', { text: 'bool valid = isbn.Length == 13' }],
        ['error', { message: 'The model stopped responding.', partial: true }],
      ]),
    }),
  );
  await ask(page, 'Show me an ISBN check.');
  const answer = page.locator('article').last();
  await expect(answer.locator('pre code')).toContainText('bool valid = isbn.Length == 13');
  await expect(answer).not.toContainText('```');
  await expect(page.getByRole('alert')).toHaveText('The model stopped responding.');
  await expect(answer).toMatchAriaSnapshot(`
    - article:
      - paragraph: "Add the ISBN check:"
      - code: bool valid = isbn.Length == 13
      - paragraph: The answer was cut off.
  `);
});

I also test the silence timer in the browser; Playwright's page.clock controls setTimeout in the page:

test('15 seconds of silence becomes a connection error', async ({ page }) => {
  await page.clock.install();
  await page.route('**/chat/sse', () => {
    // no answer at all
  });
  await ask(page);
  await page.clock.fastForward('00:15');
  await expect(page.getByRole('alert')).toHaveText('Connection lost. Check your network and try again.');
});
$ npx playwright test
Running 4 tests using 4 workers

  ✓  1 [chromium] › e2e/assistant.spec.ts:43:1 › a half code block cut by an error still renders as code (490ms)
  ✓  3 [chromium] › e2e/assistant.spec.ts:68:1 › 15 seconds of silence becomes a connection error (493ms)
  ✓  4 [chromium] › e2e/assistant.spec.ts:29:1 › Stop cancels the request in the browser (529ms)
  ✓  2 [chromium] › e2e/assistant.spec.ts:16:1 › replays a recorded stream and announces the answer once (552ms)

  4 passed (918ms)

If you set up the project for the first time, download the browser with npx playwright install chromium after npm ci.

Production Notes

Run the unit and network tests on every pull request, and the Playwright tests at least on every merge to main. None of them needs a model or the API, so they need no secrets or GPUs in CI.

If you want a "smoke" test against the real model, keep it separate. Once a day, with a single question, checking only that the stream starts and ends with done; do not assert on the answer text. If it fails it raises an alert, it does not block pull requests.

Version your fixtures. When the event vocabulary changes, old fixtures can keep passing silently, because the client that ignores unknown events (post 1) also ignores the new one. The server's contract tests and the client's fixtures should rely on the same event list.

Do not use real time in timer tests. Testing the 30-second timeout in real time makes CI slower and the test flaky on a slow machine.

Common Pitfalls

First, calling the real model in tests. In this series the same question was answered with three different sentences and twice the time; an assertion on the text fails sooner or later, and every run costs money.

Second, recognizing errors with instanceof DOMException. In the test environment the abort reason came from another realm and the store took the cancellation for an error; checking the name works everywhere.

Third, trying to fake a stream read with fetch using MSW's sse() handler. That handler is for EventSource; for a stream opened with POST use http.post and a response with a ReadableStream body.

Fourth, testing the first-token timeout with a fake stream that has no pings. The silence timer fires first, and the test verifies the wrong error or passes for the wrong reason.

Fifth, testing the streaming screen with screenshots. Font and operating system differences break the baseline; assertions on the DOM and the accessibility tree are more stable.

A Small Experiment

★☆☆ Write tests for editLast and regenerate in ChatStore: check the replaceLastTurn value of the request the fake API receives, and verify that regenerating an interrupted answer sends false.

★★☆ Test the approval flow from post 5: have the fake stream emit an approval event, check that the card appears, and verify that pressing "Approve" calls ChatApi.approve with the right approvalId and that the answer is appended to the same assistant message. Test the expiry with fake timers.

★★★ Write a small script that records fixtures from the real API for different scenarios (a tool call, a ui event, an approval, X-Chaos: fail-after-5) and automatically adds the question, the model and the date at the top. Then add a new event to the vocabulary and see which test notices.

With the tests the Angular side of the assistant is complete. In the last post of the series we look at what is coming: models that run inside the browser (Chrome's built-in AI APIs), Angular 22's experimental WebMCP APIs, and running a feature on the device first and on the server when the device cannot.