demo(vercel-ai-sdk): add e2e tests

This commit is contained in:
Musa 2026-01-08 15:22:08 -08:00
parent aad660d136
commit 8f0f335089
8 changed files with 388 additions and 0 deletions

View file

@ -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 });
}
});
});

View file

@ -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");
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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<Fixtures>({
chatPage: async ({ page }, use) => {
const chatPage = new ChatPage(page);
await use(chatPage);
},
});
export const expect = baseExpect;

View file

@ -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()}`;
}

View file

@ -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);
}
}

View file

@ -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;
}