Skip to content

Commit 635538d

Browse files
willwearingclaude
andauthored
feat: real e2e CI with local Supabase + academy signup (#104)
* feat: add package tests for shared, CLI, and MCP; wire into CI - shared: 14 unit tests covering validateParsedYaml, describeCourse, scaffoldCourseObject, scaffoldBrandObject, fillConceptInRaw, runQualityGate - CLI: 12 integration tests for offline commands (scaffold, validate, describe, fill, review, create-brand) using real files on disk - MCP: 10 unit tests covering all tool calls, tool registration, and auth enforcement. Refactored index.ts to export handleToolCall/TOOLS with require.main guard so tests can import without starting stdio. - CI: added shared/CLI/MCP test steps to ci-deploy.yml so these run on every push to main and every PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: repair pre-existing CLI test failures (spyOn console.error) bun 1.3.6 doesn't support spyOn for accessor properties on console. Replace spyOn(console, 'error') with manual save/restore in register.test.ts and login.test.ts. Also scope bun test to src/ to avoid running compiled dist/ test files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude __tests__ from shared tsconfig build tsc fails in CI because bun:test types aren't available during build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude __tests__ from MCP tsconfig build Same bun:test type issue as shared package. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: real e2e CI with local Supabase + academy signup flow Replace the stub e2e-tests CI job with a real Playwright pipeline: - Starts local Supabase via Docker in GitHub Actions - Runs Prisma migrations and seeds against local DB - Builds backend, installs Playwright browsers - Runs all e2e tests with proper env vars - Uploads Playwright report as artifact on failure Also adds: - academy-signup.spec.ts: tests branded sign-up auto-joins org - provision.service.ts: ensureLearnerMembership() for branded sites - auth callback: passes brandOrgSlug during provision - Vercel domain provisioning on org creation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger CI for e2e pipeline * fix: apply RLS migration after Prisma creates tables in CI supabase start applies migrations from supabase/migrations/ before Prisma tables exist. The RLS migration references Prisma tables (organizations, courses, etc.) so it must run after prisma migrate deploy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add DIRECT_URL to Prisma migration and seed steps in CI * fix: add IF NOT EXISTS to duplicate authored_id migration * fix: replace duplicate authored_id migration with no-op * fix: add required academyId to seed course creation * fix: add graspful.ai and app.graspful.ai brands to CI seeds * fix: pre-start backend and frontend in CI instead of relying on Playwright webServer * fix: combine services + tests into single step, set PORT=3001 for frontend * fix: use subshells for background servers so cd works correctly * fix: seed posthog-tam org + trim redundant e2e tests - Add posthog-tam org with academy, course, concepts, and problems to seed - Add electrician-prep and javascript-prep orgs (referenced by brand seeds) - Remove 4 redundant e2e test files (course-sections, posthog-lessons, failure-remediation, learn-deep-routes) — covered by remaining tests - Fix subdomain-signup test to handle local Supabase auto-confirm Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: increase diagnostic load timeout in e2e tests (5s → 15s) CI runners are slower, so the diagnostic page takes >5s to load. Use `.or()` locator to wait for either state in a single assertion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce26763 commit 635538d

16 files changed

Lines changed: 552 additions & 796 deletions

File tree

.github/workflows/ci-deploy.yml

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,110 @@ jobs:
7373
name: E2E Tests
7474
needs: unit-component-tests
7575
runs-on: ubuntu-latest
76+
timeout-minutes: 30
7677
steps:
7778
- uses: actions/checkout@v5
7879

79-
- name: Report browser-regression check
80-
run: echo "Dedicated CI browser e2e is not configured in this workflow yet."
80+
- uses: oven-sh/setup-bun@v2
81+
with:
82+
bun-version: "1.3.6"
83+
84+
- uses: supabase/setup-cli@v1
85+
with:
86+
version: latest
87+
88+
- name: Start local Supabase
89+
run: |
90+
# Move RLS migration aside — it references Prisma tables that don't exist yet
91+
mv supabase/migrations/00002_rls_policies.sql /tmp/00002_rls_policies.sql
92+
supabase start
93+
# Will apply RLS after Prisma migrations
94+
95+
- name: Extract Supabase keys
96+
id: supabase
97+
run: |
98+
echo "SUPABASE_URL=$(supabase status --output json | jq -r '.API_URL')" >> "$GITHUB_OUTPUT"
99+
echo "ANON_KEY=$(supabase status --output json | jq -r '.ANON_KEY')" >> "$GITHUB_OUTPUT"
100+
echo "SERVICE_ROLE_KEY=$(supabase status --output json | jq -r '.SERVICE_ROLE_KEY')" >> "$GITHUB_OUTPUT"
101+
echo "DB_URL=$(supabase status --output json | jq -r '.DB_URL')" >> "$GITHUB_OUTPUT"
102+
103+
- name: Install dependencies
104+
run: bun install
105+
106+
- name: Build shared package
107+
run: cd packages/shared && bun run build
108+
109+
- name: Generate Prisma client
110+
run: cd backend && bun x prisma generate
111+
112+
- name: Run Prisma migrations
113+
run: cd backend && bun x prisma migrate deploy
114+
env:
115+
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
116+
DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }}
117+
118+
- name: Apply RLS policies
119+
run: psql "${{ steps.supabase.outputs.DB_URL }}" -f /tmp/00002_rls_policies.sql
120+
121+
- name: Seed database
122+
run: cd backend && bun x prisma db seed
123+
env:
124+
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
125+
DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }}
126+
127+
- name: Seed brands
128+
run: cd backend && bun x ts-node prisma/seeds/brands.ts
129+
env:
130+
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
131+
DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }}
132+
133+
- name: Build backend
134+
run: cd backend && bun run build
135+
136+
- name: Build frontend
137+
run: cd apps/web && bun run build
138+
env:
139+
NEXT_PUBLIC_SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }}
140+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.ANON_KEY }}
141+
NEXT_PUBLIC_BACKEND_URL: http://localhost:3000/api/v1
142+
143+
- name: Install Playwright browsers
144+
run: cd apps/web && bun x playwright install --with-deps chromium
145+
146+
- name: Start services and run E2E tests
147+
run: |
148+
# Start backend (subshell so cd doesn't affect parent)
149+
(cd backend && bun run start:prod) &
150+
151+
# Start frontend on port 3001
152+
(cd apps/web && PORT=3001 bun run start) &
153+
154+
# Wait for both
155+
timeout 60 bash -c 'until curl -sf http://localhost:3000/api/v1/health 2>/dev/null; do sleep 2; done'
156+
echo "Backend ready"
157+
timeout 120 bash -c 'until curl -sf http://localhost:3001 2>/dev/null; do sleep 2; done'
158+
echo "Frontend ready"
159+
160+
# Run Playwright
161+
cd apps/web && bun run test:e2e
162+
env:
163+
CI: "true"
164+
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
165+
DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }}
166+
SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }}
167+
SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }}
168+
ALLOWED_ORIGINS: http://localhost:3001
169+
NEXT_PUBLIC_SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }}
170+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.ANON_KEY }}
171+
NEXT_PUBLIC_BACKEND_URL: http://localhost:3000/api/v1
172+
173+
- name: Upload Playwright report
174+
uses: actions/upload-artifact@v4
175+
if: ${{ !cancelled() }}
176+
with:
177+
name: playwright-report
178+
path: apps/web/playwright-report/
179+
retention-days: 14
81180

82181
deploy-backend:
83182
name: Deploy Backend
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { test, expect } from "@playwright/test";
2+
import {
3+
signUpBrandedTestUser,
4+
getBrowserAccessToken,
5+
POSTHOG_TEST_BRAND_ID,
6+
} from "./helpers/auth";
7+
8+
const BACKEND_URL = "http://localhost:3000/api/v1";
9+
const ORG_SLUG = "posthog-tam";
10+
11+
/**
12+
* Verify that signing up on a branded academy site auto-joins the user
13+
* to the brand's org so they can browse and enroll in academies.
14+
*
15+
* This covers the bug where users signed up on a branded subdomain but
16+
* saw "No academies available yet." because they weren't added to the
17+
* brand's org.
18+
*/
19+
test.describe("Academy sign-up and browse", () => {
20+
test("provision with brandOrgSlug grants learner access to browse academies", async ({
21+
page,
22+
}) => {
23+
// 1. Sign up on the branded academy site
24+
await signUpBrandedTestUser(page, POSTHOG_TEST_BRAND_ID);
25+
const token = await getBrowserAccessToken(page);
26+
expect(token).toBeTruthy();
27+
28+
// 2. Call provision with brandOrgSlug (simulates what auth callback does)
29+
const provisionRes = await fetch(`${BACKEND_URL}/auth/provision`, {
30+
method: "POST",
31+
headers: {
32+
Authorization: `Bearer ${token}`,
33+
"Content-Type": "application/json",
34+
},
35+
body: JSON.stringify({ brandOrgSlug: ORG_SLUG }),
36+
});
37+
expect(provisionRes.status).toBeLessThan(300);
38+
39+
// 3. Verify the user can see academies on the browse page
40+
await page.goto("/browse");
41+
await expect(page.getByRole("heading", { name: "Browse Academies" })).toBeVisible({
42+
timeout: 10_000,
43+
});
44+
// Should NOT show the empty state
45+
await expect(page.getByText("No academies available yet.")).not.toBeVisible({
46+
timeout: 5_000,
47+
});
48+
// Should show at least one academy card with an "Open Academy" button
49+
await expect(
50+
page.getByRole("button", { name: "Open Academy" }).or(
51+
page.getByRole("link", { name: "Open Academy" })
52+
)
53+
).toBeVisible({ timeout: 5_000 });
54+
});
55+
56+
test("provision without brandOrgSlug does NOT grant access to other orgs", async ({
57+
page,
58+
}) => {
59+
// Sign up without a brand org slug
60+
await signUpBrandedTestUser(page, POSTHOG_TEST_BRAND_ID);
61+
const token = await getBrowserAccessToken(page);
62+
expect(token).toBeTruthy();
63+
64+
// Call provision WITHOUT brandOrgSlug
65+
await fetch(`${BACKEND_URL}/auth/provision`, {
66+
method: "POST",
67+
headers: {
68+
Authorization: `Bearer ${token}`,
69+
"Content-Type": "application/json",
70+
},
71+
});
72+
73+
// Calling the academy API directly should fail (403) since the user
74+
// is not a member of the brand's org
75+
const academyRes = await fetch(
76+
`${BACKEND_URL}/orgs/${ORG_SLUG}/academies`,
77+
{
78+
headers: {
79+
Authorization: `Bearer ${token}`,
80+
"Content-Type": "application/json",
81+
},
82+
}
83+
);
84+
expect(academyRes.status).toBe(403);
85+
});
86+
87+
test("learner membership is idempotent", async ({ page }) => {
88+
await signUpBrandedTestUser(page, POSTHOG_TEST_BRAND_ID);
89+
const token = await getBrowserAccessToken(page);
90+
expect(token).toBeTruthy();
91+
92+
// Call provision with brandOrgSlug twice — should not error
93+
for (let i = 0; i < 2; i++) {
94+
const res = await fetch(`${BACKEND_URL}/auth/provision`, {
95+
method: "POST",
96+
headers: {
97+
Authorization: `Bearer ${token}`,
98+
"Content-Type": "application/json",
99+
},
100+
body: JSON.stringify({ brandOrgSlug: ORG_SLUG }),
101+
});
102+
expect(res.status).toBeLessThan(300);
103+
}
104+
105+
// Should still be able to browse
106+
await page.goto("/browse");
107+
await expect(page.getByText("No academies available yet.")).not.toBeVisible({
108+
timeout: 5_000,
109+
});
110+
});
111+
});

apps/web/e2e/academy.spec.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,9 @@ test.describe("Academy features", () => {
9696
);
9797

9898
// Should show either diagnostic or unavailable message
99-
const hasDiagnostic = await page
100-
.getByText("Diagnostic Assessment")
101-
.isVisible({ timeout: 5_000 })
102-
.catch(() => false);
103-
104-
if (!hasDiagnostic) {
105-
await expect(
106-
page.getByText(/Diagnostic Unavailable/)
107-
).toBeVisible();
108-
}
99+
const diagnosticText = page.getByText("Diagnostic Assessment");
100+
const unavailableText = page.getByText(/Diagnostic Unavailable/);
101+
await expect(diagnosticText.or(unavailableText)).toBeVisible({ timeout: 15_000 });
109102
});
110103

111104
test("academy page shows knowledge graph section", async ({ page }) => {

apps/web/e2e/course-sections.spec.ts

Lines changed: 0 additions & 147 deletions
This file was deleted.

0 commit comments

Comments
 (0)