From d5555e3746cc560db2d7f60b06c73311c3118b4d Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 2 Jun 2026 20:30:00 +0200 Subject: [PATCH] test: cover the HTTP API and agent search Extract the Express app from dev.ts into app.ts with an injectable model loader so every route can be exercised over real HTTP without booting the file watcher or the bundler. Split the WebMCP search into a pure searchCatalog so the agent-facing search contract is unit-testable without a DOM; the visible-filter mirroring stays in searchModels. Add tests/server.test.ts (15 tests) covering every endpoint, 404 paths, and the suppressed x-powered-by header, plus tests/webmcp.test.ts (11 tests) covering query, provider, capability, and auth filtering, limits, and result shape. Both run in CI via the existing npm test step. --- src/client/webmcp.ts | 20 ++++- src/server/app.ts | 133 ++++++++++++++++++++++++++++++ src/server/dev.ts | 130 +---------------------------- tests/server.test.ts | 191 +++++++++++++++++++++++++++++++++++++++++++ tests/webmcp.test.ts | 104 +++++++++++++++++++++++ 5 files changed, 448 insertions(+), 130 deletions(-) create mode 100644 src/server/app.ts create mode 100644 tests/server.test.ts create mode 100644 tests/webmcp.test.ts diff --git a/src/client/webmcp.ts b/src/client/webmcp.ts index 15cc408..57ee7ce 100644 --- a/src/client/webmcp.ts +++ b/src/client/webmcp.ts @@ -46,7 +46,7 @@ interface ModelContext { provideContext?: (context: { tools: ToolDefinition[] }) => void; } -function modelId(model: CatalogModel): string { +export function modelId(model: CatalogModel): string { const suffix = model.authType === "subscription" ? "-subscription" : ""; return `${model.provider}/${model.model}${suffix}`; } @@ -113,7 +113,10 @@ function setToggleGroup(selector: string, key: string, wanted: string[]): void { }); } -function searchModels(catalog: Catalog, params: Record) { +// Pure catalog filter shared by the agent tool and exercised directly in tests. +// Side-effect free so the search contract agents depend on can be unit-tested +// without a DOM; the visible-filter mirroring lives in searchModels below. +export function searchCatalog(catalog: Catalog, params: Record) { const query = asString(params.query)?.toLowerCase(); const auth = asString(params.auth); const providers = asStringList(params.provider); @@ -132,8 +135,6 @@ function searchModels(catalog: Catalog, params: Record) { return true; }); - reflectInUi({ query: asString(params.query) ?? "", auth, providers, capabilities }); - return { total: matches.length, returned: Math.min(matches.length, limit), @@ -149,6 +150,17 @@ function searchModels(catalog: Catalog, params: Record) { }; } +function searchModels(catalog: Catalog, params: Record) { + const result = searchCatalog(catalog, params); + reflectInUi({ + query: asString(params.query) ?? "", + auth: asString(params.auth), + providers: asStringList(params.provider), + capabilities: asStringList(params.capability), + }); + return result; +} + async function getModelParameters(params: Record): Promise { const id = asString(params.id); if (!id) return ok({ error: "Provide an `id` such as anthropic/claude-opus-4-7." }); diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..e99508a --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,133 @@ +import express from "express"; +import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; +import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; +import { DIST_ASSETS_DIR } from "../data/paths.js"; +import { buildModelJsonSchema } from "../schema/generate.js"; +import { renderIndex } from "../build/render.js"; +import { renderModelPage } from "../build/render-model.js"; +import { renderProviderPage } from "../build/render-provider.js"; +import { renderGlossaryPage } from "../build/render-glossary.js"; +import { SITE_URL } from "../data/site.js"; +import { modelId, type Model } from "../schema/model.js"; + +/** + * Supplies the catalog to each request. The dev server passes a caching loader; + * tests pass a fixed array. Keeping the data source injectable lets the routes be + * exercised over real HTTP without booting the file watcher or the bundler. + */ +export type LoadModels = () => Promise; + +/** Build the HTTP app that serves the site, the JSON API, and the llms.txt feeds. */ +export function makeApp(loadModels: LoadModels): express.Express { + const app = express(); + app.disable("x-powered-by"); + + app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 })); + + app.get("/", async (_req, res, next) => { + try { + const models = await loadModels(); + const catalog = buildCatalog(models); + const capabilities = buildCapabilityFacets(models); + const providers = buildProviderFacets(models); + const html = await renderIndex({ catalog, capabilities, providers }); + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(html); + } catch (err) { + next(err); + } + }); + + app.get("/glossary", async (_req, res, next) => { + try { + const models = await loadModels(); + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderGlossaryPage(models)); + } catch (err) { + next(err); + } + }); + + app.get("/providers/:provider", async (req, res, next) => { + try { + const models = await loadModels(); + const providerModels = models.filter((m) => m.provider === req.params.provider); + if (providerModels.length === 0) { + res.status(404).type("text/plain").send("Unknown provider"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models)); + } catch (err) { + next(err); + } + }); + + app.get("/models/:provider/:slug", async (req, res, next) => { + try { + const models = await loadModels(); + const wanted = `${req.params.provider}/${req.params.slug}`; + const model = models.find((m) => modelId(m) === wanted); + if (!model) { + res.status(404).type("text/plain").send("Unknown model"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.type("html").send(await renderModelPage(model, models)); + } catch (err) { + next(err); + } + }); + + app.get("/api/v1/models.json", async (_req, res, next) => { + try { + const models = await loadModels(); + res.json(buildCatalog(models)); + } catch (err) { + next(err); + } + }); + + app.get("/api/v1/schema.json", (_req, res) => { + res.json(buildModelJsonSchema()); + }); + + app.get("/llms.txt", async (_req, res, next) => { + try { + const models = await loadModels(); + res.type("text/plain; charset=utf-8").send(buildLlmsTxt(SITE_URL, models)); + } catch (err) { + next(err); + } + }); + + app.get("/llms-full.txt", async (_req, res, next) => { + try { + const models = await loadModels(); + res.type("text/plain; charset=utf-8").send(buildLlmsFullTxt(SITE_URL, models)); + } catch (err) { + next(err); + } + }); + + app.get("/api/v1/models/:provider/:slug.json", async (req, res, next) => { + try { + const models = await loadModels(); + const wanted = `${req.params.provider}/${req.params.slug}`; + const model = models.find((m) => modelId(m) === wanted); + if (!model) { + res.status(404).json({ error: "not_found", id: wanted }); + return; + } + res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }); + } catch (err) { + next(err); + } + }); + + app.get("/healthz", (_req, res) => { + res.json({ ok: true }); + }); + + return app; +} diff --git a/src/server/dev.ts b/src/server/dev.ts index a9246e0..d2c96d7 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,18 +1,10 @@ import path from "node:path"; import chokidar from "chokidar"; -import express from "express"; -import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js"; -import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js"; import { loadAllModels } from "../data/load.js"; -import { CLIENT_DIR, DIST_ASSETS_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js"; -import { buildModelJsonSchema } from "../schema/generate.js"; +import { CLIENT_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js"; import { bundleClientScript, compileStyles, copyStaticAssets } from "../build/assets.js"; -import { renderIndex } from "../build/render.js"; -import { renderModelPage } from "../build/render-model.js"; -import { renderProviderPage } from "../build/render-provider.js"; -import { renderGlossaryPage } from "../build/render-glossary.js"; -import { SITE_URL } from "../data/site.js"; -import { modelId, type Model } from "../schema/model.js"; +import { type Model } from "../schema/model.js"; +import { makeApp } from "./app.js"; const PORT = Number(process.env.PORT ?? 3000); @@ -74,126 +66,12 @@ async function rebuildClientAssets(): Promise { await Promise.all([bundleClientScript(false), compileStyles(), copyStaticAssets()]); } -function makeApp(): express.Express { - const app = express(); - app.disable("x-powered-by"); - - app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 })); - - app.get("/", async (_req, res, next) => { - try { - const { models } = await getCache(); - const catalog = buildCatalog(models); - const capabilities = buildCapabilityFacets(models); - const providers = buildProviderFacets(models); - const html = await renderIndex({ catalog, capabilities, providers }); - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(html); - } catch (err) { - next(err); - } - }); - - app.get("/glossary", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderGlossaryPage(models)); - } catch (err) { - next(err); - } - }); - - app.get("/providers/:provider", async (req, res, next) => { - try { - const { models } = await getCache(); - const providerModels = models.filter((m) => m.provider === req.params.provider); - if (providerModels.length === 0) { - res.status(404).type("text/plain").send("Unknown provider"); - return; - } - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models)); - } catch (err) { - next(err); - } - }); - - app.get("/models/:provider/:slug", async (req, res, next) => { - try { - const { models } = await getCache(); - const wanted = `${req.params.provider}/${req.params.slug}`; - const model = models.find((m) => modelId(m) === wanted); - if (!model) { - res.status(404).type("text/plain").send("Unknown model"); - return; - } - res.setHeader("Cache-Control", "no-store"); - res.type("html").send(await renderModelPage(model, models)); - } catch (err) { - next(err); - } - }); - - app.get("/api/v1/models.json", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.json(buildCatalog(models)); - } catch (err) { - next(err); - } - }); - - app.get("/api/v1/schema.json", (_req, res) => { - res.json(buildModelJsonSchema()); - }); - - app.get("/llms.txt", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.type("text/plain; charset=utf-8").send(buildLlmsTxt(SITE_URL, models)); - } catch (err) { - next(err); - } - }); - - app.get("/llms-full.txt", async (_req, res, next) => { - try { - const { models } = await getCache(); - res.type("text/plain; charset=utf-8").send(buildLlmsFullTxt(SITE_URL, models)); - } catch (err) { - next(err); - } - }); - - app.get("/api/v1/models/:provider/:slug.json", async (req, res, next) => { - try { - const { models } = await getCache(); - const wanted = `${req.params.provider}/${req.params.slug}`; - const model = models.find((m) => modelId(m) === wanted); - if (!model) { - res.status(404).json({ error: "not_found", id: wanted }); - return; - } - res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }); - } catch (err) { - next(err); - } - }); - - app.get("/healthz", (_req, res) => { - res.json({ ok: true }); - }); - - return app; -} - async function main(): Promise { console.log("[dev] bundling client assets..."); await rebuildClientAssets(); await refresh(); watch(); - const app = makeApp(); + const app = makeApp(async () => (await getCache()).models); app.listen(PORT, () => { console.log(`[dev] modelparams.dev → http://localhost:${PORT}`); }); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..10d043b --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,191 @@ +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { makeApp } from "../src/server/app.js"; +import type { Model } from "../src/schema/model.js"; + +function makeModel(overrides: Partial = {}): Model { + return { + provider: "anthropic", + authType: "api_key", + model: "claude-opus-4-7", + params: [ + { + path: "temperature", + type: "number", + label: "Temperature", + description: "Sampling temperature.", + default: 1, + group: "sampling", + }, + { + path: "max_tokens", + type: "integer", + label: "Max tokens", + description: "Maximum output tokens.", + default: 4096, + range: { min: 1 }, + group: "generation_length", + }, + ], + ...overrides, + } as Model; +} + +const MODELS: Model[] = [ + makeModel(), + makeModel({ authType: "subscription" }), + makeModel({ + provider: "openai", + model: "gpt-4o", + params: [ + { path: "top_p", type: "number", label: "Top P", description: "Nucleus.", group: "sampling" }, + ], + }), +]; + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + const app = makeApp(async () => MODELS); + await new Promise((resolve) => { + server = app.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +const get = (path: string): Promise => fetch(`${baseUrl}${path}`); + +describe("GET /healthz", () => { + it("reports ok", async () => { + const res = await get("/healthz"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it("hides the x-powered-by header", async () => { + const res = await get("/healthz"); + expect(res.headers.get("x-powered-by")).toBeNull(); + }); +}); + +describe("GET /api/v1/models.json", () => { + it("returns the catalog with a count and a $schema", async () => { + const res = await get("/api/v1/models.json"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = await res.json(); + expect(body.count).toBe(MODELS.length); + expect(body.models).toHaveLength(MODELS.length); + expect(body.$schema).toBe("https://modelparams.dev/api/v1/schema.json"); + }); +}); + +describe("GET /api/v1/schema.json", () => { + it("returns the JSON Schema for a model", async () => { + const res = await get("/api/v1/schema.json"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.$id).toBe("https://modelparams.dev/api/v1/schema.json"); + expect(body.title).toBe("modelparams.dev Model"); + expect(body.definitions?.Model).toBeTruthy(); + }); +}); + +describe("GET /api/v1/models/:provider/:slug.json", () => { + it("returns the full model for a known id", async () => { + const res = await get("/api/v1/models/anthropic/claude-opus-4-7.json"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.provider).toBe("anthropic"); + expect(body.model).toBe("claude-opus-4-7"); + expect(body.authType).toBe("api_key"); + expect(body.$schema).toBe("https://modelparams.dev/api/v1/schema.json"); + }); + + it("resolves the -subscription variant to the subscription model", async () => { + const res = await get("/api/v1/models/anthropic/claude-opus-4-7-subscription.json"); + expect(res.status).toBe(200); + expect((await res.json()).authType).toBe("subscription"); + }); + + it("404s with a JSON error for an unknown id", async () => { + const res = await get("/api/v1/models/anthropic/does-not-exist.json"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found", id: "anthropic/does-not-exist" }); + }); +}); + +describe("GET / (home)", () => { + it("renders HTML with no-store caching", async () => { + const res = await get("/"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(res.headers.get("cache-control")).toBe("no-store"); + const body = await res.text(); + expect(body).toContain(""); + expect(body).toContain("modelparams.dev"); + }); +}); + +describe("GET /glossary", () => { + it("renders the glossary page", async () => { + const res = await get("/glossary"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(""); + }); +}); + +describe("GET /providers/:provider", () => { + it("renders a hub for a known provider", async () => { + const res = await get("/providers/anthropic"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain("Anthropic"); + }); + + it("404s for an unknown provider", async () => { + const res = await get("/providers/not-a-provider"); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Unknown provider"); + }); +}); + +describe("GET /models/:provider/:slug", () => { + it("renders a model page for a known model", async () => { + const res = await get("/models/openai/gpt-4o"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(""); + }); + + it("404s for an unknown model", async () => { + const res = await get("/models/anthropic/does-not-exist"); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Unknown model"); + }); +}); + +describe("llms.txt feeds", () => { + it("serves llms.txt as plain text", async () => { + const res = await get("/llms.txt"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/plain"); + expect(await res.text()).toMatch(/^# modelparams\.dev/); + }); + + it("serves the full catalog dump", async () => { + const res = await get("/llms-full.txt"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("# Full catalog"); + }); +}); diff --git a/tests/webmcp.test.ts b/tests/webmcp.test.ts new file mode 100644 index 0000000..82ca964 --- /dev/null +++ b/tests/webmcp.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { modelId, searchCatalog } from "../src/client/webmcp.js"; + +type AuthType = "api_key" | "subscription"; + +function model(provider: string, name: string, authType: AuthType, paths: string[]) { + return { + provider, + model: name, + authType, + params: paths.map((path) => ({ + path, + type: "number", + group: "sampling", + description: "", + })), + }; +} + +function catalog(...models: ReturnType[]) { + return { count: models.length, models }; +} + +const CATALOG = catalog( + model("anthropic", "claude-opus-4-7", "api_key", ["temperature", "thinking.type", "max_tokens"]), + model("anthropic", "claude-opus-4-7", "subscription", ["temperature", "thinking.type"]), + model("openai", "gpt-4o", "api_key", ["temperature", "top_p"]), + model("deepseek", "deepseek-chat", "api_key", ["temperature"]), +); + +describe("modelId", () => { + it("leaves api_key models bare", () => { + expect(modelId(model("openai", "gpt-4o", "api_key", []))).toBe("openai/gpt-4o"); + }); + + it("suffixes subscription models", () => { + expect(modelId(model("anthropic", "claude-opus-4-7", "subscription", []))).toBe( + "anthropic/claude-opus-4-7-subscription", + ); + }); +}); + +describe("searchCatalog", () => { + it("returns every model when no filters are given", () => { + const result = searchCatalog(CATALOG, {}); + expect(result.total).toBe(4); + expect(result.returned).toBe(4); + expect(result.truncated).toBe(false); + }); + + it("matches free-text query case-insensitively on name, provider, or id", () => { + expect(searchCatalog(CATALOG, { query: "opus" }).total).toBe(2); + expect(searchCatalog(CATALOG, { query: "OPUS" }).total).toBe(2); + expect(searchCatalog(CATALOG, { query: "openai" }).total).toBe(1); + expect(searchCatalog(CATALOG, { query: "nonsense" }).total).toBe(0); + }); + + it("filters by provider slug", () => { + const result = searchCatalog(CATALOG, { provider: "anthropic" }); + expect(result.total).toBe(2); + expect(result.models.every((m) => m.provider === "anthropic")).toBe(true); + }); + + it("requires the model to expose every requested capability", () => { + expect(searchCatalog(CATALOG, { capability: "thinking.type" }).total).toBe(2); + expect(searchCatalog(CATALOG, { capability: ["thinking.type", "max_tokens"] }).total).toBe(1); + expect(searchCatalog(CATALOG, { capability: "does.not.exist" }).total).toBe(0); + }); + + it("filters by auth type and treats `all` as no filter", () => { + expect(searchCatalog(CATALOG, { auth: "subscription" }).total).toBe(1); + expect(searchCatalog(CATALOG, { auth: "api_key" }).total).toBe(3); + expect(searchCatalog(CATALOG, { auth: "all" }).total).toBe(4); + }); + + it("combines filters (AND semantics)", () => { + const result = searchCatalog(CATALOG, { provider: "anthropic", auth: "api_key" }); + expect(result.total).toBe(1); + expect(result.models[0]?.id).toBe("anthropic/claude-opus-4-7"); + }); + + it("caps results at the limit and flags truncation", () => { + const result = searchCatalog(CATALOG, { limit: 1 }); + expect(result.total).toBe(4); + expect(result.returned).toBe(1); + expect(result.truncated).toBe(true); + expect(result.models).toHaveLength(1); + }); + + it("clamps a non-positive limit to at least one result", () => { + expect(searchCatalog(CATALOG, { limit: 0 }).models).toHaveLength(1); + }); + + it("returns id, auth, and parameter paths for each match", () => { + const sub = searchCatalog(CATALOG, { auth: "subscription" }).models[0]; + expect(sub).toMatchObject({ + id: "anthropic/claude-opus-4-7-subscription", + provider: "anthropic", + authType: "subscription", + parameterCount: 2, + }); + expect(sub?.parameters).toEqual(["temperature", "thinking.type"]); + }); +});