diff --git a/examples/chat/aimock-e2e/a2ui-single-bubble.spec.ts b/examples/chat/aimock-e2e/a2ui-single-bubble.spec.ts index 965cec4c8..42977a12e 100644 --- a/examples/chat/aimock-e2e/a2ui-single-bubble.spec.ts +++ b/examples/chat/aimock-e2e/a2ui-single-bubble.spec.ts @@ -1,24 +1,14 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; +import { sendPromptAndWait } from './test-helpers'; test('a2ui single bubble: one assistant bubble carries the rendered surface', async ({ page }) => { - await page.goto('/embed'); + await sendPromptAndWait(page, 'Demo: render a feedback form'); - const input = page.getByRole('textbox', { name: /message|prompt/i }); - await input.fill('Demo: render a feedback form'); - await page.getByRole('button', { name: /send/i }).click(); - - // Surface element materializes in the DOM. Use toBeAttached rather than - // toBeVisible — the bubble container can have zero computed size during - // progressive mount and Playwright's strict visibility heuristic flags - // that even when the surface is rendering correctly. + // After the assistant turn finalizes, the surface element is in the DOM. const surface = page.locator('a2ui-surface'); - await expect(surface).toBeAttached({ timeout: 45_000 }); - - // Surface has the rendered Column structure (from the captured fixture). - await expect.poll(async () => surface.locator('a2ui-column, [class*="column"]').count(), { - timeout: 30_000, - }).toBeGreaterThan(0); + await expect(surface).toBeAttached(); + await expect(surface.locator('a2ui-column, [class*="column"]').first()).toBeAttached(); // Single-bubble invariant (PR #297): exactly one carries the // assistant turn. Skeleton residue from progressive mount must not survive. diff --git a/examples/chat/aimock-e2e/aimock-runner.ts b/examples/chat/aimock-e2e/aimock-runner.ts index 4fb90abd1..bc5e7e613 100644 --- a/examples/chat/aimock-e2e/aimock-runner.ts +++ b/examples/chat/aimock-e2e/aimock-runner.ts @@ -47,7 +47,16 @@ function loadFixtureEntries(fixturePath: string): FixtureFile['fixtures'] { export async function startAimock(opts: AimockStartOptions): Promise { const entries = loadFixtureEntries(opts.fixturePath); - const mock = new LLMock({ port: 0 }); + // 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.
+  const mock = new LLMock({ port: 0, chunkSize: 4096 });
   for (const fx of entries) {
     mock.onMessage(fx.match.userMessage, fx.response);
   }
diff --git a/examples/chat/aimock-e2e/markdown.spec.ts b/examples/chat/aimock-e2e/markdown.spec.ts
index fba8432a3..e66ec7e1b 100644
--- a/examples/chat/aimock-e2e/markdown.spec.ts
+++ b/examples/chat/aimock-e2e/markdown.spec.ts
@@ -1,35 +1,22 @@
 // SPDX-License-Identifier: MIT
-import { test, expect, type Locator } from '@playwright/test';
-
-async function sendPrompt(page: Awaited> extends never ? never : Parameters[1]>[0]['page'], prompt: string): Promise {
-  await page.goto('/embed');
-  const input = page.getByRole('textbox', { name: /message|prompt/i });
-  await input.fill(prompt);
-  await page.getByRole('button', { name: /send/i }).click();
-
-  const assistantBubble = page.locator('chat-message').filter({ hasNotText: prompt }).last();
-  await expect(assistantBubble).toBeVisible({ timeout: 30_000 });
-  await expect
-    .poll(async () => ((await assistantBubble.innerText()) ?? '').trim().length, { timeout: 30_000 })
-    .toBeGreaterThan(0);
-  return assistantBubble;
-}
+import { test, expect } from '@playwright/test';
+import { sendPromptAndWait } from './test-helpers';
 
 test('heading: assistant bubble renders an 

', async ({ page }) => { - const bubble = await sendPrompt(page, 'respond with a heading'); + const bubble = await sendPromptAndWait(page, 'respond with a heading'); await expect(bubble.locator('h1')).toBeVisible(); await expect(bubble.locator('h1')).toContainText(/heading one/i); }); test('code fence: assistant bubble renders
', async ({ page }) => {
-  const bubble = await sendPrompt(page, 'respond with a code fence');
+  const bubble = await sendPromptAndWait(page, 'respond with a code fence');
   const codeBlock = bubble.locator('pre code');
   await expect(codeBlock).toBeVisible();
   await expect(codeBlock).toContainText('const answer = 42');
 });
 
 test('bullet list: assistant bubble renders 
    with three
  • ', async ({ page }) => { - const bubble = await sendPrompt(page, 'respond with a bullet list'); + const bubble = await sendPromptAndWait(page, 'respond with a bullet list'); const list = bubble.locator('ul'); await expect(list).toBeVisible(); await expect(list.locator('li')).toHaveCount(3); diff --git a/examples/chat/aimock-e2e/smoke.spec.ts b/examples/chat/aimock-e2e/smoke.spec.ts index 0f0998edb..a24f64737 100644 --- a/examples/chat/aimock-e2e/smoke.spec.ts +++ b/examples/chat/aimock-e2e/smoke.spec.ts @@ -1,23 +1,9 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; +import { sendPromptAndWait } from './test-helpers'; test('hi: assistant bubble renders non-empty text from the replayed fixture', async ({ page }) => { - await page.goto('/embed'); - - const input = page.getByRole('textbox', { name: /message|prompt/i }); - await input.fill('say hi briefly'); - await page.getByRole('button', { name: /send/i }).click(); - - // Wait for the assistant bubble to appear. - const assistantBubble = page.locator('chat-message').filter({ hasNotText: 'say hi briefly' }).last(); - await expect(assistantBubble).toBeVisible({ timeout: 30_000 }); - - // Wait for streaming to settle: bubble must contain non-whitespace text. - await expect.poll( - async () => ((await assistantBubble.innerText()) ?? '').trim().length, - { timeout: 30_000 }, - ).toBeGreaterThan(0); - - const finalText = await assistantBubble.innerText(); + const bubble = await sendPromptAndWait(page, 'say hi briefly'); + const finalText = await bubble.innerText(); expect(finalText.trim()).toMatch(/hi/i); }); diff --git a/examples/chat/aimock-e2e/test-helpers.ts b/examples/chat/aimock-e2e/test-helpers.ts new file mode 100644 index 000000000..f0aaa99f2 --- /dev/null +++ b/examples/chat/aimock-e2e/test-helpers.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * 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 `agent.isLoading() && i === lastIndex` + * on the latest assistant ``, so the attribute flips to `"false"` + * once the agent stops loading and the markdown render has settled. + * + * 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. + */ +export async function sendPromptAndWait(page: Page, prompt: string): Promise { + await page.goto('/embed'); + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.fill(prompt); + await page.getByRole('button', { name: /send/i }).click(); + + const finalizedAssistant = page + .locator('chat-message[data-role="assistant"][data-streaming="false"]') + .last(); + await expect(finalizedAssistant).toBeAttached({ timeout: 45_000 }); + await expect + .poll(async () => ((await finalizedAssistant.innerText()) ?? '').trim().length, { + timeout: 30_000, + }) + .toBeGreaterThan(0); + return finalizedAssistant; +}