From 3278b942276c35ce86c58b4b32023c92226a003a Mon Sep 17 00:00:00 2001 From: Wojtek Majewski Date: Thu, 27 Nov 2025 11:28:34 +0100 Subject: [PATCH] add control plane test coverage improvements --- COMPILE_WORKER.md | 331 ------------------ PRD_control-plane-http.md | 203 ----------- .../__tests__/commands/compile/index.test.ts | 230 +++++++++++- pkgs/cli/__tests__/e2e/compile.test.ts | 35 ++ pkgs/cli/scripts/test-compile | 109 ------ .../tests/e2e/control-plane.test.ts | 36 ++ .../tests/unit/control-plane/server.test.ts | 22 ++ 7 files changed, 322 insertions(+), 644 deletions(-) delete mode 100644 COMPILE_WORKER.md delete mode 100644 PRD_control-plane-http.md delete mode 100755 pkgs/cli/scripts/test-compile diff --git a/COMPILE_WORKER.md b/COMPILE_WORKER.md deleted file mode 100644 index a014fee28..000000000 --- a/COMPILE_WORKER.md +++ /dev/null @@ -1,331 +0,0 @@ -# Auto-Compilation: Simplified Flow Development - -> **Implementation**: This feature is being built in two phases: -> -> - **Phase 1 (MVP)**: Core auto-compilation with conservative behavior -> - **Phase 2 (Enhancement)**: Smart updates that preserve data when possible - ---- - -## ๐Ÿš€ Local Development - No Manual Steps - -### 1. Start Edge Runtime - -```bash -supabase functions serve -``` - -### 2. Start Worker (Triggers Auto-Compilation) - -```bash -curl http://localhost:54321/functions/v1/my-worker -``` - -- Worker detects local environment ([see how](#environment-detection)) -- Auto-creates flow in database -- โœ… Ready to process tasks immediately - -### 3. Edit Flow Code - -Make changes to your flow definition file. - -### 4. Restart Worker (After Code Changes) - -```bash -# Kill `functions serve` (Ctrl+C), then restart -supabase functions serve -``` - -```bash -# Start worker with fresh code -curl http://localhost:54321/functions/v1/my-worker -``` - -- Worker auto-updates flow definition -- โœ… Ready to test immediately - -**What happens automatically:** - -- Worker detects local environment -- Compares flow code with database definition -- Updates database to match your code -- **Phase 1**: Always drops and recreates (fresh state guaranteed) -- **Phase 2**: Preserves test data when only runtime options change - -**No `pgflow compile` commands needed in development! ๐ŸŽ‰** - ---- - -## ๐Ÿ” Environment Detection - -Workers automatically detect whether they're running locally or in production. - -```typescript -// Check for Supabase-specific environment variables -const isLocal = !Boolean( - Deno.env.get('DENO_DEPLOYMENT_ID') || Deno.env.get('SB_REGION') -); -``` - -**How it works:** - -- These environment variables are automatically set by Supabase on hosted deployments -- When running `supabase functions serve` locally, these variables are absent -- Additional DB URL validation warns about unexpected configurations - -**Result:** - -- **Local**: Auto-compilation enabled - worker creates/updates flows automatically -- **Production**: Conservative mode - requires explicit migrations for existing flows - ---- - -## ๐Ÿญ Production Deployment - -### Phase 1: Conservative Approach - -**Behavior**: - -- **New flows**: Auto-created on first deployment โœ… -- **Existing flows**: Worker fails, requires migration โŒ - -#### Deploy New Flow - -```bash -# 1. Deploy worker code -supabase functions deploy my-worker - -# 2. First request auto-creates flow -curl https://your-project.supabase.co/functions/v1/my-worker -# โœ… Ready to handle requests -``` - -#### Update Existing Flow - -```bash -# 1. Generate migration -pgflow compile flows/my-flow.ts - -# 2. Deploy migration -supabase db push - -# 3. Deploy worker code -supabase functions deploy my-worker -# โœ… Worker verifies flow matches -``` - -**Phase 1 Benefits**: - -- โœ… Explicit control over production changes -- โœ… Clear audit trail (migrations) -- โœ… Fail-fast protection -- โœ… Simple, predictable behavior - -**Phase 1 Trade-off**: - -- โš ๏ธ Even option-only changes require migration - ---- - -### Phase 2: Smart Updates (Enhancement) - -**Additional Behavior**: - -- **Existing flows with matching structure**: Auto-updates runtime options โœ… -- **Existing flows with structure changes**: Still requires migration โŒ - -#### Update Runtime Options (No Migration Needed!) - -```bash -# 1. Change timeout/maxAttempts in code -# 2. Deploy worker -supabase functions deploy my-worker -# โœ… Options updated automatically (no migration!) -``` - -#### Update Flow Structure (Migration Required) - -```bash -# 1. Add new step or change dependencies -# 2. Generate migration -pgflow compile flows/my-flow.ts - -# 3. Deploy migration + worker -supabase db push -supabase functions deploy my-worker -``` - -**Phase 2 Benefits**: - -- โœ… Faster deploys for option changes -- โœ… Still safe (structure changes require migration) -- โœ… Backward compatible with Phase 1 - -**Phase 2 Addition: Strict Mode** _(Optional)_ - -```bash -# Require migrations even for new flows -PGFLOW_REQUIRE_MIGRATIONS=true -``` - ---- - -## โš™๏ธ Manual Compilation Command - -Generate migration files for explicit deployment control. - -### Basic Usage - -```bash -pgflow compile flows/my-flow.ts -``` - -- Infers worker: `my-flow-worker` (basename + "-worker") -- Checks staleness: compares file mtime with worker startup time -- Returns compiled SQL if worker is fresh - -### Custom Worker Name - -```bash -pgflow compile flows/my-flow.ts --worker custom-worker -``` - -- Use when worker doesn't follow naming convention -- Useful for horizontal scaling or specialized workers - -**Success output:** โœ… - -``` -โœ“ Compiled successfully: my_flow โ†’ SQL migration ready -โœ“ Created: supabase/migrations/20250108120000_create_my_flow.sql -``` - -**If worker needs restart:** โŒ - -``` -Error: Worker code changed since startup -Action: Restart worker and retry -``` - ---- - -## โš ๏ธ Edge Cases & Solutions - -### Multiple Worker Instances (Horizontal Scaling) โœ… - -```bash -# All instances handle the same flow -my-flow-worker-1, my-flow-worker-2, my-flow-worker-3 -``` - -- โœ… **Phase 1**: First instance creates, others fail gracefully and retry -- โœ… **Phase 2**: First instance creates, others detect and continue -- โœ… Advisory locks prevent race conditions - -### Stale Worker (Code Changes) โŒ - -**Problem:** Worker started before code changes. - -#### Solution: Restart Worker - -```bash -# Kill `functions serve` (Ctrl+C), then restart -supabase functions serve -``` - -```bash -# Start worker with fresh code -curl http://localhost:54321/functions/v1/my-worker -``` - -**Detection:** CLI compares file modification time with worker startup time. - ---- - -### Flow Definition Changes - -#### Local Development โœ… - -**Phase 1**: - -- โœ… Always drops and recreates -- โœ… Guaranteed fresh state -- โš ๏ธ Test data lost on every restart - -**Phase 2**: - -- โœ… Preserves test data when only options change -- โœ… Only drops when structure changes (new steps, changed dependencies) -- โœ… Better developer experience - ---- - -#### Production Deployment - -**Phase 1 - Any Change**: - -``` -Error: Flow 'my_flow' already exists -Action: Deploy migration first or use different slug -``` - -Must generate and deploy migration for any change. - -**Phase 2 - Structure Change**: - -``` -Error: Flow 'my_flow' structure mismatch -- Step 'process' dependencies changed: ['fetch'] โ†’ ['fetch', 'validate'] -- New step 'validate' added -Action: Deploy migration first (pgflow compile flows/my-flow.ts) -``` - -Structure changes still require migration (safe!). - -**Phase 2 - Option Change**: - -``` -โœ“ Runtime options updated for flow 'my_flow' -- Step 'process': timeout 30s โ†’ 60s -``` - -Option changes work automatically (convenient!). - ---- - -## ๐Ÿ“‹ Behavior Summary - -### What Gets Auto-Compiled - -| Change Type | Local (Phase 1) | Local (Phase 2) | Production (Phase 1) | Production (Phase 2) | -| -------------------- | ---------------- | ------------------ | -------------------- | -------------------- | -| **New flow** | โœ… Auto-create | โœ… Auto-create | โœ… Auto-create | โœ… Auto-create | -| **Runtime options** | โœ… Drop+recreate | โœ… **Update only** | โŒ Require migration | โœ… **Update only** | -| **Structure change** | โœ… Drop+recreate | โœ… Drop+recreate | โŒ Require migration | โŒ Require migration | - -**Key Insight**: Phase 2 adds smart updates that preserve data and allow option changes without migrations. - ---- - -## ๐ŸŽฏ When to Use Each Phase - -### Ship Phase 1 When: - -- โœ… You want auto-compilation ASAP -- โœ… You're okay with explicit migrations in production -- โœ… You don't mind losing local test data on restarts -- โœ… You want simple, predictable behavior - -### Upgrade to Phase 2 When: - -- โœ… Phase 1 is stable in production -- โœ… You want better local dev experience (preserve test data) -- โœ… You want faster production deploys (option changes without migrations) -- โœ… You've validated Phase 1 works for your workflows - ---- - -## ๐Ÿ”— See Also - -- **[PLAN_phase1.md](./PLAN_phase1.md)** - Detailed Phase 1 implementation plan -- **[PLAN_phase2.md](./PLAN_phase2.md)** - Detailed Phase 2 enhancement plan diff --git a/PRD_control-plane-http.md b/PRD_control-plane-http.md deleted file mode 100644 index 05f295348..000000000 --- a/PRD_control-plane-http.md +++ /dev/null @@ -1,203 +0,0 @@ -# PRD: ControlPlane HTTP Compilation (Phase 1) - -**Status**: Draft -**Owner**: TBD -**Last Updated**: 2025-11-20 - ---- - -## What We're Building - -**One-liner**: `pgflow compile` calls HTTP endpoint instead of spawning Deno runtime. - -Replace CLI's fragile Deno runtime spawning with HTTP calls to a ControlPlane edge function. This: -- Eliminates deno.json complexity and path resolution bugs -- Establishes pattern for Phase 2 auto-compilation -- Improves developer experience with reliable compilation - -**Alternatives considered**: Per-worker endpoints (code duplication), keep Deno spawning (too unreliable), direct SQL in CLI (wrong packaging model). - ---- - -## Before & After - -| Aspect | Old (v0.8.0) | New (v0.9.0) | -|--------|--------------|--------------| -| **Command** | `pgflow compile path/to/flow.ts --deno-json=deno.json` | `pgflow compile my-flow` | -| **How it works** | CLI spawns Deno โ†’ imports flow file โ†’ compiles to SQL | CLI calls HTTP โ†’ ControlPlane compiles โ†’ returns SQL | -| **Pain points** | Import map errors, path resolution, Deno version issues | Flow must be registered in ControlPlane first | -| **Setup** | Deno installed locally | Supabase + edge functions running | -| **Rollback** | N/A | `npx pgflow@0.8.0 compile path/to/flow.ts` | - ---- - -## Goals & Success Criteria - -**What success looks like:** -- โœ… ControlPlane pattern established (reusable for Phase 2) -- โœ… HTTP compilation works reliably (<5% users need version pinning) -- โœ… Developer setup simplified (no Deno version management) -- โœ… Clear error messages with rollback option -- โœ… `pgflow compile` uses HTTP (Deno spawn code deleted) -- โœ… `pgflow install` creates ControlPlane edge function -- โœ… Tests passing (80%+ coverage: unit, integration, E2E) -- โœ… Docs updated (installation, compilation, troubleshooting) -- โœ… Changelog complete - -**Metrics:** -- Zero HTTP compilation failures -- Positive feedback on reliability -- ControlPlane API ready for Phase 2 - ---- - -## Requirements - -### ControlPlane Edge Function -- Serve `GET /flows/:slug` โ†’ `{ flowSlug: string, sql: string[] }` -- Registry: `Map` built from flows array -- Validation: Reject duplicate slugs at startup -- Errors: 404 for unknown flows - -### CLI Changes -- Command: `pgflow compile ` (flow slug, not file path) -- HTTP call: `GET /pgflow/flows/:slug` -- URL: Parse from `supabase status` -- Deprecation: Show warning if `--deno-json` used -- Migration: Delete all Deno spawn code - -### Installation -`pgflow install` creates: -- `supabase/functions/pgflow/index.ts` - Calls `ControlPlane.serve(flows)` -- `supabase/functions/pgflow/flows.ts` - User edits, exports flow array -- `supabase/functions/pgflow/deno.json` - Minimal import map template -- Updates `supabase/config.toml` with edge function entry - -### Testing -- **Unit**: ControlPlane registry, CLI helpers, mocked HTTP -- **Integration**: Real HTTP server, endpoint responses -- **E2E**: Full flow (install โ†’ register flow โ†’ compile), error scenarios -- **Coverage**: 80% min for new code, 100% for critical paths - -### Out of Scope (Phase 2) -- โŒ Worker auto-compilation -- โŒ POST /ensure-compiled endpoint -- โŒ Shape comparison, advisory locks -- โŒ Import map auto-generation -- โŒ Flow auto-discovery - ---- - -## Error Handling - -All user-facing errors centralized here: - -| Error Scenario | CLI Output | Fix | -|----------------|------------|-----| -| **--deno-json flag used** | Warning: `--deno-json` is deprecated and has no effect (will be removed in v1.0) | Remove flag | -| **Flow not registered** | Flow 'my-flow' not found. Did you add it to flows.ts? | Add to `flows.ts` | -| **Old path syntax** | Flow 'path/to/flow.ts' not found. Did you add it to flows.ts? | Use slug instead of path | -| **ControlPlane unreachable** | ControlPlane not reachable.

Fix options:
1. Start Supabase: `supabase start`
2. Start edge functions: `supabase functions serve`
3. Use previous version: `npx pgflow@0.8.0` | Start services or rollback | -| **SERVICE_ROLE missing** (v1.1) | SERVICE_ROLE key not found. Is Supabase running? (`supabase status`) | Check Supabase status | - ---- - -## Documentation & Versioning - -### Docs to Update -1. **installation.mdx**: Add note "Creates ControlPlane function for flow compilation" -2. **compile-flow.mdx**: - - Remove Deno requirement (no longer user-facing) - - Add prerequisites: Supabase + edge functions running - - Update command examples (file path โ†’ slug) - - Keep immutability note + link to delete-flows.mdx - -### Versioning Strategy -- **Latest best practice**: Single-path documentation (v0.9.0+ only) -- **Escape hatch**: Version pinning (`npx pgflow@0.8.0`) for rollback -- **Optional (v1.1)**: Dedicated troubleshooting page if users request - -### Phase 2 Changes -Move `compile-flow.mdx` to `concepts/` (tutorial โ†’ explanation), remove from getting started. User story: "Compilation is automatic now" - ---- - -## Technical Design - -### Architecture -``` -pgflow compile my-flow - โ”‚ - โ””โ”€> HTTP GET /pgflow/flows/my-flow - โ”‚ - โ–ผ - ControlPlane Edge Function - โ”‚ - โ”œโ”€> flows.get('my-flow') - โ”œโ”€> compileFlow(flow) [reuses existing @pgflow/dsl] - โ””โ”€> { flowSlug: 'my-flow', sql: [...] } - โ”‚ - โ–ผ - CLI generates migration: ${timestamp}_create_${flowSlug}_flow.sql -``` - -### Key Decisions -- **Reuse `compileFlow()`**: No new SQL logic, ControlPlane wraps existing DSL function -- **User owns flows.ts**: Import flows, export array -- **pgflow owns index.ts**: Updated via `pgflow install` -- **Future-proof**: `--control-plane` flag for multi-instance pattern (v1.1) - -**See PLAN.md for**: API specs, code examples, detailed test plan, error patterns - ---- - -## Constraints & Risks - -### Dependencies -- Supabase CLI (any version with `supabase status`) -- Existing `compileFlow()` from @pgflow/dsl (no changes) -- nx monorepo structure - -### Primary Risk: Import Map Complexity -**Risk**: deno.json management becomes worse -**Mitigation**: Minimal template, link to Supabase docs, users manage dependencies -**Detection**: Early testing with real flows -**Escape hatch**: Multiple ControlPlanes (manual) - -### Constraints -- Zero breaking changes to output format -- 10-12 hours effort (implementation 4-5h, tests 6-7h) -- Ship v1.0 within 2 weeks - ---- - -## Release Plan - -### v1.0 (2 weeks) -- ControlPlane.serve() + `GET /flows/:slug` -- Replace `pgflow compile` with HTTP -- `pgflow install` creates edge function templates -- Tests (unit + integration + E2E) -- Docs updated -- Changelog - -### v1.1 (1-2 weeks after v1.0, based on feedback) -- Troubleshooting page (if requested) -- SERVICE_ROLE auth (if not in v1.0) -- `--control-plane` flag -- Better error messages - ---- - -## Appendix - -### Related Documents -- **PLAN.md**: Detailed implementation, API specs, test plan -- **PLAN_orchestration.md**: Phase 2+ auto-compilation vision - -### Changelog -- **2025-11-20**: Major simplification - removed duplication, centralized errors, streamlined structure -- **2025-11-20**: Clarified command signature (path โ†’ slug), deprecated --deno-json -- **2025-01-20**: Made troubleshooting page optional (v1.1) -- **2024-11-19**: Changed to `GET /flows/:slug` with `{ flowSlug, sql }` response -- **2024-11-19**: Initial PRD diff --git a/pkgs/cli/__tests__/commands/compile/index.test.ts b/pkgs/cli/__tests__/commands/compile/index.test.ts index f880658e0..f77fda0a4 100644 --- a/pkgs/cli/__tests__/commands/compile/index.test.ts +++ b/pkgs/cli/__tests__/commands/compile/index.test.ts @@ -1,5 +1,24 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchFlowSQL } from '../../../src/commands/compile'; +import { Command } from 'commander'; + +// Mock modules before imports +vi.mock('fs'); +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Import after mocks +import fs from 'fs'; +import { log } from '@clack/prompts'; +import compileCommand from '../../../src/commands/compile'; describe('fetchFlowSQL', () => { beforeEach(() => { @@ -201,4 +220,213 @@ describe('fetchFlowSQL', () => { }, }); }); + + it('should handle empty SQL array response', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ + flowSlug: 'empty_flow', + sql: [], + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + // Current behavior: returns empty array (documenting current behavior) + const result = await fetchFlowSQL( + 'empty_flow', + 'http://127.0.0.1:50621/functions/v1/pgflow', + 'test-publishable-key' + ); + + expect(result.sql).toEqual([]); + }); +}); + +describe('compile command action', () => { + let program: Command; + let mockExit: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + // Create a fresh program and register the compile command + program = new Command(); + program.exitOverride(); + compileCommand(program); + + // Mock process.exit + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + // Default fs mocks + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + }); + + afterEach(() => { + mockExit.mockRestore(); + }); + + it('should write migration file with correct filename format', async () => { + // Mock successful fetch response + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + flowSlug: 'test_flow', + sql: ["SELECT pgflow.create_flow('test_flow');"], + }), + }); + + // Mock Date to return fixed time: 2024-06-15T14:30:45.000Z + const fixedDate = new Date('2024-06-15T14:30:45.000Z'); + vi.useFakeTimers(); + vi.setSystemTime(fixedDate); + + await program.parseAsync([ + 'node', + 'test', + 'compile', + 'test_flow', + '--supabase-path', + '/tmp/supabase', + ]); + + vi.useRealTimers(); + + // Verify writeFileSync was called with correct filename pattern + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/20240615143045_create_test_flow_flow\.sql$/), + expect.any(String) + ); + }); + + it('should create migrations directory if missing', async () => { + // Supabase exists, but migrations dir doesn't + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.includes('migrations')) return false; + return true; // supabase dir exists + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + flowSlug: 'test_flow', + sql: ["SELECT pgflow.create_flow('test_flow');"], + }), + }); + + await program.parseAsync([ + 'node', + 'test', + 'compile', + 'test_flow', + '--supabase-path', + '/tmp/supabase', + ]); + + // Verify mkdirSync was called with recursive option + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('migrations'), + { recursive: true } + ); + }); + + it('should warn about existing migration and still create new one', async () => { + // Return existing migration in directory + vi.mocked(fs.readdirSync).mockReturnValue([ + '20240101120000_create_test_flow_flow.sql' as unknown as fs.Dirent, + ]); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + flowSlug: 'test_flow', + sql: ["SELECT pgflow.create_flow('test_flow');"], + }), + }); + + await program.parseAsync([ + 'node', + 'test', + 'compile', + 'test_flow', + '--supabase-path', + '/tmp/supabase', + ]); + + // Verify warning was logged + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("Found existing migration(s) for 'test_flow'") + ); + + // Verify new file was still created + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should exit with error when supabase path does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect( + program.parseAsync([ + 'node', + 'test', + 'compile', + 'test_flow', + '--supabase-path', + '/nonexistent/supabase', + ]) + ).rejects.toThrow('process.exit called'); + + // Verify error was logged + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining('Supabase directory not found') + ); + + // Verify process.exit was called with 1 + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should generate timestamp in YYYYMMDDHHMMSS UTC format', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + flowSlug: 'test_flow', + sql: ["SELECT pgflow.create_flow('test_flow');"], + }), + }); + + // Set a specific time to verify UTC formatting + // Local time could be different, but we expect UTC: 2024-12-25T09:05:03.000Z + const fixedDate = new Date('2024-12-25T09:05:03.000Z'); + vi.useFakeTimers(); + vi.setSystemTime(fixedDate); + + await program.parseAsync([ + 'node', + 'test', + 'compile', + 'test_flow', + '--supabase-path', + '/tmp/supabase', + ]); + + vi.useRealTimers(); + + // Verify filename starts with correct UTC timestamp: 20241225090503 + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/20241225090503_create_test_flow_flow\.sql$/), + expect.any(String) + ); + }); }); diff --git a/pkgs/cli/__tests__/e2e/compile.test.ts b/pkgs/cli/__tests__/e2e/compile.test.ts index 7b26da39c..df0123a86 100644 --- a/pkgs/cli/__tests__/e2e/compile.test.ts +++ b/pkgs/cli/__tests__/e2e/compile.test.ts @@ -133,4 +133,39 @@ describe('pgflow compile (e2e)', () => { console.log('โœจ Compile test complete'); }, 60000); // 1 minute timeout for the test + + it('should fail with helpful error for unknown flow', async () => { + // Wait for Edge Functions server to be fully ready + await ensureServerReady(); + + // Run pgflow compile command with non-existent flow + console.log('โš™๏ธ Compiling non-existent flow to test error handling'); + const compileResult = await runCommand( + 'node', + [ + path.join(cliDir, 'dist', 'index.js'), + 'compile', + 'nonexistent_flow', + '--supabase-path', + supabasePath, + '--control-plane-url', + CONTROL_PLANE_URL, + ], + { + cwd: cliDir, + env: { PATH: `${workspaceRoot}/node_modules/.bin:${process.env.PATH}` }, + debug: true, + } + ); + + // Should fail with non-zero exit code + expect(compileResult.code).not.toBe(0); + + // Should contain helpful error message about flow not found + const output = compileResult.stderr + compileResult.stdout; + expect(output).toMatch(/not found|flows\.ts/i); + + console.log('โœ“ Unknown flow correctly returned error'); + console.log('โœจ Unknown flow error test complete'); + }, 60000); // 1 minute timeout for the test }); diff --git a/pkgs/cli/scripts/test-compile b/pkgs/cli/scripts/test-compile deleted file mode 100755 index 65607f0df..000000000 --- a/pkgs/cli/scripts/test-compile +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "๐Ÿงช Testing pgflow compile functionality" - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Store the CLI package directory (where pnpm workspace is) -CLI_DIR=$(pwd) -# Get the workspace root directory (two levels up from CLI_DIR) -WORKSPACE_ROOT=$(cd "${CLI_DIR}/../.." && pwd) - -# Create temp directory for testing -TEST_DIR=$(mktemp -d) -echo -e "${BLUE}๐Ÿ“ Created test directory: ${TEST_DIR}${NC}" - -# Cleanup function -cleanup() { - echo -e "${BLUE}๐Ÿงน Cleaning up...${NC}" - cd / - if [ -d "${TEST_DIR}/supabase" ]; then - pnpm -C "${CLI_DIR}" exec supabase stop --no-backup --workdir "${TEST_DIR}" || true - fi - rm -rf "${TEST_DIR}" -} - -# Register cleanup on exit -trap cleanup EXIT - -# Navigate to test directory -cd "${TEST_DIR}" - -# 1. Initialize Supabase project -echo -e "${BLUE}๐Ÿ—๏ธ Creating Supabase project${NC}" -# Use --workdir to create supabase directory in TEST_DIR instead of current working directory -pnpm -C "$CLI_DIR" exec supabase init --force --yes --with-intellij-settings --with-vscode-settings --workdir "${TEST_DIR}" - -# Verify supabase directory was created in the test directory -if [ ! -d "${TEST_DIR}/supabase" ]; then - echo -e "${YELLOW}โŒ Error: supabase directory not found at ${TEST_DIR}/supabase${NC}" - echo -e "${YELLOW} supabase init may have created it in the wrong location${NC}" - exit 1 -fi -echo -e "${GREEN}โœ“ Supabase directory created at ${TEST_DIR}/supabase${NC}" - -# 2. Run pgflow install to set up ControlPlane files -echo -e "${BLUE}๐Ÿ“ฆ Installing pgflow${NC}" -(cd "${CLI_DIR}" && PATH="${WORKSPACE_ROOT}/node_modules/.bin:$PATH" node dist/index.js install -y --supabase-path "${TEST_DIR}/supabase") - -# 3. Create a test flow in flows.ts -echo -e "${BLUE}โœ๏ธ Writing test flow to flows.ts${NC}" -cat > "${TEST_DIR}/supabase/functions/pgflow/flows.ts" << 'EOF' -import { Flow } from '@pgflow/dsl'; - -// Simple test flow for e2e testing -const TestFlow = new Flow({ slug: 'test_flow' }) - .step({ slug: 'step1' }, () => ({ result: 'done' })); - -export const flows = [TestFlow]; -EOF - -# 4. Start Supabase (this starts edge functions too) -echo -e "${BLUE}๐Ÿš€ Starting Supabase (including edge functions)${NC}" -pnpm -C "${CLI_DIR}" exec supabase start --workdir "${TEST_DIR}" - -# 5. Run pgflow compile with the new HTTP-based command -echo -e "${BLUE}โš™๏ธ Compiling flow via ControlPlane${NC}" -# Run with PATH including node_modules/.bin so supabase binary is accessible -(cd "${CLI_DIR}" && PATH="${WORKSPACE_ROOT}/node_modules/.bin:$PATH" node dist/index.js compile test_flow --supabase-path "${TEST_DIR}/supabase") - -# 6. Verify migration was created -echo -e "${BLUE}โœ… Verifying migration file${NC}" -MIGRATION_COUNT=$(find "${TEST_DIR}/supabase/migrations" -name "*test_flow.sql" 2>/dev/null | wc -l) - -if [ "$MIGRATION_COUNT" -eq 0 ]; then - echo -e "${YELLOW}โŒ Error: No migration file found${NC}" - exit 1 -fi - -if [ "$MIGRATION_COUNT" -gt 1 ]; then - echo -e "${YELLOW}โŒ Error: Multiple migration files found${NC}" - exit 1 -fi - -MIGRATION_FILE=$(find "${TEST_DIR}/supabase/migrations" -name "*test_flow.sql" | head -1) -echo -e "${GREEN}โœ“ Found migration: $(basename ${MIGRATION_FILE})${NC}" - -# 7. Verify migration contains expected SQL -if ! grep -q "pgflow.create_flow('test_flow'" "${MIGRATION_FILE}"; then - echo -e "${YELLOW}โŒ Error: Migration doesn't contain create_flow statement${NC}" - exit 1 -fi - -if ! grep -q "pgflow.add_step('test_flow', 'step1'" "${MIGRATION_FILE}"; then - echo -e "${YELLOW}โŒ Error: Migration doesn't contain add_step statement${NC}" - exit 1 -fi - -echo -e "${GREEN}โœ“ Migration content is correct${NC}" - -# 8. Stop Supabase -echo -e "${BLUE}๐Ÿ›‘ Stopping Supabase${NC}" -pnpm -C "${CLI_DIR}" exec supabase stop --no-backup --workdir "${TEST_DIR}" - -echo -e "${GREEN}โœจ Compile test complete${NC}" diff --git a/pkgs/edge-worker/tests/e2e/control-plane.test.ts b/pkgs/edge-worker/tests/e2e/control-plane.test.ts index e831f6ea0..072640ca0 100644 --- a/pkgs/edge-worker/tests/e2e/control-plane.test.ts +++ b/pkgs/edge-worker/tests/e2e/control-plane.test.ts @@ -100,3 +100,39 @@ Deno.test('E2E ControlPlane - POST /flows/:slug returns 404 (wrong method)', asy log('404 error correctly returned for wrong HTTP method'); }); + +Deno.test('E2E ControlPlane - GET /flows/test_flow_2 returns compiled SQL with dependencies', async () => { + const response = await fetch(`${BASE_URL}/flows/test_flow_2`); + const data = await response.json(); + + assertEquals(response.status, 200); + assertEquals(data.flowSlug, 'test_flow_2'); + assertExists(data.sql); + assertEquals(Array.isArray(data.sql), true); + + // Verify SQL contains both steps and dependency information + const sqlContent = data.sql.join('\n'); + assertEquals(sqlContent.includes('step1'), true); + assertEquals(sqlContent.includes('step2'), true); + // step2 depends on step1, passed as ARRAY['step1'] in add_step call + assertEquals(sqlContent.includes("ARRAY['step1']"), true); + + log(`Successfully compiled flow test_flow_2 with dependencies (${data.sql.length} SQL statements)`); +}); + +Deno.test('E2E ControlPlane - GET /flows/test_flow_3 returns compiled SQL with maxAttempts', async () => { + const response = await fetch(`${BASE_URL}/flows/test_flow_3`); + const data = await response.json(); + + assertEquals(response.status, 200); + assertEquals(data.flowSlug, 'test_flow_3'); + assertExists(data.sql); + assertEquals(Array.isArray(data.sql), true); + + // Verify SQL contains maxAttempts configuration (5 as set in the flow) + const sqlContent = data.sql.join('\n'); + assertEquals(sqlContent.includes('max_attempts'), true); + assertEquals(sqlContent.includes('5'), true); + + log(`Successfully compiled flow test_flow_3 with maxAttempts (${data.sql.length} SQL statements)`); +}); diff --git a/pkgs/edge-worker/tests/unit/control-plane/server.test.ts b/pkgs/edge-worker/tests/unit/control-plane/server.test.ts index c4daae35c..3cf541abf 100644 --- a/pkgs/edge-worker/tests/unit/control-plane/server.test.ts +++ b/pkgs/edge-worker/tests/unit/control-plane/server.test.ts @@ -121,3 +121,25 @@ ALL_TEST_FLOWS.forEach((flow) => { assertEquals(data.sql, expectedSql); }); }); + +Deno.test('ControlPlane Handler - GET /flows/:slug returns 500 on compilation error', async () => { + // Create a flow with mismatched stepOrder (references non-existent step) + const brokenFlow = new Flow({ slug: 'broken_flow' }).step( + { slug: 'step1' }, + () => ({}) + ); + + // Tamper with stepOrder to reference non-existent step + // This will cause compileFlow to throw when it tries to get the step definition + // deno-lint-ignore no-explicit-any + (brokenFlow as any).stepOrder.push('nonexistent_step'); + + const handler = createControlPlaneHandler([brokenFlow]); + const request = new Request('http://localhost/pgflow/flows/broken_flow'); + const response = handler(request); + + assertEquals(response.status, 500); + const data = await response.json(); + assertEquals(data.error, 'Compilation Error'); + assertMatch(data.message, /does not exist in flow/); +});