chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""conftest.py — put both ``showcase/integrations`` (for ``import _shared.*``)
|
||||
and the langgraph-python package root (for ``import src.agents.*``) on
|
||||
``sys.path`` so the CVDIAG schema-v1 suite imports exactly as the runtime does
|
||||
(``/app`` on PYTHONPATH: ``/app/_shared`` and ``/app/src/agents`` both resolve).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# tests/conftest.py → langgraph-python (the /app root: contains src/ and _shared/)
|
||||
_LGP_ROOT = Path(__file__).resolve().parents[1]
|
||||
# langgraph-python → integrations (so the _shared symlink resolves the same way)
|
||||
_INTEGRATIONS_DIR = _LGP_ROOT.parents[0]
|
||||
|
||||
for path in (str(_LGP_ROOT), str(_INTEGRATIONS_DIR)):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/a2ui-fixed-schema.md
|
||||
// Demo source: src/app/demos/a2ui-fixed-schema/{page.tsx, a2ui/*}
|
||||
// Backend: src/agents/a2ui_fixed.py + src/agents/a2ui_schemas/flight_schema.json
|
||||
//
|
||||
// Pattern: A2UI FIXED-schema — the component tree lives on the frontend
|
||||
// (flight_schema.json has 12 nodes: root, content, title, route, from,
|
||||
// arrow, to, meta, airline, price, bookButton, bookButtonLabel) and the
|
||||
// agent only streams DATA into the data model via the `display_flight`
|
||||
// tool. Our custom renderers (Title, Airport, Arrow, AirlineBadge,
|
||||
// PriceTag, Button) bind path strings like "/airline" / "/price" to the
|
||||
// incoming data model.
|
||||
//
|
||||
// This is a pure-presentation demo: the "Book flight" Button is inert —
|
||||
// schema-swap-on-action will be wired up once the Python SDK exposes
|
||||
// `action_handlers=` on `a2ui.render` (see comment in a2ui_fixed.py).
|
||||
//
|
||||
// No data-testid anywhere in the demo. Assertions ride on:
|
||||
// - verbatim label text hardcoded in flight_schema.json ("Flight
|
||||
// Details", "Book flight") — these are literal `text` constants,
|
||||
// NOT data-model bindings, so they do not leak a {path} object
|
||||
// even if the data model is absent.
|
||||
// - brand colour fingerprints unique to each renderer (mint #189370
|
||||
// price, lilac #BEC2FF airline badge border, black #010507 book
|
||||
// button background).
|
||||
//
|
||||
// W8-8: on Railway, `display_flight` occasionally stalls the secondary
|
||||
// LLM stage; render budget is 60s.
|
||||
|
||||
test.describe("A2UI Fixed Schema (flight card)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/a2ui-fixed-schema");
|
||||
});
|
||||
|
||||
test("page loads with chat input and no flight card rendered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
// "Flight Details" is the title literal from flight_schema.json. It
|
||||
// must NOT be on the page before the agent emits an a2ui_operations
|
||||
// container — that would indicate a stale render or a schema leak.
|
||||
await expect(page.getByText("Flight Details")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("single suggestion pill renders with verbatim title", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Find SFO → JFK" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("search-flights pill renders a flight card matching flight_schema", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await suggestions.filter({ hasText: "Find SFO → JFK" }).first().click();
|
||||
|
||||
// Title is a literal in flight_schema.json ("Flight Details").
|
||||
// 90s budget: on cold starts the `display_flight` tool call + the
|
||||
// a2ui_operations container round-trip can eat most of a minute.
|
||||
await expect(page.getByText("Flight Details").first()).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
// Route: Airport renderer formats as monospace 1.5rem; the
|
||||
// user-visible content is the uppercase airport code. SFO / JFK are
|
||||
// explicit in the prompt so the secondary LLM is extremely likely
|
||||
// to bind them verbatim into origin/destination.
|
||||
await expect(page.getByText("SFO").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("JFK").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Book flight button label is a literal in flight_schema.json
|
||||
// (`bookButtonLabel.text`). Presence = the full 12-node tree
|
||||
// rendered, including the Button override.
|
||||
await expect(page.getByRole("button", { name: "Book flight" })).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// Regression guard (#4734): on Railway the deployed agent used to loop
|
||||
// `display_flight` indefinitely because the LLM (gpt-4o-mini) couldn't
|
||||
// tell the opaque `a2ui.render(...)` JSON return value was a success
|
||||
// signal. The fix tightened the docstring + system prompt to spell out
|
||||
// "card is rendered, do not call again". Assert that exactly ONE flight
|
||||
// card is present after the round-trip — duplicates mean the loop
|
||||
// re-emerged.
|
||||
const flightDetailsCount = await page.getByText("Flight Details").count();
|
||||
expect(flightDetailsCount).toBe(1);
|
||||
const bookButtons = await page
|
||||
.getByRole("button", { name: "Book flight" })
|
||||
.count();
|
||||
expect(bookButtons).toBe(1);
|
||||
|
||||
// Regression guard: no A2UI render-error banners on the page.
|
||||
await expect(page.getByText(/Catalog not found/i)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/Cannot create component .* without a type/i),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/a2ui-recovery.md
|
||||
// Demo source: src/app/demos/a2ui-recovery/{page.tsx, chat.tsx, suggestions.ts}
|
||||
//
|
||||
// A2UI error recovery (OSS-158 / OSS-375). Assert the STABLE end-states — the
|
||||
// recovered surface paints (heal) and the hard-failure UI shows (exhaust) — and
|
||||
// deliberately do NOT assert the transient "Retrying generation… (N/M)" label,
|
||||
// which is threshold-gated + timing dependent (see @copilotkit/react-core/v2
|
||||
// A2UIRecoveryStates) and would be flaky.
|
||||
//
|
||||
// The aimock fixtures (showcase/aimock/d6/langgraph-python/a2ui-recovery.json)
|
||||
// drive the inner render_a2ui sub-agent two ways: HEAL emits free-form/sloppy
|
||||
// args (components/data as JSON strings) the middleware heals via parse_and_fix;
|
||||
// EXHAUST is structurally invalid (unresolved child) on every attempt, so the
|
||||
// validate->retry loop hits the cap and returns the a2ui_recovery_exhausted
|
||||
// envelope. Healing + the loop run live in the backend ag_ui_langgraph
|
||||
// get_a2ui_tools factory (injectA2UITool=false); the failure UI text ("Couldn't
|
||||
// generate the UI") comes from @copilotkit/react-core/v2. The recovered surface
|
||||
// reuses the declarative-gen-ui catalog, so it carries the `declarative-metric`
|
||||
// testid.
|
||||
//
|
||||
// Requires the stack running with aimock so the malformed renders fire
|
||||
// deterministically; against a real LLM the demo would not reliably produce the
|
||||
// invalid attempts.
|
||||
|
||||
/** Click a suggestion pill and confirm the message dispatched (the user
|
||||
* bubble with the pill's full message text appears). The user bubble is
|
||||
* matched by `data-testid="copilot-user-message"`. Slow hydration can swallow
|
||||
* the first click, so we retry; never re-click once dispatched. */
|
||||
async function clickPill(page: Page, title: string, message: string) {
|
||||
const pill = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 });
|
||||
const userBubble = page
|
||||
.locator('[data-testid="copilot-user-message"]')
|
||||
.filter({ hasText: message })
|
||||
.first();
|
||||
await expect(async () => {
|
||||
if ((await userBubble.count()) === 0) {
|
||||
await pill.click();
|
||||
}
|
||||
await expect(userBubble).toBeVisible({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
const HEAL_PILL = "Recover a bad render";
|
||||
const HEAL_MSG =
|
||||
"Build my Q2 revenue summary and self-correct a malformed first attempt.";
|
||||
const EXHAUST_PILL = "Show an unrecoverable failure";
|
||||
const EXHAUST_MSG =
|
||||
"Build a report that fails every validation pass so I can preview the fallback.";
|
||||
|
||||
test.describe("A2UI Error Recovery", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/a2ui-recovery");
|
||||
});
|
||||
|
||||
test("page loads with both recovery pills", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of [HEAL_PILL, EXHAUST_PILL]) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
// No surface on first paint.
|
||||
await expect(page.getByTestId("declarative-metric")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("heal: free-form/sloppy render is healed into a valid surface", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(page, HEAL_PILL, HEAL_MSG);
|
||||
|
||||
// The model emits free-form (stringified) A2UI args; the middleware heals
|
||||
// them via parse_and_fix into a valid surface that paints. Allow 90s for
|
||||
// the sub-agent round-trip.
|
||||
const metrics = page.locator('[data-testid="declarative-metric"]');
|
||||
await expect
|
||||
.poll(async () => await metrics.count(), { timeout: 90_000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The healed surface, NOT the hard-failure UI, and no render-error banners.
|
||||
await expect(page.getByText("Couldn't generate the UI")).toHaveCount(0);
|
||||
await expect(page.getByText(/Catalog not found/i)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/Cannot create component .* without a type/i),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("exhaust: always-invalid render shows the hard-failure UI, no faulty surface", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(page, EXHAUST_PILL, EXHAUST_MSG);
|
||||
|
||||
// Hard-failure UI appears once the attempt cap is hit (A2UIRecoveryStates
|
||||
// renders this on status: "failed" = the a2ui_recovery_exhausted envelope).
|
||||
await expect(
|
||||
page.getByText("Couldn't generate the UI").first(),
|
||||
).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// No faulty surface ever paints (server-side no-wipe guarantee: middleware
|
||||
// gate + adapter recovery loop).
|
||||
await expect(page.getByTestId("declarative-metric")).toHaveCount(0);
|
||||
|
||||
// Conversation remains usable after the hard failure.
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Agent Config Object", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/agent-config");
|
||||
});
|
||||
|
||||
test("page loads with config card and default dropdown values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Agent Config" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-config-card"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-config-tone-select"]'),
|
||||
).toHaveValue("professional");
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-config-expertise-select"]'),
|
||||
).toHaveValue("intermediate");
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-config-length-select"]'),
|
||||
).toHaveValue("concise");
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changing a dropdown updates its DOM value immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
const toneSelect = page.locator('[data-testid="agent-config-tone-select"]');
|
||||
await toneSelect.selectOption("enthusiastic");
|
||||
await expect(toneSelect).toHaveValue("enthusiastic");
|
||||
|
||||
const expertiseSelect = page.locator(
|
||||
'[data-testid="agent-config-expertise-select"]',
|
||||
);
|
||||
await expertiseSelect.selectOption("expert");
|
||||
await expect(expertiseSelect).toHaveValue("expert");
|
||||
|
||||
const lengthSelect = page.locator(
|
||||
'[data-testid="agent-config-length-select"]',
|
||||
);
|
||||
await lengthSelect.selectOption("detailed");
|
||||
await expect(lengthSelect).toHaveValue("detailed");
|
||||
});
|
||||
|
||||
test("send produces an assistant response", async ({ page }) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Hello");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
test("properties object propagates to runtime requests", async ({ page }) => {
|
||||
const requestBodies: string[] = [];
|
||||
await page.route("**/api/copilotkit-agent-config**", async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
const body = req.postData() ?? "";
|
||||
requestBodies.push(body);
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// Change all three dropdowns from defaults
|
||||
await page
|
||||
.locator('[data-testid="agent-config-tone-select"]')
|
||||
.selectOption("enthusiastic");
|
||||
await page
|
||||
.locator('[data-testid="agent-config-expertise-select"]')
|
||||
.selectOption("expert");
|
||||
await page
|
||||
.locator('[data-testid="agent-config-length-select"]')
|
||||
.selectOption("detailed");
|
||||
|
||||
// Send
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Hello");
|
||||
await input.press("Enter");
|
||||
|
||||
// Wait for at least one POST to land
|
||||
await expect
|
||||
.poll(() => requestBodies.length, { timeout: 15000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const payload = requestBodies.join("\n");
|
||||
expect(payload).toContain("enthusiastic");
|
||||
expect(payload).toContain("expert");
|
||||
expect(payload).toContain("detailed");
|
||||
});
|
||||
|
||||
test("changing config between sends produces distinct request payloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Only capture agent/run POSTs — agent/stop requests arrive
|
||||
// asynchronously and would pollute the "afterChange" slice.
|
||||
const requestBodies: string[] = [];
|
||||
await page.route("**/api/copilotkit-agent-config**", async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
const body = req.postData() ?? "";
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.method === "agent/stop") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// non-JSON body — keep it
|
||||
}
|
||||
requestBodies.push(body);
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
|
||||
// Send 1 with defaults
|
||||
await input.fill("First");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Wait for the chat to finish processing (data-copilot-running="false")
|
||||
// before attempting the second send — some backends finalize the SSE
|
||||
// stream slightly after the assistant message is rendered.
|
||||
await expect(page.locator('[data-copilot-running="false"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const firstCount = requestBodies.length;
|
||||
|
||||
// Change config
|
||||
await page
|
||||
.locator('[data-testid="agent-config-tone-select"]')
|
||||
.selectOption("casual");
|
||||
await page
|
||||
.locator('[data-testid="agent-config-length-select"]')
|
||||
.selectOption("detailed");
|
||||
|
||||
// Send 2
|
||||
await input.fill("Second");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => requestBodies.length, { timeout: 15000 })
|
||||
.toBeGreaterThan(firstCount);
|
||||
|
||||
const beforeChange = requestBodies.slice(0, firstCount).join("\n");
|
||||
const afterChange = requestBodies.slice(firstCount).join("\n");
|
||||
|
||||
expect(beforeChange).toContain("professional");
|
||||
expect(beforeChange).toContain("concise");
|
||||
expect(afterChange).toContain("casual");
|
||||
expect(afterChange).toContain("detailed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Agentic Chat is the minimum-viable CopilotChat demo: a tiny page
|
||||
// that wraps `<CopilotChat>` plus three starter-prompt suggestions. The
|
||||
// contract here is "vanilla chat works end-to-end" — anything richer
|
||||
// belongs in dedicated demos (frontend-tools, tool-rendering, etc.).
|
||||
test.describe("Agentic Chat", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/agentic-chat");
|
||||
});
|
||||
|
||||
test("page loads with chat input and the three starter suggestions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
for (const title of ["Write a sonnet", "Tell me a joke", "Is 17 prime?"]) {
|
||||
await expect(page.getByRole("button", { name: title })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("sends a typed message and gets an assistant response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Say hello in one word.");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test("clicking a suggestion pill sends the message and gets a response", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Tell me a joke" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test("multi-turn conversation maintains context", async ({ page }) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
|
||||
await input.fill("My name is Alice.");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Wait for the chat to settle (suggestions reappear after the assistant
|
||||
// finishes streaming) before sending the follow-up message.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Write a sonnet" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await input.fill("What name did I just give you?");
|
||||
await input.press("Enter");
|
||||
|
||||
const responses = page.locator('[data-testid="copilot-assistant-message"]');
|
||||
await expect(responses.nth(1)).toBeVisible({ timeout: 30000 });
|
||||
await expect(responses.nth(1)).toContainText(/Alice/i, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Auth demo lifecycle. The demo defaults to UNAUTHENTICATED on first
|
||||
* paint and renders SignInCard. After sign-in, <CopilotKit> mounts with
|
||||
* the bearer header attached and the chat boots. After sign-out the
|
||||
* chat STAYS MOUNTED (now with no Authorization header) so the user can
|
||||
* actually watch the runtime reject an unauthenticated send — that is
|
||||
* the whole point of the demo. The AuthBanner flips between green
|
||||
* (authenticated) and amber (signed-out) variants. Only a full page
|
||||
* reload resets to the SignInCard first-paint state.
|
||||
*/
|
||||
test.describe("Authentication", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/auth");
|
||||
});
|
||||
|
||||
test("page loads unauthenticated with SignInCard visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-in-card"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-in-button"]'),
|
||||
).toBeEnabled();
|
||||
await expect(page.locator('[data-testid="auth-demo-token"]')).toBeVisible();
|
||||
// Chat surface and AuthBanner only render after the first sign-in.
|
||||
await expect(page.locator('[data-testid="auth-banner"]')).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Type a message")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("signing in mounts the chat surface with AuthBanner", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-testid="auth-sign-in-button"]').click();
|
||||
|
||||
const banner = page.locator('[data-testid="auth-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toHaveAttribute("data-authenticated", "true");
|
||||
await expect(page.locator('[data-testid="auth-status"]')).toContainText(
|
||||
"Signed in",
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-out-button"]'),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
// SignInCard is gone once we're authenticated.
|
||||
await expect(page.locator('[data-testid="auth-sign-in-card"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("authenticated send produces an assistant response", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-testid="auth-sign-in-button"]').click();
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Say hello in one short sentence");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test("signing out flips the banner amber and keeps the chat surface mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-testid="auth-sign-in-button"]').click();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-out-button"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('[data-testid="auth-sign-out-button"]').click();
|
||||
|
||||
// Banner flips to the amber variant. SignInCard does NOT come back —
|
||||
// the demo would never get to showcase the rejection if it did.
|
||||
const banner = page.locator('[data-testid="auth-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toHaveAttribute("data-authenticated", "false");
|
||||
await expect(page.locator('[data-testid="auth-status"]')).toContainText(
|
||||
"Signed out",
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-authenticate-button"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-out-button"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="auth-sign-in-card"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated send surfaces a 401 error without crashing the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-testid="auth-sign-in-button"]').click();
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await page.locator('[data-testid="auth-sign-out-button"]').click();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-authenticate-button"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// After sign-out, the next send must surface the rejection — not
|
||||
// produce an assistant response and not white-screen the page.
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Tell me a one-line joke");
|
||||
await input.press("Enter");
|
||||
|
||||
const errorSurface = page.locator('[data-testid="auth-demo-error"]');
|
||||
await expect(errorSurface).toBeVisible({ timeout: 15000 });
|
||||
// Page chrome stays visible — no white-screen.
|
||||
await expect(page.locator('[data-testid="auth-banner"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("re-signing in from the amber banner clears the error and resumes chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-testid="auth-sign-in-button"]').click();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-sign-out-button"]'),
|
||||
).toBeVisible();
|
||||
await page.locator('[data-testid="auth-sign-out-button"]').click();
|
||||
await expect(
|
||||
page.locator('[data-testid="auth-authenticate-button"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('[data-testid="auth-authenticate-button"]').click();
|
||||
const banner = page.locator('[data-testid="auth-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-authenticated", "true", {
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.locator('[data-testid="auth-demo-error"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Chat is responsive again.
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Give me a fun fact");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Beautiful Chat", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/beautiful-chat");
|
||||
// Wait for a suggestion pill to render before dispatching clicks —
|
||||
// otherwise the click can race hydration and silently no-op. Picking
|
||||
// any visible pill as the readiness signal works because all 9 pills
|
||||
// mount in the same render pass.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Toggle Theme (Frontend Tools)" }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("page loads with logo, mode toggle, and chat input", async ({
|
||||
page,
|
||||
}) => {
|
||||
// CopilotKit logo (top-left of the chat pane)
|
||||
await expect(page.locator('img[alt="CopilotKit"]')).toBeVisible();
|
||||
|
||||
// Mode toggle (Chat / App pills, fixed top-right). Use role=button + exact
|
||||
// name to disambiguate from other occurrences of the word "Chat".
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Chat", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "App", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// CopilotChat input is rendered. CopilotKit's default chat input uses a
|
||||
// textarea with placeholder "Type a message" across all v2 demos.
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("all 9 suggestion pills render with verbatim titles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const expectedPills = [
|
||||
"Pie Chart (Controlled Generative UI)",
|
||||
"Bar Chart (Controlled Generative UI)",
|
||||
"Schedule Meeting (Human In The Loop)",
|
||||
"Search Flights (A2UI Fixed Schema)",
|
||||
"Sales Dashboard (A2UI Dynamic)",
|
||||
"Excalidraw Diagram (MCP App)",
|
||||
"Calculator App (Open Generative UI)",
|
||||
"Toggle Theme (Frontend Tools)",
|
||||
"Task Manager (Shared State)",
|
||||
];
|
||||
|
||||
for (const title of expectedPills) {
|
||||
// Suggestions render as buttons containing the verbatim title text.
|
||||
await expect(page.getByRole("button", { name: title })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("Toggle Theme pill flips the html class and runs the toggleTheme tool", async ({
|
||||
page,
|
||||
}) => {
|
||||
// "Toggle Theme" is the fastest round-trip: a single frontend tool call,
|
||||
// no chart rendering. Its aimock fixture (userMessage keyword "toggle")
|
||||
// returns a toggleTheme tool call.
|
||||
const html = page.locator("html");
|
||||
const initialClass = (await html.getAttribute("class")) ?? "";
|
||||
const initiallyDark = initialClass.includes("dark");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Toggle Theme (Frontend Tools)" })
|
||||
.click();
|
||||
|
||||
// Round-trip signal: the html `dark` class flips — proves both that the
|
||||
// agent responded AND that the frontend tool fired. The beautiful-chat
|
||||
// demo does not emit `[data-testid="copilot-assistant-message"]` on its chat turns (tool
|
||||
// calls render in-transcript without a text bubble), so we assert on the
|
||||
// tool's observable side effect instead of a chat-bubble selector.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cls = (await html.getAttribute("class")) ?? "";
|
||||
return cls.includes("dark");
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(!initiallyDark);
|
||||
});
|
||||
|
||||
test("Pie Chart pill renders a donut SVG with slice circles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: "Pie Chart (Controlled Generative UI)" })
|
||||
.click();
|
||||
|
||||
// The PieChart component renders an inline <svg> with one background
|
||||
// <circle> plus one <circle> per data slice
|
||||
// (components/generative-ui/charts/pie-chart.tsx). The aimock fixture for
|
||||
// "revenue distribution by category" returns 4 slices, so wait for at
|
||||
// least 5 circles total (background + 4 slices).
|
||||
const circles = page.locator("svg circle");
|
||||
await expect
|
||||
.poll(async () => await circles.count(), { timeout: 45000 })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Legend rows include a percentage ending in "%".
|
||||
await expect(page.getByText(/\d+%/).first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("Bar Chart pill renders a recharts bar chart with rectangles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: "Bar Chart (Controlled Generative UI)" })
|
||||
.click();
|
||||
|
||||
// Recharts renders bars inside a ResponsiveContainer. The root class is
|
||||
// stable across recharts versions.
|
||||
const barChartRoot = page.locator(".recharts-responsive-container").first();
|
||||
await expect(barChartRoot).toBeVisible({ timeout: 45000 });
|
||||
|
||||
// At least 2 bar rectangles should render.
|
||||
const bars = page.locator(".recharts-bar-rectangle");
|
||||
await expect
|
||||
.poll(async () => await bars.count(), { timeout: 15000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Search Flights pill renders FlightCard surface from A2UI fixed schema", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// Backend: search_flights tool emits an a2ui_operations container with one
|
||||
// FlightCard component per flight. The agent (`src/agents/beautiful_chat.py`
|
||||
// `_build_flight_components`) emits literal-children components rather than
|
||||
// the structural-children template form, because the binder's structural
|
||||
// expansion isn't reliably exercised by sibling demos. Aimock returns 2
|
||||
// flights — United at $349 and Delta at $289.
|
||||
//
|
||||
// Visual fingerprint: the airline names and prices are inlined into each
|
||||
// FlightCard so they appear as literal text.
|
||||
const pill = page.getByRole("button", {
|
||||
name: "Search Flights (A2UI Fixed Schema)",
|
||||
});
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 });
|
||||
await pill.click();
|
||||
|
||||
// 60s budget: tool call + a2ui_operations round-trip can be slow on cold
|
||||
// starts. Assertion targets are aimock-fixture text, not LLM output, so
|
||||
// they're stable across runs.
|
||||
await expect(page.getByText("United Airlines").first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByText("Delta").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("$349").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("$289").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Sales Dashboard pill renders A2UI dashboard surface", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
// Backend: generate_a2ui tool calls a secondary LLM bound to
|
||||
// `_design_a2ui_surface` (renamed from `render_a2ui` to avoid the A2UI
|
||||
// middleware's default tool-call intercept on `render_a2ui`);
|
||||
// both calls hit aimock fixtures
|
||||
// (showcase/aimock/d4/langgraph-python/chat.json — userMessage + toolName
|
||||
// matchers differentiate primary vs secondary calls; a toolCallId match
|
||||
// breaks the post-tool loop). The render_a2ui fixture ships a 3-metric +
|
||||
// 2-chart dashboard tree with NO `catalogId` in the streamed args — real
|
||||
// models omit it per the tool-usage guide, so the route's
|
||||
// `a2ui.defaultCatalogId` must resolve the page catalog.
|
||||
//
|
||||
// Visual fingerprint: a Metric label "Total Revenue", plus a recharts
|
||||
// ResponsiveContainer (the Pie/BarChart custom renderers wrap their
|
||||
// recharts content in one).
|
||||
const pill = page.getByRole("button", {
|
||||
name: "Sales Dashboard (A2UI Dynamic)",
|
||||
});
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 });
|
||||
await pill.click();
|
||||
|
||||
// 90s budget: secondary-LLM stage inside generate_a2ui can stall on cold
|
||||
// starts. The fixture chain (feature-parity.json) returns both a
|
||||
// generate_a2ui tool call and final narration text mentioning "Total
|
||||
// Revenue". When the A2UI middleware is active AND the secondary LLM
|
||||
// fixture fires, the dashboard renders as an A2UI surface with recharts
|
||||
// charts. When running against aimock without the full A2UI pipeline
|
||||
// (e.g. the secondary-LLM fixture doesn't fire), only the narration
|
||||
// text renders. Assert on the narration text as the primary signal, and
|
||||
// treat recharts rendering as a bonus (soft assertion).
|
||||
await expect(page.getByText(/Total Revenue/i).first()).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
// Regression guard (#4733 / #4734 / #5425): the deployed Sales Dashboard
|
||||
// used to surface "A2UI render error: Catalog not found: ..." when the
|
||||
// model omitted `catalogId` and no `defaultCatalogId` was configured on
|
||||
// the route. Hard-assert the error is absent regardless of whether the
|
||||
// charts rendered — the error banner paints even when the surface fails.
|
||||
await expect(page.getByText(/Catalog not found/i)).toHaveCount(0);
|
||||
|
||||
// Soft assertion: if the full A2UI pipeline fires, recharts containers
|
||||
// should appear. When running against aimock-only (no secondary LLM),
|
||||
// only the text narration renders — so we don't hard-fail on missing
|
||||
// charts. The recharts check still catches regressions when the A2UI
|
||||
// pipeline IS active.
|
||||
const chartRoot = page.locator(".recharts-responsive-container").first();
|
||||
const chartsRendered = await chartRoot
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (chartsRendered) {
|
||||
await expect(
|
||||
page.getByText(/Cannot create component .* without a type/i),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Regression guard: only ONE dashboard surface should render.
|
||||
const allCharts = page.locator(".recharts-responsive-container");
|
||||
await expect
|
||||
.poll(async () => await allCharts.count(), { timeout: 5_000 })
|
||||
.toBeLessThanOrEqual(2); // 1 pie + 1 bar = 2 charts
|
||||
}
|
||||
});
|
||||
|
||||
test("Task Manager pill streams 3 todos into the shared-state canvas", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Task Manager (Shared State)" })
|
||||
.click();
|
||||
|
||||
const todoColumn = page.locator('section[aria-label="To Do column"]');
|
||||
await expect(todoColumn).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await expect(page.getByText("Read the CopilotKit docs")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByText("Build a CopilotKit prototype")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("Explore shared agent state")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Chat Customization (CSS)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/chat-customization-css");
|
||||
});
|
||||
|
||||
test("scope wrapper and themed chat input render on load", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The `.chat-css-demo-scope` wrapper is where all `--copilot-kit-*` CSS
|
||||
// variable overrides are applied. Its presence is the defining signal
|
||||
// that theme.css is loaded and scoped correctly.
|
||||
const scope = page.locator(".chat-css-demo-scope");
|
||||
await expect(scope).toBeVisible();
|
||||
|
||||
// v2 CopilotChat exposes its input via data-testid regardless of theme
|
||||
// overrides. Use the testid plus the default placeholder to assert the
|
||||
// themed input mounted.
|
||||
await expect(
|
||||
scope.locator('[data-testid="copilot-chat-input"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("CSS variables from theme.css resolve on the scope wrapper", async ({
|
||||
page,
|
||||
}) => {
|
||||
const scope = page.locator(".chat-css-demo-scope");
|
||||
await expect(scope).toBeVisible();
|
||||
|
||||
// The Halcyon theme sets --halcyon-* custom properties on the scope
|
||||
// wrapper. If theme.css didn't load (or its `.chat-css-demo-scope`
|
||||
// selector didn't match) these would resolve to empty strings.
|
||||
const vars = await scope.evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
ember: cs.getPropertyValue("--halcyon-ember").trim(),
|
||||
paper: cs.getPropertyValue("--halcyon-paper").trim(),
|
||||
ink: cs.getPropertyValue("--halcyon-ink").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(vars.ember.toLowerCase()).toBe("#c44a1f");
|
||||
expect(vars.paper.toLowerCase()).toBe("#f4efe6");
|
||||
expect(vars.ink.toLowerCase()).toBe("#1a1714");
|
||||
});
|
||||
|
||||
test("input textarea inherits Inter Tight sans font from theme.css", async ({
|
||||
page,
|
||||
}) => {
|
||||
// theme.css sets `.copilotKitInput textarea { font-family: var(--halcyon-sans) }`
|
||||
// which resolves to "Inter Tight", .... The default CopilotKit textarea
|
||||
// does not set Inter Tight — asserting on this computed property is a
|
||||
// reliable theme-applied signal.
|
||||
const textarea = page
|
||||
.locator(".chat-css-demo-scope .copilotKitInput textarea")
|
||||
.first();
|
||||
await expect(textarea).toBeVisible();
|
||||
const fontFamily = await textarea.evaluate(
|
||||
(el) => getComputedStyle(el).fontFamily,
|
||||
);
|
||||
expect(fontFamily).toMatch(/Inter Tight/);
|
||||
});
|
||||
|
||||
test("user bubble uses transparent background with ember left-border after sending a message", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Use a message that matches an aimock fixture to get a deterministic
|
||||
// response. Lowercase "hello" doesn't reliably match — "Say hello in
|
||||
// one short sentence" is an exact d5-all.json fixture entry.
|
||||
await page
|
||||
.getByPlaceholder("Type a message")
|
||||
.fill("Say hello in one short sentence");
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const userMsg = page
|
||||
.locator('.chat-css-demo-scope [data-testid="copilot-user-message"]')
|
||||
.first();
|
||||
await expect(userMsg).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// The Halcyon theme sets the user message outer wrapper to
|
||||
// `background: transparent` and styles the inner bg-muted child with
|
||||
// `var(--halcyon-paper-elevated)` (#fbf8f2) plus a 2px ember left border.
|
||||
// Assert the outer wrapper is transparent.
|
||||
await expect(userMsg).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
});
|
||||
|
||||
test("assistant bubble uses transparent background after round-trip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByPlaceholder("Type a message")
|
||||
.fill("Tell me a one-line joke");
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const assistant = page
|
||||
.locator('.chat-css-demo-scope [data-testid="copilot-assistant-message"]')
|
||||
.first();
|
||||
await expect(assistant).toBeVisible({ timeout: 45000 });
|
||||
|
||||
// The Halcyon theme sets the assistant message to
|
||||
// `background: transparent` — editorial serif text with no bubble, just
|
||||
// an ember left-rule via ::before. The default CopilotKit assistant has
|
||||
// a visible background, so transparent proves the theme won the cascade.
|
||||
await expect(assistant).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Each custom slot wraps the default in a `SlotMarker` that emits
|
||||
// `data-slot-label="<slot-path>"`. That attribute is the canonical
|
||||
// signal that a slot override wired through end-to-end (the welcome
|
||||
// screen + disclaimer also expose dedicated `data-testid` attributes
|
||||
// for ergonomics).
|
||||
const SLOT_LABEL_ASSISTANT = '[data-slot-label="MessageView.AssistantMessage"]';
|
||||
|
||||
test.describe("Chat Slots", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/chat-slots");
|
||||
});
|
||||
|
||||
test("custom welcome screen slot renders on first load", async ({ page }) => {
|
||||
// The custom welcomeScreen slot replaces the default welcome. Both its
|
||||
// own testid and the nested welcomeMessage sub-slot's testid prove the
|
||||
// override wired through end-to-end. Asserting both catches accidental
|
||||
// fallback to the default CopilotChat welcome.
|
||||
const welcome = page.locator('[data-testid="custom-welcome-screen"]');
|
||||
await expect(welcome).toBeVisible();
|
||||
|
||||
await expect(
|
||||
welcome.locator('[data-testid="custom-welcome-message"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("both suggestion pills render with verbatim titles", async ({
|
||||
page,
|
||||
}) => {
|
||||
// useConfigureSuggestions registers exactly two pills with available: "always".
|
||||
// Both should be visible immediately on the welcome screen.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Write a sonnet" }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Tell me a joke" }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('clicking "Tell me a joke" shows the custom assistant message slot', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Click the suggestion pill — this sends "Tell me a short joke." The
|
||||
// assistant responds with text (neutral agent, no tools), and its
|
||||
// bubble must be wrapped in the CustomAssistantMessage SlotMarker.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Tell me a joke" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The MessageView.AssistantMessage slot-marker wraps every assistant
|
||||
// bubble; its presence proves the slot override took effect rather
|
||||
// than the default CopilotChatAssistantMessage rendering bare.
|
||||
await expect(page.locator(SLOT_LABEL_ASSISTANT).first()).toBeVisible({
|
||||
timeout: 45000,
|
||||
});
|
||||
});
|
||||
|
||||
test("custom disclaimer slot renders after the first user message", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Type and send via the send button — Enter-on-textarea was intermittently
|
||||
// dropping the submit on this deployment. We assert the disclaimer +
|
||||
// custom assistant wrapper appear once we transition out of the welcome
|
||||
// state. Use exact aimock fixture messages to ensure deterministic responses.
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Say hello in one short sentence");
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
// Assistant replies and is wrapped in the custom slot.
|
||||
await expect(page.locator(SLOT_LABEL_ASSISTANT).first()).toBeVisible({
|
||||
timeout: 45000,
|
||||
});
|
||||
|
||||
// The custom disclaimer slot lives below the input on the post-welcome
|
||||
// chat view. The welcome-screen state hides it; once the assistant
|
||||
// responds the welcome is gone and the disclaimer should be visible.
|
||||
await expect(page.locator('[data-testid="custom-disclaimer"]')).toBeVisible(
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("second assistant turn is also wrapped in the custom slot", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
const sendBtn = () =>
|
||||
page.locator('[data-testid="copilot-send-button"]').first();
|
||||
|
||||
// Turn 1 — use exact aimock fixture messages for deterministic responses.
|
||||
await input.fill("Say hello in one short sentence");
|
||||
await sendBtn().click();
|
||||
await expect(page.locator(SLOT_LABEL_ASSISTANT).first()).toBeVisible({
|
||||
timeout: 45000,
|
||||
});
|
||||
|
||||
// Wait for the first turn's stream to fully complete. The assistant
|
||||
// message becomes visible as soon as the first chunk arrives, but the
|
||||
// chat input stays in "responding" state until the full stream ends.
|
||||
// Rather than race with that, wait for the assistant text to stabilize
|
||||
// (no new content for 2 seconds).
|
||||
const firstAssistant = page.locator(SLOT_LABEL_ASSISTANT).first();
|
||||
let previousText = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = (await firstAssistant.textContent()) ?? "";
|
||||
const stable = text.length > 0 && text === previousText;
|
||||
previousText = text;
|
||||
return stable;
|
||||
},
|
||||
{ timeout: 30000, intervals: [2000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Turn 2 — the slot should wrap every assistant turn, not just the first.
|
||||
await input.fill("Give me a fun fact");
|
||||
await sendBtn().click();
|
||||
|
||||
// Expect at least two custom-wrapped assistant messages.
|
||||
await expect
|
||||
.poll(async () => await page.locator(SLOT_LABEL_ASSISTANT).count(), {
|
||||
timeout: 45000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/declarative-gen-ui.md
|
||||
// Demo source: src/app/demos/declarative-gen-ui/{page.tsx, a2ui/*}
|
||||
//
|
||||
// Pattern: A2UI dynamic-schema BYOC. The frontend registers a custom catalog
|
||||
// (Row, Column, Card, StatusBadge, Metric, InfoRow, DataTable, PrimaryButton,
|
||||
// PieChart, BarChart) via `a2ui={{ catalog: myCatalog }}`. The agent plays a
|
||||
// sales analyst for the fictional "Vantage Threads" company; the dataset and
|
||||
// per-question composition rules are registered as agent context in
|
||||
// `declarative-gen-ui/sales-context.ts`. Suggestion pills are natural
|
||||
// business questions — chart-type steering lives in the agent system prompt,
|
||||
// not the user prompt (OSS-136).
|
||||
//
|
||||
// Each renderer carries a stable `data-testid` (declarative-card, -metric,
|
||||
// -pie-chart, -bar-chart, -status-badge, -data-table, -info-row). Because
|
||||
// the secondary-LLM render is multi-step, the surface can take 30-60s to
|
||||
// paint — render assertions use a 90s budget.
|
||||
//
|
||||
// W8-7 (resolved): aimock fixtures must split content and toolCalls into
|
||||
// separate responses — a combined response closes the assistant turn before
|
||||
// the A2UI tool call renders (see 2436adba6).
|
||||
|
||||
/** Click a suggestion pill and confirm the message actually dispatched
|
||||
* (the user bubble with the pill's full message text appears). On slow
|
||||
* dev-server hydration the first click can land before the chat send
|
||||
* pipeline is wired and is silently swallowed — retry until the bubble
|
||||
* shows up.
|
||||
*
|
||||
* The "dispatched" assertion is scoped to the chat-message list
|
||||
* (`[data-message-role="user"]`), NOT a bare `getByText` — the pill
|
||||
* button itself contains the message text, so an unscoped match would
|
||||
* satisfy the locator with the pill rather than the resulting user
|
||||
* bubble, neutering the dispatch guard. Before each retry we also
|
||||
* check whether the user bubble already exists; if it does the
|
||||
* earlier click DID dispatch and we must NOT re-click (which would
|
||||
* send a duplicate user message). */
|
||||
async function clickPill(page: Page, title: string, message: string) {
|
||||
const pill = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 });
|
||||
const userBubble = page
|
||||
.locator('[data-message-role="user"]')
|
||||
.filter({ hasText: message })
|
||||
.first();
|
||||
await expect(async () => {
|
||||
// If the previous attempt's click already produced the user bubble,
|
||||
// skip the click — re-clicking dispatches a duplicate user message.
|
||||
if ((await userBubble.count()) === 0) {
|
||||
await pill.click();
|
||||
}
|
||||
await expect(userBubble).toBeVisible({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Declarative Generative UI (A2UI dynamic schema)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/declarative-gen-ui");
|
||||
});
|
||||
|
||||
test("page loads with chat input and no surface rendered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
// No A2UI surface rendered on first paint (no donut SVG, no recharts
|
||||
// container).
|
||||
await expect(page.locator(".recharts-responsive-container")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("all 4 suggestion pills render with verbatim titles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
const expected = [
|
||||
"Show my sales dashboard",
|
||||
"Team performance",
|
||||
"Anything at risk?",
|
||||
"Top account details",
|
||||
];
|
||||
for (const title of expected) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("sales dashboard pill renders a composed surface: KPI strip + pie + bar (no surrounding card)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(
|
||||
page,
|
||||
"Show my sales dashboard",
|
||||
"Show me my sales dashboard for this quarter.",
|
||||
);
|
||||
|
||||
// The hero surface must contain a 4-tile KPI Metric row AND both
|
||||
// charts (no surrounding Card — the charts carry their own card
|
||||
// chrome). Composition rule (sales-context.ts) + D5 probe + aimock
|
||||
// fixtures all pin the hero at 4 Metric tiles. A single lonely
|
||||
// widget is the regression OSS-136 was filed about. 90s budget: on
|
||||
// cold starts the secondary-LLM `generate_a2ui` pass can eat most
|
||||
// of a minute.
|
||||
const metrics = page.locator('[data-testid="declarative-metric"]');
|
||||
await expect
|
||||
.poll(async () => await metrics.count(), { timeout: 90_000 })
|
||||
.toBeGreaterThanOrEqual(4);
|
||||
|
||||
// PieChart: recharts donut (mirrors beautiful-chat's sales dashboard) —
|
||||
// one sector path per slice.
|
||||
const pie = page.locator('[data-testid="declarative-pie-chart"]');
|
||||
await expect(pie.first()).toBeVisible({ timeout: 60_000 });
|
||||
const sectors = pie.locator(".recharts-pie-sector");
|
||||
await expect
|
||||
.poll(async () => await sectors.count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// BarChart: recharts markers are stable across versions.
|
||||
const bar = page.locator('[data-testid="declarative-bar-chart"]');
|
||||
await expect(bar.first()).toBeVisible({ timeout: 60_000 });
|
||||
const bars = page.locator(".recharts-bar-rectangle");
|
||||
await expect
|
||||
.poll(async () => await bars.count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Regression guard (#4734): no A2UI render-error banners (malformed
|
||||
// secondary-LLM output used to loop with "Cannot create component root
|
||||
// without a type").
|
||||
await expect(
|
||||
page.getByText(/Cannot create component .* without a type/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText(/Catalog not found/i)).toHaveCount(0);
|
||||
|
||||
// Regression guard: exactly one composed surface — pie + bar each use a
|
||||
// ResponsiveContainer, so the hero dashboard yields exactly 2.
|
||||
// Fewer = under-composed surface (a lonely chart, OSS-136 regression);
|
||||
// more = looping/duplicated renders.
|
||||
const allCharts = page.locator(".recharts-responsive-container");
|
||||
await expect
|
||||
.poll(async () => await allCharts.count(), { timeout: 5_000 })
|
||||
.toEqual(2);
|
||||
|
||||
// Composition rule (OSS-136 — QA `qa/declarative-gen-ui.md`): the hero
|
||||
// dashboard has NO surrounding Card. The charts carry their own card
|
||||
// chrome, so wrapping them in an extra Card is a planner-side
|
||||
// over-composition regression. Assert zero `declarative-card` mounts.
|
||||
await expect(page.getByTestId("declarative-card")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("team performance pill renders a DataTable with rep rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(
|
||||
page,
|
||||
"Team performance",
|
||||
"How are our sales reps performing against quota?",
|
||||
);
|
||||
|
||||
const table = page.locator('[data-testid="declarative-data-table"]');
|
||||
await expect(table.first()).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// At least 2 body rows — a header-only table is an under-specified
|
||||
// surface (the planner forgot the `rows` prop).
|
||||
const rows = table.locator("tbody tr");
|
||||
await expect
|
||||
.poll(async () => await rows.count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The surface is dashboardy, not a bare table: a quota-attainment
|
||||
// BarChart accompanies it.
|
||||
await expect(
|
||||
page.locator('[data-testid="declarative-bar-chart"]').first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("at-risk pill renders StatusBadge pills", async ({ page }) => {
|
||||
await clickPill(
|
||||
page,
|
||||
"Anything at risk?",
|
||||
"Are any accounts or pipeline deals at risk this quarter?",
|
||||
);
|
||||
|
||||
// One severity badge per at-risk account (3 in the dataset).
|
||||
const badges = page.locator('[data-testid="declarative-status-badge"]');
|
||||
await expect
|
||||
.poll(async () => await badges.count(), { timeout: 90_000 })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// The surface is a risk panel, not bare cards: a KPI strip of three
|
||||
// tiles (ARR at risk / accounts at risk / biggest exposure) leads
|
||||
// it. QA + composition rule require all three — fewer is an
|
||||
// under-specified surface.
|
||||
const metrics = page.locator('[data-testid="declarative-metric"]');
|
||||
await expect
|
||||
.poll(async () => await metrics.count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Composition rule (QA `qa/declarative-gen-ui.md`): the at-risk pill
|
||||
// renders StatusBadge cards + a KPI strip — NO charts or tables.
|
||||
// Any chart/table mount here is a planner over-composition regression.
|
||||
await expect(page.getByTestId("declarative-pie-chart")).toHaveCount(0);
|
||||
await expect(page.getByTestId("declarative-bar-chart")).toHaveCount(0);
|
||||
await expect(page.getByTestId("declarative-data-table")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("top account pill renders InfoRow facts", async ({ page }) => {
|
||||
await clickPill(
|
||||
page,
|
||||
"Top account details",
|
||||
"Pull up the details on our biggest account.",
|
||||
);
|
||||
|
||||
// The account card stacks label/value facts (owner, region, ARR,
|
||||
// renewal, last contact) — require at least 3 InfoRows.
|
||||
const infoRows = page.locator('[data-testid="declarative-info-row"]');
|
||||
await expect
|
||||
.poll(async () => await infoRows.count(), { timeout: 90_000 })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// The surface is dashboardy, not a bare fact list: a product-line
|
||||
// PieChart accompanies it.
|
||||
await expect(
|
||||
page.locator('[data-testid="declarative-pie-chart"]').first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Composition rule (QA `qa/declarative-gen-ui.md`): the top-account
|
||||
// pill renders a Card of InfoRow facts + a product-line PieChart —
|
||||
// NO DataTable, NO StatusBadge. Either is a planner over-composition
|
||||
// regression.
|
||||
await expect(page.getByTestId("declarative-data-table")).toHaveCount(0);
|
||||
await expect(page.getByTestId("declarative-status-badge")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* E2E spec for the Declarative UI: Hashbrown demo. Selectors match the
|
||||
* chart/metric components' `data-testid` hooks. Covers 3 suggestion
|
||||
* flows + page-load smoke; timeouts are streaming-friendly because
|
||||
* hashbrown assembles UI progressively from structured output.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Declarative UI: Hashbrown", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/declarative-hashbrown");
|
||||
});
|
||||
|
||||
test("page loads with header, suggestion pills, and chat composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Declarative UI: Hashbrown" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Sales dashboard").first()).toBeVisible();
|
||||
await expect(page.getByText("Revenue by category").first()).toBeVisible();
|
||||
await expect(page.getByText("Expense trend").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("sales-dashboard suggestion triggers a hashbrown render", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByText("Sales dashboard").first().click();
|
||||
|
||||
const metricCard = page.locator('[data-testid="metric-card"]').first();
|
||||
const chart = page
|
||||
.locator('[data-testid="bar-chart"], [data-testid="pie-chart"]')
|
||||
.first();
|
||||
|
||||
await expect(metricCard).toBeVisible({ timeout: 60000 });
|
||||
await expect(chart).toBeVisible({ timeout: 60000 });
|
||||
});
|
||||
|
||||
test("revenue-by-category suggestion renders a pie chart", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByText("Revenue by category").first().click();
|
||||
await expect(page.locator('[data-testid="pie-chart"]').first()).toBeVisible(
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("expense-trend suggestion renders a bar chart", async ({ page }) => {
|
||||
await page.getByText("Expense trend").first().click();
|
||||
await expect(page.locator('[data-testid="bar-chart"]').first()).toBeVisible(
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E spec for the Declarative UI: json-render demo. Structurally
|
||||
* mirrors `gen-ui-tool-based.spec.ts` so the dashboard's BYOC rows
|
||||
* exercise the same surfaces (json-render-root + metric-card + chart).
|
||||
*/
|
||||
test.describe("Declarative UI: json-render", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/declarative-json-render");
|
||||
});
|
||||
|
||||
test("page loads with chat composer and suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Chat composer
|
||||
await expect(
|
||||
page.locator('textarea, [placeholder*="message"]').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Suggestion pills driven by useConfigureSuggestions. The
|
||||
// CopilotChat welcome screen renders titles as buttons/links.
|
||||
await expect(page.getByText("Sales dashboard")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Revenue by category")).toBeVisible();
|
||||
await expect(page.getByText("Expense trend")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sales dashboard request renders a json-render tree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill(
|
||||
"Show me the sales dashboard with metrics and a revenue chart",
|
||||
);
|
||||
await input.press("Enter");
|
||||
|
||||
// The JsonRenderAssistantMessage slot wraps renders in this testid.
|
||||
await expect(
|
||||
page.locator('[data-testid="json-render-root"]').first(),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// A MetricCard should appear in the rendered tree.
|
||||
await expect(
|
||||
page.locator('[data-testid="metric-card"]').first(),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// ...plus at least one chart (either shape).
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="bar-chart"], [data-testid="pie-chart"]')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
});
|
||||
|
||||
test("revenue-by-category request renders a pie chart", async ({ page }) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill("Break down revenue by category as a pie chart");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(page.locator('[data-testid="pie-chart"]').first()).toBeVisible(
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("expense-trend request renders a bar chart", async ({ page }) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill("Show me monthly expenses as a bar chart");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(page.locator('[data-testid="bar-chart"]').first()).toBeVisible(
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/frontend-tools-async.md
|
||||
// Demo source: src/app/demos/frontend-tools-async/{page.tsx, notes-card.tsx}
|
||||
//
|
||||
// The demo registers ONE async frontend tool via `useFrontendTool`:
|
||||
// `query_notes(keyword: string)`. The handler sleeps 500ms (simulated local
|
||||
// DB latency) then returns up to 5 matches from an in-memory 7-note DB.
|
||||
// A custom `render` mounts `NotesCard` which exposes:
|
||||
// - `data-testid="notes-card"` (outer container)
|
||||
// - `data-testid="notes-keyword"` (heading: `Matching "<keyword>"`)
|
||||
// - `data-testid="notes-list"` (the <ul> of matches)
|
||||
// - `data-testid="note-n1"` … `note-n7` per-note rows
|
||||
//
|
||||
// Genuine-pass strategy: the deterministic aimock fixtures match each pill's
|
||||
// verbatim prompt with a dedicated `query_notes(keyword=…)` tool call so the
|
||||
// async handler runs against the real client-side NOTES_DB. The card's
|
||||
// `keyword` heading is then the keyword we asserted in the fixture, and the
|
||||
// `notes-list` rows reflect the actual handler-filtered results — proving
|
||||
// the async tool round-trip end-to-end.
|
||||
|
||||
test.describe("Frontend Tools (async query_notes)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/frontend-tools-async");
|
||||
});
|
||||
|
||||
test("page loads with composer and 3 pills", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Find project-planning notes/i }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Search for 'auth'/i }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /What do I have about reading\?/i }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("project-planning pill → Notes DB card with project-planning notes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: /Find project-planning notes/i })
|
||||
.click();
|
||||
|
||||
const notesCard = page.locator('[data-testid="notes-card"]').first();
|
||||
await expect(notesCard).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// The keyword heading proves the async handler resolved against the
|
||||
// fixture-emitted `query_notes(keyword="project planning")` call.
|
||||
await expect(notesCard.locator('[data-testid="notes-keyword"]')).toHaveText(
|
||||
/Matching\s+["“]project planning["”]/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// The async handler matches notes n1 ("Q2 project planning kickoff")
|
||||
// and n5 ("Project planning retrospective notes") from NOTES_DB.
|
||||
const list = notesCard.locator('[data-testid="notes-list"]');
|
||||
await expect(list).toBeVisible({ timeout: 30_000 });
|
||||
await expect(notesCard.locator('[data-testid="note-n1"]')).toBeVisible();
|
||||
await expect(notesCard.locator('[data-testid="note-n5"]')).toBeVisible();
|
||||
|
||||
// Anti-regression: the generic-plan boilerplate from the cross-cell
|
||||
// catch-all fixture must NOT appear. If it does, the d5-all.json
|
||||
// fixture lost match priority to feature-parity.json's "plan" entry.
|
||||
await expect(
|
||||
page.getByText("Research the topic, Outline key points"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("auth pill → Notes DB card with auth-related notes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: /Search for 'auth'/i }).click();
|
||||
|
||||
const notesCard = page.locator('[data-testid="notes-card"]').first();
|
||||
await expect(notesCard).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await expect(notesCard.locator('[data-testid="notes-keyword"]')).toHaveText(
|
||||
/Matching\s+["“]auth["”]/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// The async handler matches note n2 ("Planning: migrate auth to
|
||||
// passkeys") on the "auth" tag.
|
||||
const list = notesCard.locator('[data-testid="notes-list"]');
|
||||
await expect(list).toBeVisible({ timeout: 30_000 });
|
||||
await expect(notesCard.locator('[data-testid="note-n2"]')).toBeVisible();
|
||||
|
||||
// Anti-regression: the showcase-assistant catch-all from
|
||||
// feature-parity.json must NOT have intercepted this prompt.
|
||||
await expect(page.getByText("I'm your showcase assistant")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reading pill → Notes DB card with Book recommendations + locked narration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: /What do I have about reading\?/i })
|
||||
.click();
|
||||
|
||||
const notesCard = page.locator('[data-testid="notes-card"]').first();
|
||||
await expect(notesCard).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Keyword heading + match count + per-note testid + content +
|
||||
// tag chip — the full canonical shape per spec test #4.
|
||||
await expect(notesCard.locator('[data-testid="notes-keyword"]')).toHaveText(
|
||||
/Matching\s+["“]reading["”]/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(notesCard.getByText("1 match", { exact: false })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const note = notesCard.locator('[data-testid="note-n4"]');
|
||||
await expect(note).toBeVisible({ timeout: 30_000 });
|
||||
await expect(note.getByText("Book recommendations")).toBeVisible();
|
||||
await expect(note.getByText(/Thinking Fast and Slow/i)).toBeVisible();
|
||||
await expect(
|
||||
note.getByText(/The Design of Everyday Things/i),
|
||||
).toBeVisible();
|
||||
await expect(note.getByText("reading", { exact: true })).toBeVisible();
|
||||
|
||||
// Locked narration leading phrase — proves the deterministic 2nd-turn
|
||||
// fixture wired correctly through the async tool result.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({
|
||||
hasText:
|
||||
'You have a note titled "Book recommendations" that is tagged with "reading',
|
||||
})
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
// Regression for the aimock multi-pill bug:
|
||||
// The three frontend-tools-async fixtures used `hasToolResult: false/true`
|
||||
// gates to split first-turn (emit `query_notes`) vs. follow-up (narration).
|
||||
// After the user clicked a tool-using pill earlier in the same thread, the
|
||||
// first-turn fixture was skipped (the thread already had a prior tool
|
||||
// result), the follow-up fixture fired immediately with just narration,
|
||||
// and the Notes DB card never rendered. Fix: chain via `toolCallId`, drop
|
||||
// the gates. This test drives all three pills in a single thread and
|
||||
// asserts every pill renders its own Notes DB card.
|
||||
test("sequential pills in one thread each render their own Notes DB card", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Three pills × async-handler latency × LLM mock chain; the existing
|
||||
// describe-level 120s is not enough once we drive all three in one test.
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const cards = page.locator('[data-testid="notes-card"]');
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Find project-planning notes/i })
|
||||
.click();
|
||||
await expect.poll(() => cards.count(), { timeout: 60_000 }).toBe(1);
|
||||
await expect(
|
||||
page.locator('[data-testid="notes-keyword"]', {
|
||||
hasText: /Matching\s+[""“]project planning[""”]/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page.getByRole("button", { name: /Search for 'auth'/i }).click();
|
||||
await expect.poll(() => cards.count(), { timeout: 60_000 }).toBe(2);
|
||||
await expect(
|
||||
page.locator('[data-testid="notes-keyword"]', {
|
||||
hasText: /Matching\s+[""“]auth[""”]/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /What do I have about reading\?/i })
|
||||
.click();
|
||||
await expect.poll(() => cards.count(), { timeout: 60_000 }).toBe(3);
|
||||
await expect(
|
||||
page.locator('[data-testid="notes-keyword"]', {
|
||||
hasText: /Matching\s+[""“]reading[""”]/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/frontend-tools.md
|
||||
// Demo source: src/app/demos/frontend-tools/page.tsx
|
||||
//
|
||||
// The demo registers ONE frontend tool via `useFrontendTool`:
|
||||
// `change_background(background: string)`. The handler calls `setBackground`
|
||||
// with a CSS value — gradient or solid color. The background host
|
||||
// (`data-testid="frontend-tools-background"`) starts at `#4f46e5` (solid
|
||||
// indigo) and mutates inline on success.
|
||||
//
|
||||
// We assert on the observable side effect (inline style changes) rather
|
||||
// than on any LLM-generated text. The demo exposes three suggestion pills:
|
||||
// "Sunset theme", "Forest theme", and "Cosmic theme". The existing aimock
|
||||
// feature-parity fixture covers the "sunset-themed gradient" prompt, and
|
||||
// the real Railway LLM handles the free-form prompts.
|
||||
|
||||
test.describe("Frontend Tools (change_background)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/frontend-tools");
|
||||
});
|
||||
|
||||
test("page loads with chat input and background container", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="frontend-tools-background"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("background container starts with the solid indigo default", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bg = page.locator('[data-testid="frontend-tools-background"]');
|
||||
// The initial inline style is #4f46e5 (solid indigo, from background.tsx).
|
||||
const initial = await bg.getAttribute("style");
|
||||
expect(initial ?? "").toContain("#4f46e5");
|
||||
});
|
||||
|
||||
test("suggestion pills for Sunset, Forest, and Cosmic themes render", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Sunset theme/i }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Forest theme/i }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Cosmic theme/i }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("Forest theme pill mutates the background inline style", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: /Forest theme/i }).click();
|
||||
|
||||
const bg = page.locator('[data-testid="frontend-tools-background"]');
|
||||
|
||||
// The inline style flips away from the #4f46e5 default once the
|
||||
// agent invokes change_background. Poll the style attribute rather
|
||||
// than any LLM text.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const s = (await bg.getAttribute("style")) ?? "";
|
||||
return !s.includes("#4f46e5");
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Sunset theme pill triggers a gradient change", async ({ page }) => {
|
||||
// The pill sends the verbatim prompt "Make the background a sunset
|
||||
// gradient." — aimock fixture covers this; real LLM handles it on Railway.
|
||||
await page.getByRole("button", { name: /Sunset theme/i }).click();
|
||||
|
||||
const bg = page.locator('[data-testid="frontend-tools-background"]');
|
||||
|
||||
// Expect a gradient-containing inline style. `linear-gradient` is the
|
||||
// common case for the sunset theme; `radial-gradient` is also accepted.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const s = (await bg.getAttribute("style")) ?? "";
|
||||
return /linear-gradient|radial-gradient/.test(s);
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Agentic Generative UI", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/gen-ui-agent");
|
||||
});
|
||||
|
||||
test("page loads with chat input", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sends message and gets assistant response", async ({ page }) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Hello");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
test("message list container exists", async ({ page }) => {
|
||||
// CopilotChat v2 renders a welcome screen when there are no messages,
|
||||
// so the messageView.children callback (which renders copilot-message-list)
|
||||
// is only invoked after the first message is sent.
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Hello");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-message-list"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
// Regression: every set_steps tool call used to push a brand-new card into
|
||||
// the chat (one card per state-changing message), so a 7-call run produced
|
||||
// 7+ stacked duplicate cards. The fix moved the demo from
|
||||
// `useCoAgentStateRender` (V1, per-message claiming) to V2 `useAgent` +
|
||||
// `messageView.children`, which renders a single live-updating card. This
|
||||
// test pins that contract — one card, regardless of how many state updates
|
||||
// arrive during the run.
|
||||
test("renders a single agent-state-card that updates in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Plan a product launch for a new mobile app.");
|
||||
await input.press("Enter");
|
||||
|
||||
const card = page.locator('[data-testid="agent-state-card"]');
|
||||
await expect(card).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Wait for at least one step to be published, then assert there is still
|
||||
// only one card (not one per state update).
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-step"]').first(),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
await expect(card).toHaveCount(1);
|
||||
|
||||
// Wait until the agent finishes the run, then re-assert single card.
|
||||
// `agent.isRunning` flips to false → the card's spinner becomes a check.
|
||||
await expect(card.locator(".animate-spin")).toHaveCount(0, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await expect(card).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("eventually marks every step as completed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Plan a product launch for a new mobile app.");
|
||||
await input.press("Enter");
|
||||
|
||||
// First, wait for at least one step to appear — otherwise the
|
||||
// completion check below vacuously passes on 0 elements.
|
||||
const steps = page.locator('[data-testid="agent-step"]');
|
||||
await expect(steps.first()).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Wait for all 3 steps to reach `completed` status. The fixture chain
|
||||
// transitions each step through pending → in_progress → completed.
|
||||
// With aimock's fast responses the chain runs in seconds; the 60s
|
||||
// timeout is generous to accommodate cold starts.
|
||||
const completed = page.locator(
|
||||
'[data-testid="agent-step"][data-status="completed"]',
|
||||
);
|
||||
await expect(completed).toHaveCount(3, { timeout: 60000 });
|
||||
|
||||
// Also verify the total step count matches completed (no orphans).
|
||||
const total = await steps.count();
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
|
||||
// Regression: the aimock fixture used to emit a single set_steps tool call
|
||||
// with all three steps already `completed`, so the card mounted in its
|
||||
// final state with no sequential animation. The pill's whole point is the
|
||||
// pending → in_progress → completed progression spelled out in the
|
||||
// backend's SYSTEM_PROMPT, which requires a 7-call chain of set_steps
|
||||
// emissions threaded via toolCallId. This test pins that the card appears
|
||||
// AND that step elements render with the expected data-status attributes.
|
||||
// With aimock's near-instant responses the entire chain may complete before
|
||||
// the browser can observe the transient `pending` state, so we assert on
|
||||
// the final state: at least one step exists and the card rendered.
|
||||
test("steps animate through pending before completing (no fixture short-circuit)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: /Plan a product launch/i }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="agent-state-card"]')).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// The fixture chain produces 3 steps that transition through pending →
|
||||
// in_progress → completed. With aimock, the chain runs so fast that all
|
||||
// steps may already be `completed` by the time we check. Assert that
|
||||
// steps appeared (non-zero count) and reached their terminal state.
|
||||
const steps = page.locator('[data-testid="agent-step"]');
|
||||
await expect(steps.first()).toBeVisible({ timeout: 30000 });
|
||||
const total = await steps.count();
|
||||
expect(total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/gen-ui-interrupt.md
|
||||
// Demo source: src/app/demos/gen-ui-interrupt/{page.tsx, time-picker-card.tsx}
|
||||
//
|
||||
// Uses `useInterrupt({ renderInChat: true })` — the low-level CopilotKit
|
||||
// primitive wired to LangGraph's `interrupt()` on the `interrupt_agent`
|
||||
// graph (shared with `interrupt-headless`). When the agent invokes the
|
||||
// backend `schedule_meeting` tool, the graph interrupts and a
|
||||
// `TimePickerCard` renders INLINE in the chat transcript (no portal).
|
||||
//
|
||||
// Card states (mutually exclusive, per-interrupt):
|
||||
// - `time-picker-card` — initial, 4 slot buttons + "None of these work"
|
||||
// - `time-picker-picked` — after a slot is clicked
|
||||
// - `time-picker-cancelled` — after the ghost cancel button
|
||||
//
|
||||
// Typed prompts (not suggestion pills) are used for the tool-trigger flows:
|
||||
// pill-click was observed to not always drive schedule_meeting on Railway.
|
||||
// No LLM-text assertions — only testid state transitions plus the inline
|
||||
// (non-body) render contract.
|
||||
|
||||
test.describe("Gen UI via useInterrupt (inline time picker)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Wait for the CopilotKit runtime info response to complete before
|
||||
// interacting. Without this, messages sent before the runtime
|
||||
// connects are silently dropped because the agent reference used by
|
||||
// CopilotChat's submit handler is a provisional stub whose
|
||||
// onMessagesChanged subscribers are replaced when the real agent
|
||||
// arrives — orphaning the in-flight run's state updates.
|
||||
const runtimeReady = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/copilotkit") &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() === 200,
|
||||
);
|
||||
await page.goto("/demos/gen-ui-interrupt");
|
||||
await runtimeReady;
|
||||
});
|
||||
|
||||
test("page loads with chat input and no picker rendered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="time-picker-card"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("both suggestion pills render", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Book a call with sales" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Schedule a 1:1 with Alice" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("picking a slot transitions the card to the picked state", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(
|
||||
"Use schedule_meeting to book an intro call with the sales team about pricing.",
|
||||
);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const card = page.locator('[data-testid="time-picker-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Contract: inline render, NOT a body portal (unlike hitl-in-app).
|
||||
await expect(
|
||||
page.locator('body > [data-testid="time-picker-card"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const expectedSlots = [
|
||||
"Tomorrow 10:00 AM",
|
||||
"Tomorrow 2:00 PM",
|
||||
"Monday 9:00 AM",
|
||||
"Monday 3:30 PM",
|
||||
];
|
||||
for (const label of expectedSlots) {
|
||||
await expect(card.getByRole("button", { name: label })).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
card.getByRole("button", { name: "None of these work" }),
|
||||
).toBeVisible();
|
||||
|
||||
await card.getByRole("button", { name: "Monday 9:00 AM" }).click();
|
||||
|
||||
const picked = page.locator('[data-testid="time-picker-picked"]').first();
|
||||
await expect(picked).toBeVisible({ timeout: 10_000 });
|
||||
await expect(picked).toContainText("Monday 9:00 AM");
|
||||
|
||||
// The picked-state card replaces the interactive card.
|
||||
await expect(page.locator('[data-testid="time-picker-card"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("cancel path: None-of-these-work transitions to cancelled state", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(
|
||||
"Use schedule_meeting to book a 1:1 with Alice next week to review Q2 goals.",
|
||||
);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const card = page.locator('[data-testid="time-picker-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await card.getByRole("button", { name: "None of these work" }).click();
|
||||
|
||||
const cancelled = page
|
||||
.locator('[data-testid="time-picker-cancelled"]')
|
||||
.first();
|
||||
await expect(cancelled).toBeVisible({ timeout: 10_000 });
|
||||
await expect(cancelled).toContainText("Cancelled");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Tool-Based Generative UI demo: a centered <CopilotChat> with two
|
||||
// useComponent registrations (render_bar_chart + render_pie_chart) plus
|
||||
// three suggestion pills wired via useConfigureSuggestions. The demo
|
||||
// has no header / chrome — the chat surface IS the page.
|
||||
test.describe("Tool-Based Generative UI", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/gen-ui-tool-based");
|
||||
});
|
||||
|
||||
test("page loads with chat composer and the three suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('textarea, [placeholder*="message"]').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
for (const title of [
|
||||
"Sales bar chart",
|
||||
"Traffic pie chart",
|
||||
"Market share",
|
||||
]) {
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: title }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("pie chart request renders SVG visualization", async ({ page }) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill("Show me a pie chart of revenue by category");
|
||||
await input.press("Enter");
|
||||
|
||||
// PieChart renders as Recharts SVG inside the assistant message.
|
||||
const assistantMessage = page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.first();
|
||||
await expect(assistantMessage.locator("svg").first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
test("bar chart request renders SVG visualization", async ({ page }) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill("Show me a bar chart of monthly expenses");
|
||||
await input.press("Enter");
|
||||
|
||||
const assistantMessage = page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.first();
|
||||
await expect(assistantMessage.locator("svg").first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
test("sends message and gets assistant response", async ({ page }) => {
|
||||
const input = page.locator('textarea, [placeholder*="message"]').first();
|
||||
await input.fill("Hello");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Headless Chat (Complete) — full headless surface in one demo. The
|
||||
* hand-rolled chat shell wires every render hook CopilotKit exposes
|
||||
* (useRenderTool, useDefaultRenderTool, useComponent, useRenderToolCall,
|
||||
* useSuggestions, useAttachments) on top of shadcn primitives.
|
||||
*
|
||||
* The 5-test plan drives the 4 empty-state pills and asserts:
|
||||
* - the per-tool render component (Weather / Stock / Highlight / Chart)
|
||||
* is mounted on the headless surface (scoped testid)
|
||||
* - the assistant narration arrives in the custom message-assistant
|
||||
* bubble (`[data-testid="headless-message-assistant"]`)
|
||||
*
|
||||
* Each pill exercises a DIFFERENT render-hook path. If any hook regresses,
|
||||
* only that test fails. If the surface silently demotes back to the default
|
||||
* <CopilotChat />, the headless-specific testids vanish and all 4 tool
|
||||
* tests fail.
|
||||
*/
|
||||
|
||||
const PILL_WEATHER = "Try suggestion: What's the weather in Tokyo?";
|
||||
const PILL_STOCK = "Try suggestion: What's AAPL trading at?";
|
||||
const PILL_HIGHLIGHT = "Try suggestion: Highlight: ship the demo on Friday";
|
||||
const PILL_CHART =
|
||||
"Try suggestion: Show me a chart of revenue over the last six months";
|
||||
|
||||
const ASSERT_TIMEOUT = 45_000;
|
||||
|
||||
test.describe("Headless Chat (Complete)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/headless-complete");
|
||||
});
|
||||
|
||||
test("page loads with custom composer and four suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="headless-composer"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Pills use aria-label `Try suggestion: ${prompt}` so screen readers can
|
||||
// disambiguate. We assert all four are mounted on first paint.
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_WEATHER, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_STOCK, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_HIGHLIGHT, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_CHART, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("weather pill renders the headless WeatherCard via useRenderTool plus the deterministic narration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_WEATHER, exact: true }).click();
|
||||
|
||||
const card = page.locator('[data-testid="headless-weather-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(card).toContainText("Tokyo");
|
||||
await expect(card).toContainText("Sunny");
|
||||
await expect(card).toContainText("68°F");
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.last();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText("Tokyo is 22°C and partly cloudy.", {
|
||||
timeout: ASSERT_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("AAPL pill renders the headless StockCard via useRenderTool plus the deterministic narration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_STOCK, exact: true }).click();
|
||||
|
||||
const card = page.locator('[data-testid="headless-stock-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(card).toContainText("AAPL");
|
||||
await expect(card).toContainText("$189.42");
|
||||
await expect(card).toContainText("+1.27%");
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.last();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText(
|
||||
"AAPL is trading at $189.42, up 1.27% on the day",
|
||||
{ timeout: ASSERT_TIMEOUT },
|
||||
);
|
||||
});
|
||||
|
||||
test("highlight pill renders the headless HighlightNote via useComponent plus the deterministic narration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: PILL_HIGHLIGHT, exact: true })
|
||||
.click();
|
||||
|
||||
// The highlight card is the frontend-tool render surface (useComponent).
|
||||
const card = page
|
||||
.locator('[data-testid="headless-highlight-card"]')
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(card).toContainText("ship the demo on Friday");
|
||||
|
||||
// Narration leading phrase comes from the new high-priority fixture in
|
||||
// d5-all.json; the showcase-assistant catch-all in feature-parity.json
|
||||
// would otherwise win and reply with "Hi there! I'm your showcase
|
||||
// assistant…".
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.last();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText("ship the demo on Friday", {
|
||||
timeout: ASSERT_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("revenue chart pill renders the headless ChartCard via useRenderTool plus the deterministic narration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_CHART, exact: true }).click();
|
||||
|
||||
const card = page.locator('[data-testid="headless-revenue-chart"]').first();
|
||||
await expect(card).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(card).toContainText("Quarterly revenue");
|
||||
await expect(card).toContainText("Last six months · USD thousands");
|
||||
|
||||
// Month labels come from the python tool's deterministic mock series
|
||||
// (Jan…Jun); recharts renders them as <text> tick labels inside the SVG.
|
||||
for (const month of ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]) {
|
||||
await expect(card).toContainText(month);
|
||||
}
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.last();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText(
|
||||
"Here is the chart of revenue over the last six months",
|
||||
{ timeout: ASSERT_TIMEOUT },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Headless = bring-your-own-UI. The cell exercises the minimum-viable
|
||||
* headless chat: useAgent + useCopilotKit, dressed in shadcn primitives,
|
||||
* no tool rendering, no generative UI — just text in / text out via a
|
||||
* hand-rolled UI.
|
||||
*
|
||||
* The 4-test plan drives the 3 empty-state pills and asserts the
|
||||
* deterministic aimock fixture leading phrases land in the custom
|
||||
* assistant bubble (`[data-testid="headless-message-assistant"]`).
|
||||
*
|
||||
* If the headless surface ever regressed to the default <CopilotChat />
|
||||
* surface, the headless-specific testid would be missing and tests 2-4
|
||||
* would fail. If a fixture-matcher misroute swapped one pill's response
|
||||
* for another's, the wrong leading phrase would surface.
|
||||
*/
|
||||
|
||||
const PILL_HELLO = "Say hello in one short sentence.";
|
||||
const PILL_JOKE = "Tell me a one-line joke.";
|
||||
const PILL_FACT = "Give me a fun fact.";
|
||||
|
||||
// Intentionally NOT the showcase-assistant catch-all phrase ("Hello! I can
|
||||
// help you with weather lookups, creating pie and bar charts...") — that
|
||||
// boilerplate is what other tests in this PR explicitly guard AGAINST.
|
||||
// The dedicated d5-all.json fixture for "Say hello in one short sentence"
|
||||
// returns the leading phrase below; if fixture priority ever misroutes
|
||||
// this prompt to the catch-all, this assertion will fail with a clear
|
||||
// "expected non-boilerplate greeting" diff.
|
||||
const HELLO_LEADING = "Hi! In one short sentence: I'm a CopilotKit demo agent";
|
||||
const JOKE_LEADING =
|
||||
"Why did the scarecrow win an award? Because he was outstanding in his field!";
|
||||
const FACT_LEADING = "A fun fact: Honey never spoils!";
|
||||
|
||||
const ASSERT_TIMEOUT = 30_000;
|
||||
|
||||
test.describe("Headless Chat (Simple)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/headless-simple");
|
||||
});
|
||||
|
||||
test("page loads with custom composer and three suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Custom composer is the structural signal that the demo is headless;
|
||||
// no default CopilotChat input is rendered on this surface.
|
||||
await expect(
|
||||
page.locator('[data-testid="headless-composer"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The 3 empty-state pills are hand-rolled <button>s containing the
|
||||
// verbatim sample prompts.
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_HELLO, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_JOKE, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: PILL_FACT, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking the hello pill renders the deterministic greeting in the custom assistant bubble", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_HELLO, exact: true }).click();
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.first();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText(HELLO_LEADING, {
|
||||
timeout: ASSERT_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the joke pill renders the deterministic joke in the custom assistant bubble", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_JOKE, exact: true }).click();
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.first();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText(JOKE_LEADING, {
|
||||
timeout: ASSERT_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the fun fact pill renders the deterministic fun fact in the custom assistant bubble", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: PILL_FACT, exact: true }).click();
|
||||
|
||||
const assistant = page
|
||||
.locator('[data-testid="headless-message-assistant"]')
|
||||
.first();
|
||||
await expect(assistant).toBeVisible({ timeout: ASSERT_TIMEOUT });
|
||||
await expect(assistant).toContainText(FACT_LEADING, {
|
||||
timeout: ASSERT_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/hitl-in-app.md
|
||||
// Demo source: src/app/demos/hitl-in-app/{page.tsx, approval-dialog.tsx}
|
||||
//
|
||||
// Demo registers ONE frontend tool via `useFrontendTool`:
|
||||
// `request_user_approval(message, context?)`. The handler returns a Promise
|
||||
// whose `resolve` is stashed in state; the parent renders `ApprovalDialog`,
|
||||
// portal'd to `document.body` via `createPortal` — so the modal lives
|
||||
// OUTSIDE the chat transcript. Approve / Reject click resolves the tool
|
||||
// promise with `{ approved, reason? }` and hands it back to the agent.
|
||||
//
|
||||
// Genuine assertion strategy: the deterministic aimock fixtures emit two
|
||||
// branched continuations per pill (sequenceIndex 0 = approve, 1 = reject).
|
||||
// Since the JSON fixture matcher cannot inspect tool message content, we
|
||||
// rely on test ordering (serial mode) so test #1 of the pair claims
|
||||
// sequenceIndex 0 (approve response) and test #2 claims sequenceIndex 1
|
||||
// (reject response). If the framework wired approve/reject into the same
|
||||
// payload, both branches would still receive the same fixture's response
|
||||
// — but the assertion text differs, so at least one of the two tests
|
||||
// would fail. That asymmetry is what makes the assertion genuine.
|
||||
|
||||
test.describe("HITL In-App (approval dialog portaled to <body>)", () => {
|
||||
// Serial mode is load-bearing: aimock's `sequenceIndex` matcher counts
|
||||
// matches across the whole process, so the approve test (sequenceIndex 0)
|
||||
// MUST run before the reject test (sequenceIndex 1) for each pill pair.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/hitl-in-app");
|
||||
});
|
||||
|
||||
test("page loads with 3 tickets, chat input, and no open modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByTestId("ticket-12345")).toBeVisible();
|
||||
await expect(page.getByTestId("ticket-12346")).toBeVisible();
|
||||
await expect(page.getByTestId("ticket-12347")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("suggestion pills reference each open ticket", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Approve refund for #12345" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Downgrade plan for #12346" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Escalate ticket #12347" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("refund #12345 → approve → assistant confirms processing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Approve refund for #12345" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Modal mounts as a DIRECT child of <body> (createPortal contract).
|
||||
const bodyModal = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(bodyModal).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByTestId("approval-dialog")).toBeVisible();
|
||||
await expect(page.getByTestId("approval-dialog-reason")).toBeVisible();
|
||||
|
||||
await page.getByTestId("approval-dialog-approve").click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Approve branch: deterministic fixture leading phrase.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: "I am processing the $50 refund" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
test("refund #12345 → reject → assistant acknowledges rejection", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Approve refund for #12345" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const bodyModal = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(bodyModal).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page.getByTestId("approval-dialog-reject").click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Reject branch: deterministic fixture leading phrase. Substring
|
||||
// assertion is intentional — locks the reject-branch identity.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: "refund request was not approved" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
test("escalate #12347 → approve → assistant confirms escalation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Escalate ticket #12347" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const bodyModal = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(bodyModal).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page.getByTestId("approval-dialog-approve").click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Approve branch leading phrase: "Escalated ticket #12347 ...".
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: "Escalated ticket #12347" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
test("escalate #12347 → reject → assistant acknowledges non-escalation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Escalate ticket #12347" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const bodyModal = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(bodyModal).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page.getByTestId("approval-dialog-reject").click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Reject branch leading phrase: "Not escalated ...".
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: "Not escalated" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
// TODO: re-enable when downgrade flow is fixed (broken upstream as of 2026-05-07)
|
||||
test("downgrade #12346 → approve/reject flow", async () => {
|
||||
// Intentionally skipped per spec: the downgrade pill exposes a
|
||||
// separate upstream bug (ticket-12346 surface) that is out of scope
|
||||
// for the genuine-pass rewrite. Re-enable when the upstream demo
|
||||
// reliably emits request_user_approval for the downgrade prompt.
|
||||
});
|
||||
|
||||
// Regression for the aimock multi-pill bug:
|
||||
// The refund (#12345) and escalate (#12347) fixtures used
|
||||
// `hasToolResult: false/true` to split the request_user_approval emit
|
||||
// from the approve/reject narration. After approving the first pill, the
|
||||
// thread already had a tool result, so the second pill's first-turn
|
||||
// fixture was skipped — the approve/reject branch fired immediately with
|
||||
// no approval dialog. Fix: chain the follow-up fixtures via the
|
||||
// request_user_approval `toolCallId`, drop `hasToolResult: false` from
|
||||
// the tool-emitting fixture. This test approves refund #12345 then
|
||||
// clicks escalate #12347 in the same thread and asserts the second pill
|
||||
// mounts its own approval dialog.
|
||||
test("approve refund then click escalate — each pill mounts its own approval dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Approve refund for #12345" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const refundDialog = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(refundDialog).toBeVisible({ timeout: 60_000 });
|
||||
// The first dialog targets ticket #12345.
|
||||
await expect(refundDialog.getByText(/#12345/).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("approval-dialog-approve").click();
|
||||
await expect(
|
||||
page.locator('[data-testid="approval-dialog-overlay"]'),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// First pill's approve narration lands before we click the next pill.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: "processing the $50 refund" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Second pill: must trigger its OWN request_user_approval tool call
|
||||
// and mount a fresh approval dialog targeting ticket #12347.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Escalate ticket #12347" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const escalateDialog = page.locator(
|
||||
'body > [data-testid="approval-dialog-overlay"]',
|
||||
);
|
||||
await expect(escalateDialog).toBeVisible({ timeout: 60_000 });
|
||||
await expect(escalateDialog.getByText(/#12347/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("HITL in chat — booking flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/hitl-in-chat");
|
||||
});
|
||||
|
||||
test("page loads with chat input", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
// Regression: the broad `userMessage: "Alice"` fixture in
|
||||
// showcase/aimock/feature-parity.json was intercepting this suggestion and
|
||||
// returning a generic "Nice to meet you, Alice! ... Tokyo" greeting, so the
|
||||
// book_call HITL flow never fired. The fixture pair added in the same PR
|
||||
// as this test (full-sentence userMessage match, ordered before the broad
|
||||
// Alice match) routes the suggestion to a `book_call` toolCall, which is
|
||||
// what makes the time-picker card render.
|
||||
test("'Schedule a 1:1 with Alice' suggestion renders the time-picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Schedule a 1:1 with Alice next week to review Q2 goals.");
|
||||
await input.press("Enter");
|
||||
|
||||
const card = page.locator('[data-testid="time-picker-card"]');
|
||||
await expect(card).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// The Tokyo greeting starts with "Nice to meet you, Alice" — if any
|
||||
// assistant message contains that, the broad Alice fixture won and the
|
||||
// fix has regressed.
|
||||
await expect(page.getByText(/Nice to meet you, Alice/i)).toHaveCount(0);
|
||||
|
||||
// The card should advertise the booking and the attendee parsed by the
|
||||
// fixture's toolCall arguments.
|
||||
await expect(
|
||||
card.locator("p").filter({ hasText: /^With Alice$/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// At least one selectable time slot is present.
|
||||
const slots = page.locator('[data-testid="time-picker-slot"]');
|
||||
expect(await slots.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("picking a time slot resolves the HITL with a confirmation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Schedule a 1:1 with Alice next week to review Q2 goals.");
|
||||
await input.press("Enter");
|
||||
|
||||
const slot = page.locator('[data-testid="time-picker-slot"]').first();
|
||||
await expect(slot).toBeVisible({ timeout: 60000 });
|
||||
await slot.click();
|
||||
|
||||
// The picked-state card replaces the slot grid.
|
||||
await expect(
|
||||
page.locator('[data-testid="time-picker-picked"]'),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Agent's follow-up confirmation arrives next.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: /Booked.*Alice/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
// The other suggestion in the demo. Same HITL flow, different
|
||||
// attendee/topic. Pinned here so a missing/regressed aimock fixture for
|
||||
// this suggestion (or a wiring regression in this integration's
|
||||
// useHumanInTheLoop binding) fails CI loudly instead of silently falling
|
||||
// through to "the agent rambled at me with no toolCall."
|
||||
test("'Book a call with sales' suggestion runs the HITL flow end-to-end", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(
|
||||
"Please book an intro call with the sales team to discuss pricing.",
|
||||
);
|
||||
await input.press("Enter");
|
||||
|
||||
const card = page.locator('[data-testid="time-picker-card"]');
|
||||
await expect(card).toBeVisible({ timeout: 60000 });
|
||||
await expect(card.getByText(/Sales team/i)).toBeVisible();
|
||||
|
||||
const slot = page.locator('[data-testid="time-picker-slot"]').first();
|
||||
await slot.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="time-picker-picked"]'),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: /Booked.*sales team/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
// Regression: the second booking flow in a single chat session used to
|
||||
// skip the picker entirely and jump straight to "Booked ..." text. Cause
|
||||
// was the aimock confirmation fixture being keyed on `hasToolResult: true`
|
||||
// — once the first flow finished, the conversation had a tool message in
|
||||
// history, so on the second user message the confirmation fixture matched
|
||||
// before the toolCall fixture (which required `hasToolResult: false`) had
|
||||
// a chance to fire. Fix re-keyed confirmation fixtures on `toolCallId`
|
||||
// (matches only when the LAST message is a tool result with that id) and
|
||||
// dropped the `hasToolResult: false` constraint on the toolCall fixtures.
|
||||
// This test explicitly walks both flows in one page session — if the
|
||||
// multi-flow regression returns, the second time-picker never renders and
|
||||
// this test fails at step 2.
|
||||
test("both suggestions render the picker when run back-to-back without refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
const card = page.locator('[data-testid="time-picker-card"]');
|
||||
|
||||
// Flow 1: Alice
|
||||
await input.fill("Schedule a 1:1 with Alice next week to review Q2 goals.");
|
||||
await input.press("Enter");
|
||||
await expect(card.first()).toBeVisible({ timeout: 60000 });
|
||||
await page.locator('[data-testid="time-picker-slot"]').first().click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: /Booked.*Alice/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Let the runtime fully settle after the HITL resolution before
|
||||
// starting a second flow. A short timed wait is more reliable
|
||||
// than networkidle (which can resolve before LangGraph finalises
|
||||
// thread state) and avoids a stale-state race on the next run.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Flow 2: sales — same page, no refresh.
|
||||
await input.fill(
|
||||
"Please book an intro call with the sales team to discuss pricing.",
|
||||
);
|
||||
await input.press("Enter");
|
||||
|
||||
// A SECOND picker card must appear. The first card transitioned to
|
||||
// `time-picker-picked` after Flow 1, so only the new one carries the
|
||||
// `time-picker-card` testid.
|
||||
await expect(card).toHaveCount(1, { timeout: 60000 });
|
||||
await expect(card.first().getByText(/Sales team/i)).toBeVisible();
|
||||
|
||||
// Pick a slot in the new card and verify the sales-specific
|
||||
// confirmation arrives.
|
||||
await page.locator('[data-testid="time-picker-slot"]').last().click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.filter({ hasText: /Booked.*sales team/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/mcp-apps.md
|
||||
// Demo source: src/app/demos/mcp-apps/page.tsx
|
||||
// Backend: src/agents/mcp_apps_agent.py
|
||||
// Runtime: src/app/api/copilotkit-mcp-apps/route.ts (mcpApps.servers wires
|
||||
// the public Excalidraw MCP app at https://mcp.excalidraw.com, pinned
|
||||
// serverId: "excalidraw").
|
||||
//
|
||||
// Pattern: MCP server-driven UI via ACTIVITY RENDERERS. The runtime
|
||||
// middleware fetches the UI resource associated with the `create_view`
|
||||
// MCP tool call and emits an activity event; CopilotKit's built-in
|
||||
// `MCPAppsActivityRenderer` auto-registers for the `mcp-apps` activity
|
||||
// type (per `@region[no-frontend-renderer-needed]` comment on page.tsx)
|
||||
// and paints a sandboxed <iframe> inline in the chat transcript.
|
||||
//
|
||||
// The app registers NO custom activity renderer and NO data-testid. Our
|
||||
// render-signal is the sandboxed <iframe> element itself — the built-in
|
||||
// renderer always sets the `sandbox` attribute. The iframe payload
|
||||
// (the actual Excalidraw drawing) is rendered inside a cross-origin
|
||||
// frame and is not introspectable from Playwright's page context, so we
|
||||
// only assert presence + the sandbox contract.
|
||||
//
|
||||
// W8-9: the MCP round-trip (agent → create_view → server-side resource
|
||||
// fetch → activity event) is the slowest flow in the suite on Railway,
|
||||
// regularly sitting above 60s. The end-to-end tool-driven test uses a
|
||||
// 90s budget and is kept un-skipped; the deterministic draw-prompt
|
||||
// version is covered by the suggestion pill flow.
|
||||
|
||||
test.describe("MCP Apps (Excalidraw activity iframe)", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/mcp-apps");
|
||||
});
|
||||
|
||||
test("page loads with chat input and no activity iframe rendered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
// No sandboxed iframe on first paint.
|
||||
await expect(page.locator("iframe[sandbox]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("both suggestion pills render with verbatim titles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Draw a flowchart" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Sketch a system diagram" }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
// SKIP: on Railway the MCP round-trip (agent -> create_view ->
|
||||
// server-side resource fetch -> activity event -> iframe render)
|
||||
// regularly sits above 90s and intermittently fails to paint an
|
||||
// iframe at all when the Excalidraw MCP server is slow. See W8-9.
|
||||
// Un-skip when the MCP Apps middleware / Excalidraw upstream
|
||||
// stabilises on Railway.
|
||||
test.skip("Draw-a-flowchart pill renders a sandboxed activity iframe", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
await suggestions.filter({ hasText: "Draw a flowchart" }).first().click();
|
||||
|
||||
// The built-in MCPAppsActivityRenderer always sets the `sandbox`
|
||||
// attribute on the UI-resource iframe (that is the load-bearing
|
||||
// renderer contract — no renderer can skip it).
|
||||
const iframe = page.locator("iframe[sandbox]").first();
|
||||
await expect(iframe).toBeVisible({ timeout: 90_000 });
|
||||
});
|
||||
|
||||
// SKIP: same root cause as the flowchart flow — the typed-prompt
|
||||
// variant also depends on the MCP round-trip. See W8-9.
|
||||
test.skip("explicit create_view prompt renders a sandboxed iframe", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Typed prompt (not suggestion pill) — the QA doc calls this out as
|
||||
// the canonical end-to-end MCP interaction: single `create_view`
|
||||
// call with 3 elements.
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(
|
||||
"Use Excalidraw to draw exactly 2 rectangles labelled 'A' and 'B' connected by one arrow from A to B.",
|
||||
);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const iframe = page.locator("iframe[sandbox]").first();
|
||||
await expect(iframe).toBeVisible({ timeout: 90_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E spec for the Multimodal Attachments demo.
|
||||
*
|
||||
* Exercises the sample-file injection path only — the real OS file
|
||||
* picker isn't automatable through Playwright without a flakey host-
|
||||
* file dependency. The sample buttons go through the same V2 agent
|
||||
* surface (`agent.addMessage` + `copilotkit.runAgent`) that the
|
||||
* paperclip path ultimately feeds, so this suite covers the render +
|
||||
* round-trip path end-to-end.
|
||||
*
|
||||
* Behavior pinned by this suite (each was an actual regression caught
|
||||
* during the multimodal rewrite — see headers in
|
||||
* sample-attachment-buttons.tsx and legacy-converter-shim.tsx):
|
||||
*
|
||||
* 1. Clicking a sample button auto-sends the canned prompt — no manual
|
||||
* fill / send required. Earlier the DataTransfer-into-file-input
|
||||
* approach raced the upload state, the send got rejected, and the
|
||||
* prompt got eaten.
|
||||
* 2. The user message keeps showing exactly ONE attachment chip after
|
||||
* the assistant response arrives. The @ag-ui/langgraph round-trip
|
||||
* used to duplicate the attachment (modern + re-converted shape) so
|
||||
* the user saw two thumbnails for one file.
|
||||
* 3. Sample-PDF specifically renders as a `DocumentAttachment` chip,
|
||||
* NOT as an `<img>` that errors with "Failed to load image". Earlier
|
||||
* the round-trip mis-tagged PDFs with `type: "image"` and forced the
|
||||
* image renderer.
|
||||
* 4. The PDF flattened text doesn't bleed into the rendered user
|
||||
* message. Earlier `_PdfFlattenMiddleware` ran in `before_model`
|
||||
* and persisted the flattened "[Attached document]\n<pdf body>"
|
||||
* into state, which the chat UI rendered inline.
|
||||
* 5. Clicking image then PDF in the same session (and vice versa)
|
||||
* leaves each user message with its own single, correct chip — no
|
||||
* cross-contamination, no doubling.
|
||||
*
|
||||
* Assumes the demo is reachable at BASE_URL/demos/multimodal and
|
||||
* aimock is configured with the canned prompts:
|
||||
* - "can you tell me what is in this demo image I just attached"
|
||||
* - "can you tell me what is in this demo pdf I just attached"
|
||||
* Both fixtures live in showcase/aimock/feature-parity.json.
|
||||
*/
|
||||
|
||||
const ROUTE = "/demos/multimodal";
|
||||
|
||||
const SAMPLE_IMAGE_BTN = '[data-testid="multimodal-sample-image-button"]';
|
||||
const SAMPLE_PDF_BTN = '[data-testid="multimodal-sample-pdf-button"]';
|
||||
const CHAT_TEXTAREA = '[data-testid="copilot-chat-textarea"]';
|
||||
const ADD_MENU_BUTTON = '[data-testid="copilot-add-menu-button"]';
|
||||
const USER_MESSAGE = '[data-testid="copilot-user-message"]';
|
||||
const ASSISTANT_MESSAGE = '[data-testid="copilot-assistant-message"]';
|
||||
|
||||
// The canned auto-prompts live in sample-attachment-buttons.tsx — kept in
|
||||
// sync here so the user-message bubble assertion stays accurate.
|
||||
const IMAGE_PROMPT =
|
||||
"can you tell me what is in this demo image I just attached";
|
||||
const PDF_PROMPT = "can you tell me what is in this demo pdf I just attached";
|
||||
|
||||
async function openDemo(page: Page) {
|
||||
await page.goto(ROUTE);
|
||||
await expect(
|
||||
page.locator('[data-testid="multimodal-demo-root"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the sample-injection flow finishes for the nth user message:
|
||||
* the user bubble shows up, then the matching assistant response. Returns
|
||||
* both locators so the caller can probe for chips and dedup invariants.
|
||||
*/
|
||||
async function waitForRoundTrip(
|
||||
page: Page,
|
||||
index = 0,
|
||||
): Promise<{
|
||||
userMsg: ReturnType<Page["locator"]>;
|
||||
asstMsg: ReturnType<Page["locator"]>;
|
||||
}> {
|
||||
const userMsg = page.locator(USER_MESSAGE).nth(index);
|
||||
await expect(userMsg).toBeVisible({ timeout: 60_000 });
|
||||
const asstMsg = page.locator(ASSISTANT_MESSAGE).nth(index);
|
||||
await expect(asstMsg).toBeVisible({ timeout: 90_000 });
|
||||
return { userMsg, asstMsg };
|
||||
}
|
||||
|
||||
test.describe("Multimodal Attachments", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openDemo(page);
|
||||
});
|
||||
|
||||
test("page loads with sample row, sample buttons, composer, and paperclip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="multimodal-sample-row"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(SAMPLE_IMAGE_BTN)).toBeEnabled();
|
||||
await expect(page.locator(SAMPLE_PDF_BTN)).toBeEnabled();
|
||||
await expect(page.locator(CHAT_TEXTAREA)).toBeVisible();
|
||||
await expect(page.locator(ADD_MENU_BUTTON)).toBeVisible();
|
||||
});
|
||||
|
||||
test("sample image: auto-sends, user msg shows EXACTLY ONE image, assistant references it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator(SAMPLE_IMAGE_BTN).click();
|
||||
const { userMsg, asstMsg } = await waitForRoundTrip(page);
|
||||
|
||||
// Auto-prompt landed as the user message body — confirms the
|
||||
// useAgent / runAgent path fired and the prompt wasn't eaten.
|
||||
await expect(userMsg).toContainText(IMAGE_PROMPT);
|
||||
|
||||
// Exactly ONE rendered image — pin the dedupe + normalize behavior
|
||||
// so the @ag-ui/langgraph round-trip can't quietly start doubling
|
||||
// attachments again.
|
||||
await expect(userMsg.locator("img")).toHaveCount(1);
|
||||
await expect(userMsg.getByText(/Failed to load image/i)).toHaveCount(0);
|
||||
|
||||
await expect(asstMsg).toContainText(/copilotkit|logo|image/i);
|
||||
});
|
||||
|
||||
test("sample PDF: auto-sends, user msg shows EXACTLY ONE document chip (not a broken image)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator(SAMPLE_PDF_BTN).click();
|
||||
const { userMsg, asstMsg } = await waitForRoundTrip(page);
|
||||
|
||||
await expect(userMsg).toContainText(PDF_PROMPT);
|
||||
|
||||
// Pinned regression: PDFs round-tripped through @ag-ui/langgraph
|
||||
// used to come back as `type: "image"` with `mimeType:
|
||||
// application/pdf`, forcing the image renderer to fail load and
|
||||
// show "Failed to load image" twice. The page-level dedupe + type-
|
||||
// normalize subscriber turns them back into `document` parts so
|
||||
// the icon-+-filename renderer fires exactly once.
|
||||
await expect(userMsg.getByText(/Failed to load image/i)).toHaveCount(0);
|
||||
await expect(userMsg.locator("img")).toHaveCount(0);
|
||||
// DocumentAttachment surfaces a "PDF" label via getDocumentIcon.
|
||||
await expect(userMsg.getByText(/^PDF$/)).toBeVisible();
|
||||
|
||||
// Pinned regression: the PDF flattened text used to bleed into the
|
||||
// rendered user message when the Python middleware mutated state
|
||||
// via `before_model`. Switching to `wrap_model_call` scopes the
|
||||
// rewrite to the model request only — the bracket-tagged dump must
|
||||
// never appear in the UI bubble.
|
||||
await expect(userMsg).not.toContainText("[Attached document]");
|
||||
|
||||
await expect(asstMsg).toContainText(/copilotkit/i);
|
||||
});
|
||||
|
||||
test("image then PDF in same session: each message keeps its own chip, no doubling", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator(SAMPLE_IMAGE_BTN).click();
|
||||
const first = await waitForRoundTrip(page, 0);
|
||||
await expect(first.userMsg.locator("img")).toHaveCount(1);
|
||||
|
||||
await page.locator(SAMPLE_PDF_BTN).click();
|
||||
const second = await waitForRoundTrip(page, 1);
|
||||
|
||||
// First message still shows exactly one image — the second send
|
||||
// must not re-render or double the prior attachment.
|
||||
await expect(first.userMsg.locator("img")).toHaveCount(1);
|
||||
|
||||
// Second message is a clean PDF chip.
|
||||
await expect(second.userMsg.locator("img")).toHaveCount(0);
|
||||
await expect(second.userMsg.getByText(/Failed to load image/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(second.userMsg.getByText(/^PDF$/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("PDF then image in same session: each message keeps its own chip, no doubling", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator(SAMPLE_PDF_BTN).click();
|
||||
const first = await waitForRoundTrip(page, 0);
|
||||
await expect(first.userMsg.locator("img")).toHaveCount(0);
|
||||
await expect(first.userMsg.getByText(/^PDF$/)).toBeVisible();
|
||||
|
||||
await page.locator(SAMPLE_IMAGE_BTN).click();
|
||||
const second = await waitForRoundTrip(page, 1);
|
||||
|
||||
// First message still shows the PDF chip — no contamination.
|
||||
await expect(first.userMsg.getByText(/^PDF$/)).toBeVisible();
|
||||
await expect(first.userMsg.locator("img")).toHaveCount(0);
|
||||
|
||||
// Second message has exactly one image, no broken-image fallback.
|
||||
await expect(second.userMsg.locator("img")).toHaveCount(1);
|
||||
await expect(second.userMsg.getByText(/Failed to load image/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/open-gen-ui-advanced.md
|
||||
// Demo source: src/app/demos/open-gen-ui-advanced/{page.tsx,
|
||||
// sandbox-functions.ts, suggestions.ts}
|
||||
// Aimock fixture: showcase/aimock/d5-all.json (entries keyed by the
|
||||
// verbatim suggestion `message` strings)
|
||||
//
|
||||
// Advanced Open-Gen-UI: each prompt triggers a deterministic
|
||||
// `generateSandboxedUi` tool call whose `jsFunctions` wires in-iframe
|
||||
// click handlers to the two host-side sandbox functions registered in
|
||||
// `sandbox-functions.ts` via `Websandbox.connection.remote.*`.
|
||||
//
|
||||
// Two layers of assertions:
|
||||
// 1. SMOKE — each prompt produces an iframe with a non-empty `srcdoc`.
|
||||
// Confirms the open-gen-ui pipeline mounted SOMETHING.
|
||||
// 2. ROUND-TRIP — driving the iframe's buttons fires the host-side
|
||||
// handler (observed via the handler's `console.log`) AND updates
|
||||
// the iframe's output element with the host response. Confirms
|
||||
// the fixture's `jsFunctions` is wired correctly. Catches the
|
||||
// regression where a fixture ships HTML+CSS only and every button
|
||||
// becomes a silent no-op.
|
||||
//
|
||||
// Iframe driving works because the fixture HTML is deterministic
|
||||
// (stable `#hi`, `#in`, `#go`, `#eq`, `#d`, `#out` selectors) and
|
||||
// Playwright `frameLocator` can interact with `sandbox="allow-scripts"`
|
||||
// iframes via CDP regardless of the null origin.
|
||||
//
|
||||
// Composer drive note: we send the prompt by filling the textarea and
|
||||
// pressing Enter rather than clicking a suggestion pill. The demo has
|
||||
// two chip surfaces (EmptyState vs SuggestionBar) whose mount depends
|
||||
// on `messages.length`, which makes pill clicks timing-flaky — see
|
||||
// commit 15db0bbf3 (gen-ui-headless-complete: drive via textarea, not
|
||||
// chip click). Chip-click and textarea-Enter dispatch the same
|
||||
// `runAgent`, so the fixture matcher catches either route and the
|
||||
// textarea is always present.
|
||||
//
|
||||
// Aimock priority note: each pill `message` string is a verbatim short
|
||||
// label that matches a high-priority entry in `d5-all.json`. d5-all.json
|
||||
// loads BEFORE feature-parity.json, so its first-match-wins ordering
|
||||
// beats the broad `userMessage: "hi"` catch-all in feature-parity.json
|
||||
// that would otherwise return the showcase-assistant boilerplate
|
||||
// greeting. Keep pill message strings in `suggestions.ts` aligned with
|
||||
// the fixture keys.
|
||||
|
||||
// Verbatim `message` values from src/app/demos/open-gen-ui-advanced/suggestions.ts.
|
||||
const PROMPTS = {
|
||||
calculator: "Calculator (calls evaluateExpression)",
|
||||
ping: "Ping the host (calls notifyHost)",
|
||||
inlineEval: "Inline expression evaluator",
|
||||
};
|
||||
|
||||
test.describe("Open Generative UI (advanced)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/open-gen-ui-advanced");
|
||||
// Wait for React hydration: without this the textarea's keydown
|
||||
// handler may not yet be attached when fill+Enter runs, so the
|
||||
// Enter press becomes a no-op and the iframe never mounts.
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 15_000 })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
test("page loads with chat composer and 3 suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Suggestion titles are verbatim from openGenUiSuggestions.
|
||||
const expected = [
|
||||
"Calculator",
|
||||
"Ping the host",
|
||||
"Inline expression evaluator",
|
||||
];
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of expected) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sendPromptAndAwaitIframe = async (
|
||||
page: import("@playwright/test").Page,
|
||||
prompt: string,
|
||||
) => {
|
||||
// Use the stable testid (not getByPlaceholder): the chat composer's
|
||||
// textarea wires onKeyDown only after React hydration, and the
|
||||
// testid locator + an explicit click force focus + handler attach
|
||||
// before fill, preventing a flaky Enter-as-no-op race.
|
||||
const textarea = page.locator('[data-testid="copilot-chat-textarea"]');
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
await textarea.click();
|
||||
await textarea.fill(prompt);
|
||||
await textarea.press("Enter");
|
||||
|
||||
const iframe = page.locator('iframe[sandbox*="allow-scripts"]').first();
|
||||
await expect(iframe).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const srcdoc = await iframe.getAttribute("srcdoc");
|
||||
if (srcdoc && srcdoc.length > 0) return true;
|
||||
const src = await iframe.getAttribute("src");
|
||||
if (src && src.length > 0) return true;
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
test("Inline expression evaluator prompt renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.inlineEval);
|
||||
});
|
||||
|
||||
test("Calculator prompt renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.calculator);
|
||||
});
|
||||
|
||||
test("Ping the host prompt renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.ping);
|
||||
});
|
||||
|
||||
// ── Round-trip tests ────────────────────────────────────────────────
|
||||
//
|
||||
// Drive the in-iframe controls and assert the host-side sandbox-function
|
||||
// handler ran. The handlers in `sandbox-functions.ts` log to the host
|
||||
// console with a stable `[open-gen-ui/advanced]` prefix; Playwright's
|
||||
// `page.on('console')` catches those even though the call originates
|
||||
// inside the sandboxed iframe (the handler runs on the host page).
|
||||
|
||||
const captureHostHandlerLogs = (
|
||||
page: import("@playwright/test").Page,
|
||||
needle: string,
|
||||
): string[] => {
|
||||
const logs: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
if (text.includes("[open-gen-ui/advanced]") && text.includes(needle)) {
|
||||
logs.push(text);
|
||||
}
|
||||
});
|
||||
return logs;
|
||||
};
|
||||
|
||||
test("Ping the host prompt: in-iframe click fires host notifyHost handler", async ({
|
||||
page,
|
||||
}) => {
|
||||
const hostLogs = captureHostHandlerLogs(page, "notifyHost");
|
||||
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.ping);
|
||||
|
||||
const frame = page.frameLocator('iframe[sandbox*="allow-scripts"]').first();
|
||||
await frame.locator("#hi").click();
|
||||
|
||||
await expect
|
||||
.poll(() => hostLogs.length, { timeout: 30_000 })
|
||||
.toBeGreaterThan(0);
|
||||
expect(hostLogs.some((l) => l.includes("Hello from sandbox"))).toBe(true);
|
||||
|
||||
// Iframe output element must reflect the host's `receivedAt` reply.
|
||||
await expect(frame.locator("#out")).toContainText("host replied at", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Inline expression evaluator prompt: typing + clicking Evaluate fires host evaluateExpression handler", async ({
|
||||
page,
|
||||
}) => {
|
||||
const hostLogs = captureHostHandlerLogs(page, "evaluateExpression");
|
||||
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.inlineEval);
|
||||
|
||||
const frame = page.frameLocator('iframe[sandbox*="allow-scripts"]').first();
|
||||
// `fill()` silently no-ops inside sandbox="allow-scripts" iframes on
|
||||
// some Playwright/Chromium combos (null origin blocks the set-value
|
||||
// protocol message). `pressSequentially` sends individual key events
|
||||
// that always reach the input regardless of sandbox restrictions.
|
||||
const input = frame.locator("#in");
|
||||
await input.click();
|
||||
await input.pressSequentially("2+2");
|
||||
await frame.locator("#go").click();
|
||||
|
||||
await expect
|
||||
.poll(() => hostLogs.length, { timeout: 30_000 })
|
||||
.toBeGreaterThan(0);
|
||||
// Handler logs `evaluateExpression <expr> = <value>` — verify the value.
|
||||
expect(hostLogs.some((l) => /=\s*4\b/.test(l))).toBe(true);
|
||||
|
||||
await expect(frame.locator("#out")).toContainText("= 4", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Calculator prompt: digit + operator + '=' fires host evaluateExpression handler", async ({
|
||||
page,
|
||||
}) => {
|
||||
const hostLogs = captureHostHandlerLogs(page, "evaluateExpression");
|
||||
|
||||
await sendPromptAndAwaitIframe(page, PROMPTS.calculator);
|
||||
|
||||
const frame = page.frameLocator('iframe[sandbox*="allow-scripts"]').first();
|
||||
// Build `2+3` then evaluate. Each digit/operator is a separate
|
||||
// <button>; `=` has id="eq".
|
||||
await frame.getByRole("button", { name: "2", exact: true }).click();
|
||||
await frame.getByRole("button", { name: "+", exact: true }).click();
|
||||
await frame.getByRole("button", { name: "3", exact: true }).click();
|
||||
await frame.locator("#eq").click();
|
||||
|
||||
await expect
|
||||
.poll(() => hostLogs.length, { timeout: 30_000 })
|
||||
.toBeGreaterThan(0);
|
||||
expect(hostLogs.some((l) => /=\s*5\b/.test(l))).toBe(true);
|
||||
|
||||
await expect(frame.locator("#d")).toHaveText("5", { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/open-gen-ui.md
|
||||
// Demo source: src/app/demos/open-gen-ui/{page.tsx,suggestions.ts}
|
||||
// Aimock fixture: showcase/harness/fixtures/d5/gen-ui-open.json
|
||||
// (bundled into showcase/aimock/d5-all.json)
|
||||
//
|
||||
// Open-Ended Generative UI (minimal). The agent streams a single
|
||||
// `generateSandboxedUi` tool call; the runtime middleware at
|
||||
// `/api/copilotkit-ogui` converts the stream into `open-generative-ui`
|
||||
// activity events which the built-in `OpenGenerativeUIActivityRenderer`
|
||||
// mounts inside a sandboxed `<iframe sandbox="allow-scripts">`.
|
||||
//
|
||||
// Assertion bar: iframe presence + non-empty `srcdoc` (or `src`). We do
|
||||
// NOT introspect iframe DOM via `contentFrame()` — sandbox="allow-scripts"
|
||||
// without `allow-same-origin` blocks cross-origin frame access from the
|
||||
// host, and the inner authored HTML varies between runs. "Renders
|
||||
// something" means the host successfully populated an iframe; whether
|
||||
// the inner HTML is correct is out of scope here.
|
||||
//
|
||||
// Aimock priority note: each pill `message` string is a verbatim short
|
||||
// label that matches a high-priority entry in `d5-all.json`. d5-all.json
|
||||
// loads BEFORE feature-parity.json, so its first-match-wins ordering
|
||||
// beats the broad `userMessage: "hi"` catch-all in feature-parity.json
|
||||
// that would otherwise return the showcase-assistant boilerplate
|
||||
// greeting (and never emit the open-gen-ui tool call). Keep the pill
|
||||
// message strings in `suggestions.ts` aligned with the fixture keys.
|
||||
|
||||
test.describe("Open Generative UI (minimal)", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/open-gen-ui");
|
||||
});
|
||||
|
||||
test("page loads with chat composer and 4 suggestion pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Suggestion titles are verbatim from minimalSuggestions in suggestions.ts.
|
||||
const expected = [
|
||||
"3D axis visualization",
|
||||
"How a neural network works",
|
||||
"Quicksort visualization",
|
||||
"Fourier: square wave from sines",
|
||||
];
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of expected) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: click a pill and assert that a sandboxed iframe mounts with
|
||||
// a non-empty source attribute. `srcdoc` is the expected attribute
|
||||
// (the renderer composes the HTML+CSS into srcdoc); fall back to `src`
|
||||
// for completeness.
|
||||
const assertPillRendersIframe = async (
|
||||
page: import("@playwright/test").Page,
|
||||
pillTitle: string,
|
||||
) => {
|
||||
const suggestion = page
|
||||
.locator('[data-testid="copilot-suggestion"]', { hasText: pillTitle })
|
||||
.first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 15_000 });
|
||||
await suggestion.click();
|
||||
|
||||
const iframe = page.locator('iframe[sandbox*="allow-scripts"]').first();
|
||||
await expect(iframe).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Either srcdoc (preferred — the renderer composes HTML into srcdoc)
|
||||
// or src must be present and non-empty.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const srcdoc = await iframe.getAttribute("srcdoc");
|
||||
if (srcdoc && srcdoc.length > 0) return true;
|
||||
const src = await iframe.getAttribute("src");
|
||||
if (src && src.length > 0) return true;
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
test("Fourier pill renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertPillRendersIframe(page, "Fourier: square wave from sines");
|
||||
});
|
||||
|
||||
test("3D axis pill renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertPillRendersIframe(page, "3D axis visualization");
|
||||
});
|
||||
|
||||
test("Neural network pill renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertPillRendersIframe(page, "How a neural network works");
|
||||
});
|
||||
|
||||
test("Quicksort pill renders a sandboxed iframe with non-empty source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertPillRendersIframe(page, "Quicksort visualization");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Pre-Built Popup", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/prebuilt-popup");
|
||||
});
|
||||
|
||||
test("page loads with heading and the popup open by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Verbatim heading from the demo source confirms the route mounted.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Popup demo" }),
|
||||
).toBeVisible();
|
||||
|
||||
// defaultOpen={true} means the popup window is open on first paint. The
|
||||
// demo sets a custom placeholder via labels.chatInputPlaceholder — we
|
||||
// assert on that literal string to prove the popup rendered AND its
|
||||
// labels override took effect.
|
||||
await expect(
|
||||
page.getByPlaceholder("Ask the popup anything..."),
|
||||
).toBeVisible();
|
||||
|
||||
// The floating launcher/toggle is present on the page.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-chat-toggle"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('"Say hi" suggestion pill renders and produces an assistant response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// useConfigureSuggestions registers "Say hi" with available: "always".
|
||||
const sayHiPill = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Say hi" })
|
||||
.first();
|
||||
await expect(sayHiPill).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await sayHiPill.click();
|
||||
|
||||
// Pill sends "Say hi from the popup!" — neutral agent replies with text.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 45000 });
|
||||
});
|
||||
|
||||
test("typing a message and clicking send produces an assistant response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Ask the popup anything...");
|
||||
await input.fill("Hello");
|
||||
|
||||
// Click the send button directly — Enter-on-textarea was intermittently
|
||||
// dropping the submit on this deployment. The send-button testid is the
|
||||
// stable per-chat-input affordance that always triggers the submit
|
||||
// handler.
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
// Assistant responds — neutral agent, no tools.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 45000 });
|
||||
});
|
||||
|
||||
test("popup close button unmounts the popup; launcher re-mounts it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const popup = page.locator('[data-testid="copilot-popup"]');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
// CopilotPopupView unmounts its content when closed (tracked by its
|
||||
// internal isRendered state), so the most reliable close signal is the
|
||||
// popup's own testid disappearing from the DOM. The dev-only
|
||||
// <cpk-web-inspector> overlay (auto-enabled on localhost) intercepts
|
||||
// Playwright's pointer-based click, so use a JS-level .click() that
|
||||
// bypasses the overlay (same pattern as _genuine-shared.ts:clickByJs).
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'[data-testid="copilot-close-button"]',
|
||||
);
|
||||
if (btn) (btn as HTMLElement).click();
|
||||
});
|
||||
await expect(popup).toBeHidden({ timeout: 10000 });
|
||||
|
||||
// Floating launcher remains on the page and re-mounts the popup.
|
||||
await page.locator('[data-testid="copilot-chat-toggle"]').first().click();
|
||||
await expect(popup).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// URL unchanged — toggling is pure client-side state.
|
||||
await expect(page).toHaveURL(/\/demos\/prebuilt-popup$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Pre-Built Sidebar", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/prebuilt-sidebar");
|
||||
});
|
||||
|
||||
test("page loads with heading, main content, and sidebar open by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Main content heading is verbatim from the demo source and confirms the
|
||||
// route mounted the expected page.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Sidebar demo" }),
|
||||
).toBeVisible();
|
||||
|
||||
// defaultOpen={true} means the sidebar's chat input is mounted and
|
||||
// visible on first paint.
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
|
||||
// The sidebar ships its own toggle button with a dedicated testid.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-chat-toggle"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('"Say hi" suggestion pill renders and sends on click', async ({
|
||||
page,
|
||||
}) => {
|
||||
// useConfigureSuggestions registers a single "Say hi" pill with
|
||||
// available: "always", so it should render inside the sidebar on load.
|
||||
const sayHiPill = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Say hi" })
|
||||
.first();
|
||||
await expect(sayHiPill).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await sayHiPill.click();
|
||||
|
||||
// The pill sends "Say hi!". We assert on the assistant message testid
|
||||
// rather than response text since this demo has no frontend tools — the
|
||||
// round-trip signal is simply "an assistant bubble appeared".
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 45000 });
|
||||
});
|
||||
|
||||
test("typing a message and clicking send produces an assistant response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Hello");
|
||||
|
||||
// Click the send button directly — using Enter on the textarea was
|
||||
// intermittently dropping the submit on this deployment (the welcome
|
||||
// screen stayed mounted with the text still in the box), so we use the
|
||||
// stable send-button testid the v2 CopilotChatInput exposes.
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
// Assistant responds — the neutral agent just chats, no tools.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 45000 });
|
||||
});
|
||||
|
||||
test("sidebar close toggles aria-hidden and the launcher re-opens it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sidebar = page.locator('[data-testid="copilot-sidebar"]');
|
||||
|
||||
// Opens with aria-hidden="false" because of defaultOpen={true}.
|
||||
await expect(sidebar).toHaveAttribute("aria-hidden", "false");
|
||||
|
||||
// CopilotSidebar renders its own close button in the modal header.
|
||||
// Clicking the external toggle doesn't work while the sidebar is open
|
||||
// (it intercepts pointer events on this viewport width), so we close
|
||||
// from within. The dev-only <cpk-web-inspector> overlay (auto-enabled
|
||||
// on localhost) intercepts Playwright's pointer-based click, so use a
|
||||
// JS-level .click() that bypasses the overlay (same pattern as the
|
||||
// harness probes in _genuine-shared.ts:clickByJs).
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'[data-testid="copilot-close-button"]',
|
||||
);
|
||||
if (btn) (btn as HTMLElement).click();
|
||||
});
|
||||
|
||||
// The sidebar slides out via CSS transform but stays mounted. The
|
||||
// authoritative open/closed signal is the aria-hidden attribute the
|
||||
// component writes based on its isModalOpen state.
|
||||
await expect(sidebar).toHaveAttribute("aria-hidden", "true", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Re-open via the floating toggle button, now uncovered.
|
||||
await page.locator('[data-testid="copilot-chat-toggle"]').first().click();
|
||||
await expect(sidebar).toHaveAttribute("aria-hidden", "false", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// URL unchanged — toggling is pure client-side state.
|
||||
await expect(page).toHaveURL(/\/demos\/prebuilt-sidebar$/);
|
||||
});
|
||||
});
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/readonly-state-agent-context.md
|
||||
// Demo source: src/app/demos/readonly-state-agent-context/page.tsx
|
||||
//
|
||||
// The demo publishes three frontend-only values to the agent via
|
||||
// `useAgentContext`: `userName` (default "Atai"), `userTimezone`
|
||||
// (default "America/Los_Angeles"), and `recentActivity` (defaults to
|
||||
// ACTIVITIES[0] "Viewed the pricing page" + ACTIVITIES[2] "Watched the
|
||||
// product demo video"). Suggestion pills render verbatim message bodies:
|
||||
// - "Who am I?" → "What do you know about me from my context?"
|
||||
// - "Suggest next steps" → "Based on my recent activity, what should I try next?"
|
||||
// Both prompts are pinned to deterministic aimock fixtures (see
|
||||
// showcase/aimock/d5-all.json) so the assistant's leading phrase is
|
||||
// stable in CI. On Railway, the same prompts produce a real LLM reply
|
||||
// that mentions the published context fields — proving end-to-end
|
||||
// `useAgentContext` wiring.
|
||||
|
||||
test.describe("Readonly Agent Context (useAgentContext)", () => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/readonly-state-agent-context");
|
||||
});
|
||||
|
||||
test("page loads: context-card + composer render", async ({ page }) => {
|
||||
await expect(page.getByTestId("context-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByPlaceholder("Ask about your context..."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("editing name + timezone updates the published JSON preview", async ({
|
||||
page,
|
||||
}) => {
|
||||
const json = page.getByTestId("ctx-state-json");
|
||||
|
||||
// Edit name → "Jamie".
|
||||
await page.getByTestId("ctx-name").fill("Jamie");
|
||||
await expect(json).toContainText('"name": "Jamie"');
|
||||
|
||||
// Change timezone via <select>.
|
||||
await page.getByTestId("ctx-timezone").selectOption("Asia/Tokyo");
|
||||
await expect(json).toContainText('"timezone": "Asia/Tokyo"');
|
||||
});
|
||||
|
||||
test('"Who am I?" pill — assistant acknowledges identity + identity card matches defaults', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Identity card shows the defaults BEFORE we click the pill — these
|
||||
// assertions don't depend on the round-trip and lock the testids.
|
||||
await expect(page.getByTestId("identity-name")).toHaveText("Atai");
|
||||
await expect(page.getByTestId("identity-timezone")).toHaveText(
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
await expect(page.getByTestId("identity-avatar")).toHaveText("A");
|
||||
|
||||
const suggestion = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Who am I?" });
|
||||
await expect(suggestion.first()).toBeVisible({ timeout: 15_000 });
|
||||
await suggestion.first().click();
|
||||
|
||||
// Aimock fixture for the verbatim pill prompt
|
||||
// ("What do you know about me from my context?") returns a content reply
|
||||
// beginning with "I see you're Atai".
|
||||
const assistant = page.locator('[data-testid="copilot-assistant-message"]');
|
||||
await expect(
|
||||
assistant.filter({ hasText: "I see you're Atai" }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
test("activity checkboxes default-checked: pricing page + product demo video", async ({
|
||||
page,
|
||||
}) => {
|
||||
// ACTIVITIES[0] and ACTIVITIES[2] are the default-selected entries in
|
||||
// page.tsx. Their <label> testids embed the kebab-cased activity name.
|
||||
const pricingLabel = page.getByTestId("activity-viewed-the-pricing-page");
|
||||
const demoLabel = page.getByTestId(
|
||||
"activity-watched-the-product-demo-video",
|
||||
);
|
||||
await expect(pricingLabel).toBeVisible();
|
||||
await expect(demoLabel).toBeVisible();
|
||||
|
||||
// Each <label> wraps a <Checkbox> whose `checked` prop reflects
|
||||
// selection. Assert the underlying input is checked.
|
||||
await expect(pricingLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||
await expect(demoLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||
});
|
||||
|
||||
test('"Suggest next steps" pill — assistant grounds reply in default activities', async ({
|
||||
page,
|
||||
}) => {
|
||||
const suggestion = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Suggest next steps" });
|
||||
await expect(suggestion.first()).toBeVisible({ timeout: 15_000 });
|
||||
await suggestion.first().click();
|
||||
|
||||
// Aimock fixture for the verbatim pill prompt
|
||||
// ("Based on my recent activity, what should I try next?") returns a
|
||||
// content reply beginning with "Since you recently viewed the pricing
|
||||
// page and watched the product demo video".
|
||||
const assistant = page.locator('[data-testid="copilot-assistant-message"]');
|
||||
await expect(
|
||||
assistant
|
||||
.filter({
|
||||
hasText:
|
||||
"Since you recently viewed the pricing page and watched the product demo video",
|
||||
})
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/reasoning-custom.md
|
||||
// Demo source: src/app/demos/reasoning-custom/{page.tsx, reasoning-block.tsx}
|
||||
//
|
||||
// The demo mounts a custom `reasoningMessage` slot (`ReasoningBlock`) that
|
||||
// renders an amber banner with `data-testid="reasoning-block"`. The label
|
||||
// inside the banner reads "Thinking…" while the agent is streaming, then
|
||||
// flips to "Agent reasoning" once streaming settles. The backend uses
|
||||
// `deepagents.create_deep_agent` with a reasoning-capable OpenAI model
|
||||
// (`gpt-5-mini` by default, override via `OPENAI_REASONING_MODEL`) routed
|
||||
// through the Responses API so the model's chain of thought streams as
|
||||
// AG-UI REASONING_MESSAGE_* events.
|
||||
//
|
||||
// Streaming assertions exercise the aimock fixture in
|
||||
// `showcase/aimock/d5-all.json` — its `reasoning` field makes aimock emit
|
||||
// `response.reasoning_summary_text.delta` events deterministically, no
|
||||
// real LLM call required. Local stack: a "Show reasoning" suggestion pill
|
||||
// fires the same prompt, so a single click reproduces the streaming UX.
|
||||
//
|
||||
// Selectors are testid / role / stable text only. No LLM-text assertions.
|
||||
|
||||
const REASONING_PROMPT = "show your reasoning step by step";
|
||||
|
||||
test.describe("Reasoning: Custom", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/reasoning-custom");
|
||||
});
|
||||
|
||||
test("page loads with chat input", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("send button is visible alongside the input", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-send-button"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("typing a prompt and submitting does not crash the UI", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("Say hello in one short sentence.");
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
await expect(input).toHaveValue("", { timeout: 10_000 });
|
||||
});
|
||||
|
||||
// --- Reasoning-block streaming coverage --------------------------------
|
||||
|
||||
test("reasoning prompt renders a reasoning-block before the answer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(REASONING_PROMPT);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const reasoningBlock = page
|
||||
.locator('[data-testid="reasoning-block"]')
|
||||
.first();
|
||||
await expect(reasoningBlock).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
reasoningBlock.getByText("Reasoning", { exact: true }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("reasoning-block label flips from Thinking to Agent reasoning", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(REASONING_PROMPT);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const reasoningBlock = page
|
||||
.locator('[data-testid="reasoning-block"]')
|
||||
.first();
|
||||
await expect(reasoningBlock).toBeVisible({ timeout: 60_000 });
|
||||
await expect(reasoningBlock.getByText("Agent reasoning")).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("reasoning-block accumulates italic reasoning content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill(REASONING_PROMPT);
|
||||
await page.locator('[data-testid="copilot-send-button"]').first().click();
|
||||
|
||||
const reasoningBlock = page
|
||||
.locator('[data-testid="reasoning-block"]')
|
||||
.first();
|
||||
await expect(reasoningBlock).toBeVisible({ timeout: 60_000 });
|
||||
// ReasoningBlock renders content inside a div with italic class when
|
||||
// `message.content` is non-empty (see reasoning-block.tsx).
|
||||
await expect(reasoningBlock.locator(".italic")).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Show reasoning suggestion pill fires the reasoning prompt", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The page wires `useConfigureSuggestions` with a single "Show reasoning"
|
||||
// pill whose message exactly matches the aimock fixture key.
|
||||
const pill = page.getByRole("button", { name: /Show reasoning/i }).first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await pill.click();
|
||||
|
||||
const reasoningBlock = page
|
||||
.locator('[data-testid="reasoning-block"]')
|
||||
.first();
|
||||
await expect(reasoningBlock).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/reasoning-default.md
|
||||
// Demo source: src/app/demos/reasoning-default/page.tsx
|
||||
//
|
||||
// This cell does NOT override the `reasoningMessage` slot. CopilotKit's
|
||||
// built-in `CopilotChatReasoningMessage` renders the reasoning as a
|
||||
// collapsible card. The page exposes a "Show reasoning" suggestion pill
|
||||
// whose message matches the aimock fixture in showcase/aimock/d5-all.json,
|
||||
// so streaming is deterministic in CI.
|
||||
|
||||
test.describe("Reasoning: Default", () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/reasoning-default");
|
||||
});
|
||||
|
||||
test("page renders without errors", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-chat-input"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Show reasoning pill renders a reasoning-role message", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pill = page.getByRole("button", { name: /Show reasoning/i }).first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await pill.click();
|
||||
|
||||
// The cell uses CopilotKit's default `CopilotChatReasoningMessage`,
|
||||
// which doesn't emit a testid — its visible signal is the
|
||||
// streaming/complete header label ("Thinking…" while streaming,
|
||||
// "Thought for …" once complete). Asserting on either label proves
|
||||
// the reasoning collapsible mounted.
|
||||
await expect(page.getByText(/Thinking…|Thought for/i).first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Shared State (Read + Write) — Preferences + Scratch Pad demo. The page
|
||||
// publishes `agent.state.preferences` via setState, and the agent writes
|
||||
// back via its `set_notes` tool. All three pills must hit
|
||||
// shared-state-specific fixtures rather than falling through to the bare
|
||||
// "hi" / "plan" catch-alls in feature-parity.json.
|
||||
test.describe("Shared State (Read + Write)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/shared-state-read-write");
|
||||
});
|
||||
|
||||
test("preferences panel and agent scratch pad both mount", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByText("Your preferences")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(page.getByText("Agent Scratch pad")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test("starter suggestions render", async ({ page }) => {
|
||||
for (const title of ["Greet me", "Remember something", "Plan a weekend"]) {
|
||||
await expect(page.getByRole("button", { name: title })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Regression for the wrong-fixture-fallthrough bug:
|
||||
// The Greet me pill ("Say hi and introduce yourself.") was matching
|
||||
// feature-parity.json's bare `userMessage: "hi"` fixture and replying
|
||||
// with the generic showcase-assistant blurb ("Hi there! I'm your
|
||||
// showcase assistant. I can help with weather, charts, meetings…").
|
||||
// Likewise, Plan a weekend ("Suggest a weekend plan based on my
|
||||
// interests.") was matching the bare `userMessage: "plan"` fixture and
|
||||
// replying with a generic 5-step content-marketing plan. Fix: add
|
||||
// d5-all.json fixtures with longer substring matchers that win first.
|
||||
test("Greet me pill returns a shared-state-aware greeting, not the generic showcase blurb", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.getByRole("button", { name: /^Greet me$/i }).click();
|
||||
|
||||
const assistantMessage = page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.last();
|
||||
await expect(assistantMessage).toContainText(/shared-state co-pilot/i, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Negative assertion: the wrong-fixture response is gone.
|
||||
await expect(assistantMessage).not.toContainText(
|
||||
/showcase assistant\. I can help with weather, charts/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("Plan a weekend pill returns an interests-aware plan, not the generic content-marketing plan", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.getByRole("button", { name: /^Plan a weekend$/i }).click();
|
||||
|
||||
const assistantMessage = page
|
||||
.locator('[data-testid="copilot-assistant-message"]')
|
||||
.last();
|
||||
await expect(assistantMessage).toContainText(/interests panel/i, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Negative assertion: the wrong-fixture response is gone.
|
||||
await expect(assistantMessage).not.toContainText(
|
||||
/Research the topic.*Outline key points/is,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Shared State (Reading) — Recipe Editor demo. The page publishes
|
||||
// `agent.state.recipe` via `agent.setState`; the agent reads (but does
|
||||
// not mutate) that recipe on every turn. Spec mirrors the QA contract
|
||||
// in qa/shared-state-read.md and the testids exposed by recipe-card.tsx.
|
||||
test.describe("Shared State (Reading)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/shared-state-read");
|
||||
});
|
||||
|
||||
test("recipe card loads with default ingredients and the sidebar mounts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator('[data-testid="recipe-card"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(page.getByText("AI Recipe Assistant")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-testid="ingredients-container"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="instructions-container"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("starter suggestions render", async ({ page }) => {
|
||||
for (const title of [
|
||||
"Create Italian recipe",
|
||||
"Make it healthier",
|
||||
"Suggest variations",
|
||||
]) {
|
||||
await expect(page.getByRole("button", { name: title })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking 'Add Ingredient' appends a new ingredient-card row", async ({
|
||||
page,
|
||||
}) => {
|
||||
const ingredientCards = page.locator('[data-testid="ingredient-card"]');
|
||||
const initialCount = await ingredientCards.count();
|
||||
await page.locator('[data-testid="add-ingredient-button"]').click();
|
||||
await expect(ingredientCards).toHaveCount(initialCount + 1, {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("sending a sidebar message returns an assistant response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByPlaceholder("Type a message");
|
||||
await input.fill("What recipe am I making?");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Shared State (Streaming) — Document Viewer demo. The agent streams a
|
||||
// `document` field in its state; the frontend renders it token-by-token
|
||||
// in a read-only DocumentView panel alongside a CopilotSidebar.
|
||||
test.describe("State Streaming", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/shared-state-streaming");
|
||||
});
|
||||
|
||||
test("page loads with document panel and chat sidebar", async ({ page }) => {
|
||||
// The document panel should mount with its testid
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// The "Document" heading inside the panel
|
||||
await expect(page.getByText("Document")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Character count starts at 0
|
||||
await expect(
|
||||
page.locator('[data-testid="document-char-count"]'),
|
||||
).toHaveText("0 chars", { timeout: 10000 });
|
||||
|
||||
// The sidebar chat input should be present
|
||||
await expect(
|
||||
page.getByPlaceholder("Ask me to write something..."),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("empty state shows placeholder text", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// When no document has been streamed, the placeholder italic text shows
|
||||
await expect(
|
||||
page.getByText("Ask the agent to write something"),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// document-content testid should NOT be present in the empty state
|
||||
await expect(
|
||||
page.locator('[data-testid="document-content"]'),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("starter suggestions render in the sidebar", async ({ page }) => {
|
||||
// The suggestions defined in suggestions.ts should appear as buttons
|
||||
for (const title of [
|
||||
"Write a short poem",
|
||||
"Draft an email",
|
||||
"Explain quantum computing",
|
||||
]) {
|
||||
await expect(page.getByRole("button", { name: title })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("sending a message triggers document streaming", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Send a message via the sidebar
|
||||
const input = page.getByPlaceholder("Ask me to write something...");
|
||||
await input.fill("Write a short poem about autumn leaves.");
|
||||
await input.press("Enter");
|
||||
|
||||
// The document-content area should appear as the agent streams tokens
|
||||
await expect(page.locator('[data-testid="document-content"]')).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// Content should have meaningful length (not empty)
|
||||
const content = page.locator('[data-testid="document-content"]');
|
||||
await expect(async () => {
|
||||
const text = await content.textContent();
|
||||
expect(text!.length).toBeGreaterThan(10);
|
||||
}).toPass({ timeout: 60000 });
|
||||
});
|
||||
|
||||
test("character count updates as document streams", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const charCount = page.locator('[data-testid="document-char-count"]');
|
||||
await expect(charCount).toHaveText("0 chars");
|
||||
|
||||
// Send a message to trigger streaming
|
||||
const input = page.getByPlaceholder("Ask me to write something...");
|
||||
await input.fill("Write a short poem about autumn leaves.");
|
||||
await input.press("Enter");
|
||||
|
||||
// Wait for char count to increase above 0
|
||||
await expect(async () => {
|
||||
const text = await charCount.textContent();
|
||||
const count = parseInt(text!.replace(/\D/g, ""), 10);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 60000 });
|
||||
});
|
||||
|
||||
test("live badge appears while agent is streaming", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// No live badge before we send anything
|
||||
await expect(
|
||||
page.locator('[data-testid="document-live-badge"]'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Send a message to trigger streaming
|
||||
const input = page.getByPlaceholder("Ask me to write something...");
|
||||
await input.fill("Write a short poem about autumn leaves.");
|
||||
await input.press("Enter");
|
||||
|
||||
// The LIVE badge should appear while the agent is running
|
||||
await expect(
|
||||
page.locator('[data-testid="document-live-badge"]'),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
});
|
||||
|
||||
test("assistant responds in the sidebar chat", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="document-view"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const input = page.getByPlaceholder("Ask me to write something...");
|
||||
await input.fill("Write a short poem about autumn leaves.");
|
||||
await input.press("Enter");
|
||||
|
||||
// The sidebar should show an assistant message
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-assistant-message"]').first(),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
|
||||
// Sub-Agents demo (Phase-1D, multi-agent family).
|
||||
//
|
||||
// Demo source: src/app/demos/subagents/page.tsx
|
||||
// Backend agent: src/agents/subagents.py (supervisor + 3 sub-agents)
|
||||
// Aimock fixtures: showcase/aimock/d5-all.json (3 pill chains)
|
||||
//
|
||||
// The supervisor delegates to research → writing → critique sub-agents.
|
||||
// Each delegation renders an inline tool-card in the chat stream via
|
||||
// `useRenderTool` plus an entry in the side-panel delegation log.
|
||||
//
|
||||
// Each test drives a verbatim suggestion-pill prompt (see
|
||||
// `src/app/demos/subagents/suggestions.ts`) end-to-end through the
|
||||
// fixture chain and asserts:
|
||||
// 1. all three role-scoped cards render
|
||||
// (`[data-testid="subagent-card-<role>"]`),
|
||||
// 2. each card's `[data-testid="subagent-result"]` is non-empty AND
|
||||
// does not echo the showcase-assistant boilerplate (catches the
|
||||
// previous bug where Writer/Critic cards leaked the chat
|
||||
// welcome text), and
|
||||
// 3. exactly one critic card renders per supervisor run, with a
|
||||
// stable `done` status (catches the previous critic-loop bug).
|
||||
|
||||
const PILLS = {
|
||||
blog: "Write a blog post",
|
||||
explain: "Explain a topic",
|
||||
summarize: "Summarize a topic",
|
||||
} as const;
|
||||
|
||||
const ROLES = ["researcher", "writer", "critic"] as const;
|
||||
type Role = (typeof ROLES)[number];
|
||||
|
||||
// Showcase boilerplate strings the previous wiring leaked into Writer
|
||||
// and Critic cards. Asserting these are absent guards against any
|
||||
// regression where the assistant intro overwrites real sub-agent
|
||||
// output.
|
||||
const BOILERPLATE_FRAGMENTS = [
|
||||
"Hi there! I'm your showcase assistant",
|
||||
"Here are the things I can help with",
|
||||
] as const;
|
||||
|
||||
async function clickPill(page: Page, title: string): Promise<void> {
|
||||
const pill = page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 });
|
||||
await pill.click();
|
||||
}
|
||||
|
||||
async function waitForAllCardsDone(page: Page): Promise<void> {
|
||||
// All three subagent cards must be visible AND in the `done` status
|
||||
// before we assert on result content (the result `<div>` only
|
||||
// mounts when the tool-render status hits `complete`).
|
||||
for (const role of ROLES) {
|
||||
const card = page.locator(`[data-testid="subagent-card-${role}"]`).first();
|
||||
await expect(card).toBeVisible({ timeout: 90_000 });
|
||||
await expect(card).toHaveAttribute("data-status", "complete", {
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCardResultGenuine(page: Page, role: Role): Promise<void> {
|
||||
const card = page.locator(`[data-testid="subagent-card-${role}"]`).first();
|
||||
const result = card.locator('[data-testid="subagent-result"]').first();
|
||||
await expect(result).toBeVisible({ timeout: 30_000 });
|
||||
const text = (await result.textContent())?.trim() ?? "";
|
||||
expect(text.length, `${role} result should be non-empty`).toBeGreaterThan(0);
|
||||
expect(text, `${role} result should not be the empty-fallback`).not.toBe(
|
||||
"(empty)",
|
||||
);
|
||||
for (const fragment of BOILERPLATE_FRAGMENTS) {
|
||||
expect(
|
||||
text,
|
||||
`${role} result must not echo showcase boilerplate "${fragment}"`,
|
||||
).not.toContain(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Sub-Agents", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/subagents");
|
||||
// Wait for the chat composer + pills to mount before any click
|
||||
// dispatches — the suggestion handler attaches asynchronously.
|
||||
await expect(
|
||||
page.getByPlaceholder("Give the supervisor a task..."),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("page loads with composer, 3 pills, and 3 subagent indicators", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Composer textarea visible.
|
||||
await expect(
|
||||
page.getByPlaceholder("Give the supervisor a task..."),
|
||||
).toBeVisible();
|
||||
|
||||
// 3 verbatim suggestion pills render.
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of Object.values(PILLS)) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// 3 always-visible subagent role indicators in the side panel.
|
||||
for (const role of ROLES) {
|
||||
await expect(
|
||||
page.locator(`[data-testid="subagent-indicator-${role}"]`),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Write a blog post pill produces 3 subagent cards with non-boilerplate results", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(page, PILLS.blog);
|
||||
await waitForAllCardsDone(page);
|
||||
for (const role of ROLES) {
|
||||
await assertCardResultGenuine(page, role);
|
||||
}
|
||||
});
|
||||
|
||||
test("Explain a topic pill produces 3 subagent cards with non-boilerplate results", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(page, PILLS.explain);
|
||||
await waitForAllCardsDone(page);
|
||||
for (const role of ROLES) {
|
||||
await assertCardResultGenuine(page, role);
|
||||
}
|
||||
});
|
||||
|
||||
test("Summarize a topic pill produces 3 subagent cards (regression: delegations reducer)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This pill historically returned HTTP 400 with
|
||||
// INVALID_CONCURRENT_GRAPH_UPDATE on the `delegations` state key
|
||||
// because the TypedDict didn't declare a reducer. Reaching `done`
|
||||
// on all three cards confirms the `Annotated[..., operator.add]`
|
||||
// reducer in `subagents.py` is in place — without it the supervisor
|
||||
// run would error out and at least one card would never reach
|
||||
// `complete`.
|
||||
await clickPill(page, PILLS.summarize);
|
||||
await waitForAllCardsDone(page);
|
||||
for (const role of ROLES) {
|
||||
await assertCardResultGenuine(page, role);
|
||||
}
|
||||
});
|
||||
|
||||
test("Critic runs exactly once per pill click and stays done (no loop)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickPill(page, PILLS.blog);
|
||||
await waitForAllCardsDone(page);
|
||||
|
||||
const criticCards = page.locator('[data-testid="subagent-card-critic"]');
|
||||
await expect(criticCards).toHaveCount(1);
|
||||
|
||||
const critic = criticCards.first();
|
||||
await expect(critic).toHaveAttribute("data-status", "complete");
|
||||
|
||||
// Hold for 5s and re-check: if the supervisor were to re-enter the
|
||||
// critic, a second card would render (per-call useRenderTool is
|
||||
// one-card-per-tool-call) and/or the existing card would flip
|
||||
// away from `complete`. The status must stay `complete` and the
|
||||
// count must stay at 1 across the dwell.
|
||||
await page.waitForTimeout(5_000);
|
||||
await expect(criticCards).toHaveCount(1);
|
||||
await expect(critic).toHaveAttribute("data-status", "complete");
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/threadid-frontend-tool-roundtrip.md
|
||||
// Demo source: src/app/demos/threadid-frontend-tool-roundtrip/page.tsx
|
||||
//
|
||||
// The source-level regression for ENT-658 lives in react-core. This smoke keeps
|
||||
// the showcase demo route and generated-thread toggle covered without depending
|
||||
// on fixture-driven tool execution in the standalone showcase package.
|
||||
|
||||
test.describe("Thread ID frontend-tool round trip", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/threadid-frontend-tool-roundtrip");
|
||||
});
|
||||
|
||||
test("page loads with generated-thread mode selected", async ({ page }) => {
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
|
||||
await expect(page.getByLabel("Explicit threadId")).not.toBeChecked();
|
||||
await expect(page.getByTestId("ent-658-thread-mode")).toHaveText(
|
||||
/SDK-generated thread/i,
|
||||
);
|
||||
|
||||
await page.getByLabel("Explicit threadId").check();
|
||||
await expect(page.getByTestId("ent-658-thread-mode")).toHaveText(
|
||||
/explicit thread/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/tool-rendering-custom-catchall.md
|
||||
// Demo source: src/app/demos/tool-rendering-custom-catchall/page.tsx
|
||||
// Renderer source: src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
|
||||
//
|
||||
// This cell registers a SINGLE branded wildcard renderer via
|
||||
// `useDefaultRenderTool`. Every tool call must paint via the same
|
||||
// `[data-testid="custom-wildcard-card"]` shell — no per-tool
|
||||
// specialization. Test 6 is the load-bearing assertion: every card on
|
||||
// the page after each pill click shares the same testid signature.
|
||||
|
||||
const SUGGESTION_TIMEOUT = 15000;
|
||||
const TOOL_TIMEOUT = 60000;
|
||||
|
||||
const PILLS = ["Weather in SF", "Find flights", "Roll a d20", "Chain tools"];
|
||||
|
||||
test.describe("Tool Rendering — Custom Catch-all (branded wildcard)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/tool-rendering-custom-catchall");
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("page loads with composer and 4 suggestion pills", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of PILLS) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanity: per-tool branded testids from sibling cells stay at zero.
|
||||
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="flights-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="stock-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="d20-card"]')).toHaveCount(0);
|
||||
// Sanity: the OOTB default-renderer testid does NOT appear here.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-tool-render"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Weather in SF pill paints the branded wildcard card for get_weather", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Weather in SF" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page
|
||||
.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="get_weather"]',
|
||||
)
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
card.locator('[data-testid="custom-wildcard-tool-name"]'),
|
||||
).toHaveText("get_weather");
|
||||
await expect(
|
||||
card.locator('[data-testid="custom-wildcard-args"]'),
|
||||
).toContainText("San Francisco", { timeout: TOOL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("Find flights pill paints the SAME branded wildcard card for search_flights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Find flights" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page
|
||||
.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="search_flights"]',
|
||||
)
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
card.locator('[data-testid="custom-wildcard-tool-name"]'),
|
||||
).toHaveText("search_flights");
|
||||
|
||||
// Result block surfaces the deterministic flights from our fixture
|
||||
// (NOT the a2ui beautiful-chat boilerplate).
|
||||
await expect(
|
||||
card.locator('[data-testid="custom-wildcard-result"]'),
|
||||
).toContainText(/United|Delta|JetBlue/, { timeout: TOOL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("Roll a d20 pill paints exactly 5 wildcard cards, last result is 20", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Roll a d20" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const cards = page.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="roll_d20"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(5);
|
||||
|
||||
// 5th card's result is 20.
|
||||
await expect(
|
||||
cards.nth(4).locator('[data-testid="custom-wildcard-result"]'),
|
||||
).toContainText(/"value":\s*20|"result":\s*20/, { timeout: TOOL_TIMEOUT });
|
||||
|
||||
// First 4 are non-20.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const txt = await cards
|
||||
.nth(i)
|
||||
.locator('[data-testid="custom-wildcard-result"]')
|
||||
.innerText();
|
||||
expect(txt).not.toMatch(/"value":\s*20|"result":\s*20/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chain tools pill paints 3 wildcard cards (one per tool)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain tools" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="get_weather"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="search_flights"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="custom-wildcard-card"][data-tool-name="roll_d20"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("every rendered card shares the same wildcard testid signature", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Cross-tool sanity: drive Chain tools (3 distinct tools → 3
|
||||
// cards) and assert every card matches the same wildcard shell.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain tools" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const cards = page.locator('[data-testid="custom-wildcard-card"]');
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
const total = await cards.count();
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-wildcard-tool-name"]'),
|
||||
).toHaveCount(total);
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-wildcard-args"]'),
|
||||
).toHaveCount(total);
|
||||
|
||||
// All cards expose distinct tool names but the SAME shell.
|
||||
const toolNames = await cards.evaluateAll((nodes) =>
|
||||
nodes.map((n) => n.getAttribute("data-tool-name")),
|
||||
);
|
||||
const uniqueNames = new Set(toolNames);
|
||||
expect(uniqueNames.size).toBeGreaterThanOrEqual(3);
|
||||
for (const name of toolNames) {
|
||||
expect(["get_weather", "search_flights", "roll_d20"]).toContain(name);
|
||||
}
|
||||
|
||||
// The OOTB default-renderer testid stays at zero — proves the
|
||||
// single custom wildcard is what painted, not the framework
|
||||
// fallback.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-tool-render"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Regression for the aimock multi-pill bug:
|
||||
// The d20 and Chain-tools fixtures used global thread state
|
||||
// (`turnIndex`, `hasToolResult`) to drive sequencing. After clicking
|
||||
// Find flights, the d20 loop entered at turnIndex=2 (rendering only 3
|
||||
// cards instead of 5) and the Chain-tools tool-emitting fixture was
|
||||
// skipped entirely (no tool cards, just the final "Done — Tokyo is
|
||||
// sunny…" content). Fix: chain all follow-ups via `toolCallId`. This
|
||||
// test drives Find flights → Roll a d20 → Chain tools in one thread
|
||||
// and asserts the wildcard renderer paints the full card sequence for
|
||||
// every pill (1 + 5 + 3 = 9 cards).
|
||||
test("sequential pills in one thread render full card sequences for each", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Three sequential pills × multi-tool chains × LLM-mock latency easily
|
||||
// exceeds Playwright's 30s default. Bumped to cover the worst case.
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Find flights" })
|
||||
.first()
|
||||
.click();
|
||||
const cards = page.locator('[data-testid="custom-wildcard-card"]');
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(1);
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Roll a d20" })
|
||||
.first()
|
||||
.click();
|
||||
// 1 (flights) + 5 (d20) = 6 cards once d20 chain finishes.
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(6);
|
||||
await expect(page.getByText("Rolled the d20 five times")).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain tools" })
|
||||
.first()
|
||||
.click();
|
||||
// 1 + 5 + 3 = 9 once chain tools mounts get_weather + search_flights +
|
||||
// roll_d20 cards.
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(9);
|
||||
await expect(page.getByText("Done — Tokyo is sunny")).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/tool-rendering-default-catchall.md
|
||||
// Demo source: src/app/demos/tool-rendering-default-catchall/page.tsx
|
||||
//
|
||||
// This cell registers ZERO custom render hooks. The runtime falls back
|
||||
// to the framework's built-in DefaultToolCallRenderer, which paints
|
||||
// every tool call with a stable `[data-testid="copilot-tool-render"]`
|
||||
// wrapper plus a `data-tool-name="<name>"` attribute. We assert on the
|
||||
// built-in contract — branded testids from sibling cells stay at zero.
|
||||
|
||||
const SUGGESTION_TIMEOUT = 15000;
|
||||
const TOOL_TIMEOUT = 60000;
|
||||
|
||||
const PILLS = ["Weather in SF", "Find flights", "Roll a d20", "Chain tools"];
|
||||
|
||||
test.describe("Tool Rendering — Default Catch-all", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/tool-rendering-default-catchall");
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("page loads with composer and 4 suggestion pills", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of PILLS) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanity: branded sibling-cell testids stay at zero on this cell.
|
||||
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="flights-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="stock-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="d20-card"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-wildcard-card"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Weather in SF pill paints the built-in default card for get_weather", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Weather in SF" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page
|
||||
.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="get_weather"]',
|
||||
)
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
|
||||
// Args are pinned to San Francisco (verbatim pill prompt → fixture).
|
||||
await expect
|
||||
.poll(async () => card.getAttribute("data-args"), {
|
||||
timeout: TOOL_TIMEOUT,
|
||||
})
|
||||
.toContain("San Francisco");
|
||||
|
||||
// No branded sibling-cell card mounted.
|
||||
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-wildcard-card"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Find flights pill paints the built-in default card for search_flights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Find flights" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page
|
||||
.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
|
||||
)
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
|
||||
// Result attribute carries the deterministic fixture flights (NOT
|
||||
// the a2ui beautiful-chat shape).
|
||||
await expect
|
||||
.poll(async () => card.getAttribute("data-result"), {
|
||||
timeout: TOOL_TIMEOUT,
|
||||
})
|
||||
.toMatch(/United|Delta|JetBlue/);
|
||||
|
||||
await expect(page.locator('[data-testid="flights-card"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Roll a d20 pill paints exactly 5 default cards for roll_d20", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Roll a d20" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const cards = page.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(5);
|
||||
|
||||
// 5th card's result must contain "20" (the final scripted roll).
|
||||
const lastResult = await cards.nth(4).getAttribute("data-result");
|
||||
expect(lastResult ?? "").toMatch(/"value":\s*20|"result":\s*20/);
|
||||
|
||||
// First 4 results are not-20.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = (await cards.nth(i).getAttribute("data-result")) ?? "";
|
||||
expect(r).not.toMatch(/"value":\s*20|"result":\s*20/);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="d20-card"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Chain tools pill paints 3 default cards (weather + flights + d20)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain tools" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="get_weather"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page
|
||||
.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
});
|
||||
|
||||
// Regression for the aimock multi-pill bug:
|
||||
// The d20 and Chain-tools fixtures used `turnIndex` + `hasToolResult` to
|
||||
// disambiguate sequential iterations of the same prompt. Those gates
|
||||
// count *global* thread state: clicking Find flights first left two
|
||||
// assistant messages and one tool message behind, so the d20 loop
|
||||
// entered at `turnIndex=2` (skipping rolls 7 and 14, hence only 3
|
||||
// cards), and the Chain-tools tool-emitting fixture was skipped
|
||||
// entirely (`hasToolResult: false` failed) so the pill went straight to
|
||||
// the "Done — Tokyo is sunny…" content with no tool cards. Fix: chain
|
||||
// all follow-ups via `toolCallId`, drop the global gates. This test
|
||||
// drives the three offending pills in a single thread and asserts the
|
||||
// expected card counts for each.
|
||||
test("sequential pills in one thread render full card sequences for each", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Three sequential pills × multi-tool chains × LLM-mock latency easily
|
||||
// exceeds Playwright's 30s default. Bumped to cover the worst case.
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Find flights" })
|
||||
.first()
|
||||
.click();
|
||||
const flights = page.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
|
||||
);
|
||||
await expect(flights).toHaveCount(1, { timeout: TOOL_TIMEOUT });
|
||||
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Roll a d20" })
|
||||
.first()
|
||||
.click();
|
||||
const d20 = page.locator(
|
||||
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => d20.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(5);
|
||||
// Final scripted roll lands the 20 — proves the chain advanced through
|
||||
// all 5 fixtures, not just the first two before bailing to content.
|
||||
await expect
|
||||
.poll(async () => d20.nth(4).getAttribute("data-result"), {
|
||||
timeout: TOOL_TIMEOUT,
|
||||
})
|
||||
.toMatch(/"value":\s*20|"result":\s*20/);
|
||||
await expect(page.getByText("Rolled the d20 five times")).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("every rendered card matches the built-in default-renderer DOM signature", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Drive a single pill that produces a single card so the assertions
|
||||
// here are scoped to the exact DOM the framework's default renderer
|
||||
// produces.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Weather in SF" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page.locator('[data-testid="copilot-tool-render"]').first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
|
||||
// The built-in default renderer always exposes name + status pill.
|
||||
await expect(
|
||||
card.locator('[data-testid="copilot-tool-render-name"]'),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
card.locator('[data-testid="copilot-tool-render-status"]'),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
|
||||
// Every card on the page shares the same wrapper testid count as
|
||||
// the inner-name and inner-status testids — proves the built-in
|
||||
// shell is what's painting (no per-tool shells).
|
||||
const total = await page
|
||||
.locator('[data-testid="copilot-tool-render"]')
|
||||
.count();
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-tool-render-name"]'),
|
||||
).toHaveCount(total);
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-tool-render-status"]'),
|
||||
).toHaveCount(total);
|
||||
});
|
||||
});
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/tool-rendering-reasoning-chain.md
|
||||
// Demo source: src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
//
|
||||
// The reasoning-chain cell composes two patterns into one chat surface:
|
||||
// - Reasoning-summary streaming (OpenAI Responses API, `reasoning={
|
||||
// "effort":"medium","summary":"detailed"}`) rendered through a
|
||||
// `messageView.reasoningMessage` slot (<ReasoningBlock>).
|
||||
// - Per-tool renderers wired via `useRenderTool` for `get_weather` and
|
||||
// `search_flights`, plus a `useDefaultRenderTool` catchall that
|
||||
// paints `get_stock_price` and `roll_dice`.
|
||||
//
|
||||
// Every pill drives a CHAINED two-tool flow:
|
||||
// - Stocks: get_stock_price(AAPL) → get_stock_price(MSFT) → comparison.
|
||||
// - Dice: roll_dice(sides=20) → roll_dice(sides=6) → contrast.
|
||||
// - Flights+weather: search_flights(SFO,JFK) → get_weather(JFK) → plan.
|
||||
//
|
||||
// Aimock fixtures live in showcase/aimock/d5-all.json (and the matching
|
||||
// harness source at showcase/harness/fixtures/d5/tool-rendering-
|
||||
// reasoning-chain.json) and pin every pill to a deterministic two-leg
|
||||
// chain. The sequential-pills test is the regression guard for the
|
||||
// AG-UI reasoning-role message bug in @copilotkit/runtime — without
|
||||
// `LangGraphAgent.run`'s reasoning-role filter, clicking a second pill
|
||||
// in the same thread used to crash with INCOMPLETE_STREAM because
|
||||
// @ag-ui/langgraph's message converter throws on `role:"reasoning"`.
|
||||
|
||||
const SUGGESTION_TIMEOUT = 15_000;
|
||||
const TOOL_TIMEOUT = 60_000;
|
||||
const REASONING_TIMEOUT = 30_000;
|
||||
|
||||
const PILLS = [
|
||||
"Compare two stocks",
|
||||
"Chain of dice rolls",
|
||||
"Flights + destination weather",
|
||||
] as const;
|
||||
|
||||
test.describe("Tool Rendering — Reasoning Chain", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/tool-rendering-reasoning-chain");
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("page loads with composer and 3 suggestion pills", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of PILLS) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanity: no per-tool cards mounted before any pill click.
|
||||
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="flight-list-card"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-catchall-card"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="reasoning-block"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("Compare two stocks pill chains AAPL → MSFT through the catchall renderer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Compare two stocks" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Both legs of the chain mount via the catchall renderer — scoped
|
||||
// by `data-tool-name` so we'd notice if a future per-tool stock
|
||||
// renderer landed and only one card rendered.
|
||||
const stockCards = page.locator(
|
||||
'[data-testid="custom-catchall-card"][data-tool-name="get_stock_price"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => stockCards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(2);
|
||||
|
||||
// Reasoning slot mounts at least once — proves the agent's
|
||||
// reasoning summaries reached the messageView slot, which is the
|
||||
// whole reason this cell exists vs the plain tool-rendering demo.
|
||||
await expect(
|
||||
page.locator('[data-testid="reasoning-block"]').first(),
|
||||
).toBeVisible({ timeout: REASONING_TIMEOUT });
|
||||
|
||||
// Narration text comes from the fixture final-content leg.
|
||||
await expect(page.getByText("AAPL is at")).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
await expect(page.getByText("MSFT is at")).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("Chain of dice rolls pill chains d20 → d6 through the catchall renderer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain of dice rolls" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const diceCards = page.locator(
|
||||
'[data-testid="custom-catchall-card"][data-tool-name="roll_dice"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => diceCards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(2);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="reasoning-block"]').first(),
|
||||
).toBeVisible({ timeout: REASONING_TIMEOUT });
|
||||
|
||||
// Final narration mentions both dice + the contrast framing.
|
||||
await expect(page.getByText(/d20 came up/i)).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("Flights + destination weather pill chains search_flights → get_weather through branded per-tool renderers", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Flights + destination weather" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Flights card uses its branded renderer (not the catchall).
|
||||
const flights = page.locator('[data-testid="flight-list-card"]').first();
|
||||
await expect(flights).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
flights.locator('[data-testid="flight-origin"]'),
|
||||
).toContainText("SFO", { timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
flights.locator('[data-testid="flight-destination"]'),
|
||||
).toContainText("JFK", { timeout: TOOL_TIMEOUT });
|
||||
|
||||
// Destination weather card uses its branded renderer.
|
||||
const weather = page.locator('[data-testid="weather-card"]').first();
|
||||
await expect(weather).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(weather.locator('[data-testid="weather-city"]')).toContainText(
|
||||
"JFK",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="reasoning-block"]').first(),
|
||||
).toBeVisible({ timeout: REASONING_TIMEOUT });
|
||||
|
||||
// Catchall renderer must NOT mount for these tools — both have
|
||||
// per-tool registrations.
|
||||
await expect(
|
||||
page.locator('[data-testid="custom-catchall-card"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// REGRESSION for the AG-UI reasoning-role message bug:
|
||||
// `@ag-ui/langgraph`'s message converter throws "message role is
|
||||
// not supported." on any role outside {user,assistant,system,tool}.
|
||||
// Reasoning-stream agents emit `role:"reasoning"` messages that the
|
||||
// AG-UI client replays on subsequent turns. Without the
|
||||
// reasoning-role filter in @copilotkit/runtime's LangGraphAgent.run
|
||||
// subclass, the SECOND pill click crashes before the model is
|
||||
// called and the user sees a runtime error toast.
|
||||
//
|
||||
// This test clicks all three pills sequentially in ONE thread and
|
||||
// asserts the full chain renders for each — proving cross-turn safety.
|
||||
// It also catches a regression in any of:
|
||||
// - The fixture toolCallId chains (degrading multi-pill to single
|
||||
// tool calls).
|
||||
// - The reasoning summary emission on follow-up turns.
|
||||
// - Per-tool renderer state isolation between turns.
|
||||
test("sequential pills in one thread render full chains + reasoning blocks for each", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Three sequential pills × 2-tool chains × LLM-mock latency easily
|
||||
// exceeds Playwright's 30s default. Match the budget the
|
||||
// tool-rendering-default-catchall multi-pill regression uses.
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const reasoningBlocks = page.locator('[data-testid="reasoning-block"]');
|
||||
|
||||
// Pill 1 — stocks chain.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Compare two stocks" })
|
||||
.first()
|
||||
.click();
|
||||
const stockCards = page.locator(
|
||||
'[data-testid="custom-catchall-card"][data-tool-name="get_stock_price"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => stockCards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(2);
|
||||
await expect
|
||||
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Pill 2 — dice chain. The KEY assertion: this used to crash with
|
||||
// INCOMPLETE_STREAM before the reasoning-role filter landed.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain of dice rolls" })
|
||||
.first()
|
||||
.click();
|
||||
const diceCards = page.locator(
|
||||
'[data-testid="custom-catchall-card"][data-tool-name="roll_dice"]',
|
||||
);
|
||||
await expect
|
||||
.poll(async () => diceCards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(2);
|
||||
// Reasoning blocks should have INCREASED — proves the second turn
|
||||
// produced fresh reasoning, not just reusing turn 1's block.
|
||||
await expect
|
||||
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Pill 3 — flights + destination weather. Final regression hop.
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Flights + destination weather" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="flight-list-card"]').first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page.locator('[data-testid="weather-card"]').first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect
|
||||
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Final sanity: card counts for the prior turns survived (no
|
||||
// unmounts mid-thread).
|
||||
await expect(stockCards).toHaveCount(2);
|
||||
await expect(diceCards).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// QA reference: qa/tool-rendering.md
|
||||
// Demo source: src/app/demos/tool-rendering/page.tsx
|
||||
//
|
||||
// Pill-driven 6-test plan. The cell registers a per-tool useRenderTool
|
||||
// for every "interesting" tool (get_weather, search_flights,
|
||||
// get_stock_price, roll_d20) plus a wildcard catch-all. Each pill
|
||||
// drives the corresponding tool path and asserts on the per-tool
|
||||
// branded card's stable testid plus deterministic fixture values.
|
||||
//
|
||||
// Aimock fixtures live in showcase/aimock/d5-all.json and pin every
|
||||
// pill prompt to a deterministic tool-call sequence.
|
||||
|
||||
const SUGGESTION_TIMEOUT = 15000;
|
||||
const TOOL_TIMEOUT = 60000;
|
||||
|
||||
test.describe("Tool Rendering", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/tool-rendering");
|
||||
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("page loads with composer and 5 suggestion pills", async ({ page }) => {
|
||||
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
|
||||
for (const title of [
|
||||
"Weather in SF",
|
||||
"Find flights",
|
||||
"Stock price",
|
||||
"Roll a d20",
|
||||
"Chain tools",
|
||||
]) {
|
||||
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
|
||||
timeout: SUGGESTION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("Weather in SF pill renders the SF weather card", async ({ page }) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Weather in SF" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page.locator('[data-testid="weather-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(card.locator('[data-testid="weather-city"]')).toContainText(
|
||||
"San Francisco",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
await expect(
|
||||
card.locator('[data-testid="weather-humidity"]'),
|
||||
).toContainText("55%", { timeout: TOOL_TIMEOUT });
|
||||
await expect(card.locator('[data-testid="weather-wind"]')).toContainText(
|
||||
"10",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
});
|
||||
|
||||
test("Find flights pill renders the flights card with deterministic flights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Find flights" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page.locator('[data-testid="flights-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(card.locator('[data-testid="flight-origin"]')).toContainText(
|
||||
"SFO",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
await expect(
|
||||
card.locator('[data-testid="flight-destination"]'),
|
||||
).toContainText("JFK", { timeout: TOOL_TIMEOUT });
|
||||
|
||||
// At least 2 flight rows from the deterministic fixture (NOT the
|
||||
// a2ui beautiful-chat boilerplate — a2ui shows a different shell).
|
||||
const rows = card.locator('[data-testid="flight-row"]');
|
||||
await expect
|
||||
.poll(async () => rows.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Stock price pill renders the AAPL stock card", async ({ page }) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Stock price" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const card = page.locator('[data-testid="stock-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(card.locator('[data-testid="stock-ticker"]')).toHaveText(
|
||||
"AAPL",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
await expect(card.locator('[data-testid="stock-price"]')).toContainText(
|
||||
"$338.37",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
await expect(card.locator('[data-testid="stock-change"]')).toContainText(
|
||||
"-2.96%",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
});
|
||||
|
||||
test("Roll a d20 pill produces exactly 5 d20 cards, last result is 20", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Roll a d20" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const cards = page.locator('[data-testid="d20-card"]');
|
||||
|
||||
// Wait for all 5 sequential rolls to land.
|
||||
await expect
|
||||
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
|
||||
.toBe(5);
|
||||
|
||||
// Final roll is a 20.
|
||||
await expect(cards.nth(4).locator('[data-testid="d20-value"]')).toHaveText(
|
||||
"20",
|
||||
{ timeout: TOOL_TIMEOUT },
|
||||
);
|
||||
|
||||
// First 4 rolls are non-20.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const value = await cards
|
||||
.nth(i)
|
||||
.locator('[data-testid="d20-value"]')
|
||||
.innerText();
|
||||
expect(value.trim()).not.toBe("20");
|
||||
}
|
||||
});
|
||||
|
||||
test("Chain tools pill renders weather + flights + d20 cards in one turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('[data-testid="copilot-suggestion"]')
|
||||
.filter({ hasText: "Chain tools" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="weather-card"]').first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(
|
||||
page.locator('[data-testid="flights-card"]').first(),
|
||||
).toBeVisible({ timeout: TOOL_TIMEOUT });
|
||||
await expect(page.locator('[data-testid="d20-card"]').first()).toBeVisible({
|
||||
timeout: TOOL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// E2E for the voice demo — sample-audio path only.
|
||||
//
|
||||
// The "Play sample" button is a deterministic test/demo affordance: it
|
||||
// synchronously injects the canned phrase ("What is the weather in Tokyo?")
|
||||
// into the chat composer without touching the runtime's `/transcribe`
|
||||
// endpoint. That keeps this suite stable across environments where Whisper
|
||||
// or aimock might be unavailable.
|
||||
//
|
||||
// The microphone path is intentionally out of scope: MediaRecorder is hard
|
||||
// to exercise headlessly without mocking, and the mic is the only path that
|
||||
// actually exercises real transcription. It's covered by the manual QA
|
||||
// checklist at qa/voice.md.
|
||||
//
|
||||
// Stability expectation: 3 consecutive runs against Railway must pass.
|
||||
|
||||
test.describe("Voice Input", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/demos/voice");
|
||||
});
|
||||
|
||||
test("page loads with sample button, chat composer, and mic affordance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Voice input" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="voice-sample-audio-button"]'),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByText("Try a sample audio")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-chat-input"]'),
|
||||
).toBeVisible();
|
||||
// The mic button is the authoritative signal that the runtime advertised
|
||||
// `audioFileTranscriptionEnabled: true` — i.e. transcriptionService is
|
||||
// wired on /api/copilotkit-voice. Exposed by react-core's v2 CopilotChatInput.
|
||||
// It renders after the /info round trip resolves on the client, which on
|
||||
// a cold dev server can exceed Playwright's 5s default — give it room.
|
||||
await expect(
|
||||
page.locator('[data-testid="copilot-start-transcribe-button"]'),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("sample audio button injects the canned phrase into the input", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sampleButton = page.locator(
|
||||
'[data-testid="voice-sample-audio-button"]',
|
||||
);
|
||||
const textarea = page.locator('[data-testid="copilot-chat-textarea"]');
|
||||
|
||||
await expect(sampleButton).toBeEnabled();
|
||||
await expect(textarea).toHaveValue("");
|
||||
await sampleButton.click();
|
||||
|
||||
// The button is synchronous — clicking immediately populates the
|
||||
// textarea with the canned sample text. No transient "Transcribing…"
|
||||
// state, no /transcribe round trip.
|
||||
await expect(textarea).toHaveValue(/weather|tokyo/i, { timeout: 1000 });
|
||||
await expect(sampleButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("sending the transcribed text produces a weather tool render", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The end-to-end flow (click → run agent → first assistant chunk) can run
|
||||
// up to ~50s on a cold langgraph dev server, so override the default 30s
|
||||
// suite timeout to give the locator's own 45s timeout headroom.
|
||||
test.setTimeout(90_000);
|
||||
const sampleButton = page.locator(
|
||||
'[data-testid="voice-sample-audio-button"]',
|
||||
);
|
||||
const textarea = page.locator('[data-testid="copilot-chat-textarea"]');
|
||||
const sendButton = page.locator('[data-testid="copilot-send-button"]');
|
||||
|
||||
await sampleButton.click();
|
||||
await expect(textarea).toHaveValue(/weather|tokyo/i, { timeout: 1000 });
|
||||
await sendButton.click();
|
||||
|
||||
// The voice-demo route reuses the neutral sample_agent graph, which
|
||||
// doesn't itself render a weather card — but if the runtime has a
|
||||
// tool-rendering configuration that handles weather, one of these will
|
||||
// be visible. The assertion is permissive: we care that *some*
|
||||
// agent-authored response surface appeared, not exactly which renderer
|
||||
// was used.
|
||||
const assistantOrTool = page
|
||||
.locator(
|
||||
[
|
||||
'[data-testid="weather-card"]',
|
||||
'[data-testid="custom-catchall-card"][data-tool-name="get_weather"]',
|
||||
'[data-testid="copilot-assistant-message"]',
|
||||
].join(", "),
|
||||
)
|
||||
.first();
|
||||
await expect(assistantOrTool).toBeVisible({ timeout: 45000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
"""test_cvdiag_schema_v1.py — L1-I suite for langgraph-python schema-v1 CVDIAG.
|
||||
|
||||
Covers:
|
||||
1. All 11 backend boundaries emit a valid schema-v1 envelope (stdout capture)
|
||||
for a synthetic request with ``CVDIAG_BACKEND_EMITTER=1``.
|
||||
2. firsttoken↔first_byte correlation sanity (ingress→first-byte delta ≥ 0).
|
||||
3. The Phase-4 PROPAGATION RELIABILITY abandonment gate: 100 synthetic
|
||||
requests carrying ``x-test-id`` must propagate that id to the backend emit
|
||||
at ≥90% (else BLOCKER).
|
||||
4. Guard discipline: with ``CVDIAG_BACKEND_EMITTER`` unset, nothing is emitted.
|
||||
|
||||
The 11 backend boundaries are exercised through ``CvdiagBackendRun`` — the exact
|
||||
emitter the LGP middleware drives in ``(a)wrap_model_call`` — so this exercises
|
||||
the real failure surface, not a mock.
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python3 -m pytest showcase/integrations/langgraph-python/tests/test_cvdiag_schema_v1.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from _shared.cvdiag_schema import CvdiagEnvelope
|
||||
from src.agents import _cvdiag_backend as cvb
|
||||
|
||||
# The 11 backend boundaries this integration owns (spec §3 / §5).
|
||||
_BACKEND_BOUNDARIES = {
|
||||
"backend.request.ingress",
|
||||
"backend.agent.enter",
|
||||
"backend.llm.call.start",
|
||||
"backend.llm.call.heartbeat",
|
||||
"backend.llm.call.response",
|
||||
"backend.sse.first_byte",
|
||||
"backend.sse.event",
|
||||
"backend.sse.aborted",
|
||||
"backend.agent.exit",
|
||||
"backend.response.complete",
|
||||
"backend.error.caught",
|
||||
}
|
||||
|
||||
|
||||
def _new_test_id() -> str:
|
||||
"""A valid UUIDv7-shaped test_id (version nibble 7, variant 8..b)."""
|
||||
h = uuid.uuid4().hex
|
||||
return f"{h[0:8]}-{h[8:12]}-7{h[13:16]}-8{h[17:20]}-{h[20:32]}"
|
||||
|
||||
|
||||
def _headers(test_id: str) -> Dict[str, str]:
|
||||
return {
|
||||
"x-aimock-context": "langgraph-python",
|
||||
"x-test-id": test_id,
|
||||
"x-diag-run-id": "run-" + test_id[:8],
|
||||
"cf-ray": "abc123-EWR",
|
||||
}
|
||||
|
||||
|
||||
def _parse_cvdiag_lines(captured: str) -> List[Dict[str, Any]]:
|
||||
"""Extract + JSON-parse every structured ``CVDIAG {json}`` line from stdout.
|
||||
|
||||
The legacy free-form ``CVDIAG component=...`` log line is NOT JSON and is
|
||||
skipped; only the schema-v1 ``CVDIAG {...}`` envelopes are returned.
|
||||
"""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for line in captured.splitlines():
|
||||
if not line.startswith("CVDIAG "):
|
||||
continue
|
||||
payload = line[len("CVDIAG ") :].strip()
|
||||
if not payload.startswith("{"):
|
||||
continue
|
||||
rows.append(json.loads(payload))
|
||||
return rows
|
||||
|
||||
|
||||
def _emit_all_eleven(headers: Dict[str, str]) -> None:
|
||||
"""Drive the emitter through all 11 boundaries (debug tier so sse.event +
|
||||
heartbeat fire) — mirrors the middleware wrap path plus the error path."""
|
||||
run = cvb.CvdiagBackendRun(headers)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name="HeaderForwardingMiddleware", model_id="gpt-5.4")
|
||||
run.llm_call_start(provider="langchain", model="gpt-5.4")
|
||||
run.emit_heartbeat_once() # backend.llm.call.heartbeat
|
||||
run.llm_call_response(provider="langchain", model="gpt-5.4", latency_ms=42)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=128)
|
||||
run.sse_aborted(termination_kind="client", bytes_before_abort=0)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200, sse_event_count=1)
|
||||
run.error_caught(RuntimeError("synthetic"))
|
||||
|
||||
|
||||
def test_all_eleven_boundaries_emit_valid_envelopes(monkeypatch, capsys):
|
||||
"""All 11 schema-v1 boundaries present + each validates against the model."""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.setenv("CVDIAG_DEBUG", "1")
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test") # non-prod so DEBUG is allowed
|
||||
# Re-run bootstrap so the debug tier takes effect for this test's env.
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
boot.setup()
|
||||
|
||||
test_id = _new_test_id()
|
||||
_emit_all_eleven(_headers(test_id))
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
seen = {row["boundary"] for row in rows}
|
||||
missing = _BACKEND_BOUNDARIES - seen
|
||||
assert not missing, f"missing backend boundaries: {sorted(missing)}"
|
||||
|
||||
# Every emitted envelope must validate against the generated model.
|
||||
for row in rows:
|
||||
CvdiagEnvelope.model_validate(row)
|
||||
assert row["layer"] == "backend"
|
||||
assert row["slug"] == "langgraph-python"
|
||||
assert row["test_id"] == test_id
|
||||
|
||||
|
||||
def test_firsttoken_first_byte_correlation_non_negative(monkeypatch, capsys):
|
||||
"""The ingress→first_byte delta is present and non-negative (end-to-end).
|
||||
|
||||
``backend.sse.first_byte`` is a VERBOSE-only boundary (§6 tier matrix), so
|
||||
drive at VERBOSE tier — at DEFAULT tier it is correctly suppressed.
|
||||
"""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.setenv("CVDIAG_VERBOSE", "1")
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
boot.setup({"SHOWCASE_ENV": "test", "CVDIAG_VERBOSE": "1"})
|
||||
|
||||
run = cvb.CvdiagBackendRun(_headers(_new_test_id()))
|
||||
run.request_ingress()
|
||||
run.sse_first_byte()
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
fb = [r for r in rows if r["boundary"] == "backend.sse.first_byte"]
|
||||
assert len(fb) == 1
|
||||
delta = fb[0]["metadata"]["delta_ms_from_ingress"]
|
||||
assert isinstance(delta, int) and delta >= 0
|
||||
|
||||
|
||||
def test_disabled_emitter_is_noop(monkeypatch, capsys):
|
||||
"""With CVDIAG_BACKEND_EMITTER unset, no schema-v1 envelope is written."""
|
||||
monkeypatch.delenv("CVDIAG_BACKEND_EMITTER", raising=False)
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
boot.setup()
|
||||
|
||||
_emit_all_eleven(_headers(_new_test_id()))
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_propagation_reliability_gate(monkeypatch, capsys):
|
||||
"""PHASE-4 ABANDONMENT GATE: ≥90% of 100 requests propagate their test_id.
|
||||
|
||||
Each synthetic request carries a distinct ``x-test-id``; we assert the
|
||||
backend emit carries that SAME id through to the envelope. <90% is a BLOCKER.
|
||||
"""
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
boot.setup()
|
||||
|
||||
total = 100
|
||||
propagated = 0
|
||||
for _ in range(total):
|
||||
test_id = _new_test_id()
|
||||
run = cvb.CvdiagBackendRun(_headers(test_id))
|
||||
# The agent.enter boundary is representative of the backend emit path.
|
||||
run.agent_enter(agent_name="m", model_id="gpt-5.4")
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
enter = [r for r in rows if r["boundary"] == "backend.agent.enter"]
|
||||
if enter and enter[0]["test_id"] == test_id:
|
||||
propagated += 1
|
||||
|
||||
pct = 100.0 * propagated / total
|
||||
print(f"\nPROPAGATION_RELIABILITY: {propagated}/{total} = {pct:.1f}%")
|
||||
assert pct >= 90.0, (
|
||||
f"BLOCKER: test_id propagation {pct:.1f}% < 90% "
|
||||
f"({propagated}/{total}) — Phase-4 abandonment gate failed"
|
||||
)
|
||||
|
||||
|
||||
# ── FIX-2: live tier (env flip after import must arm tier-gated paths) ───────
|
||||
|
||||
|
||||
def test_tier_read_live_after_import(monkeypatch, capsys):
|
||||
"""RED: setting ``CVDIAG_VERBOSE`` AFTER bootstrap ``setup()`` must let a
|
||||
VERBOSE-tier boundary fire. The tier was frozen at import, so a post-setup
|
||||
env flip armed the emitter (read live) but tier-gated heartbeat/llm paths
|
||||
kept no-op'ing."""
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
# Resolve tier at DEFAULT (no verbose/debug) — the frozen-tier trap.
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
boot.setup({"SHOWCASE_ENV": "test"})
|
||||
# NOW flip verbose on, post-setup.
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.setenv("CVDIAG_VERBOSE", "1")
|
||||
|
||||
run = cvb.CvdiagBackendRun(_headers(_new_test_id()))
|
||||
run.emit_heartbeat_once() # VERBOSE-tier boundary
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
hb = [r for r in rows if r["boundary"] == "backend.llm.call.heartbeat"]
|
||||
assert hb, "verbose boundary suppressed: tier was frozen at import"
|
||||
|
||||
|
||||
# ── C5: VERBOSE-only backend boundaries must be tier-gated ──────────────────
|
||||
|
||||
# The four boundaries the §6 tier matrix marks VERBOSE-only (emit.ts ~58-63 and
|
||||
# the middleware-canonical agno ``_BOUNDARY_TIER``): at DEFAULT tier they MUST
|
||||
# be suppressed; at VERBOSE tier they emit. LGP previously called ``_emit`` with
|
||||
# NO ``tier_gate`` for these, so they over-emitted at default tier — 4 extra
|
||||
# events/request vs the middleware family, breaking the §7 budget + parity.
|
||||
_VERBOSE_ONLY_BOUNDARIES = {
|
||||
"backend.request.ingress",
|
||||
"backend.llm.call.start",
|
||||
"backend.llm.call.response",
|
||||
"backend.sse.first_byte",
|
||||
}
|
||||
|
||||
|
||||
def _drive_verbose_only(headers: Dict[str, str]) -> None:
|
||||
"""Drive exactly the four VERBOSE-only lifecycle boundaries (no debug paths)."""
|
||||
run = cvb.CvdiagBackendRun(headers)
|
||||
run.request_ingress()
|
||||
run.llm_call_start(provider="langchain", model="gpt-5.4")
|
||||
run.llm_call_response(provider="langchain", model="gpt-5.4", latency_ms=42)
|
||||
run.sse_first_byte()
|
||||
|
||||
|
||||
def test_verbose_only_boundaries_suppressed_at_default_tier(monkeypatch, capsys):
|
||||
"""RED: at DEFAULT tier the four VERBOSE-only boundaries must NOT emit.
|
||||
|
||||
Pre-fix they fired ungated, over-emitting at default tier (breaking the §7
|
||||
tier budget + cross-backend parity); post-fix they are suppressed.
|
||||
"""
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.delenv("CVDIAG_VERBOSE", raising=False)
|
||||
monkeypatch.delenv("CVDIAG_DEBUG", raising=False)
|
||||
boot.setup({"SHOWCASE_ENV": "test", "CVDIAG_BACKEND_EMITTER": "1"})
|
||||
|
||||
_drive_verbose_only(_headers(_new_test_id()))
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
leaked = {r["boundary"] for r in rows} & _VERBOSE_ONLY_BOUNDARIES
|
||||
assert not leaked, (
|
||||
f"VERBOSE-only boundaries over-emitted at DEFAULT tier: {sorted(leaked)}"
|
||||
)
|
||||
|
||||
|
||||
def test_verbose_only_boundaries_emit_at_verbose_tier(monkeypatch, capsys):
|
||||
"""GREEN companion: at VERBOSE tier all four boundaries DO emit."""
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.setenv("CVDIAG_VERBOSE", "1")
|
||||
boot.setup({"SHOWCASE_ENV": "test", "CVDIAG_VERBOSE": "1"})
|
||||
|
||||
_drive_verbose_only(_headers(_new_test_id()))
|
||||
|
||||
rows = _parse_cvdiag_lines(capsys.readouterr().out)
|
||||
seen = {r["boundary"] for r in rows} & _VERBOSE_ONLY_BOUNDARIES
|
||||
missing = _VERBOSE_ONLY_BOUNDARIES - seen
|
||||
assert not missing, (
|
||||
f"VERBOSE-only boundaries suppressed at VERBOSE tier: {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
# ── FIX-3: stop_heartbeat cooperative cancellation ──────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(asyncio.Task, "cancelling"),
|
||||
reason="cooperative-cancel detection uses Task.cancelling() (Python 3.11+); "
|
||||
"production runs 3.12",
|
||||
)
|
||||
def test_stop_heartbeat_propagates_caller_cancellation(monkeypatch):
|
||||
"""RED: ``stop_heartbeat``'s ``except (CancelledError, Exception)`` swallows
|
||||
the CALLER's CancelledError, breaking cooperative cancellation.
|
||||
|
||||
Deterministic repro (no scheduling race): a heartbeat whose cancellation is
|
||||
SLOW (shielded cleanup) keeps ``await task`` suspended; the surrounding task
|
||||
is cancelled a SECOND time while suspended exactly there, so the caller's
|
||||
CancelledError lands inside ``stop_heartbeat``. With the swallow it runs to
|
||||
completion (``AFTER_STOP`` reached); with cooperative cancellation the
|
||||
CancelledError propagates and ``AFTER_STOP`` is NEVER reached.
|
||||
"""
|
||||
monkeypatch.setenv("SHOWCASE_ENV", "test")
|
||||
monkeypatch.setenv("CVDIAG_BACKEND_EMITTER", "1")
|
||||
monkeypatch.setenv("CVDIAG_VERBOSE", "1")
|
||||
import _shared.cvdiag_bootstrap as boot
|
||||
|
||||
boot.setup({"SHOWCASE_ENV": "test", "CVDIAG_VERBOSE": "1"})
|
||||
reached: List = []
|
||||
|
||||
async def run_test():
|
||||
run = cvb.CvdiagBackendRun(_headers(_new_test_id()))
|
||||
run.start_heartbeat()
|
||||
assert run._heartbeat_task is not None, "heartbeat task did not arm"
|
||||
# Swap in a heartbeat that is SLOW to cancel so ``await task`` suspends.
|
||||
run._heartbeat_task.cancel()
|
||||
|
||||
async def slow_hb():
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.shield(asyncio.sleep(0.2))
|
||||
return
|
||||
|
||||
run._heartbeat_task = asyncio.ensure_future(slow_hb())
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
at_await = asyncio.Event()
|
||||
|
||||
async def body():
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
finally:
|
||||
at_await.set()
|
||||
await run.stop_heartbeat()
|
||||
reached.append("AFTER_STOP")
|
||||
|
||||
task = asyncio.ensure_future(body())
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel() # enter finally → reach the stop_heartbeat await
|
||||
await at_await.wait()
|
||||
await asyncio.sleep(0) # yield so we're inside ``await task``
|
||||
task.cancel() # caller cancel lands inside stop_heartbeat's await
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(run_test())
|
||||
assert not reached, (
|
||||
"caller CancelledError was swallowed by stop_heartbeat: it ran to "
|
||||
"completion instead of propagating cooperative cancellation"
|
||||
)
|
||||
Reference in New Issue
Block a user