Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

dxt-extension.test.tsBlame169 lines · 1 contributor
cd4f63bTest User1/**
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
19import { describe, it, expect, beforeAll, afterAll } from "bun:test";
20import { readFileSync, existsSync, renameSync } from "node:fs";
21import { join } from "node:path";
22import app from "../app";
23import { defaultTools } from "../lib/mcp-tools";
24
25const ROOT = join(import.meta.dir, "..", "..");
26const MANIFEST_PATH = join(ROOT, "extension", "gluecron.dxt", "manifest.json");
27const BUNDLE_PATH = join(ROOT, "public", "gluecron.dxt");
28
29type 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
43function loadManifest(): Manifest {
44 const raw = readFileSync(MANIFEST_PATH, "utf8");
45 return JSON.parse(raw) as Manifest;
46}
47
48describe("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
81 it("declares all 15 MCP tools, cross-checked against defaultTools()", () => {
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 }
94 // Lock the count at 15 so a future tool addition forces this test
95 // (and therefore the manifest) to be updated in lockstep.
96 expect(manifestNames.size).toBe(15);
97 expect(handlerNames.size).toBe(15);
98 });
99});
100
101describe("Block Q1 — GET /gluecron.dxt", () => {
102 // The build script writes public/gluecron.dxt. If a prior test (or a
103 // local dev session) has already produced it we use that; otherwise we
104 // build it on the fly via the same script so the route can be exercised.
105 beforeAll(async () => {
106 if (!existsSync(BUNDLE_PATH)) {
107 const proc = Bun.spawn(["bash", join(ROOT, "scripts", "build-dxt.sh")], {
108 cwd: ROOT,
109 stdout: "pipe",
110 stderr: "pipe",
111 });
112 await proc.exited;
113 }
114 });
115
116 it("returns 200 + octet-stream + Content-Disposition when bundle exists", async () => {
117 expect(existsSync(BUNDLE_PATH)).toBe(true);
118 const res = await app.request("/gluecron.dxt");
119 expect(res.status).toBe(200);
120 expect(res.headers.get("content-type")).toContain("application/octet-stream");
121 expect(res.headers.get("content-disposition") || "").toContain(
122 'filename="gluecron.dxt"'
123 );
124 // Cache-Control must be set so CDNs can cache the download.
125 expect(res.headers.get("cache-control") || "").toContain("max-age=3600");
126 // Body must be a non-trivial ZIP — first 2 bytes are the PK signature.
127 const buf = new Uint8Array(await res.arrayBuffer());
128 expect(buf[0]).toBe(0x50); // 'P'
129 expect(buf[1]).toBe(0x4b); // 'K'
130 });
131
132 it("returns 404 JSON with a friendly message when the bundle is missing", async () => {
133 // Move the file out of the way, hit the route, restore it.
134 const stash = `${BUNDLE_PATH}.test-stash`;
135 let movedAway = false;
136 try {
137 if (existsSync(BUNDLE_PATH)) {
138 renameSync(BUNDLE_PATH, stash);
139 movedAway = true;
140 }
141 const res = await app.request("/gluecron.dxt");
142 expect(res.status).toBe(404);
143 expect(res.headers.get("content-type") || "").toContain("application/json");
144 const body = (await res.json()) as {
145 error: string;
146 message: string;
147 fallback: string;
148 };
149 expect(body.error).toBe("extension_not_built");
150 expect(body.message).toContain("scripts/build-dxt.sh");
151 expect(body.fallback).toContain("/install");
152 } finally {
153 if (movedAway && existsSync(stash)) renameSync(stash, BUNDLE_PATH);
154 }
155 });
156});
157
158describe("Block Q1 — landing CTA", () => {
159 it("GET / renders the 'Add to Claude Desktop' CTA pointing at /gluecron.dxt", async () => {
160 const res = await app.request("/");
161 expect(res.status).toBe(200);
162 const body = await res.text();
163 expect(body).toContain("Add to Claude Desktop");
164 expect(body).toContain('href="/gluecron.dxt"');
165 // The CTA carries the test hook + the download attribute so browsers
166 // know to save rather than navigate.
167 expect(body).toContain('data-testid="cta-dxt"');
168 });
169});