Fix logo gradient id on mobile, appearance hydration; Resend sender mail.spboucher.ai; prod smoke + visual + integration tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 changed files +182 −7
modified
.env.example
+1 −1
@@ -14,7 +14,7 @@ AUTH_SECRET= | ||
| 14 | 14 | |
| 15 | 15 | # --- Email (Resend) ------------------------------------------------------ |
| 16 | 16 | RESEND_API_KEY= |
| 17 | −RESEND_FROM_EMAIL="PolyLLM <mail@spboucher.ai>" | |
| 17 | +RESEND_FROM_EMAIL="PolyLLM <polyllm@mail.spboucher.ai>" | |
| 18 | 18 | # Set to 1 to log emails instead of sending (development). |
| 19 | 19 | EMAIL_DRY_RUN=0 |
| 20 | 20 | |
modified
e2e/helpers.ts
+2 −2
@@ -43,10 +43,10 @@ export async function noConsoleErrors(page: Page) { | ||
| 43 | 43 | return errors; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | −const STATE_FILE = "test-results/.e2e-session.json"; | |
| 46 | +const STATE_FILE = "qa/.e2e-session.json"; | |
| 47 | 47 | /** Persist the signed-in cookies so later tests don't hit the sign-in rate limit (8/min). */ |
| 48 | 48 | export async function saveSession(page: Page) { |
| 49 | − fs.mkdirSync("test-results", { recursive: true }); | |
| 49 | + fs.mkdirSync("qa", { recursive: true }); | |
| 50 | 50 | await page.context().storageState({ path: STATE_FILE }); |
| 51 | 51 | } |
| 52 | 52 | export async function restoreSession(page: Page) { |
added
e2e/prod-smoke.spec.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +import { test, expect } from "@playwright/test"; | |
| 2 | +import { login, readEnv } from "./helpers"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Production smoke test — runs against E2E_BASE_URL (https://www.polyllm.io) with an existing, | |
| 6 | + * verified account (E2E_EMAIL / E2E_PASSWORD). Adds a real Anthropic key, sends a message and | |
| 7 | + * checks that tokens stream back through ngrok. Skipped unless PROD_SMOKE=1. | |
| 8 | + */ | |
| 9 | +test.skip(process.env.PROD_SMOKE !== "1", "set PROD_SMOKE=1 to run against production"); | |
| 10 | + | |
| 11 | +test("production: login → key → real streamed answer", async ({ page }) => { | |
| 12 | + const email = process.env.E2E_EMAIL!; | |
| 13 | + const password = process.env.E2E_PASSWORD!; | |
| 14 | + const key = readEnv("ANTHROPIC_API_KEY")!; | |
| 15 | + const errors: string[] = []; | |
| 16 | + page.on("pageerror", (e) => errors.push(e.message)); | |
| 17 | + | |
| 18 | + await login(page, email, password); | |
| 19 | + await page.goto("/app/settings/providers"); | |
| 20 | + const item = page.locator("li", { has: page.getByRole("heading", { level: 3, name: /^Anthropic$/ }) }).first(); | |
| 21 | + await item.getByRole("button", { name: /add key|replace key/i }).click(); | |
| 22 | + await page.locator("#provider-api-key").fill(key); | |
| 23 | + await page.getByRole("button", { name: /^connect$|^replace key$/i }).last().click(); | |
| 24 | + await expect(item).toContainText(/connected/i, { timeout: 60_000 }); | |
| 25 | + | |
| 26 | + await page.goto("/app/chat"); | |
| 27 | + await page.getByRole("button", { name: /select model/i }).click(); | |
| 28 | + await page.getByLabel(/search models/i).fill("haiku 4.5"); | |
| 29 | + await page.getByRole("option").first().click(); | |
| 30 | + await page.getByLabel(/^message$/i).fill("Reply with exactly the word PONG and nothing else."); | |
| 31 | + await page.getByRole("button", { name: /^send$/i }).click(); | |
| 32 | + await expect(page.getByText(/PONG/).first()).toBeVisible({ timeout: 90_000 }); | |
| 33 | + await page.waitForURL(/\/app\/chat\/cnv_/, { timeout: 30_000 }); | |
| 34 | + await expect(page.getByText(/tok\/s|\bin ·/).first()).toBeVisible({ timeout: 30_000 }); | |
| 35 | + await page.screenshot({ path: "qa/out/prod-chat.png" }); | |
| 36 | + expect(errors).toEqual([]); | |
| 37 | +}); | |
added
e2e/visual.spec.ts
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import { test, expect, type Page } from "@playwright/test"; | |
| 2 | +import fs from "node:fs"; | |
| 3 | +import { restoreSession } from "./helpers"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Visual pass: every main screen in light + dark, desktop + mobile widths. | |
| 7 | + * Asserts: no console errors, no horizontal overflow. Screenshots → qa/out/. | |
| 8 | + * Requires a saved session from full-flow.spec.ts (run it first). | |
| 9 | + */ | |
| 10 | +const PAGES = ["/app/chat", "/app/arena", "/app/models", "/app/prompts", "/app/presets", "/app/usage", "/app/settings/account", "/app/settings/security", "/app/settings/providers", "/app/settings/appearance", "/app/settings/data", "/"]; | |
| 11 | +const VIEWPORTS = { desktop: { width: 1360, height: 860 }, mobile: { width: 390, height: 844 } }; | |
| 12 | + | |
| 13 | +async function setTheme(page: Page, theme: "light" | "dark") { | |
| 14 | + await page.addInitScript((t) => { | |
| 15 | + window.localStorage.setItem("theme", t); | |
| 16 | + }, theme); | |
| 17 | +} | |
| 18 | + | |
| 19 | +for (const theme of ["dark", "light"] as const) { | |
| 20 | + for (const [vp, size] of Object.entries(VIEWPORTS)) { | |
| 21 | + test(`screens ${theme} ${vp}`, async ({ page }) => { | |
| 22 | + test.setTimeout(240_000); | |
| 23 | + const errors: string[] = []; | |
| 24 | + page.on("console", (m) => { | |
| 25 | + if (m.type() === "error" && !/favicon|Download the React DevTools/i.test(m.text())) errors.push(`${m.text()}`); | |
| 26 | + }); | |
| 27 | + page.on("pageerror", (e) => errors.push(e.message)); | |
| 28 | + await page.setViewportSize(size); | |
| 29 | + await setTheme(page, theme); | |
| 30 | + await restoreSession(page); | |
| 31 | + fs.mkdirSync("qa/out", { recursive: true }); | |
| 32 | + for (const path of PAGES) { | |
| 33 | + await page.goto(path, { waitUntil: "networkidle" }).catch(() => page.goto(path)); | |
| 34 | + await page.waitForTimeout(400); | |
| 35 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); | |
| 36 | + expect.soft(overflow, `${path} (${theme}/${vp}) horizontal overflow ${overflow}px`).toBeLessThanOrEqual(1); | |
| 37 | + await page.screenshot({ path: `qa/out/${theme}-${vp}-${path.replace(/\//g, "_") || "_home"}.png`, fullPage: vp === "desktop" }); | |
| 38 | + } | |
| 39 | + expect(errors, "console errors").toEqual([]); | |
| 40 | + }); | |
| 41 | + } | |
| 42 | +} | |
added
qa/.e2e-session.json
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +{ | |
| 2 | + "cookies": [ | |
| 3 | + { | |
| 4 | + "name": "polyllm.session_token", | |
| 5 | + "value": "OW8aTEihDFQdZszuaP7ZOTlBJzCGcL4X.fh4lrQahmOboChg8zxS9lYyIExLxJNlvkbeFUyKcBp8%3D", | |
| 6 | + "domain": "localhost", | |
| 7 | + "path": "/", | |
| 8 | + "expires": 1791439031.995432, | |
| 9 | + "httpOnly": true, | |
| 10 | + "secure": false, | |
| 11 | + "sameSite": "Lax" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "name": "polyllm.session_data", | |
| 15 | + "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0wOFQwNTo1NzoxMS45OTFaIiwidG9rZW4iOiJPVzhhVEVpaERGUWRac3p1YVA3Wk9UbEJKekNHY0w0WCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuOTkxWiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuOTkxWiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xNTMuMC44MDEwLjEyIFNhZmFyaS81MzcuMzYiLCJ1c2VySWQiOiJESjRuTjNieUttVkJCZG5DcEw2Z0tYbU52Vk5jU0s2NiIsImlkIjoiUkhCaDRtdzU3YnN3NEZObEwxOEczR2pQNGhrb2MyV3MifSwidXNlciI6eyJuYW1lIjoiRTJFIFRlc3RlciIsImVtYWlsIjoiZTJlKzE3ODg4NDcwMjkzNjJAcG9seWxsbS50ZXN0IiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTAuMzI5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuMDAwWiIsInJvbGUiOiJ1c2VyIiwib25ib2FyZGluZ0NvbXBsZXRlZEF0IjpudWxsLCJpZCI6IkRKNG5OM2J5S21WQkJkbkNwTDZnS1htTnZWTmNTSzY2In0sInVwZGF0ZWRBdCI6MTc4ODg0NzAzMTk5NCwidmVyc2lvbiI6IjEifSwiZXhwaXJlc0F0IjoxNzg4ODQ3MzMxOTk0LCJzaWduYXR1cmUiOiJ2R2t0b3pvODhRU05YaHNHcjFRUThKQ3VpdVhDaWQ1bUVPdlpRSHNqTUdBIn0", | |
| 16 | + "domain": "localhost", | |
| 17 | + "path": "/", | |
| 18 | + "expires": 1788847331.99546, | |
| 19 | + "httpOnly": true, | |
| 20 | + "secure": false, | |
| 21 | + "sameSite": "Lax" | |
| 22 | + } | |
| 23 | + ], | |
| 24 | + "origins": [] | |
| 25 | +} | |
| \ No newline at end of file | ||
modified
src/app/app/settings/appearance/page.tsx
+6 −1
@@ -43,6 +43,11 @@ const TOGGLES: { key: BoolKey; label: string; description: React.ReactNode; grou | ||
| 43 | 43 | export default function AppearanceSettingsPage() { |
| 44 | 44 | const { preferences, updatePreferences } = useApp(); |
| 45 | 45 | const { setTheme, resolvedTheme } = useTheme(); |
| 46 | + const [mounted, setMounted] = React.useState(false); | |
| 47 | + React.useEffect(() => { | |
| 48 | + // eslint-disable-next-line react-hooks/set-state-in-effect | |
| 49 | + setMounted(true); | |
| 50 | + }, []); | |
| 46 | 51 | const [pending, setPending] = React.useState<string | null>(null); |
| 47 | 52 | const currentTheme = (preferences.theme as Theme) ?? "system"; |
| 48 | 53 | |
@@ -65,7 +70,7 @@ export default function AppearanceSettingsPage() { | ||
| 65 | 70 | return ( |
| 66 | 71 | <div className="space-y-5"> |
| 67 | 72 | <Card> |
| 68 | − <CardHeader title="Theme" description={`Currently rendering ${resolvedTheme ?? "…"}.`} /> | |
| 73 | + <CardHeader title="Theme" description={mounted && resolvedTheme ? `Currently rendering ${resolvedTheme}.` : "Choose how PolyLLM looks."} /> | |
| 69 | 74 | <CardBody> |
| 70 | 75 | <div role="radiogroup" aria-label="Theme" className="grid grid-cols-3 gap-2.5"> |
| 71 | 76 | {THEMES.map((t) => { |
modified
src/components/brand/logo.tsx
+7 −2
@@ -1,17 +1,22 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useId } from "react"; | |
| 1 | 3 | import { cn } from "@/lib/utils"; |
| 2 | 4 | |
| 3 | 5 | /** PolyLLM mark: four overlapping nodes converging into one — "one interface, every model". */ |
| 4 | 6 | export function LogoMark({ className, size = 28 }: { className?: string; size?: number }) { |
| 7 | + // Unique gradient id per instance: a duplicate id inside a `display:none` sidebar would otherwise | |
| 8 | + // win the lookup and the mark would paint black on mobile. | |
| 9 | + const gid = `plm-g-${useId().replace(/[^a-zA-Z0-9]/g, "")}`; | |
| 5 | 10 | return ( |
| 6 | 11 | <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn("shrink-0", className)} aria-hidden> |
| 7 | 12 | <defs> |
| 8 | − <linearGradient id="plm-g" x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse"> | |
| 13 | + <linearGradient id={gid} x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse"> | |
| 9 | 14 | <stop stopColor="#8b8dff" /> |
| 10 | 15 | <stop offset="0.55" stopColor="#b58cff" /> |
| 11 | 16 | <stop offset="1" stopColor="#ff8fab" /> |
| 12 | 17 | </linearGradient> |
| 13 | 18 | </defs> |
| 14 | − <rect x="1" y="1" width="30" height="30" rx="9" fill="url(#plm-g)" /> | |
| 19 | + <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} /> | |
| 15 | 20 | <g stroke="#0b0c10" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"> |
| 16 | 21 | <path d="M9 10.5h4.5M9 16h6M9 21.5h4.5" /> |
| 17 | 22 | <path d="M23 10.5h-4.5M23 16h-6M23 21.5h-4.5" /> |
modified
src/lib/email/index.ts
+1 −1
@@ -17,7 +17,7 @@ class EmailService { | ||
| 17 | 17 | |
| 18 | 18 | constructor() { |
| 19 | 19 | const apiKey = process.env.RESEND_API_KEY; |
| 20 | − this.from = process.env.RESEND_FROM_EMAIL ?? "PolyLLM <mail@spboucher.ai>"; | |
| 20 | + this.from = process.env.RESEND_FROM_EMAIL ?? "PolyLLM <polyllm@mail.spboucher.ai>"; | |
| 21 | 21 | this.dryRun = !apiKey || process.env.EMAIL_DRY_RUN === "1"; |
| 22 | 22 | this.resend = apiKey ? new Resend(apiKey) : null; |
| 23 | 23 | } |
added
tests/integration/registry.test.ts
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +import { describe, it, expect, afterAll } from "vitest"; | |
| 2 | +import { execSync } from "node:child_process"; | |
| 3 | + | |
| 4 | +// Loads .env (DATABASE_URL, provider keys) without printing anything. | |
| 5 | +import "../../scripts/load-env"; | |
| 6 | + | |
| 7 | +const hasDb = (() => { | |
| 8 | + try { | |
| 9 | + execSync(`psql "${process.env.DATABASE_URL ?? "postgres://localhost:5432/polyllm"}" -Atc "select 1"`, { stdio: "ignore" }); | |
| 10 | + return true; | |
| 11 | + } catch { | |
| 12 | + return false; | |
| 13 | + } | |
| 14 | +})(); | |
| 15 | + | |
| 16 | +describe.skipIf(!hasDb)("integration: database + registry + key vault", () => { | |
| 17 | + it("syncs a provider into the registry with a real key and reads it back", async () => { | |
| 18 | + const key = process.env.ANTHROPIC_API_KEY; | |
| 19 | + if (!key) return; // no key → nothing to sync | |
| 20 | + const { syncProvider, listRegistryModels, getModel } = await import("@/lib/ai/registry"); | |
| 21 | + const res = await syncProvider("anthropic", key, "test"); | |
| 22 | + expect(res.ok).toBe(true); | |
| 23 | + expect(res.found).toBeGreaterThan(3); | |
| 24 | + const models = await listRegistryModels({ provider: "anthropic" }); | |
| 25 | + expect(models.some((m) => m.id.startsWith("claude-"))).toBe(true); | |
| 26 | + const m = await getModel(models[0].key); | |
| 27 | + expect(m?.capabilities.text).toBe(true); | |
| 28 | + expect(m?.pricing?.inputPerMillion).toBeGreaterThan(0); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("stores provider keys encrypted and never returns them to callers", async () => { | |
| 32 | + process.env.API_KEY_ENCRYPTION_SECRET ??= "integration-secret-0123456789abcdef0123456789abcdef"; | |
| 33 | + const { getDb, users, providerConnections } = await import("@/db"); | |
| 34 | + const { upsertConnection, listConnections, getDecryptedKey, deleteConnection } = await import("@/lib/providers/keys"); | |
| 35 | + const { eq } = await import("drizzle-orm"); | |
| 36 | + const db = getDb(); | |
| 37 | + const id = `it_${Date.now()}`; | |
| 38 | + await db.insert(users).values({ id, name: "Integration", email: `${id}@polyllm.test`, emailVerified: true }); | |
| 39 | + try { | |
| 40 | + const fake = "xai-integration-test-key-0000000000000000"; | |
| 41 | + const res = await upsertConnection(id, "xai", fake, { validate: false }); | |
| 42 | + expect(res.ok).toBe(true); | |
| 43 | + const [row] = await db.select().from(providerConnections).where(eq(providerConnections.userId, id)); | |
| 44 | + expect(row.encryptedKey.startsWith("v1.")).toBe(true); | |
| 45 | + expect(row.encryptedKey).not.toContain("integration-test"); | |
| 46 | + expect(row.keyHint).toMatch(/••••/); | |
| 47 | + const pub = await listConnections(id); | |
| 48 | + expect(JSON.stringify(pub)).not.toContain("integration-test"); | |
| 49 | + expect(await getDecryptedKey(id, "xai")).toBe(fake); | |
| 50 | + await deleteConnection(id, "xai"); | |
| 51 | + expect(await getDecryptedKey(id, "xai")).toBeNull(); | |
| 52 | + } finally { | |
| 53 | + await db.delete(users).where(eq(users.id, id)); | |
| 54 | + } | |
| 55 | + }); | |
| 56 | + | |
| 57 | + afterAll(async () => { | |
| 58 | + const { closeDb } = await import("@/db"); | |
| 59 | + await closeDb(); | |
| 60 | + }); | |
| 61 | +}); | |
| 62 | ||