mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
demo(vercel-ai-sdk): add e2e tests
This commit is contained in:
parent
aad660d136
commit
8f0f335089
8 changed files with 388 additions and 0 deletions
95
demos/use_cases/vercel-ai-sdk/tests/e2e/api.test.ts
Normal file
95
demos/use_cases/vercel-ai-sdk/tests/e2e/api.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
31
demos/use_cases/vercel-ai-sdk/tests/e2e/auth.test.ts
Normal file
31
demos/use_cases/vercel-ai-sdk/tests/e2e/auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
61
demos/use_cases/vercel-ai-sdk/tests/e2e/chat.test.ts
Normal file
61
demos/use_cases/vercel-ai-sdk/tests/e2e/chat.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
15
demos/use_cases/vercel-ai-sdk/tests/fixtures.ts
Normal file
15
demos/use_cases/vercel-ai-sdk/tests/fixtures.ts
Normal 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;
|
||||
16
demos/use_cases/vercel-ai-sdk/tests/helpers.ts
Normal file
16
demos/use_cases/vercel-ai-sdk/tests/helpers.ts
Normal 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()}`;
|
||||
}
|
||||
71
demos/use_cases/vercel-ai-sdk/tests/pages/chat.ts
Normal file
71
demos/use_cases/vercel-ai-sdk/tests/pages/chat.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
demos/use_cases/vercel-ai-sdk/tests/prompts/utils.ts
Normal file
30
demos/use_cases/vercel-ai-sdk/tests/prompts/utils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue