SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.9 KB · 166 lines typescript
Raw Blame History
1import { test, expect } from "@playwright/test";2import { TEST_EMAIL, TEST_PASSWORD, lastLinkFromLog, login, noConsoleErrors, readEnv, restoreSession, saveSession, sql } from "./helpers";34/**5 * Full workflow against the dev server (EMAIL_DRY_RUN=1 EMAIL_DRY_RUN_PRINT=1 so links land in the log):6 * sign up → verify email → login → add provider key → validate → select model → send → stream → new chat →7 * reload → persistence → change password → logout → login again.8 */9test.describe.serial("PolyLLM full flow", () => {10  const email = TEST_EMAIL;11  const password = TEST_PASSWORD;12  let conversationUrl = "";1314  test("landing renders without console errors", async ({ page }) => {15    const errors = await noConsoleErrors(page);16    await page.goto("/");17    await expect(page.getByRole("heading", { level: 1 })).toContainText(/every model/i);18    await expect(page.getByRole("link", { name: /start using polyllm/i }).first()).toBeVisible();19    expect(errors).toEqual([]);20  });2122  test("sign up sends a verification email", async ({ page }) => {23    await page.goto("/signup");24    await page.getByLabel(/name/i).fill("E2E Tester");25    await page.getByLabel(/email/i).fill(email);26    await page.getByLabel(/^password$/i).fill(password);27    const confirm = page.getByLabel(/confirm/i);28    if (await confirm.count()) await confirm.fill(password);29    await page.getByRole("button", { name: /create account|sign up/i }).click();30    await expect(page.getByText(/check your inbox|verify/i).first()).toBeVisible({ timeout: 30_000 });31    // the account exists but is unverified32    await expect.poll(() => sql(`select email_verified from users where email='${email}'`)).toBe("f");33  });3435  test("login before verification is refused", async ({ page }) => {36    await page.goto("/login");37    await page.getByLabel(/email/i).fill(email);38    await page.getByLabel(/^password$/i).fill(password);39    await page.getByRole("button", { name: /sign in/i }).click();40    await expect(page.getByText(/verify|not verified|check your inbox/i).first()).toBeVisible({ timeout: 20_000 });41  });4243  test("verification link activates the account and signs in", async ({ page }) => {44    const link = lastLinkFromLog(/http:\/\/localhost:3000\/api\/auth\/verify-email\?token=[^\s"'<>]+/g);45    expect(link, "verification link should be printed in the dev log").toBeTruthy();46    await page.goto(link!);47    await page.waitForURL(/\/app|\/verify-email/, { timeout: 30_000 });48    await expect.poll(() => sql(`select email_verified from users where email='${email}'`)).toBe("t");49    if (!/\/app/.test(page.url())) await login(page, email, password);50    await expect(page).toHaveURL(/\/app\/chat/);51  });5253  test("add + validate provider keys (real providers)", async ({ page }) => {54    await login(page, email, password);55    await saveSession(page);56    await page.goto("/app/settings/providers");57    const names: Record<string, RegExp> = { anthropic: /^Anthropic$/, openai: /^OpenAI$/, xai: /^xAI$/, gemini: /^Google Gemini$/, mistral: /^Mistral AI$/, deepseek: /^DeepSeek$/, kimi: /^Kimi/, openrouter: /^OpenRouter$/, cerebras: /^Cerebras$/ };58    for (const [provider, envName] of [59      ["anthropic", "ANTHROPIC_API_KEY"],60      ["openai", "OPENAI_API_KEY"],61      ["xai", "XAI_API_KEY"],62      ["gemini", "GOOGLE_GEMINI_API_KEY"],63      ["mistral", "MISTRAL_API_KEY"],64      ["deepseek", "DEEPSEEK_API_KEY"],65      ["kimi", "KIMI_API_KEY"],66      ["openrouter", "OPENROUTER_API_KEY"],67      ["cerebras", "CEREBRAS_API_KEY"],68    ] as const) {69      const key = readEnv(envName);70      test.skip(!key, `${envName} missing`);71      const item = page.locator("li", { has: page.getByRole("heading", { level: 3, name: names[provider] }) }).first();72      await item.getByRole("button", { name: /add key|replace key/i }).click();73      await page.locator("#provider-api-key").fill(key!);74      await page.getByRole("button", { name: /^connect$|^replace key$/i }).last().click();75      await expect(item).toContainText(/connected/i, { timeout: 60_000 });76      await expect(page.locator("#provider-api-key")).toHaveCount(0);77    }78    // never in the DOM79    const html = await page.content();80    expect(html).not.toContain(readEnv("ANTHROPIC_API_KEY")!.slice(10, 30));81    await expect.poll(() => sql(`select count(*) from provider_connections pc join users u on u.id=pc.user_id where u.email='${email}' and pc.status='valid'`)).toBe("9");82    // keys are encrypted at rest83    const stored = sql(`select encrypted_key from provider_connections pc join users u on u.id=pc.user_id where u.email='${email}' and provider='anthropic'`);84    expect(stored.startsWith("v1.")).toBe(true);85    expect(stored).not.toContain("sk-ant");86  });8788  test("select a model, send a message and receive a real stream", async ({ page }) => {89    await restoreSession(page);90    await page.goto("/app/chat");91    await page.getByRole("button", { name: /select model/i }).click();92    await page.getByLabel(/search models/i).fill("haiku 4.5");93    await page.getByRole("option").first().click();94    await expect(page.getByRole("button", { name: /select model/i })).toContainText(/haiku/i);95    await page.getByLabel(/^message$/i).fill("Reply with exactly the word PONG and nothing else.");96    await page.getByRole("button", { name: /^send$/i }).click();97    await expect(page.getByText(/PONG/).first()).toBeVisible({ timeout: 60_000 });98    await page.waitForURL(/\/app\/chat\/cnv_/, { timeout: 30_000 });99    conversationUrl = page.url();100    // footer meta appears (tokens + latency)101    await expect(page.getByText(/tok\/s|\bin ·/).first()).toBeVisible({ timeout: 30_000 });102    // sidebar shows the conversation with an auto title103    await expect(page.locator("aside").getByText(/reply with exactly/i).first()).toBeVisible();104  });105106  test("conversation persists after reload and a second model works", async ({ page }) => {107    await restoreSession(page);108    await page.goto(conversationUrl);109    await expect(page.getByText(/PONG/).first()).toBeVisible();110    // switch to an OpenAI model in the same conversation and continue111    await page.getByRole("button", { name: /select model/i }).click();112    await page.getByLabel(/search models/i).fill("gpt-5.4-nano");113    await page.getByRole("option").first().click();114    await page.getByLabel(/^message$/i).fill("Now reply with exactly the word PING.");115    await page.getByRole("button", { name: /^send$/i }).click();116    await expect(page.getByText(/PING/).first()).toBeVisible({ timeout: 60_000 });117    await page.reload();118    await expect(page.getByText(/PONG/).first()).toBeVisible();119    await expect(page.getByText(/PING/).first()).toBeVisible();120  });121122  test("model configuration is capability-driven", async ({ page }) => {123    await restoreSession(page);124    await page.goto("/app/chat");125    await page.getByRole("button", { name: /select model/i }).click();126    // Pin the native OpenAI catalog (OpenRouter also lists openai/* models with different parameter sheets).127    await page.getByRole("dialog").getByRole("button", { name: /^OpenAI$/ }).click();128    await page.getByLabel(/search models/i).fill("gpt-5.5");129    await page.getByRole("option").first().click();130    await page.getByRole("button", { name: /model configuration/i }).click();131    await expect(page.getByText(/reasoning/i).first()).toBeVisible();132    await expect(page.getByText(/^Effort$/)).toBeVisible();133    // seed/stop are not supported by the Responses API → never shown134    await expect(page.getByText(/^Seed$/)).toHaveCount(0);135    await expect(page.getByText(/^Stop sequences$/)).toHaveCount(0);136    await expect(page.getByText(/not available for this model/i)).toContainText(/seed/i);137    await page.keyboard.press("Escape");138  });139140  test("usage dashboard shows the requests", async ({ page }) => {141    await restoreSession(page);142    await page.goto("/app/usage");143    await expect(page.getByText(/requests/i).first()).toBeVisible();144    await expect.poll(() => Number(sql(`select count(*) from usage_records ur join users u on u.id=ur.user_id where u.email='${email}'`))).toBeGreaterThanOrEqual(2);145  });146147  test("change password, logout, login again", async ({ page }) => {148    await restoreSession(page);149    await page.goto("/app/settings/security");150    const newPassword = `${password}-2`;151    await page.getByLabel(/current password/i).fill(password);152    await page.getByLabel(/^new password$/i).fill(newPassword);153    const confirm = page.getByLabel(/confirm/i);154    if (await confirm.count()) await confirm.fill(newPassword);155    await page.getByRole("button", { name: /change password|update password/i }).click();156    await expect(page.getByText(/password (changed|updated)/i).first()).toBeVisible({ timeout: 20_000 });157    // sign out via the sidebar menu158    await page.goto("/app/chat");159    await page.getByText(email).first().click();160    await page.getByRole("menuitem", { name: /sign out/i }).click();161    await page.waitForURL(/\/login/);162    await login(page, email, newPassword);163    await expect(page).toHaveURL(/\/app/);164  });165});166