Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions examples/chat/aimock-e2e/a2ui-single-bubble.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <chat-message> carries the
// assistant turn. Skeleton residue from progressive mount must not survive.
Expand Down
11 changes: 10 additions & 1 deletion examples/chat/aimock-e2e/aimock-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ function loadFixtureEntries(fixturePath: string): FixtureFile['fixtures'] {
export async function startAimock(opts: AimockStartOptions): Promise<AimockHandle> {
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 <code>
// instead of <pre><code>. 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);
}
Expand Down
23 changes: 5 additions & 18 deletions examples/chat/aimock-e2e/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
// SPDX-License-Identifier: MIT
import { test, expect, type Locator } from '@playwright/test';

async function sendPrompt(page: Awaited<ReturnType<typeof import('@playwright/test').test.step>> extends never ? never : Parameters<Parameters<typeof test>[1]>[0]['page'], prompt: string): Promise<Locator> {
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 <h1>', 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 <pre><code>', 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 <ul> with three <li>', 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);
Expand Down
20 changes: 3 additions & 17 deletions examples/chat/aimock-e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 32 additions & 0 deletions examples/chat/aimock-e2e/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -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 `<chat-message>`, so the attribute flips to `"false"`
* once the agent stops loading and the markdown render has settled.
*
* Asserting on intermediate streaming-state DOM (partial `<ul>`, 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<Locator> {
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;
}
Loading