CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
dxt-extension.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| cd4f63b | 1 | /** |
| 2 | * BLOCK Q1 — Claude Desktop (.dxt) extension tests. | |
| 3 | * | |
| 4 | * Covers: | |
| 5 | * - extension/gluecron.dxt/manifest.json is valid JSON | |
| 6 | * - Manifest declares all 15 tools (cross-checked against | |
| 7 | * src/lib/mcp-tools.ts's defaultTools() exports) | |
| 8 | * - server.endpoint contains the templated host placeholder | |
| 9 | * - GET /gluecron.dxt returns 200 + correct Content-Type when the | |
| 10 | * pre-built bundle exists in public/ | |
| 11 | * - GET /gluecron.dxt returns 404 with friendly JSON when missing | |
| 12 | * - Landing page renders the "Add to Claude Desktop" CTA | |
| 13 | * | |
| 14 | * Static-content surface — no DB stubs, no mocks needed. The 404 case is | |
| 15 | * exercised by temporarily renaming the bundle out of the way and | |
| 16 | * restoring it before the next test. | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 20 | import { readFileSync, existsSync, renameSync } from "node:fs"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import app from "../app"; | |
| 23 | import { defaultTools } from "../lib/mcp-tools"; | |
| 24 | ||
| 25 | const ROOT = join(import.meta.dir, "..", ".."); | |
| 26 | const MANIFEST_PATH = join(ROOT, "extension", "gluecron.dxt", "manifest.json"); | |
| 27 | const BUNDLE_PATH = join(ROOT, "public", "gluecron.dxt"); | |
| 28 | ||
| 29 | type Manifest = { | |
| 30 | dxt_version: string; | |
| 31 | name: string; | |
| 32 | display_name: string; | |
| 33 | version: string; | |
| 34 | server: { | |
| 35 | type: string; | |
| 36 | endpoint: string; | |
| 37 | headers?: Record<string, string>; | |
| 38 | }; | |
| 39 | user_config: Record<string, { required?: boolean; sensitive?: boolean }>; | |
| 40 | tools: Array<{ name: string; description: string }>; | |
| 41 | }; | |
| 42 | ||
| 43 | function loadManifest(): Manifest { | |
| 44 | const raw = readFileSync(MANIFEST_PATH, "utf8"); | |
| 45 | return JSON.parse(raw) as Manifest; | |
| 46 | } | |
| 47 | ||
| 48 | describe("Block Q1 — .dxt manifest", () => { | |
| 49 | it("extension/gluecron.dxt/manifest.json is valid JSON", () => { | |
| 50 | expect(() => loadManifest()).not.toThrow(); | |
| 51 | const m = loadManifest(); | |
| 52 | expect(m.name).toBe("gluecron"); | |
| 53 | expect(m.display_name).toBe("Gluecron"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("declares server.type=http with the templated host placeholder", () => { | |
| 57 | const m = loadManifest(); | |
| 58 | expect(m.server.type).toBe("http"); | |
| 59 | expect(m.server.endpoint).toContain("${user_config.gluecron_host}/mcp"); | |
| 60 | expect(m.server.headers?.Authorization).toContain( | |
| 61 | "${user_config.gluecron_pat}" | |
| 62 | ); | |
| 63 | }); | |
| 64 | ||
| 65 | it("declares both user_config prompts (host + PAT, PAT marked sensitive)", () => { | |
| 66 | const m = loadManifest(); | |
| 67 | expect(m.user_config.gluecron_host).toBeDefined(); | |
| 68 | expect(m.user_config.gluecron_host.required).toBe(true); | |
| 69 | expect(m.user_config.gluecron_pat).toBeDefined(); | |
| 70 | expect(m.user_config.gluecron_pat.required).toBe(true); | |
| 71 | expect(m.user_config.gluecron_pat.sensitive).toBe(true); | |
| 72 | }); | |
| 73 | ||
| 74 | it("does not embed any sensitive default values", () => { | |
| 75 | const raw = readFileSync(MANIFEST_PATH, "utf8"); | |
| 76 | // PAT prefixes — these must never be hard-coded into the manifest. | |
| 77 | expect(raw).not.toMatch(/glc_[A-Za-z0-9_-]+/); | |
| 78 | expect(raw).not.toMatch(/glct_[A-Za-z0-9_-]+/); | |
| 79 | }); | |
| 80 | ||
| 0feb720 | 81 | it("declares every MCP tool, cross-checked against defaultTools()", () => { |
| cd4f63b | 82 | const m = loadManifest(); |
| 83 | const manifestNames = new Set(m.tools.map((t) => t.name)); | |
| 84 | const handlerNames = new Set(Object.keys(defaultTools())); | |
| 85 | ||
| 86 | // Every handler MUST appear in the manifest. | |
| 87 | for (const name of handlerNames) { | |
| 88 | expect(manifestNames.has(name)).toBe(true); | |
| 89 | } | |
| 90 | // ... and vice versa — no orphan declarations. | |
| 91 | for (const name of manifestNames) { | |
| 92 | expect(handlerNames.has(name)).toBe(true); | |
| 93 | } | |
| 0feb720 | 94 | // Floor at 50 to guarantee the expanded surface is exposed; the |
| 95 | // exact count travels with `defaultTools()` so adding a tool there | |
| 96 | // and forgetting the manifest still trips the per-name assertions | |
| 97 | // above. | |
| 98 | expect(manifestNames.size).toBe(handlerNames.size); | |
| 99 | expect(manifestNames.size).toBeGreaterThanOrEqual(50); | |
| cd4f63b | 100 | }); |
| 101 | }); | |
| 102 | ||
| 103 | describe("Block Q1 — GET /gluecron.dxt", () => { | |
| 104 | // The build script writes public/gluecron.dxt. If a prior test (or a | |
| 105 | // local dev session) has already produced it we use that; otherwise we | |
| 106 | // build it on the fly via the same script so the route can be exercised. | |
| 107 | beforeAll(async () => { | |
| 108 | if (!existsSync(BUNDLE_PATH)) { | |
| 109 | const proc = Bun.spawn(["bash", join(ROOT, "scripts", "build-dxt.sh")], { | |
| 110 | cwd: ROOT, | |
| 111 | stdout: "pipe", | |
| 112 | stderr: "pipe", | |
| 113 | }); | |
| 114 | await proc.exited; | |
| 115 | } | |
| 116 | }); | |
| 117 | ||
| 118 | it("returns 200 + octet-stream + Content-Disposition when bundle exists", async () => { | |
| 119 | expect(existsSync(BUNDLE_PATH)).toBe(true); | |
| 120 | const res = await app.request("/gluecron.dxt"); | |
| 121 | expect(res.status).toBe(200); | |
| 122 | expect(res.headers.get("content-type")).toContain("application/octet-stream"); | |
| 123 | expect(res.headers.get("content-disposition") || "").toContain( | |
| 124 | 'filename="gluecron.dxt"' | |
| 125 | ); | |
| 126 | // Cache-Control must be set so CDNs can cache the download. | |
| 127 | expect(res.headers.get("cache-control") || "").toContain("max-age=3600"); | |
| 128 | // Body must be a non-trivial ZIP — first 2 bytes are the PK signature. | |
| 129 | const buf = new Uint8Array(await res.arrayBuffer()); | |
| 130 | expect(buf[0]).toBe(0x50); // 'P' | |
| 131 | expect(buf[1]).toBe(0x4b); // 'K' | |
| 132 | }); | |
| 133 | ||
| 134 | it("returns 404 JSON with a friendly message when the bundle is missing", async () => { | |
| 135 | // Move the file out of the way, hit the route, restore it. | |
| 136 | const stash = `${BUNDLE_PATH}.test-stash`; | |
| 137 | let movedAway = false; | |
| 138 | try { | |
| 139 | if (existsSync(BUNDLE_PATH)) { | |
| 140 | renameSync(BUNDLE_PATH, stash); | |
| 141 | movedAway = true; | |
| 142 | } | |
| 143 | const res = await app.request("/gluecron.dxt"); | |
| 144 | expect(res.status).toBe(404); | |
| 145 | expect(res.headers.get("content-type") || "").toContain("application/json"); | |
| 146 | const body = (await res.json()) as { | |
| 147 | error: string; | |
| 148 | message: string; | |
| 149 | fallback: string; | |
| 150 | }; | |
| 151 | expect(body.error).toBe("extension_not_built"); | |
| 152 | expect(body.message).toContain("scripts/build-dxt.sh"); | |
| 153 | expect(body.fallback).toContain("/install"); | |
| 154 | } finally { | |
| 155 | if (movedAway && existsSync(stash)) renameSync(stash, BUNDLE_PATH); | |
| 156 | } | |
| 157 | }); | |
| 158 | }); | |
| 159 | ||
| 74a8784 | 160 | describe("Block Q1 — .dxt discoverability", () => { |
| 161 | // 2026-06-10: the landing-page CTA was demoted in the 2030 landing | |
| 162 | // reboot (GET / now serves the self-contained Landing2030Page). The | |
| 163 | // bundle stays discoverable from the docs surface. | |
| 164 | it("GET /docs/mcp-server links to /gluecron.dxt", async () => { | |
| 165 | const res = await app.request("/docs/mcp-server"); | |
| cd4f63b | 166 | expect(res.status).toBe(200); |
| 167 | const body = await res.text(); | |
| 168 | expect(body).toContain('href="/gluecron.dxt"'); | |
| 169 | }); | |
| 170 | }); |