Skip to content

Commit 6c2cd95

Browse files
Miriadpm
andcommitted
feat: add Supabase, Playwright E2E tests, and GitHub Actions CI
- Initialize Supabase: config.toml, initial migration (dashboard_users table), lib/supabase/client.ts and lib/supabase/server.ts utility files - Add @supabase/supabase-js and @supabase/ssr packages - Set up Playwright: config with chromium-only for CI speed, smoke tests for homepage, /blog, and /studio routes - Add @playwright/test dev dependency - Create .github/workflows/ci.yml: checkout → Node 20 → pnpm → install → type-check → biome lint → build → Playwright tests with caching - Update .env.example with Supabase env vars - Update .gitignore for Playwright artifacts - Update biome.json to ignore supabase/ and playwright output dirs Co-authored-by: pm <pm@miriad.systems>
1 parent 6be66df commit 6c2cd95

14 files changed

Lines changed: 1128 additions & 572 deletions

File tree

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
# Sanity CMS — project configuration
1414
NEXT_PUBLIC_SANITY_PROJECT_ID= # Sanity project ID (from sanity.io/manage)
15-
NEXT_PUBLIC_SANITY_DATASET= # Sanity dataset name (e.g. "production")
15+
NEXT_PUBLIC_SANITY_DATASET= # Sanity dataset name (e.g. "prod")
1616
NEXT_PUBLIC_SANITY_API_VERSION= # Sanity API version date (e.g. "2024-01-01")
1717

1818
# Site URLs
@@ -58,3 +58,10 @@ YOUTUBE_CHANNEL_ID= # YouTube channel ID to fetch videos fr
5858

5959
# Vercel
6060
VERCEL_PROJECT_PRODUCTION_URL= # Production URL (auto-set by Vercel)
61+
62+
# -----------------------------------------------------------------------------
63+
# Supabase
64+
# -----------------------------------------------------------------------------
65+
NEXT_PUBLIC_SUPABASE_URL= # Supabase project URL (e.g. "https://xxx.supabase.co")
66+
NEXT_PUBLIC_SUPABASE_ANON_KEY= # Supabase anonymous/public key
67+
SUPABASE_SERVICE_ROLE_KEY= # Supabase service role key (server-only, bypasses RLS)

.github/workflows/ci.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [dev]
6+
pull_request:
7+
branches: [dev]
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
env:
14+
NODE_VERSION: "20"
15+
16+
jobs:
17+
ci:
18+
name: Lint, Build & Test
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 15
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup pnpm
27+
uses: pnpm/action-setup@v4
28+
29+
- name: Setup Node.js
30+
uses: actions/setup-node@v4
31+
with:
32+
node-version: ${{ env.NODE_VERSION }}
33+
cache: pnpm
34+
35+
- name: Install dependencies
36+
run: pnpm install --frozen-lockfile
37+
38+
- name: Type-check
39+
run: pnpm exec tsc --noEmit
40+
41+
- name: Lint (Biome)
42+
run: pnpm exec biome check .
43+
44+
- name: Build
45+
run: pnpm build
46+
env:
47+
# Provide dummy values for build-time env vars so Next.js build succeeds
48+
NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
49+
NEXT_PUBLIC_SANITY_DATASET: ${{ secrets.NEXT_PUBLIC_SANITY_DATASET }}
50+
NEXT_PUBLIC_SANITY_API_VERSION: ${{ secrets.NEXT_PUBLIC_SANITY_API_VERSION }}
51+
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
52+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
53+
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
54+
55+
- name: Cache Playwright browsers
56+
uses: actions/cache@v4
57+
id: playwright-cache
58+
with:
59+
path: ~/.cache/ms-playwright
60+
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
61+
62+
- name: Install Playwright browsers
63+
if: steps.playwright-cache.outputs.cache-hit != 'true'
64+
run: pnpm exec playwright install --with-deps chromium
65+
66+
- name: Install Playwright system deps
67+
if: steps.playwright-cache.outputs.cache-hit == 'true'
68+
run: pnpm exec playwright install-deps chromium
69+
70+
- name: Run Playwright tests
71+
run: pnpm exec playwright test
72+
env:
73+
CI: true
74+
PLAYWRIGHT_BASE_URL: http://localhost:3000
75+
NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
76+
NEXT_PUBLIC_SANITY_DATASET: ${{ secrets.NEXT_PUBLIC_SANITY_DATASET }}
77+
NEXT_PUBLIC_SANITY_API_VERSION: ${{ secrets.NEXT_PUBLIC_SANITY_API_VERSION }}
78+
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
79+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
80+
81+
- name: Upload Playwright report
82+
uses: actions/upload-artifact@v4
83+
if: ${{ !cancelled() }}
84+
with:
85+
name: playwright-report
86+
path: playwright-report/
87+
retention-days: 7

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
# testing
1111
/coverage
12+
/test-results/
13+
/playwright-report/
14+
/blob-report/
15+
/playwright/.cache/
1216

1317
# next.js
1418
/.next/

biome.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@
3636
"enabled": true
3737
},
3838
"files": {
39-
"ignore": ["node_modules/**", "sanity/types.ts"]
39+
"ignore": ["node_modules/**", "sanity/types.ts", "supabase/**", "playwright-report/**", "test-results/**"]
4040
},
4141
"javascript": {
42-
"jsxRuntime": "reactClassic",
42+
"jsxRuntime": "automatic",
4343
"formatter": {
4444
"trailingCommas": "all",
4545
"semicolons": "always"

e2e/smoke.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test.describe("Smoke tests", () => {
4+
test("homepage renders", async ({ page }) => {
5+
await page.goto("/");
6+
await expect(page).toHaveTitle(/codingcat/i);
7+
// Ensure the page has meaningful content (not a blank error page)
8+
await expect(page.locator("body")).not.toBeEmpty();
9+
});
10+
11+
test("/blog page renders", async ({ page }) => {
12+
await page.goto("/blog");
13+
// The blog page should have rendered content
14+
await expect(page.locator("body")).not.toBeEmpty();
15+
// Check for a heading or main content area
16+
await expect(
17+
page.locator("main, [role='main'], article, h1").first(),
18+
).toBeVisible();
19+
});
20+
21+
test("Sanity studio route exists", async ({ page }) => {
22+
const response = await page.goto("/studio");
23+
// The studio route should return a valid response (not 404)
24+
expect(response).not.toBeNull();
25+
expect(response!.status()).toBeLessThan(400);
26+
});
27+
});

lib/supabase/client.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createBrowserClient } from "@supabase/ssr";
2+
3+
/**
4+
* Creates a Supabase client for use in Client Components (browser).
5+
*
6+
* Usage:
7+
* import { createClient } from "@/lib/supabase/client";
8+
* const supabase = createClient();
9+
*/
10+
export function createClient() {
11+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
12+
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
13+
14+
if (!supabaseUrl || !supabaseAnonKey) {
15+
throw new Error(
16+
"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables",
17+
);
18+
}
19+
20+
return createBrowserClient(supabaseUrl, supabaseAnonKey);
21+
}

lib/supabase/server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createServerClient } from "@supabase/ssr";
2+
import { cookies } from "next/headers";
3+
4+
/**
5+
* Creates a Supabase client for use in Server Components, Route Handlers,
6+
* and Server Actions.
7+
*
8+
* Usage:
9+
* import { createClient } from "@/lib/supabase/server";
10+
* const supabase = await createClient();
11+
*/
12+
export async function createClient() {
13+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
14+
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
15+
16+
if (!supabaseUrl || !supabaseAnonKey) {
17+
throw new Error(
18+
"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables",
19+
);
20+
}
21+
22+
const cookieStore = await cookies();
23+
24+
return createServerClient(supabaseUrl, supabaseAnonKey, {
25+
cookies: {
26+
getAll() {
27+
return cookieStore.getAll();
28+
},
29+
setAll(cookiesToSet) {
30+
try {
31+
for (const { name, value, options } of cookiesToSet) {
32+
cookieStore.set(name, value, options);
33+
}
34+
} catch {
35+
// The `setAll` method was called from a Server Component.
36+
// This can be ignored if you have middleware refreshing
37+
// user sessions.
38+
}
39+
},
40+
},
41+
});
42+
}
43+
44+
/**
45+
* Creates a Supabase admin client using the service role key.
46+
* WARNING: This bypasses Row Level Security — use only in trusted server contexts.
47+
*
48+
* Usage:
49+
* import { createAdminClient } from "@/lib/supabase/server";
50+
* const supabase = createAdminClient();
51+
*/
52+
export function createAdminClient() {
53+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
54+
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
55+
56+
if (!supabaseUrl || !supabaseServiceRoleKey) {
57+
throw new Error(
58+
"Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables",
59+
);
60+
}
61+
62+
// Import directly to avoid cookie overhead for admin operations
63+
const { createClient } = require("@supabase/supabase-js");
64+
return createClient(supabaseUrl, supabaseServiceRoleKey, {
65+
auth: {
66+
autoRefreshToken: false,
67+
persistSession: false,
68+
},
69+
});
70+
}

package.json

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,33 @@
1212
},
1313
"dependencies": {
1414
"@codingcatdev/sanity-plugin-podcast-rss": "^1.0.0",
15-
"@hookform/resolvers": "^5.2.1",
16-
"@marsidev/react-turnstile": "^1.3.1",
17-
"@portabletext/block-tools": "^3.5.5",
18-
"@portabletext/react": "^4.0.3",
19-
"@portabletext/to-html": "^3.0.0",
15+
"@hookform/resolvers": "^5.2.2",
16+
"@marsidev/react-turnstile": "^1.4.2",
17+
"@portabletext/block-tools": "^5.0.5",
18+
"@portabletext/react": "^6.0.2",
19+
"@portabletext/to-html": "^5.0.1",
2020
"@radix-ui/react-accordion": "^1.2.12",
2121
"@radix-ui/react-alert-dialog": "^1.1.15",
22-
"@radix-ui/react-aspect-ratio": "^1.1.7",
23-
"@radix-ui/react-avatar": "^1.1.10",
22+
"@radix-ui/react-aspect-ratio": "^1.1.8",
23+
"@radix-ui/react-avatar": "^1.1.11",
2424
"@radix-ui/react-checkbox": "^1.3.3",
2525
"@radix-ui/react-collapsible": "^1.1.12",
2626
"@radix-ui/react-context-menu": "^2.2.16",
2727
"@radix-ui/react-dialog": "^1.1.15",
2828
"@radix-ui/react-dropdown-menu": "^2.1.16",
2929
"@radix-ui/react-hover-card": "^1.1.15",
3030
"@radix-ui/react-icons": "^1.3.2",
31-
"@radix-ui/react-label": "^2.1.7",
31+
"@radix-ui/react-label": "^2.1.8",
3232
"@radix-ui/react-menubar": "^1.1.16",
3333
"@radix-ui/react-navigation-menu": "^1.2.14",
3434
"@radix-ui/react-popover": "^1.1.15",
35-
"@radix-ui/react-progress": "^1.1.7",
35+
"@radix-ui/react-progress": "^1.1.8",
3636
"@radix-ui/react-radio-group": "^1.3.8",
3737
"@radix-ui/react-scroll-area": "^1.2.10",
3838
"@radix-ui/react-select": "^2.2.6",
39-
"@radix-ui/react-separator": "^1.1.7",
39+
"@radix-ui/react-separator": "^1.1.8",
4040
"@radix-ui/react-slider": "^1.3.6",
41-
"@radix-ui/react-slot": "^1.2.3",
41+
"@radix-ui/react-slot": "^1.2.4",
4242
"@radix-ui/react-switch": "^1.2.6",
4343
"@radix-ui/react-tabs": "^1.1.13",
4444
"@radix-ui/react-toast": "^1.2.15",
@@ -59,18 +59,17 @@
5959
"@supabase/ssr": "^0.9.0",
6060
"@supabase/supabase-js": "^2.98.0",
6161
"@uidotdev/usehooks": "^2.4.1",
62-
"algoliasearch": "^5.37.0",
63-
"autoprefixer": "^10.4.21",
62+
"algoliasearch": "^5.49.1",
6463
"class-variance-authority": "^0.7.1",
6564
"clsx": "^2.1.1",
6665
"cmdk": "^1.1.1",
6766
"date-fns": "^4.1.0",
6867
"embla-carousel-react": "^8.6.0",
69-
"feed": "^5.1.0",
68+
"feed": "^5.2.0",
7069
"input-otp": "^1.4.2",
71-
"instantsearch.js": "^4.80.0",
70+
"instantsearch.js": "^4.90.0",
7271
"jwt-decode": "^4.0.0",
73-
"lucide-react": "^0.544.0",
72+
"lucide-react": "^0.576.0",
7473
"micromark": "^4.0.2",
7574
"next": "^15.5.3",
7675
"next-cloudinary": "^6.16.0",
@@ -80,34 +79,35 @@
8079
"postcss": "^8.5.6",
8180
"react": "^19.1.1",
8281
"react-cookie": "^8.0.1",
83-
"react-day-picker": "^9.9.0",
82+
"react-day-picker": "^9.14.0",
8483
"react-dom": "^19.1.1",
85-
"react-dropzone": "^14.3.8",
84+
"react-dropzone": "^15.0.0",
8685
"react-facebook-pixel": "^1.0.4",
87-
"react-hook-form": "^7.62.0",
86+
"react-hook-form": "^7.71.2",
8887
"react-icons": "^5.5.0",
8988
"react-inlinesvg": "^4.2.0",
90-
"react-instantsearch": "^7.16.3",
91-
"react-instantsearch-nextjs": "^1.0.2",
92-
"react-resizable-panels": "^3.0.6",
93-
"react-syntax-highlighter": "^15.6.6",
89+
"react-instantsearch": "^7.26.0",
90+
"react-instantsearch-nextjs": "^1.1.0",
91+
"react-resizable-panels": "^4.7.0",
92+
"react-syntax-highlighter": "^16.1.1",
9493
"react-twitter-embed": "^4.0.4",
95-
"recharts": "2.15.4",
94+
"recharts": "3.7.0",
9695
"sanity": "^4.8.1",
9796
"sanity-plugin-cloudinary": "^1.4.0",
9897
"server-only": "^0.0.1",
9998
"sonner": "^2.0.7",
10099
"styled-components": "^6.1.19",
101-
"tailwind-merge": "^3.3.1",
100+
"tailwind-merge": "^3.5.0",
102101
"tailwindcss-animate": "^1.0.7",
103102
"typescript": "5.9.2",
104103
"uuid": "^13.0.0",
105104
"vaul": "^1.1.2",
106-
"zod": "^4.1.8"
105+
"zod": "^4.3.6"
107106
},
108107
"devDependencies": {
109108
"@biomejs/biome": "2.2.4",
110109
"@eslint/eslintrc": "^3.3.1",
110+
"@playwright/test": "^1.51.1",
111111
"@tailwindcss/postcss": "^4.2.1",
112112
"@tailwindcss/typography": "^0.5.19",
113113
"@types/node": "^24.3.1",

0 commit comments

Comments
 (0)