CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pricing-page.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.
| 5f2e749 | 1 | /** |
| 2 | * Block L8 — public /pricing page tests. | |
| 3 | * | |
| 4 | * Anonymous-safe route. Verifies: | |
| 5 | * - GET /pricing returns 200 HTML to a logged-out visitor | |
| 6 | * - All four plan names (Free, Pro, Team, Enterprise) render | |
| 7 | * - The "what you get on free" block contains the AI features | |
| 8 | * - All five FAQ questions are present verbatim | |
| 9 | * - CTA links resolve to /register?next=... or /settings/billing | |
| 10 | * - The self-host column mentions the curl install line | |
| 11 | */ | |
| 12 | ||
| 13 | import { describe, it, expect } from "bun:test"; | |
| 14 | import app from "../app"; | |
| 15 | import { FALLBACK_PLANS } from "../lib/billing"; | |
| 16 | ||
| 17 | describe("L8 — /pricing public page", () => { | |
| 18 | it("returns 200 HTML to an anonymous visitor", async () => { | |
| 19 | const res = await app.request("/pricing"); | |
| 20 | expect(res.status).toBe(200); | |
| 21 | const ct = res.headers.get("content-type") || ""; | |
| 22 | expect(ct.toLowerCase()).toContain("text/html"); | |
| 23 | }); | |
| 24 | ||
| 25 | it("renders all four plan names (Free, Pro, Team, Enterprise)", async () => { | |
| 26 | const res = await app.request("/pricing"); | |
| 27 | const body = await res.text(); | |
| 28 | // FALLBACK_PLANS guarantees the four names exist regardless of whether | |
| 29 | // the DB seeds are loaded — listPlans() falls back to these. | |
| 30 | for (const slug of Object.keys(FALLBACK_PLANS)) { | |
| 31 | const name = FALLBACK_PLANS[slug].name; | |
| 32 | expect(body).toContain(name); | |
| 33 | } | |
| 34 | }); | |
| 35 | ||
| 36 | it("the free-tier block lists at least 6 of the included AI features", async () => { | |
| 37 | const res = await app.request("/pricing"); | |
| 38 | const body = await res.text(); | |
| 39 | const features = [ | |
| 40 | "Unlimited public repos", | |
| 41 | "AI code review on every PR", | |
| 42 | "AI auto-merge", | |
| 43 | "ai:build label", | |
| 44 | "Sleep Mode digest", | |
| 45 | "AI hours saved counter", | |
| 46 | "MCP server access", | |
| 47 | "Claude Code skill bundle", | |
| 48 | "One-command install", | |
| 49 | "GitHub OIDC sign-in", | |
| 50 | ]; | |
| 51 | const present = features.filter((f) => body.includes(f)); | |
| 52 | expect(present.length).toBeGreaterThanOrEqual(6); | |
| 53 | }); | |
| 54 | ||
| 55 | it("FAQ contains all five required questions", async () => { | |
| 56 | const res = await app.request("/pricing"); | |
| 57 | const body = await res.text(); | |
| 58 | expect(body).toContain("Is it really free? What's the catch?"); | |
| 59 | expect(body).toContain( | |
| 60 | "Do I need to bring my own Anthropic API key on the free tier?" | |
| 61 | ); | |
| 62 | expect(body).toContain("What happens when I exceed my plan's quota?"); | |
| 63 | expect(body).toContain("Can I migrate from GitHub for free?"); | |
| 64 | expect(body).toContain("Does the free tier include private repos?"); | |
| 65 | }); | |
| 66 | ||
| 67 | it("CTAs route anonymous users through /register?next=/settings/billing", async () => { | |
| 68 | const res = await app.request("/pricing"); | |
| 69 | const body = await res.text(); | |
| 70 | // At least one register-funnel CTA must exist. | |
| 71 | expect(body).toMatch(/href="\/register(\?next=\/settings\/billing[^"]*)?"/); | |
| 72 | // And the page must mention /settings/billing somewhere as the | |
| 73 | // destination after sign-up. | |
| 74 | expect(body).toContain("/settings/billing"); | |
| 75 | }); | |
| 76 | ||
| 77 | it("self-host column mentions `curl gluecron.com/install`", async () => { | |
| 78 | const res = await app.request("/pricing"); | |
| 79 | const body = await res.text(); | |
| 80 | expect(body).toContain("curl gluecron.com/install"); | |
| 81 | }); | |
| 82 | ||
| 83 | it("does not require authentication (no redirect)", async () => { | |
| 84 | const res = await app.request("/pricing"); | |
| 85 | expect(res.status).toBe(200); | |
| 86 | expect(res.status).not.toBe(302); | |
| 87 | expect(res.status).not.toBe(401); | |
| 88 | }); | |
| 89 | ||
| 13ac035 | 90 | it("hero copy reflects the 2026 polish — bundle-math positioning", async () => { |
| 91 | // 2026-05-16 polish — pricing hero copy rewritten to emphasise the | |
| 92 | // "GitHub bundle math" positioning instead of the original | |
| 93 | // "Free for the AI-curious" line. The new hero leads with "One | |
| 94 | // subscription. Replaces three on GitHub." and the sub-copy lands | |
| 95 | // the $89/user/mo GitHub-stack comparison. | |
| 5f2e749 | 96 | const res = await app.request("/pricing"); |
| 97 | const body = await res.text(); | |
| 13ac035 | 98 | expect(body).toContain("One subscription."); |
| 99 | expect(body).toContain("Replaces three on GitHub."); | |
| 100 | // The "vs GitHub bundle math" comparison block must render. | |
| 101 | expect(body).toContain("Bundle math vs GitHub"); | |
| 102 | expect(body).toContain("$89"); | |
| 5f2e749 | 103 | }); |
| 41a1450 | 104 | |
| 105 | it("layout nav styles are scoped to .site-header, never bare <header>", async () => { | |
| 106 | // Regression — 2026-06-10: layout.tsx styled the bare `header` element | |
| 107 | // (sticky, height: var(--header-h), blur). Every page that uses a | |
| 108 | // semantic <header> for a hero or section head (~200 across src/routes) | |
| 109 | // inherited a 60px sticky bar, which crushed the /pricing hero and | |
| 110 | // overlaid its title on top of the plan cards. The nav rule must stay | |
| 111 | // scoped to .site-header. | |
| 112 | const res = await app.request("/pricing"); | |
| 113 | const body = await res.text(); | |
| 114 | expect(body).toContain('<header class="site-header">'); | |
| 115 | // No CSS rule may target the bare element: `header {` or a | |
| 116 | // `... header {` descendant/`theme header` selector outside a class. | |
| 117 | const bareHeaderSelector = /(^|[}\s])header(\s+nav)?\s*\{/m; | |
| 118 | expect(bareHeaderSelector.test(body)).toBe(false); | |
| 119 | }); | |
| 5f2e749 | 120 | }); |