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%

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>
Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent 47ed804

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