test(e2e): cover edge functions on the real Deno edge runtime (#111)

* test(e2e): cover edge functions on the real Deno edge runtime

* fix(e2e): work around CLI import-scanner crash on vendored JSDoc examples
This commit is contained in:
Katerina Skroumpelou
2026-08-13 12:11:32 +03:00
committed by GitHub
parent 2b5d4fd82e
commit c8fd9de39e
11 changed files with 195 additions and 8 deletions
+4
View File
@@ -37,6 +37,10 @@ jobs:
- name: Typecheck E2E suite
run: pnpm typecheck:e2e
# The edge function imports the packed library artifact — vendor it
# into supabase/functions before the stack starts (see e2e/README.md).
- run: pnpm vendor:e2e
- name: Start local Supabase stack
run: supabase start
working-directory: e2e
+1
View File
@@ -7,3 +7,4 @@ api-docs
!.env.example
.DS_Store
.idea
e2e/supabase/functions/_vendor
+14 -3
View File
@@ -3,7 +3,8 @@
End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS
validation over HTTP, real Supabase client operations — across all four
adapters (Hono, H3, Elysia, NestJS) plus the core `withSupabase` fetch
wrapper, the programming model Supabase Edge Functions use.
wrapper on Node and on the real Deno edge runtime, the programming model
Supabase Edge Functions use.
Unlike the unit/integration tests (mocked env, `jwks: null`), this suite:
@@ -21,6 +22,7 @@ Unlike the unit/integration tests (mocked env, `jwks: null`), this suite:
```sh
pnpm build # e2e imports from dist/
pnpm vendor:e2e # packs dist/ for the edge function's import map
cd e2e && supabase start # local stack (Docker) on ports 5433x
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
pnpm test:e2e
@@ -38,8 +40,17 @@ Run a single adapter with `pnpm test:e2e h3`.
`GET /all-notes` (user, admin client with no filter — proves the admin
client is not scoped to the caller)
- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)`
fetch wrapper (no adapter) — what an Edge Function deploys. A real Deno
`supabase functions serve` e2e is tracked as a follow-up issue.
fetch wrapper (no adapter) — what an Edge Function deploys, running on Node.
- `supabase/functions/server-e2e/` — the same surface again, but on the real
Deno edge runtime, served by `supabase start` through the Kong gateway and
covered by `edge.e2e.ts`. Imports the library from a vendored `pnpm pack`
artifact (`pnpm vendor:e2e``functions/_vendor/`, gitignored) — the exact
bytes `npm publish` ships. `verify_jwt = false` in `supabase/config.toml`
keeps the gateway's JWT pre-check out of the way so the middleware's own
401 behavior is what the scenarios exercise. Needs Supabase CLI ≥ 2.109 —
older local edge runtimes don't inject the new-key env
(`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS` / `SUPABASE_JWKS`)
the library reads.
- `scenarios.ts` — the single scenario set run against every adapter
- `setup/global-setup.ts` — checks the stack is up, signs in two test users,
provides their tokens to the tests
+2 -2
View File
@@ -1,8 +1,8 @@
// Minimal app on the CORE withSupabase(config, handler) fetch wrapper — no
// framework adapter. This is the exact programming model Supabase Edge
// Functions use (`Deno.serve(withSupabase(...))`); here the same handler runs
// behind node:http. A real Deno `supabase functions serve` e2e is tracked
// separately.
// behind node:http. The same surface runs on the real Deno edge runtime in
// e2e/supabase/functions/server-e2e/ — keep the two in sync.
//
// The core wrapper has no router, so routes are dispatched on pathname and
// each auth mode gets its own wrapped handler.
+35
View File
@@ -0,0 +1,35 @@
import { beforeAll } from 'vitest'
import { runAdapterScenarios } from './scenarios.ts'
// Served by the local stack's edge runtime (`supabase start` with
// [edge_runtime] enabled in e2e/supabase/config.toml) and reached through
// the Kong gateway — unlike the app suites, there is no local server to
// start. vitest-setup.ts has already loaded e2e/.env by the time this
// module is evaluated.
const baseUrl = `${process.env.SUPABASE_URL}/functions/v1/server-e2e`
beforeAll(async () => {
// The first invocation boots the Deno worker and resolves its npm:
// imports — poll /health so the cold start is spent here, not inside the
// first scenario's 15s timeout.
const deadline = Date.now() + 90_000
for (;;) {
try {
const res = await fetch(`${baseUrl}/health`)
if (res.ok) return
} catch {
// gateway not answering yet
}
if (Date.now() > deadline) {
throw new Error(
`edge function at ${baseUrl} not ready after 90s — is the stack ` +
'running with [edge_runtime] enabled and the vendor step done? ' +
'(pnpm vendor:e2e, then supabase start in e2e/)',
)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}, 120_000)
runAdapterScenarios('edge', baseUrl)
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Vendors the packed library into the edge functions directory. The edge
# runtime container mounts only e2e/supabase/functions/, so the function
# cannot import the repo-root dist/ directly. `pnpm pack` produces the exact
# artifact `npm publish` would ship — the suite keeps testing built output.
# Requires `pnpm build` first.
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
vendor_dir="$repo_root/e2e/supabase/functions/_vendor"
if [ ! -f "$repo_root/dist/index.mjs" ]; then
echo "dist/index.mjs not found — run \`pnpm build\` first" >&2
exit 1
fi
rm -rf "$vendor_dir"
mkdir -p "$vendor_dir"
tarball="$(pnpm --dir "$repo_root" pack --pack-destination "$vendor_dir" | tail -n1)"
tar -xzf "$tarball" -C "$vendor_dir"
rm "$tarball"
# Workaround for a Supabase CLI regression (JS rewrite, >= 2.110): its
# functions import scanner also picks specifiers out of JSDoc @example
# comments, resolves subpaths by concatenating onto the bare import-map key
# ('@supabase/server' + '/core' -> .../index.mjs/core), and aborts
# `supabase start` on the resulting ENOTDIR. Neutralize subpath specifiers
# on comment lines only (dist code imports use double quotes; JSDoc
# examples use single quotes) — runtime code is untouched. Remove once the
# CLI treats comment text / not-found paths correctly.
find "$vendor_dir/package/dist" -type f \( -name '*.mjs' -o -name '*.cjs' -o -name '*.mts' -o -name '*.cts' \) \
-exec sed -i.bak "/^[[:space:]]*\*/ s|'@supabase/server/[^']*'|'@supabase/server'|g" {} + \
&& find "$vendor_dir/package/dist" -name '*.bak' -delete
echo "Vendored $(basename "$tarball") -> ${vendor_dir#"$repo_root/"}/package"
+8 -1
View File
@@ -34,8 +34,15 @@ enabled = false
[analytics]
enabled = false
# Enabled for the server-e2e function (SDK-1280). Everything else above
# stays disabled to keep `supabase start` fast in CI.
[edge_runtime]
enabled = false
enabled = true
# The middleware's own 401 behavior is under test — the gateway's JWT
# pre-check must not answer first.
[functions.server-e2e]
verify_jwt = false
[auth]
enabled = true
@@ -0,0 +1,9 @@
{
"imports": {
"@supabase/server": "../_vendor/package/dist/index.mjs",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/supabase-js/cors": "npm:@supabase/supabase-js@2/cors",
"@supabase/middleware": "npm:@supabase/middleware@0.3.0",
"jose": "npm:jose@6"
}
}
@@ -0,0 +1,82 @@
// The e2e edge function: the same route surface as e2e/apps/core/app.ts —
// the same core withSupabase(config, handler) wrapper — but on the real
// Deno edge runtime behind the local gateway. Kept self-contained: the
// runtime mounts only supabase/functions/, so the shared e2e helpers are
// out of reach and the small dispatch + queries are duplicated by design.
// Keep the two files in sync when the route surface changes.
import { withSupabase } from '@supabase/server'
const COLUMNS = 'id, user_id, body'
// The gateway forwards /functions/v1/server-e2e/<path> with the function
// name still in the pathname; some CLI versions strip it. Handle both.
function route(req: Request): string {
const { pathname } = new URL(req.url)
return pathname.replace(/^\/server-e2e/, '') || '/'
}
const userHandler = withSupabase({ auth: 'user' }, async (req, ctx) => {
const pathname = route(req)
const { supabase, supabaseAdmin, userClaims } = ctx
if (pathname === '/me') return Response.json({ userClaims })
if (pathname === '/my-notes') {
// No WHERE clause — the caller's JWT reaches PostgREST through
// ctx.supabase and the RLS policy alone scopes the rows.
const { data, error } = await supabase
.from('notes')
.select(COLUMNS)
.order('created_at', { ascending: true })
if (error) throw new Error(`list own notes failed: ${error.message}`)
return Response.json(data)
}
if (pathname === '/all-notes') {
// Admin client, no filter — proves it is not scoped to the caller.
const { data, error } = await supabaseAdmin
.from('notes')
.select(COLUMNS)
.order('created_at', { ascending: true })
if (error) throw new Error(`list all notes failed: ${error.message}`)
return Response.json(data)
}
if (pathname === '/notes' && req.method === 'GET') {
const { data, error } = await supabaseAdmin
.from('notes')
.select(COLUMNS)
.eq('user_id', userClaims!.id)
.order('created_at', { ascending: true })
if (error) throw new Error(`list notes failed: ${error.message}`)
return Response.json(data)
}
if (pathname === '/notes' && req.method === 'POST') {
const { body } = (await req.json()) as { body?: string }
if (!body) {
return Response.json({ error: 'body required' }, { status: 400 })
}
const { data, error } = await supabaseAdmin
.from('notes')
.insert({ user_id: userClaims!.id, body })
.select(COLUMNS)
.single()
if (error) throw new Error(`insert note failed: ${error.message}`)
return Response.json(data, { status: 201 })
}
return Response.json({ error: 'not found' }, { status: 404 })
})
const optionalHandler = withSupabase(
{ auth: ['user', 'none'] },
async (_req, ctx) => Response.json({ userClaims: ctx.userClaims }),
)
Deno.serve((req) => {
const pathname = route(req)
if (pathname === '/health') return Response.json({ status: 'ok' })
if (pathname === '/me-optional') return optionalHandler(req)
return userHandler(req)
})
+2 -1
View File
@@ -12,5 +12,6 @@
"emitDecoratorMetadata": true,
"types": ["node"]
},
"include": ["."]
"include": ["."],
"exclude": ["supabase/functions"]
}
+2 -1
View File
@@ -158,7 +158,8 @@
"test:e2e": "vitest run --project e2e",
"test:watch": "vitest --project unit --project nestjs",
"typecheck": "tsc --noEmit && tsc --noEmit -p src/adapters/nestjs",
"typecheck:e2e": "tsc --noEmit -p e2e"
"typecheck:e2e": "tsc --noEmit -p e2e",
"vendor:e2e": "bash e2e/scripts/vendor-pack.sh"
},
"simple-git-hooks": {
"pre-commit": "pnpm pretty-quick --staged",