From a4d62165560d9c11c19ec064c698dfe395558ebf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 15:06:17 -0700 Subject: [PATCH] test(ag-ui): harden streaming markdown conformance --- examples/ag-ui/angular/e2e/aimock-runner.ts | 16 +- .../ag-ui/angular/e2e/fixtures/markdown.json | 60 ++- .../angular/e2e/markdown-surfaces.spec.ts | 504 +++++++++++++++++- examples/ag-ui/angular/e2e/test-helpers.ts | 24 +- 4 files changed, 572 insertions(+), 32 deletions(-) diff --git a/examples/ag-ui/angular/e2e/aimock-runner.ts b/examples/ag-ui/angular/e2e/aimock-runner.ts index 5392cb777..80fc88e2f 100644 --- a/examples/ag-ui/angular/e2e/aimock-runner.ts +++ b/examples/ag-ui/angular/e2e/aimock-runner.ts @@ -44,18 +44,14 @@ function loadFixtureEntries(fixturePath: string): FixtureFileEntry[] { return out; } -export async function startAimock(opts: AimockStartOptions): Promise { +export async function startAimock( + opts: AimockStartOptions +): Promise { const entries = loadFixtureEntries(opts.fixturePath); - // Use a large chunkSize so each response arrives in 1-2 SSE deltas. This - // intentionally turns off the partial-markdown streaming path for harness - // tests: structural assertions (code fence, list) measure the FINAL rendered - // DOM, not the progressive render. With aggressive default chunking, the - // partial-markdown parser sometimes can't recover a triple-backtick fence - // that gets split mid-token, and the final state ends up as inline - // instead of
. Streaming-progressive behavior is covered by the
-  // Phase 1 unit-variance tables; the e2e harness is for final-state
-  // invariants and cross-stack integration.
+  // Keep ordinary fixtures fast with a large default chunk. Progressive
+  // rendering regressions opt into deterministic chunk sizes and latency on
+  // the individual fixture so they exercise the complete AG-UI transport.
   const mock = new LLMock({ port: 0, chunkSize: 4096 });
   if (entries.length > 0) {
     mock.addFixturesFromJSON(entries as never);
diff --git a/examples/ag-ui/angular/e2e/fixtures/markdown.json b/examples/ag-ui/angular/e2e/fixtures/markdown.json
index 4fa49a08e..1752ba4bc 100644
--- a/examples/ag-ui/angular/e2e/fixtures/markdown.json
+++ b/examples/ag-ui/angular/e2e/fixtures/markdown.json
@@ -2,21 +2,75 @@
   "fixtures": [
     {
       "match": { "userMessage": "respond with a heading" },
-      "response": { "content": "# Heading One\n\nA short paragraph below the heading." }
+      "response": {
+        "content": "# Heading One\n\nA short paragraph below the heading."
+      }
     },
     {
       "match": { "userMessage": "respond with a code fence" },
-      "response": { "content": "Here's the snippet:\n\n```typescript\nconst answer = 42;\n```\n\nThat's it." }
+      "response": {
+        "content": "Here's the snippet:\n\n```typescript\nconst answer = 42;\n```\n\nThat's it."
+      }
     },
     {
       "match": { "userMessage": "respond with a bullet list" },
       "response": { "content": "Three things:\n\n- alpha\n- beta\n- gamma" }
     },
     {
-      "match": { "userMessage": "respond with the markdown checklist kitchen sink" },
+      "match": {
+        "userMessage": "respond with the markdown checklist kitchen sink"
+      },
       "response": {
         "content": "# Heading One\n\n## Heading Two\n\n### Heading Three\n\nA paragraph with **bold text**, *italic text*, and `inline code`.\n\n- parent item\n  - nested child\n- second parent\n\n1. first ordered\n2. second ordered\n\n- [ ] unchecked task\n- [x] checked task\n\n```typescript\nconst answer = 42;\n```\n\n| Name | Mental model | When to use |\n| --- | --- | --- |\n| Signals | value graph | local state |\n| RxJS | event stream | async events |\n\n> This is a blockquote.\n\n[Angular](https://angular.dev)\n\n---\n\n"
       }
+    },
+    {
+      "match": {
+        "userMessage": "stream an AG-UI markdown comparison table regression"
+      },
+      "response": {
+        "content": "Here is the comparison:\n\n| Name | Mental model | When to use |\n| --- | --- | --- |\n| Angular Signals | Synchronous value graph | Local component state |\n| RxJS | Event stream | Async flows and cancellation |\n| zone.js | Async task patching | Zone-based change detection |\n\nDone."
+      },
+      "chunkSize": 4,
+      "latency": 75
+    },
+    {
+      "match": {
+        "userMessage": "stream an AG-UI blockquote then table regression"
+      },
+      "response": {
+        "content": "> First line of the quote.\n> Second line of the quote.\n\n| Issue | Expected behavior | Verification |\n| --- | --- | --- |\n| Button click does not update UI | UI reflects new state immediately | Click button and observe state |\n| Slow initial render | Main content appears within target | Measure first meaningful paint |"
+      },
+      "chunkSize": 6,
+      "latency": 25
+    },
+    {
+      "match": {
+        "userMessage": "stream an AG-UI markdown table with a long header pause"
+      },
+      "response": {
+        "content": "Long pause table:\n\n| Name | Value |\n| --- | --- |\n| alpha | one |\n| beta | two |\n"
+      },
+      "chunkSize": 36,
+      "latency": 750
+    },
+    {
+      "match": {
+        "userMessage": "stream an AG-UI TypeScript code fence regression"
+      },
+      "response": {
+        "content": "Here is the snippet:\n\n```typescript\nconst answer = 42;\n```\n\nThe constant is available for later use."
+      },
+      "chunkSize": 3,
+      "latency": 35
+    },
+    {
+      "match": { "userMessage": "stream an AG-UI thematic break regression" },
+      "response": {
+        "content": "Before the break.\n\n---\n\nAfter the break."
+      },
+      "chunkSize": 8,
+      "latency": 200
     }
   ]
 }
diff --git a/examples/ag-ui/angular/e2e/markdown-surfaces.spec.ts b/examples/ag-ui/angular/e2e/markdown-surfaces.spec.ts
index 55f10a275..d1f3fe9da 100644
--- a/examples/ag-ui/angular/e2e/markdown-surfaces.spec.ts
+++ b/examples/ag-ui/angular/e2e/markdown-surfaces.spec.ts
@@ -1,6 +1,13 @@
 // SPDX-License-Identifier: MIT
-import { test, expect } from '@playwright/test';
-import { sendPromptAndWait } from './test-helpers';
+import { test, expect, type Locator, type Page } from '@playwright/test';
+import {
+  attachBrowserHygiene,
+  messageInput,
+  openDemo,
+  sendButton,
+  sendPromptAndWait,
+  waitForFinalAssistant,
+} from './test-helpers';
 
 test('heading: assistant bubble renders an 

', async ({ page }) => { const bubble = await sendPromptAndWait(page, 'respond with a heading'); @@ -15,7 +22,9 @@ test('code fence: assistant bubble renders
', async ({ page }) => {
   await expect(codeBlock).toContainText('const answer = 42');
 });
 
-test('bullet list: assistant bubble renders 
    with three
  • ', async ({ page }) => { +test('bullet list: assistant bubble renders
      with three
    • ', async ({ + page, +}) => { const bubble = await sendPromptAndWait(page, 'respond with a bullet list'); const list = bubble.locator('ul'); await expect(list).toBeVisible(); @@ -25,8 +34,13 @@ test('bullet list: assistant bubble renders
        with three
      • ', async ({ page await expect(list.locator('li').nth(2)).toContainText('gamma'); }); -test('markdown checklist matrix: rich markdown renders with escaped html', async ({ page }) => { - const bubble = await sendPromptAndWait(page, 'respond with the markdown checklist kitchen sink'); +test('markdown checklist matrix: rich markdown renders with escaped html', async ({ + page, +}) => { + const bubble = await sendPromptAndWait( + page, + 'respond with the markdown checklist kitchen sink' + ); await expect(bubble.locator('h1')).toContainText('Heading One'); await expect(bubble.locator('h2')).toContainText('Heading Two'); @@ -37,19 +51,491 @@ test('markdown checklist matrix: rich markdown renders with escaped html', async await expect(bubble.locator('ul').first()).toContainText('parent item'); await expect(bubble.locator('ul').first()).toContainText('nested child'); await expect(bubble.locator('ul').first()).toContainText('second parent'); - await expect(bubble.locator('ol li')).toHaveText(['first ordered', 'second ordered']); + await expect(bubble.locator('ol li')).toHaveText([ + 'first ordered', + 'second ordered', + ]); await expect(bubble.locator('input[type="checkbox"]')).toHaveCount(2); - await expect(bubble.locator('code').filter({ hasText: 'const answer = 42' })).toBeVisible(); + await expect( + bubble.locator('code').filter({ hasText: 'const answer = 42' }) + ).toBeVisible(); await expect(bubble.locator('table')).toBeVisible(); - await expect(bubble.locator('thead th')).toHaveText(['Name', 'Mental model', 'When to use']); + await expect(bubble.locator('thead th')).toHaveText([ + 'Name', + 'Mental model', + 'When to use', + ]); await expect(bubble.locator('tbody tr')).toHaveCount(2); await expect(bubble.locator('blockquote')).toBeVisible(); await expect(bubble).toContainText('This is a blockquote.'); await expect(bubble.locator('a', { hasText: 'Angular' })).toHaveAttribute( 'href', - 'https://angular.dev', + 'https://angular.dev' ); await expect(bubble.locator('hr')).toBeVisible(); await expect(bubble.locator('script')).toHaveCount(0); await expect(bubble).toContainText(""); }); + +test('streaming markdown table: keeps in-progress rows inside one table', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt( + page, + 'stream an AG-UI markdown comparison table regression' + ); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + + const samples = await collectStreamingSamples( + page, + streamingAssistant, + 2_000 + ); + expect( + new Set(samples.map((sample) => sample.contentTextLength)).size + ).toBeGreaterThan(2); + const tableSamples = samples.filter((sample) => sample.tableCount > 0); + expect(tableSamples.length).toBeGreaterThan(2); + expect(tableSamples.every((sample) => sample.tableCount === 1)).toBe(true); + expect(tableSamples.every(tableStructureIsValid)).toBe(true); + expect( + tableSamples.every((sample) => sample.rawPipeTextOutsideTable.length === 0) + ).toBe(true); + + const bubble = await waitForFinalAssistant(page); + await expect(bubble.locator('table')).toHaveCount(1); + await expect(bubble.locator('thead th')).toHaveText([ + 'Name', + 'Mental model', + 'When to use', + ]); + await expect(bubble.locator('tbody tr')).toHaveCount(3); + await expect(bubble.locator('tbody tr').nth(2)).toContainText('zone.js'); + await expect.poll(async () => tableColumnsAlign(bubble)).toBe(true); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +test('streaming markdown table: blockquote boundary remains stable', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt(page, 'stream an AG-UI blockquote then table regression'); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples( + page, + streamingAssistant, + 2_000 + ); + expect( + new Set(samples.map((sample) => sample.contentTextLength)).size + ).toBeGreaterThan(2); + const firstTableSample = samples.findIndex((sample) => sample.tableCount > 0); + expect(firstTableSample).toBeGreaterThanOrEqual(0); + const tableSamples = samples.slice(firstTableSample); + expect(tableSamples.length).toBeGreaterThan(2); + expect( + tableSamples.every( + (sample) => + sample.tableCount === 1 && + tableStructureIsValid(sample) && + sample.rawPipeTextOutsideTable.length === 0 && + sample.tableFollowsBlockquote && + !sample.tableNestedInBlockquote + ) + ).toBe(true); + + const bubble = await waitForFinalAssistant(page); + await expect(bubble.locator('blockquote')).toContainText( + 'Second line of the quote.' + ); + await expect(bubble.locator('table')).toHaveCount(1); + await expect(bubble.locator('tbody tr')).toHaveCount(2); + await expect.poll(async () => tableColumnsAlign(bubble)).toBe(true); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +test('streaming markdown table: long header pause does not detach syntax', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt( + page, + 'stream an AG-UI markdown table with a long header pause' + ); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples( + page, + streamingAssistant, + 2_000 + ); + expect( + new Set(samples.map((sample) => sample.contentTextLength)).size + ).toBeGreaterThan(1); + expect(hasStreamingPauseFollowedByContent(samples, 600)).toBe(true); + expect(streamingPhaseIsMonotonic(samples)).toBe(true); + expect(samples.every((sample) => sample.tableCount <= 1)).toBe(true); + expect(samples.every(tableStructureIsValid)).toBe(true); + expect( + samples.every((sample) => sample.rawPipeTextOutsideTable.length === 0) + ).toBe(true); + + const bubble = await waitForFinalAssistant(page); + await expect(bubble.locator('table')).toHaveCount(1); + await expect(bubble.locator('tbody tr')).toHaveCount(2); + await expect.poll(async () => tableColumnsAlign(bubble)).toBe(true); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +test('streaming code fence: closing marker never renders as text', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt(page, 'stream an AG-UI TypeScript code fence regression'); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + + const samples = await collectStreamingSamples( + page, + streamingAssistant, + 2_000 + ); + expect( + new Set(samples.map((sample) => sample.contentTextLength)).size + ).toBeGreaterThan(2); + const firstCodeSample = samples.findIndex( + (sample) => sample.codeBlockCount > 0 + ); + expect(firstCodeSample).toBeGreaterThanOrEqual(0); + const samplesAfterCodeAppears = samples.slice(firstCodeSample); + expect(samplesAfterCodeAppears.length).toBeGreaterThan(2); + expect( + samplesAfterCodeAppears.every( + (sample) => sample.codeBlockCount === 1 && !sample.hasRawFenceMarker + ) + ).toBe(true); + + const bubble = await waitForFinalAssistant(page); + await expect(bubble.locator('pre code')).toHaveCount(1); + await expect(bubble.locator('pre code')).toContainText('const answer = 42'); + await expect(bubble).not.toContainText('```'); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +test('streaming thematic break: delimiter never renders as text', async ({ + page, +}) => { + const hygiene = attachBrowserHygiene(page); + await sendPrompt(page, 'stream an AG-UI thematic break regression'); + + const streamingAssistant = latestAssistant(page); + await expect(streamingAssistant).toBeAttached({ timeout: 15_000 }); + await waitForStreamingContent(streamingAssistant); + + const samples = await collectStreamingSamples( + page, + streamingAssistant, + 1_200 + ); + expect( + samples.some( + (sample) => sample.isStreaming && sample.thematicBreakCount > 0 + ) + ).toBe(true); + expect( + samples.every( + (sample) => sample.rawThematicBreakTextOutsideCode.length === 0 + ) + ).toBe(true); + + const bubble = await waitForFinalAssistant(page); + await expect(bubble.locator('hr')).toHaveCount(1); + await expect(bubble).toContainText('After the break.'); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); + +async function sendPrompt(page: Page, prompt: string): Promise { + await openDemo(page, '/'); + await installStreamingMarkdownRecorder(page); + await messageInput(page).fill(prompt); + await sendButton(page).click(); +} + +function latestAssistant(page: Page): Locator { + return page.locator('chat-message[data-role="assistant"]').last(); +} + +interface StreamingMarkdownSample { + readonly capturedAtMs: number; + readonly isStreaming: boolean; + readonly contentTextLength: number; + readonly tableCount: number; + readonly rowsOutsideTable: number; + readonly tableElementsOutsideTable: number; + readonly nonDirectTableRows: number; + readonly nonDirectTableCells: number; + readonly tableRowsWithinBounds: boolean; + readonly rawPipeTextOutsideTable: string[]; + readonly tableFollowsBlockquote: boolean; + readonly tableNestedInBlockquote: boolean; + readonly codeBlockCount: number; + readonly hasRawFenceMarker: boolean; + readonly thematicBreakCount: number; + readonly rawThematicBreakTextOutsideCode: string[]; +} + +interface StreamingMarkdownRecorder { + readonly samples: StreamingMarkdownSample[]; + capture(element: Element): StreamingMarkdownSample; +} + +type BrowserWindow = Window & { + __streamingMarkdownRecorder?: StreamingMarkdownRecorder; +}; + +async function installStreamingMarkdownRecorder(page: Page): Promise { + await page.evaluate(() => { + const browserWindow = window as BrowserWindow; + const samples: StreamingMarkdownSample[] = []; + const capture = (element: Element): StreamingMarkdownSample => { + const rawPipeTextOutsideTable: string[] = []; + const rawThematicBreakTextOutsideCode: string[] = []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const text = node.textContent?.trim() ?? ''; + if ( + text.includes('|') && + !node.parentElement?.closest('table, pre, code') + ) { + rawPipeTextOutsideTable.push(text); + } + if ( + !node.parentElement?.closest('pre, code') && + /(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*(?=\n|$)/.test( + node.textContent ?? '' + ) + ) { + rawThematicBreakTextOutsideCode.push(text); + } + node = walker.nextNode(); + } + + const tables = Array.from(element.querySelectorAll('table')); + const table = tables[0]; + const blockquote = element.querySelector('blockquote'); + const tableRows = table + ? Array.from(table.querySelectorAll('tbody tr')) + : []; + const directTableRows = table + ? Array.from(table.querySelectorAll(':scope > tbody > tr')) + : []; + const allTableCells = table + ? Array.from(table.querySelectorAll('tbody tr td')) + : []; + const directTableCells = directTableRows.flatMap((row) => + Array.from(row.querySelectorAll(':scope > td')) + ); + const tableRect = table?.getBoundingClientRect(); + + return { + capturedAtMs: performance.now(), + isStreaming: element.getAttribute('data-streaming') === 'true', + contentTextLength: element.textContent?.length ?? 0, + tableCount: tables.length, + rowsOutsideTable: Array.from(element.querySelectorAll('tr')).filter( + (row) => !row.closest('table') + ).length, + tableElementsOutsideTable: Array.from( + element.querySelectorAll('tr, th, td') + ).filter((tableElement) => !tableElement.closest('table')).length, + nonDirectTableRows: tableRows.length - directTableRows.length, + nonDirectTableCells: allTableCells.length - directTableCells.length, + tableRowsWithinBounds: Boolean( + !table || + !tableRect || + tableRows.every((row) => { + const rowRect = row.getBoundingClientRect(); + return ( + rowRect.top >= tableRect.top - 1 && + rowRect.bottom <= tableRect.bottom + 1 && + rowRect.left >= tableRect.left - 1 && + rowRect.right <= tableRect.right + 1 + ); + }) + ), + rawPipeTextOutsideTable, + tableFollowsBlockquote: Boolean( + blockquote && + table && + Boolean( + blockquote.compareDocumentPosition(table) & + Node.DOCUMENT_POSITION_FOLLOWING + ) + ), + tableNestedInBlockquote: Boolean(table?.closest('blockquote')), + codeBlockCount: element.querySelectorAll('pre code').length, + hasRawFenceMarker: element.textContent?.includes('```') ?? false, + thematicBreakCount: element.querySelectorAll('hr').length, + rawThematicBreakTextOutsideCode, + }; + }; + + browserWindow.__streamingMarkdownRecorder = { samples, capture }; + const observer = new MutationObserver(() => { + const assistants = document.querySelectorAll( + 'chat-message[data-role="assistant"]' + ); + const assistant = assistants.item(assistants.length - 1); + if (assistant) samples.push(capture(assistant)); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['data-streaming'], + characterData: true, + childList: true, + subtree: true, + }); + window.setInterval(() => { + const assistants = document.querySelectorAll( + 'chat-message[data-role="assistant"][data-streaming="true"]' + ); + const assistant = assistants.item(assistants.length - 1); + if (assistant) samples.push(capture(assistant)); + }, 25); + }); +} + +async function collectStreamingSamples( + page: Page, + bubble: Locator, + minimumDurationMs: number +): Promise { + await page.waitForTimeout(minimumDurationMs); + await sampleStreamingMarkdown(bubble); + return page.evaluate(() => [ + ...((window as BrowserWindow).__streamingMarkdownRecorder?.samples ?? []), + ]); +} + +async function waitForStreamingContent(bubble: Locator): Promise { + await expect + .poll( + async () => (await sampleStreamingMarkdown(bubble)).contentTextLength, + { intervals: [25], timeout: 15_000 } + ) + .toBeGreaterThan(0); +} + +async function sampleStreamingMarkdown( + bubble: Locator +): Promise { + return bubble.evaluate((element) => { + const recorder = (window as BrowserWindow).__streamingMarkdownRecorder; + if (!recorder) + throw new Error('Streaming markdown recorder is not installed'); + const sample = recorder.capture(element); + recorder.samples.push(sample); + return sample; + }); +} + +function tableStructureIsValid(sample: StreamingMarkdownSample): boolean { + return ( + sample.rowsOutsideTable === 0 && + sample.tableElementsOutsideTable === 0 && + sample.nonDirectTableRows === 0 && + sample.nonDirectTableCells === 0 && + sample.tableRowsWithinBounds + ); +} + +function hasStreamingPauseFollowedByContent( + samples: StreamingMarkdownSample[], + minimumPauseMs: number +): boolean { + for (let start = 0; start < samples.length; start += 1) { + const first = samples[start]; + if (!first?.isStreaming || first.contentTextLength === 0) continue; + + let end = start; + while ( + samples[end + 1]?.isStreaming && + samples[end + 1]?.contentTextLength === first.contentTextLength + ) { + end += 1; + } + + const last = samples[end]; + const laterContentArrived = samples + .slice(end + 1) + .some( + (sample) => + sample.isStreaming && + sample.contentTextLength > first.contentTextLength + ); + if ( + last.capturedAtMs - first.capturedAtMs > minimumPauseMs && + laterContentArrived + ) { + return true; + } + + start = end; + } + return false; +} + +function streamingPhaseIsMonotonic( + samples: StreamingMarkdownSample[] +): boolean { + const firstStreamingSample = samples.findIndex( + (sample) => sample.isStreaming + ); + if (firstStreamingSample < 0) return false; + + const firstFinalSample = samples.findIndex( + (sample, index) => index > firstStreamingSample && !sample.isStreaming + ); + return ( + firstFinalSample < 0 || + samples.slice(firstFinalSample + 1).every((sample) => !sample.isStreaming) + ); +} + +async function tableColumnsAlign(bubble: Locator): Promise { + const table = bubble.locator('table').first(); + return table.evaluate((element) => { + const headerCells = Array.from(element.querySelectorAll('thead th')); + const rows = Array.from(element.querySelectorAll('tbody tr')); + if (headerCells.length === 0 || rows.length === 0) return false; + + const headerLefts = headerCells.map( + (cell) => cell.getBoundingClientRect().left + ); + return rows.every((row) => { + const cells = Array.from(row.querySelectorAll('td')); + if (cells.length !== headerLefts.length) return false; + return cells.every( + (cell, index) => + Math.abs(cell.getBoundingClientRect().left - headerLefts[index]) <= 1 + ); + }); + }); +} diff --git a/examples/ag-ui/angular/e2e/test-helpers.ts b/examples/ag-ui/angular/e2e/test-helpers.ts index 72e5ce24c..a6a39455d 100644 --- a/examples/ag-ui/angular/e2e/test-helpers.ts +++ b/examples/ag-ui/angular/e2e/test-helpers.ts @@ -11,7 +11,10 @@ export function attachBrowserHygiene(page: Page): { page.on('console', (msg) => { if (msg.type() !== 'error') return; const text = msg.text(); - if (/PostHog|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|license/i.test(text)) return; + if ( + /PostHog|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|license/i.test(text) + ) + return; consoleErrors.push(text); }); page.on('pageerror', (err) => { @@ -20,7 +23,8 @@ export function attachBrowserHygiene(page: Page): { page.on('requestfailed', (request) => { const failure = request.failure()?.errorText ?? ''; const url = request.url(); - if (/runs\/stream|\/agent/.test(url) && /abort|ERR_ABORTED/i.test(failure)) return; + if (/runs\/stream|\/agent/.test(url) && /abort|ERR_ABORTED/i.test(failure)) + return; failedRequests.push(`${request.method()} ${url} ${failure}`.trim()); }); @@ -52,7 +56,9 @@ export function sendButton(page: Page): Locator { * Use this in place of `JSON.parse(localStorage.getItem(...)).threadId`, * which no longer exists. */ -export async function activeThreadIdFromUrl(page: Page): Promise { +export async function activeThreadIdFromUrl( + page: Page +): Promise { const url = new URL(page.url()); const segments = url.pathname.split('/').filter(Boolean); return segments.length >= 2 ? segments[1] : null; @@ -189,14 +195,12 @@ export async function waitForFinalAssistant(page: Page): Promise { /** * Send a user prompt and wait for the assistant bubble to finalize. * - * "Finalized" means `chat-message[data-role="assistant"][data-streaming="false"]`: - * the chat composition wires `[streaming]` to the message delivery phase - * on the latest assistant ``, so the attribute flips to `"false"` - * once the agent stops loading and the markdown render has settled. + * "Finalized" means `chat-message[data-role="assistant"][data-streaming="false"]`. + * The attribute reflects the message's authoritative delivery phase and flips + * only when the adapter completes that message generation. * - * Asserting on intermediate streaming-state DOM (partial `
          `, in-flight - * code fences, etc.) is the source of e2e flake — always wait on this - * attribute before counting or text-matching downstream of the assistant turn. + * Use this helper for final-state assertions. Tests that intentionally verify + * progressive rendering should sample the streaming message directly. */ export async function sendPromptAndWait( page: Page,