Skip to content

chore: add watcher tests and main.ts #3717

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/functions/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa
if err != nil {
return err
}
go watcher.Start(ctx)
go watcher.Start()
defer watcher.Close()
// TODO: refactor this to edge runtime service
runtimeOption.fileWatcher = watcher
Expand Down
280 changes: 275 additions & 5 deletions internal/functions/serve/serve_test.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,102 @@
package serve

import (
"bytes"
"context"
"net/http"
"os"
"path/filepath"
"testing"
"time"

"encoding/json"

"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/h2non/gock"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/supabase/cli/internal/testing/apitest"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/pkg/cast"
)

// Test helper functions
type TestSetup struct {
T *testing.T
Fsys afero.Fs
Context context.Context
Cancel context.CancelFunc
ProjectId string
RootPath string
}

func NewTestSetup(t *testing.T) *TestSetup {
fsys := afero.NewMemMapFs()
ctx, cancel := context.WithCancel(context.Background())

setup := &TestSetup{
T: t,
Fsys: fsys,
Context: ctx,
Cancel: cancel,
ProjectId: "test",
RootPath: "/project",
}

// Initialize basic config
require.NoError(t, utils.InitConfig(utils.InitParams{ProjectId: setup.ProjectId}, fsys))

return setup
}

func (s *TestSetup) Cleanup() {
s.Cancel()
gock.OffAll()
}

// SetupFunction creates a test function with given name and content
func (s *TestSetup) SetupFunction(name, content string) {
funcDir := filepath.Join(utils.FunctionsDir, name)
require.NoError(s.T, s.Fsys.MkdirAll(funcDir, 0755))
require.NoError(s.T, afero.WriteFile(s.Fsys, filepath.Join(funcDir, "index.ts"), []byte(content), 0644))
}

// SetupEnvFile creates an environment file with given content
func (s *TestSetup) SetupEnvFile(path, content string) {
if path == "" {
path = utils.FallbackEnvFilePath
}
require.NoError(s.T, afero.WriteFile(s.Fsys, path, []byte(content), 0644))
}

// SetupImportMap creates an import map file with given content
func (s *TestSetup) SetupImportMap(path, content string) {
if path == "" {
path = utils.FallbackImportMapPath
}
require.NoError(s.T, afero.WriteFile(s.Fsys, path, []byte(content), 0644))
}

// SetupConfigWithFunctions creates a supabase config.toml with function configurations
func (s *TestSetup) SetupConfigWithFunctions() {
configContent := `[functions.hello]
enabled = true
verify_jwt = false

[functions.protected]
enabled = true
verify_jwt = true

[functions.goodbye]
enabled = false
verify_jwt = false`

require.NoError(s.T, afero.WriteFile(s.Fsys, "supabase/config.toml", []byte(configContent), 0644))
}

func TestServeCommand(t *testing.T) {
t.Run("serves all functions", func(t *testing.T) {
// Setup in-memory fs
Expand All @@ -36,18 +116,50 @@ func TestServeCommand(t *testing.T) {
Delete("/v" + utils.Docker.ClientVersion() + "/containers/" + containerId).
Reply(http.StatusOK)
apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), containerId)
require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "success"))
// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
assert.NoError(t, err)
// Mock streaming logs for the log streamer - first call returns logs, subsequent calls return empty
var streamBody bytes.Buffer
streamWriter := stdcopy.NewStdWriter(&streamBody, stdcopy.Stdout)
_, streamErr := streamWriter.Write([]byte("streaming logs"))
require.NoError(t, streamErr)
// First request returns the logs
gock.New(utils.Docker.DaemonHost()).
Get("/v"+utils.Docker.ClientVersion()+"/containers/"+containerId+"/logs").
Reply(http.StatusOK).
SetHeader("Content-Type", "application/vnd.docker.raw-stream").
Body(&streamBody)
// Subsequent requests return empty response (simulating no new logs)
gock.New(utils.Docker.DaemonHost()).
Get("/v"+utils.Docker.ClientVersion()+"/containers/"+containerId+"/logs").
Persist().
Reply(http.StatusOK).
SetHeader("Content-Type", "application/vnd.docker.raw-stream").
Body(bytes.NewReader([]byte{}))
// Mock container inspection for exit code check
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/" + containerId + "/json").
Persist().
Reply(http.StatusOK).
JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
ExitCode: 0,
}}})

// Create a context with timeout to prevent test from hanging
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// Run test with timeout context
err := Run(ctx, "", nil, "", RuntimeOption{}, fsys)
// Check error - expect context.DeadlineExceeded because the server runs until cancelled
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("throws error on malformed config", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644))

// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
Expand All @@ -64,6 +176,7 @@ func TestServeCommand(t *testing.T) {
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusNotFound)

// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
Expand All @@ -81,6 +194,7 @@ func TestServeCommand(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusOK).
JSON(container.InspectResponse{})

// Run test
err := Run(context.Background(), ".env", nil, "", RuntimeOption{}, fsys)
// Check error
Expand All @@ -102,9 +216,165 @@ func TestServeCommand(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusOK).
JSON(container.InspectResponse{})

// Run test
err := Run(context.Background(), ".env", cast.Ptr(true), "import_map.json", RuntimeOption{}, fsys)
// Check error
assert.ErrorIs(t, err, os.ErrNotExist)
})
}

func TestParseEnvFile(t *testing.T) {
// Save original CurrentDirAbs
originalCurrentDirAbs := utils.CurrentDirAbs
defer func() {
utils.CurrentDirAbs = originalCurrentDirAbs
}()

t.Run("parses env file successfully", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

envContent := `DATABASE_URL=postgresql://localhost:5432/test
API_KEY=secret123
DEBUG=true`
envPath := "/project/.env"
setup.SetupEnvFile(envPath, envContent)

env, err := parseEnvFile(envPath, setup.Fsys)
assert.NoError(t, err)
assert.Contains(t, env, "DATABASE_URL=postgresql://localhost:5432/test")
assert.Contains(t, env, "API_KEY=secret123")
assert.Contains(t, env, "DEBUG=true")
})

t.Run("uses fallback env file when path is empty", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

envContent := `FALLBACK_VAR=fallback_value`
setup.SetupEnvFile("", envContent)

env, err := parseEnvFile("", setup.Fsys)
assert.NoError(t, err)
assert.Contains(t, env, "FALLBACK_VAR=fallback_value")
})
}

func TestPopulatePerFunctionConfigs(t *testing.T) {
// Save original values
originalFunctionsDir := utils.FunctionsDir
defer func() {
utils.FunctionsDir = originalFunctionsDir
}()

t.Run("populates function configs successfully", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

utils.FunctionsDir = "functions"
setup.SetupFunction("hello", "export default () => 'hello'")
setup.SetupConfigWithFunctions()

binds, configString, err := populatePerFunctionConfigs("/project", "", cast.Ptr(false), setup.Fsys)
assert.NoError(t, err)
assert.NotEmpty(t, binds)
assert.NotEmpty(t, configString)

var config map[string]interface{}
err = json.Unmarshal([]byte(configString), &config)
assert.NoError(t, err)
assert.Contains(t, config, "hello")
})

t.Run("handles function config creation", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

utils.FunctionsDir = "functions"
setup.SetupFunction("enabled", "export default () => 'enabled'")

_, configString, err := populatePerFunctionConfigs("/project", "", nil, setup.Fsys)
assert.NoError(t, err)

var resultConfig map[string]interface{}
err = json.Unmarshal([]byte(configString), &resultConfig)
assert.NoError(t, err)
assert.Contains(t, resultConfig, "enabled")

enabledConfig := resultConfig["enabled"].(map[string]interface{})
assert.Contains(t, enabledConfig, "entrypointPath")
assert.Contains(t, enabledConfig, "verifyJWT")
})

t.Run("handles import map path", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

utils.FunctionsDir = "functions"
setup.SetupFunction("hello", "export default () => 'hello'")
setup.SetupImportMap("import_map.json", "{}")

binds, configString, err := populatePerFunctionConfigs("/project", "import_map.json", nil, setup.Fsys)
assert.NoError(t, err)
assert.NotEmpty(t, binds)
assert.NotEmpty(t, configString)
})

t.Run("returns empty config when no functions exist", func(t *testing.T) {
setup := NewTestSetup(t)
defer setup.Cleanup()

utils.FunctionsDir = "functions"
require.NoError(t, setup.Fsys.MkdirAll("functions", 0755))

_, configString, err := populatePerFunctionConfigs("/project", "", nil, setup.Fsys)
assert.NoError(t, err)

var resultConfig map[string]interface{}
err = json.Unmarshal([]byte(configString), &resultConfig)
assert.NoError(t, err)
assert.Empty(t, resultConfig)
})
}

func TestServeFunctions(t *testing.T) {
// Save original values
originalConfig := utils.Config
originalDebug := viper.Get("DEBUG")
originalFunctionsDir := utils.FunctionsDir
defer func() {
utils.Config = originalConfig
viper.Set("DEBUG", originalDebug)
utils.FunctionsDir = originalFunctionsDir
}()

t.Run("returns error on env file parsing failure", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Test with nonexistent file to trigger error

// Test function
err := ServeFunctions(context.Background(), "nonexistent.env", nil, "", "postgresql://localhost:5432/test", RuntimeOption{}, fsys)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open env file")
})

t.Run("returns error on function config failure", func(t *testing.T) {
// Setup config
utils.Config.Auth.AnonKey.Value = "anon_key"
utils.Config.Auth.ServiceRoleKey.Value = "service_role_key"
utils.Config.Auth.JwtSecret.Value = "jwt_secret"
utils.Config.Api.Port = 8000
utils.Config.EdgeRuntime.Policy = "permissive"
utils.KongAliases = []string{"supabase_kong_test"}

// Setup in-memory fs with invalid functions directory
fsys := afero.NewMemMapFs()
utils.FunctionsDir = "nonexistent"

// Test function
err := ServeFunctions(context.Background(), "", nil, "", "postgresql://localhost:5432/test", RuntimeOption{}, fsys)
assert.Error(t, err)
})
}
34 changes: 31 additions & 3 deletions internal/functions/serve/templates/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const DENO_SB_ERROR_MAP = new Map([
SB_SPECIFIC_ERROR_CODE.WorkerLimit,
],
]);
const GENERIC_FUNCTION_SERVE_MESSAGE = `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/<function-name>`

interface FunctionConfig {
entrypointPath: string;
Expand Down Expand Up @@ -228,9 +229,36 @@ Deno.serve({
},

onListen: () => {
console.log(
`Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/<function-name>\nUsing ${Deno.version.deno}`,
);
try {
const functionsConfigString = Deno.env.get(
"SUPABASE_INTERNAL_FUNCTIONS_CONFIG"
);
if (functionsConfigString) {
const MAX_FUNCTIONS_URL_EXAMPLES = 5
const functionsConfig = JSON.parse(functionsConfigString) as Record<
string,
unknown
>;
const functionNames = Object.keys(functionsConfig);
const exampleFunctions = functionNames.slice(0, MAX_FUNCTIONS_URL_EXAMPLES);
const functionsUrls = exampleFunctions.map(
(fname) => ` - http://127.0.0.1:${HOST_PORT}/functions/v1/${fname}`
);
const functionsExamplesMessages = functionNames.length > 0
// Show some functions urls examples
? `\n${functionsUrls.join(`\n`)}${functionNames.length > MAX_FUNCTIONS_URL_EXAMPLES
// If we have more than 10 functions to serve, then show examples for first 10
// and a count for the remaining ones
? `\n... and ${functionNames.length - MAX_FUNCTIONS_URL_EXAMPLES} more functions`
: ''}`
: ''
console.log(`${GENERIC_FUNCTION_SERVE_MESSAGE}${functionsExamplesMessages}\nUsing ${Deno.version.deno}`);
}
} catch (e) {
console.log(
`${GENERIC_FUNCTION_SERVE_MESSAGE}\nUsing ${Deno.version.deno}`
);
}
},

onError: e => {
Expand Down
Loading
Loading