diff --git a/demos/use_cases/vercel-ai-sdk/tests/e2e/api.test.ts b/demos/use_cases/vercel-ai-sdk/tests/e2e/api.test.ts new file mode 100644 index 00000000..9b787796 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/e2e/api.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; + +const CHAT_URL_REGEX = /\/chat\/[\w-]+/; +const ERROR_TEXT_REGEX = /error|failed|trouble/i; + +test.describe("Chat API Integration", () => { + test("sends message and receives AI response", async ({ page }) => { + await page.goto("/"); + + const input = page.getByTestId("multimodal-input"); + await input.fill("Hello"); + await page.getByTestId("send-button").click(); + + // Wait for assistant response to appear + const assistantMessage = page.locator("[data-role='assistant']").first(); + await expect(assistantMessage).toBeVisible({ timeout: 30_000 }); + + // Verify it has some text content + const content = await assistantMessage.textContent(); + expect(content?.length).toBeGreaterThan(0); + }); + + test("redirects to /chat/:id after sending message", async ({ page }) => { + await page.goto("/"); + + const input = page.getByTestId("multimodal-input"); + await input.fill("Test redirect"); + await page.getByTestId("send-button").click(); + + // URL should change to /chat/:id format + await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 }); + }); + + test("clears input after sending", async ({ page }) => { + await page.goto("/"); + + const input = page.getByTestId("multimodal-input"); + await input.fill("Test message"); + await page.getByTestId("send-button").click(); + + // Input should be cleared + await expect(input).toHaveValue(""); + }); + + test("shows stop button during generation", async ({ page }) => { + await page.goto("/"); + const input = page.getByTestId("multimodal-input"); + await input.fill("Test"); + await page.getByTestId("send-button").click(); + + // Stop button should appear during generation + const stopButton = page.getByTestId("stop-button"); + await expect(stopButton).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe("Chat Error Handling", () => { + test("handles API error gracefully", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Internal server error" }), + }); + }); + + await page.goto("/"); + const input = page.getByTestId("multimodal-input"); + await input.fill("Test error"); + await page.getByTestId("send-button").click(); + + // Should show error toast or message + await expect(page.getByText(ERROR_TEXT_REGEX).first()).toBeVisible({ + timeout: 5000, + }); + }); +}); + +test.describe("Suggested Actions", () => { + test("suggested actions are clickable", async ({ page }) => { + await page.goto("/"); + + const suggestions = page.locator( + "[data-testid='suggested-actions'] button" + ); + const count = await suggestions.count(); + + if (count > 0) { + await suggestions.first().click(); + + // Should redirect after clicking suggestion + await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 }); + } + }); +}); diff --git a/demos/use_cases/vercel-ai-sdk/tests/e2e/auth.test.ts b/demos/use_cases/vercel-ai-sdk/tests/e2e/auth.test.ts new file mode 100644 index 00000000..5dfcbb0a --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/e2e/auth.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Authentication Pages", () => { + test("login page renders correctly", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByPlaceholder("user@acme.com")).toBeVisible(); + await expect(page.getByLabel("Password")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); + await expect(page.getByText("Don't have an account?")).toBeVisible(); + }); + + test("register page renders correctly", async ({ page }) => { + await page.goto("/register"); + await expect(page.getByPlaceholder("user@acme.com")).toBeVisible(); + await expect(page.getByLabel("Password")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign Up" })).toBeVisible(); + await expect(page.getByText("Already have an account?")).toBeVisible(); + }); + + test("can navigate from login to register", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("link", { name: "Sign up" }).click(); + await expect(page).toHaveURL("/register"); + }); + + test("can navigate from register to login", async ({ page }) => { + await page.goto("/register"); + await page.getByRole("link", { name: "Sign in" }).click(); + await expect(page).toHaveURL("/login"); + }); +}); diff --git a/demos/use_cases/vercel-ai-sdk/tests/e2e/chat.test.ts b/demos/use_cases/vercel-ai-sdk/tests/e2e/chat.test.ts new file mode 100644 index 00000000..26cf7531 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/e2e/chat.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Chat Page", () => { + test("home page loads with input field", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("multimodal-input")).toBeVisible(); + }); + + test("can type in the input field", async ({ page }) => { + await page.goto("/"); + const input = page.getByTestId("multimodal-input"); + await input.fill("Hello world"); + await expect(input).toHaveValue("Hello world"); + }); + + test("submit button is visible", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("send-button")).toBeVisible(); + }); + + test("suggested actions are visible on empty chat", async ({ page }) => { + await page.goto("/"); + const suggestions = page.locator("[data-testid='suggested-actions']"); + await expect(suggestions).toBeVisible(); + }); + + test("can stop generation with stop button", async ({ page }) => { + await page.goto("/"); + + // Type and send a message + await page.getByTestId("multimodal-input").fill("Hello"); + await page.getByTestId("send-button").click(); + + // Stop button should appear during generation + const stopButton = page.getByTestId("stop-button"); + // If generation starts, stop button appears + // This is a best-effort check since timing depends on API + await stopButton.click({ timeout: 5000 }).catch(() => { + // Generation may have finished before we could click + }); + }); +}); + +test.describe("Chat Input Features", () => { + test("input clears after sending", async ({ page }) => { + await page.goto("/"); + const input = page.getByTestId("multimodal-input"); + await input.fill("Test message"); + await page.getByTestId("send-button").click(); + + // Input should clear after sending + await expect(input).toHaveValue(""); + }); + + test("input supports multiline text", async ({ page }) => { + await page.goto("/"); + const input = page.getByTestId("multimodal-input"); + await input.fill("Line 1\nLine 2\nLine 3"); + await expect(input).toContainText("Line 1"); + }); +}); diff --git a/demos/use_cases/vercel-ai-sdk/tests/e2e/model-selector.test.ts b/demos/use_cases/vercel-ai-sdk/tests/e2e/model-selector.test.ts new file mode 100644 index 00000000..4943796b --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/e2e/model-selector.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +const MODEL_BUTTON_REGEX = /Gemini|Claude|GPT|Grok/i; + +test.describe("Model Selector", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("displays a model button", async ({ page }) => { + // Look for any button with model-related content + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await expect(modelButton).toBeVisible(); + }); + + test("opens model selector popover on click", async ({ page }) => { + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await modelButton.click(); + + // Search input should be visible in the popover + await expect(page.getByPlaceholder("Search models...")).toBeVisible(); + }); + + test("can search for models", async ({ page }) => { + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await modelButton.click(); + + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("Claude"); + + // Should show at least one Claude model + await expect(page.getByText("Claude Haiku").first()).toBeVisible(); + }); + + test("can close model selector by clicking outside", async ({ page }) => { + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await modelButton.click(); + + await expect(page.getByPlaceholder("Search models...")).toBeVisible(); + + // Click outside to close + await page.keyboard.press("Escape"); + + await expect(page.getByPlaceholder("Search models...")).not.toBeVisible(); + }); + + test("shows model provider groups", async ({ page }) => { + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await modelButton.click(); + + // Should show provider group headers + await expect(page.getByText("Anthropic")).toBeVisible(); + await expect(page.getByText("Google")).toBeVisible(); + }); + + test("can select a different model", async ({ page }) => { + const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first(); + await modelButton.click(); + + // Select a specific model + await page.getByText("Claude Haiku").first().click(); + + // Popover should close + await expect(page.getByPlaceholder("Search models...")).not.toBeVisible(); + + // Model button should now show the selected model + await expect(page.locator("button").filter({ hasText: "Claude Haiku" }).first()).toBeVisible(); + }); +}); diff --git a/demos/use_cases/vercel-ai-sdk/tests/fixtures.ts b/demos/use_cases/vercel-ai-sdk/tests/fixtures.ts new file mode 100644 index 00000000..c782fc0a --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/fixtures.ts @@ -0,0 +1,15 @@ +import { expect as baseExpect, test as baseTest } from "@playwright/test"; +import { ChatPage } from "./pages/chat"; + +type Fixtures = { + chatPage: ChatPage; +}; + +export const test = baseTest.extend({ + chatPage: async ({ page }, use) => { + const chatPage = new ChatPage(page); + await use(chatPage); + }, +}); + +export const expect = baseExpect; diff --git a/demos/use_cases/vercel-ai-sdk/tests/helpers.ts b/demos/use_cases/vercel-ai-sdk/tests/helpers.ts new file mode 100644 index 00000000..6d492fac --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/helpers.ts @@ -0,0 +1,16 @@ +import { generateId } from "ai"; +import { getUnixTime } from "date-fns"; + +export function generateRandomTestUser() { + const email = `test-${getUnixTime(new Date())}@playwright.com`; + const password = generateId(); + + return { + email, + password, + }; +} + +export function generateTestMessage() { + return `Test message ${Date.now()}`; +} diff --git a/demos/use_cases/vercel-ai-sdk/tests/pages/chat.ts b/demos/use_cases/vercel-ai-sdk/tests/pages/chat.ts new file mode 100644 index 00000000..22983fa2 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/pages/chat.ts @@ -0,0 +1,71 @@ +import type { Page } from "@playwright/test"; + +const MODEL_BUTTON_REGEX = /Gemini|Claude|GPT|Grok/i; + +export class ChatPage { + page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await this.page.goto("/"); + } + + async createNewChat() { + await this.page.goto("/"); + await this.page.waitForSelector("[data-testid='multimodal-input']"); + } + + getInput() { + return this.page.getByTestId("multimodal-input"); + } + + async typeMessage(message: string) { + const input = this.getInput(); + await input.fill(message); + } + + async sendMessage() { + await this.page.getByTestId("send-button").click(); + } + + async sendUserMessage(message: string) { + await this.typeMessage(message); + await this.sendMessage(); + } + + getSendButton() { + return this.page.getByTestId("send-button"); + } + + getStopButton() { + return this.page.getByTestId("stop-button"); + } + + async clickSuggestedAction(index = 0) { + const suggestions = this.page.locator( + "[data-testid='suggested-actions'] button" + ); + await suggestions.nth(index).click(); + } + + async openModelSelector() { + const modelButton = this.page + .locator("button") + .filter({ hasText: MODEL_BUTTON_REGEX }) + .first(); + await modelButton.click(); + } + + async selectModel(modelName: string) { + await this.openModelSelector(); + await this.page.getByText(modelName).first().click(); + } + + async searchModels(query: string) { + await this.openModelSelector(); + await this.page.getByPlaceholder("Search models...").fill(query); + } +} diff --git a/demos/use_cases/vercel-ai-sdk/tests/prompts/utils.ts b/demos/use_cases/vercel-ai-sdk/tests/prompts/utils.ts new file mode 100644 index 00000000..83ccf9da --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tests/prompts/utils.ts @@ -0,0 +1,30 @@ +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +const mockUsage = { + inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 20, text: 20, reasoning: 0 }, +}; + +export function getResponseChunksByPrompt( + _prompt: unknown, + includeReasoning = false +): LanguageModelV3StreamPart[] { + const chunks: LanguageModelV3StreamPart[] = []; + + if (includeReasoning) { + chunks.push( + { type: "reasoning-start", id: "r1" }, + { type: "reasoning-delta", id: "r1", delta: "Let me think about this." }, + { type: "reasoning-end", id: "r1" } + ); + } + + chunks.push( + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "Hello, world!" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: "stop", usage: mockUsage } + ); + + return chunks; +}