diff --git a/internal/buildengine/build_go_integration_test.go b/internal/buildengine/build_go_integration_test.go new file mode 100644 index 0000000000..9527c0f19e --- /dev/null +++ b/internal/buildengine/build_go_integration_test.go @@ -0,0 +1,57 @@ +//go:build integration + +package buildengine + +import ( + "os" + "testing" + + "github.com/alecthomas/assert/v2" + + in "github.com/TBD54566975/ftl/internal/integration" +) + +func TestGoBuildClearsBuildDir(t *testing.T) { + file := "./another/.ftl/test-clear-build.tmp" + in.Run(t, + in.WithTestDataDir("testdata"), + in.CopyModule("another"), + in.Build("another"), + in.WriteFile(file, []byte{1}), + in.FileExists(file), + in.Build("another"), + in.ExpectError(in.FileExists(file), "no such file"), + ) +} + +func TestExternalType(t *testing.T) { + in.Run(t, + in.WithTestDataDir("testdata"), + in.CopyModule("external"), + in.ExpectError(in.Build("external"), + `unsupported type "time.Month" for field "Month"`, + `unsupported external type "time.Month"; see FTL docs on using external types: tbd54566975.github.io/ftl/docs/reference/externaltypes/`, + `unsupported response type "ftl/external.ExternalResponse"`, + ), + ) +} + +func TestGeneratedTypeRegistry(t *testing.T) { + expected, err := os.ReadFile("testdata/type_registry_main.go") + assert.NoError(t, err) + + file := "other/.ftl/go/main/main.go" + + in.Run(t, + in.WithTestDataDir("testdata"), + // Deploy dependency + in.CopyModule("another"), + in.Deploy("another"), + // Build the module under test + in.CopyModule("other"), + in.ExpectError(in.FileExists(file), "no such file"), + in.Build("other"), + // Validate the generated main.go + in.FileContent(file, string(expected)), + ) +} diff --git a/internal/buildengine/build_go_test.go b/internal/buildengine/build_go_test.go deleted file mode 100644 index 2a5ffe06a4..0000000000 --- a/internal/buildengine/build_go_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package buildengine - -import ( - "os" - "testing" - - "github.com/alecthomas/assert/v2" - - "github.com/TBD54566975/ftl/backend/schema" -) - -func TestGoBuildClearsBuildDir(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - sch := &schema.Schema{ - Modules: []*schema.Module{ - schema.Builtins(), - {Name: "test"}, - }, - } - bctx := buildContext{ - moduleDir: "testdata/another", - buildDir: ".ftl", - sch: sch, - } - testBuildClearsBuildDir(t, bctx) -} - -func TestExternalType(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - bctx := buildContext{ - moduleDir: "testdata/external", - buildDir: ".ftl", - sch: &schema.Schema{}, - } - testBuild(t, bctx, "", "unsupported external type", []assertion{ - assertBuildProtoErrors( - `unsupported type "time.Month" for field "Month"`, - `unsupported external type "time.Month"; see FTL docs on using external types: tbd54566975.github.io/ftl/docs/reference/externaltypes/`, - `unsupported response type "ftl/external.ExternalResponse"`, - ), - }) -} - -func TestGeneratedTypeRegistry(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - sch := &schema.Schema{ - Modules: []*schema.Module{ - {Name: "another", Decls: []schema.Decl{ - &schema.Enum{ - Name: "TypeEnum", - Export: true, - Variants: []*schema.EnumVariant{ - {Name: "A", Value: &schema.TypeValue{Value: &schema.Int{}}}, - {Name: "B", Value: &schema.TypeValue{Value: &schema.String{}}}, - }, - }, - &schema.Enum{ - Name: "SecondTypeEnum", - Export: true, - Variants: []*schema.EnumVariant{ - {Name: "One", Value: &schema.TypeValue{Value: &schema.Int{}}}, - {Name: "Two", Value: &schema.TypeValue{Value: &schema.String{}}}, - }, - }, - &schema.Data{ - Name: "TransitiveTypeEnum", - Export: true, - Fields: []*schema.Field{ - {Name: "TypeEnumRef", Type: &schema.Ref{Name: "SecondTypeEnum", Module: "another"}}, - }, - }, - }}, - }, - } - expected, err := os.ReadFile("testdata/type_registry_main.go") - assert.NoError(t, err) - bctx := buildContext{ - moduleDir: "testdata/other", - buildDir: ".ftl", - sch: sch, - } - testBuild(t, bctx, "", "", []assertion{ - assertGeneratedMain(string(expected)), - }) -} diff --git a/internal/buildengine/build_test.go b/internal/buildengine/build_test.go deleted file mode 100644 index e28a4ee598..0000000000 --- a/internal/buildengine/build_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package buildengine - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/alecthomas/assert/v2" - - "github.com/TBD54566975/ftl/backend/schema" - "github.com/TBD54566975/ftl/internal/log" - "github.com/TBD54566975/ftl/internal/moduleconfig" -) - -type buildContext struct { - moduleDir string - buildDir string - sch *schema.Schema -} - -type assertion func(t testing.TB, bctx buildContext) error - -type mockModifyFilesTransaction struct{} - -func (t *mockModifyFilesTransaction) Begin() error { - return nil -} - -func (t *mockModifyFilesTransaction) ModifiedFiles(paths ...string) error { - return nil -} - -func (t *mockModifyFilesTransaction) End() error { - return nil -} - -func testBuild( - t *testing.T, - bctx buildContext, - expectedGeneratStubsErrMsg string, // emptystr if no error expected - expectedBuildErrMsg string, // emptystr if no error expected - assertions []assertion, -) { - t.Helper() - ctx := log.ContextWithLogger(context.Background(), log.Configure(os.Stderr, log.Config{})) - abs, err := filepath.Abs(bctx.moduleDir) - assert.NoError(t, err, "Error getting absolute path for module directory") - module, err := LoadModule(abs) - assert.NoError(t, err) - - projectRootDir := t.TempDir() - - configs := []moduleconfig.ModuleConfig{} - if bctx.moduleDir != "" { - config, err := moduleconfig.LoadModuleConfig(bctx.moduleDir) - assert.NoError(t, err, "Error loading project config") - configs = append(configs, config) - } - - // generate stubs to create the shared modules directory - err = GenerateStubs(ctx, projectRootDir, bctx.sch.Modules, configs) - if len(expectedGeneratStubsErrMsg) > 0 { - assert.Error(t, err) - assert.Contains(t, err.Error(), expectedGeneratStubsErrMsg) - } else { - assert.NoError(t, err) - - err = Build(ctx, projectRootDir, bctx.sch, module, &mockModifyFilesTransaction{}) - if len(expectedBuildErrMsg) > 0 { - assert.Error(t, err) - assert.Contains(t, err.Error(), expectedBuildErrMsg) - } else { - assert.NoError(t, err) - } - } - - for _, a := range assertions { - err = a(t, bctx) - assert.NoError(t, err) - } - - err = os.RemoveAll(filepath.Join(bctx.moduleDir, bctx.buildDir)) - assert.NoError(t, err, "Error removing build directory") -} - -func testBuildClearsBuildDir(t *testing.T, bctx buildContext) { - t.Helper() - ctx := log.ContextWithLogger(context.Background(), log.Configure(os.Stderr, log.Config{})) - abs, err := filepath.Abs(bctx.moduleDir) - assert.NoError(t, err, "Error getting absolute path for module directory") - - projectRoot := t.TempDir() - - // generate stubs to create the shared modules directory - err = GenerateStubs(ctx, projectRoot, bctx.sch.Modules, []moduleconfig.ModuleConfig{{Dir: bctx.moduleDir, Language: "go"}}) - assert.NoError(t, err) - - // build to generate the build directory - module, err := LoadModule(abs) - assert.NoError(t, err) - err = Build(ctx, projectRoot, bctx.sch, module, &mockModifyFilesTransaction{}) - assert.NoError(t, err) - - // create a temporary file in the build directory - buildDir := filepath.Join(bctx.moduleDir, bctx.buildDir) - tempFile, err := os.Create(filepath.Join(buildDir, "test-clear-build.tmp")) - assert.NoError(t, err, "Error creating temporary file in module directory") - tempFile.Close() - - // build to clear the old build directory - module, err = LoadModule(abs) - assert.NoError(t, err) - err = Build(ctx, projectRoot, bctx.sch, module, &mockModifyFilesTransaction{}) - assert.NoError(t, err) - - // ensure the temporary file was removed - _, err = os.Stat(filepath.Join(buildDir, "test-clear-build.tmp")) - assert.Error(t, err, "Build directory was not removed") -} - -func assertGeneratedModule(generatedModulePath string, expectedContent string) assertion { - return func(t testing.TB, bctx buildContext) error { - t.Helper() - target := filepath.Join(bctx.moduleDir, bctx.buildDir) - output := filepath.Join(target, generatedModulePath) - - fileContent, err := os.ReadFile(output) - assert.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - return nil - } -} - -func assertGeneratedMain(expectedContent string) assertion { - return func(t testing.TB, bctx buildContext) error { - t.Helper() - output := filepath.Join(bctx.moduleDir, bctx.buildDir, "go/main/main.go") - fileContent, err := os.ReadFile(output) - assert.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - return nil - } -} - -func assertBuildProtoErrors(msgs ...string) assertion { - return func(t testing.TB, bctx buildContext) error { - t.Helper() - config, err := moduleconfig.LoadModuleConfig(bctx.moduleDir) - assert.NoError(t, err, "Error loading module config") - errorList, err := loadProtoErrors(config.Abs()) - assert.NoError(t, err, "Error loading proto errors") - - expected := make([]*schema.Error, 0, len(msgs)) - for _, msg := range msgs { - expected = append(expected, &schema.Error{Msg: msg, Level: schema.ERROR}) - } - - // normalize results - for _, e := range errorList.Errors { - e.EndColumn = 0 - } - - assert.Equal(t, errorList.Errors, expected, assert.Exclude[schema.Position]()) - return nil - } -} diff --git a/internal/buildengine/engine_integration_test.go b/internal/buildengine/engine_integration_test.go new file mode 100644 index 0000000000..81ce95f881 --- /dev/null +++ b/internal/buildengine/engine_integration_test.go @@ -0,0 +1,35 @@ +//go:build integration + +package buildengine_test + +import ( + "testing" + + in "github.com/TBD54566975/ftl/internal/integration" +) + +func TestCycleDetection(t *testing.T) { + in.Run(t, + in.WithTestDataDir("testdata"), + in.CopyModule("depcycle1"), + in.CopyModule("depcycle2"), + + in.ExpectError( + in.Build("depcycle1", "depcycle2"), + `detected a module dependency cycle that impacts these modules:`, + ), + ) +} + +func TestInt64BuildError(t *testing.T) { + in.Run(t, + in.WithTestDataDir("testdata"), + in.CopyModule("integer"), + + in.ExpectError( + in.Build("integer"), + `unsupported type "int64" for field "Input"`, + `unsupported type "int64" for field "Output"`, + ), + ) +} diff --git a/internal/buildengine/engine_test.go b/internal/buildengine/engine_test.go index 4ab7cd73b4..84e1caddc9 100644 --- a/internal/buildengine/engine_test.go +++ b/internal/buildengine/engine_test.go @@ -11,10 +11,7 @@ import ( "github.com/TBD54566975/ftl/internal/log" ) -func TestEngine(t *testing.T) { - if testing.Short() { - t.SkipNow() - } +func TestGraph(t *testing.T) { ctx := log.ContextWithNewDefaultLogger(context.Background()) engine, err := buildengine.New(ctx, nil, t.TempDir(), []string{"testdata/alpha", "testdata/other", "testdata/another"}) assert.NoError(t, err) @@ -58,34 +55,3 @@ func TestEngine(t *testing.T) { err = engine.Build(ctx) assert.NoError(t, err) } - -func TestCycleDetection(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - ctx := log.ContextWithNewDefaultLogger(context.Background()) - engine, err := buildengine.New(ctx, nil, t.TempDir(), []string{"testdata/depcycle1", "testdata/depcycle2"}) - assert.NoError(t, err) - - defer engine.Close() - - err = engine.Build(ctx) - assert.Error(t, err) - assert.Contains(t, err.Error(), "detected a module dependency cycle that impacts these modules:") -} - -func TestInt64BuildError(t *testing.T) { - if testing.Short() { - t.SkipNow() - } - ctx := log.ContextWithNewDefaultLogger(context.Background()) - engine, err := buildengine.New(ctx, nil, t.TempDir(), []string{"testdata/integer"}) - assert.NoError(t, err) - - defer engine.Close() - - err = engine.Build(ctx) - assert.Error(t, err) - assert.Contains(t, err.Error(), `unsupported type "int64" for field "Input"`) - assert.Contains(t, err.Error(), `unsupported type "int64" for field "Output"`) -} diff --git a/internal/buildengine/testdata/type_registry_main.go b/internal/buildengine/testdata/type_registry_main.go index 61a8d30ab8..0eb77e2b08 100644 --- a/internal/buildengine/testdata/type_registry_main.go +++ b/internal/buildengine/testdata/type_registry_main.go @@ -35,7 +35,7 @@ func init() { } func main() { - verbConstructor := server.NewUserVerbServer("ftl", "other", + verbConstructor := server.NewUserVerbServer("integration", "other", server.HandleCall(other.Echo), ) plugin.Start(context.Background(), "other", verbConstructor, ftlv1connect.VerbServiceName, ftlv1connect.NewVerbServiceHandler) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 2981ddc643..003e8fbdee 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -2,6 +2,7 @@ package exec import ( "context" + "errors" "os" "os/exec" //nolint:depguard "syscall" @@ -65,6 +66,20 @@ func (c *Cmd) RunBuffered(ctx context.Context) error { return nil } +// RunStderrError runs the command and captures the output. If the command fails, the stderr is returned as the error message. +func (c *Cmd) RunStderrError(ctx context.Context) error { + errorBuffer := NewCircularBuffer(100) + + c.Cmd.Stdout = nil + c.Cmd.Stderr = errorBuffer.WriterAt(ctx, c.level) + + if err := c.Run(); err != nil { + return errors.New(string(errorBuffer.Bytes())) + } + + return nil +} + // Kill sends a signal to the process group of the command. func (c *Cmd) Kill(signal syscall.Signal) error { if c.Process == nil { diff --git a/internal/integration/actions.go b/internal/integration/actions.go index e2136d381f..6d08dc11eb 100644 --- a/internal/integration/actions.go +++ b/internal/integration/actions.go @@ -160,7 +160,7 @@ func DebugShell() Action { func Exec(cmd string, args ...string) Action { return func(t testing.TB, ic TestContext) { Infof("Executing (in %s): %s %s", ic.workDir, cmd, shellquote.Join(args...)) - err := ftlexec.Command(ic, log.Debug, ic.workDir, cmd, args...).RunBuffered(ic) + err := ftlexec.Command(ic, log.Debug, ic.workDir, cmd, args...).RunStderrError(ic) assert.NoError(t, err) } } @@ -199,13 +199,15 @@ func ExecWithOutput(cmd string, args []string, capture func(output string)) Acti } } -// ExpectError wraps an action and expects it to return an error with the given message. -func ExpectError(action Action, expectedErrorMsg string) Action { +// ExpectError wraps an action and expects it to return an error containing the given messages. +func ExpectError(action Action, expectedErrorMsg ...string) Action { return func(t testing.TB, ic TestContext) { defer func() { if r := recover(); r != nil { if e, ok := r.(TestingError); ok { - assert.Contains(t, string(e), expectedErrorMsg) + for _, msg := range expectedErrorMsg { + assert.Contains(t, string(e), msg) + } } else { panic(r) }