From 9c2138eb5b371499a5d251e3a995c6261df7da5b Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:05:12 +0200 Subject: [PATCH 1/3] feat: First draft of --watch in the start command --- go.mod | 1 + go.sum | 2 + integration/execute_command.go | 15 ++- integration/watch_directory.go | 72 ++++++++++++ mcli/start/start.go | 196 +++++++++++++++++++++++---------- mcli/test/test.go | 49 +++++---- tui/run.go | 21 ++-- tui/test.go | 31 ------ 8 files changed, 267 insertions(+), 120 deletions(-) create mode 100644 integration/watch_directory.go delete mode 100644 tui/test.go diff --git a/go.mod b/go.mod index 2264f6b..cb43a57 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect diff --git a/go.sum b/go.sum index 55c3b3e..dbf66ad 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/integration/execute_command.go b/integration/execute_command.go index 78dacd0..5b0cad6 100644 --- a/integration/execute_command.go +++ b/integration/execute_command.go @@ -7,8 +7,15 @@ import ( "path/filepath" ) +type RunConfig struct { + Print func(string) + Start func(*exec.Cmd) + Directory string + Arguments []string +} + // Build and then run a go program. -func BuildThenRun(funcPrint func(string), funcStart func(*exec.Cmd), directory string, args ...string) error { +func BuildThenRun(config RunConfig) error { // Get the old working directory workDir, err := os.Getwd() @@ -17,12 +24,12 @@ func BuildThenRun(funcPrint func(string), funcStart func(*exec.Cmd), directory s } // Change directory to the file - if err := os.Chdir(directory); err != nil { + if err := os.Chdir(config.Directory); err != nil { return err } // Build the program - if err := ExecCmdWithFuncStart(funcPrint, func(c *exec.Cmd) {}, "go", "build", "-o", "program.exe"); err != nil { + if err := ExecCmdWithFuncStart(config.Print, func(c *exec.Cmd) {}, "go", "build", "-o", "program.exe"); err != nil { return err } @@ -32,7 +39,7 @@ func BuildThenRun(funcPrint func(string), funcStart func(*exec.Cmd), directory s } // Execute and return the process - if err := ExecCmdWithFuncStart(funcPrint, funcStart, filepath.Join(directory, "program.exe"), args...); err != nil { + if err := ExecCmdWithFuncStart(config.Print, config.Start, filepath.Join(config.Directory, "program.exe"), config.Arguments...); err != nil { return err } diff --git a/integration/watch_directory.go b/integration/watch_directory.go new file mode 100644 index 0000000..561c69d --- /dev/null +++ b/integration/watch_directory.go @@ -0,0 +1,72 @@ +package integration + +import ( + "fmt" + "log" + "os" + "path/filepath" + "slices" + + "github.com/fsnotify/fsnotify" +) + +// exclusions has to be a list of non-relative paths +func WatchDirectory(dir string, listener func(), exclusions ...string) error { + // Create new watcher. + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + + // Start listening for events. + go func() { + for { + select { + case _, ok := <-watcher.Events: + if !ok { + return + } + listener() + case err, ok := <-watcher.Errors: + if !ok { + log.Println("error not okay") + return + } + log.Println("watch error:", err) + } + } + }() + + // Start watching all of the directories recursively + for i, exclusion := range exclusions { + exclusions[i] = filepath.Clean(exclusion) + } + return startWatchingRecursive(watcher, dir, exclusions) +} + +// Helper function that calls itself recursively adding all directories to the watcher +func startWatchingRecursive(watcher *fsnotify.Watcher, dir string, cleanedExclusions []string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("couldn't read directory %s: %s", dir, err) + } + + log.Println("Watching", dir) + if err := watcher.Add(dir); err != nil { + return fmt.Errorf("couldn't watch directory %s: %s", dir, err) + } + + for _, entry := range entries { + entryPath := filepath.Clean(filepath.Join(dir, entry.Name())) + + // If it's a directory and not an exclusion, watch it as well + if entry.IsDir() && !slices.ContainsFunc(cleanedExclusions, func(path string) bool { + return filepath.Clean(path) == entryPath + }) { + if err := startWatchingRecursive(watcher, entryPath, cleanedExclusions); err != nil { + return err + } + } + } + return nil +} diff --git a/mcli/start/start.go b/mcli/start/start.go index d9adf06..60cc5fd 100644 --- a/mcli/start/start.go +++ b/mcli/start/start.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/Liphium/magic/integration" "github.com/Liphium/magic/mconfig" @@ -22,11 +23,12 @@ import ( func BuildCommand() *cli.Command { var startConfig = "" var startProfile = "" + var startWatch = false return &cli.Command{ Name: "start", Description: "Magically start your project.", Action: func(ctx context.Context, c *cli.Command) error { - return startCommand(startConfig, startProfile) + return startCommand(startConfig, startProfile, startWatch) }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -43,13 +45,20 @@ func BuildCommand() *cli.Command { Destination: &startConfig, Usage: "The path to the config file that should be used.", }, + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Value: false, + Destination: &startWatch, + Usage: "Watch for changes and restart the project automatically.", + }, }, } } // Command: magic start -func startCommand(config string, profile string) error { - wbOld, err := os.Getwd() +func startCommand(config string, profile string, watch bool) error { + wdOld, err := os.Getwd() if err != nil { return err } @@ -74,55 +83,103 @@ func startCommand(config string, profile string) error { } go func() { - logLeaf.Println("Starting...") - err := integration.BuildThenRun(func(s string) { - if strings.HasPrefix(s, mrunner.PlanPrefix) { - mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) - if err != nil { - logLeaf.Println(strings.TrimLeft(s, mrunner.PlanPrefix)) - quitLeaf.Append(fmt.Errorf("ERROR: couldn't parse plan: %w", err)) - return - } - return - } - if strings.HasPrefix(s, msdk.StartSignal) { - return - } - logLeaf.Println(strings.TrimRight(s, "\n")) - }, func(cmd *exec.Cmd) { - if err = os.Chdir(wbOld); err != nil { - quitLeaf.Append(fmt.Errorf("ERROR: couldn't change working directory: %w", err)) - } + processChan := make(chan *exec.Cmd) + + // Append a closing function here to make sure the process is stopped and all the containers are stopped + exitLeaf.Append(func() { - exitLeaf.Append(func() { + if mconfig.CurrentPlan != nil { // Create a runner and stop all the containers runner, err := mrunner.NewRunnerFromPlan(mconfig.CurrentPlan) if err == nil { runner.StopContainers() } + } + + // Stop the process in case there + process, ok := <-processChan + if !ok { + return // No process has been started yet, nothing to kill + } + if err := process.Process.Kill(); err != nil { + + // test for err process already finished + if os.ErrProcessDone != err { + logLeaf.Println("shutdown err:", err) + } else { + logLeaf.Println("process already finished") + } + } else { + logLeaf.Println("successfully killed") + } + }) - if err := cmd.Process.Kill(); err != nil { + // Create a start function to re-use it for watch mode + start := func() { + err := startBuildAndRun(genDir, wdOld, logLeaf, quitLeaf, processChan, mod, config, profile, mDir) + if err != nil { - // test for err process already finished - if os.ErrProcessDone != err { - logLeaf.Println("shutdown err:", err) - } else { - logLeaf.Println("process already finished") + // If we are in watch mode, only print the error to the command line + if watch { + if err.Error() != "exit status 1" { + logLeaf.Println("ERROR: failed to start config:", err) } } else { - logLeaf.Println("successfully killed") + quitLeaf.Append(fmt.Errorf("ERROR: failed to start config: %w", err)) } - }) - }, genDir, mod, config, profile, mDir) - if err != nil { - quitLeaf.Append(fmt.Errorf("ERROR: failed to start config: %w", err)) - } else { + } else { + // Don't end the process when we're watching for changes (it needs to be executed again) + if watch || os.Getenv("MAGIC_NO_END") == "true" { + return + } + quitLeaf.Append(fmt.Errorf("application finished")) + } + } + + if watch { + logLeaf.Println("Preparing watching...") - if os.Getenv("MAGIC_NO_END") == "true" { + modDir, err := mrunner.NewFactory(mDir).ModuleDirectory() + if err != nil { + quitLeaf.Append(fmt.Errorf("couldn't get module directory: %w", err)) return } - quitLeaf.Append(fmt.Errorf("Application finished.")) + + // Create debounced listener function + var debounceTimer *time.Timer + debouncedListener := func() { + // Cancel previous timer if it exists + if debounceTimer != nil { + debounceTimer.Stop() + } + + // Create new timer for 500ms + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + logLeaf.Println("Changes detected, rebuilding...") + + // Get the previous process and kill it + process, ok := <-processChan + if !ok { + quitLeaf.Append(fmt.Errorf("couldn't get previous process")) + return + } + if err := process.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + quitLeaf.Append(fmt.Errorf("couldn't kill previous process: %w", err)) + return + } + + start() + }) + } + + // Start watching + if err := integration.WatchDirectory(modDir, debouncedListener, mDir); err != nil { + quitLeaf.Append(fmt.Errorf("couldn't watch: %w", err)) + } } + + logLeaf.Println("Starting...") + start() }() // Config for tui @@ -146,6 +203,51 @@ func startCommand(config string, profile string) error { return nil } +// Start the build and run the program. +func startBuildAndRun(directory string, wdOld string, logLeaf *greentea.StringLeaf, quitLeaf *greentea.Leaf[error], processChan chan *exec.Cmd, arguments ...string) error { + return integration.BuildThenRun(integration.RunConfig{ + + // Function for printing the stuff returned by the process to the current tui + Print: func(s string) { + + // If it's a plan, make sure to set the current plan from it + if strings.HasPrefix(s, mrunner.PlanPrefix) { + var err error + mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) + if err != nil { + logLeaf.Println(strings.TrimLeft(s, mrunner.PlanPrefix)) + quitLeaf.Append(fmt.Errorf("ERROR: couldn't parse plan: %w", err)) + return + } + return + } + + // If it's the start signal, don't print it + if strings.HasPrefix(s, msdk.StartSignal) { + return + } + + // Otherwise print to output + logLeaf.Println(strings.TrimRight(s, "\n")) + }, + + // Make sure to properly kill the process when the tui is closed + Start: func(cmd *exec.Cmd) { + if err := os.Chdir(wdOld); err != nil { + quitLeaf.Append(fmt.Errorf("ERROR: couldn't change working directory: %w", err)) + } + + // In case we want to give the process to the next person, do that + if processChan != nil { + processChan <- cmd + } + }, + + Directory: directory, + Arguments: arguments, + }) +} + // Create the environment for starting from config and profile arguments // // Also changes working directory to the folder generated. @@ -188,7 +290,6 @@ func CreateStartEnvironment(config string, profile string, mDir string, deleteCo func getCommands(logLeaf *greentea.StringLeaf, quitLeaf *greentea.Leaf[error], exitLeaf *greentea.Leaf[func()], commandError *greentea.CommandError) []*cli.Command { // Implement commands - var testPath string commands := []*cli.Command{ { Name: "run", @@ -199,25 +300,6 @@ func getCommands(logLeaf *greentea.StringLeaf, quitLeaf *greentea.Leaf[error], e return nil }, }, - { - Name: "test", - Usage: "", - Aliases: []string{"t"}, - Arguments: []cli.Argument{ - &cli.StringArg{ - Name: "path", - Destination: &testPath, - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - if testPath != "" { - go tui.TestCommand(testPath, logLeaf) - } else { - commandError.CommandError = "usage: test [path]" - } - return nil - }, - }, } return commands } diff --git a/mcli/test/test.go b/mcli/test/test.go index 3fa9545..8944085 100644 --- a/mcli/test/test.go +++ b/mcli/test/test.go @@ -148,32 +148,39 @@ func startTestRunner(mDir string, paths []string, config string, profile string) processChan := make(chan *exec.Cmd) finishedChan := make(chan struct{}) go func() { - if err := integration.BuildThenRun(func(s string) { + if err := integration.BuildThenRun(integration.RunConfig{ + Print: func(s string) { + + // Wait for a plan to be sent + if strings.HasPrefix(s, mrunner.PlanPrefix) { + mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) + if err != nil { + log.Fatalln("Couldn't parse plan:", err) + } + return + } - // Wait for a plan to be sent - if strings.HasPrefix(s, mrunner.PlanPrefix) { - mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) - if err != nil { - log.Fatalln("Couldn't parse plan:", err) + // Wait for the start signal from the SDK + if strings.HasPrefix(s, msdk.StartSignal) { + finishedChan <- struct{}{} + return } - return - } - // Wait for the start signal from the SDK - if strings.HasPrefix(s, msdk.StartSignal) { - finishedChan <- struct{}{} - return - } + // Only print logs when verbose logging + if !strings.HasPrefix(s, "ERROR") && !mconfig.VerboseLogging { + return + } - // Only print logs when verbose logging - if !strings.HasPrefix(s, "ERROR") && !mconfig.VerboseLogging { - return - } + log.Println(s) + }, - log.Println(s) - }, func(c *exec.Cmd) { - processChan <- c - }, genDir, mod, config, profile, mDir); err != nil { + Start: func(c *exec.Cmd) { + processChan <- c + }, + + Directory: genDir, + Arguments: []string{mod, config, profile, mDir}, + }); err != nil { log.Fatalln("couldn't run the app:", err) } }() diff --git a/tui/run.go b/tui/run.go index f63f550..6c2cece 100644 --- a/tui/run.go +++ b/tui/run.go @@ -87,13 +87,20 @@ func RunCommand(cmd *cli.Command, logLeaf *greentea.StringLeaf, quitLeaf *greent logLeaf.Printlnf("couldn't stringify plan: %s", err) return } - if err := integration.BuildThenRun(func(s string) { - logLeaf.Println(s) - }, func(cmd *exec.Cmd) { - if err = os.Chdir(wOld); err != nil { - quitLeaf.Append(fmt.Errorf("ERROR: couldn't change working directory: %s", err)) - } - }, scriptDir, printable); err != nil { + if err := integration.BuildThenRun(integration.RunConfig{ + Print: func(s string) { + logLeaf.Println(s) + }, + + Start: func(cmd *exec.Cmd) { + if err = os.Chdir(wOld); err != nil { + quitLeaf.Append(fmt.Errorf("ERROR: couldn't change working directory: %s", err)) + } + }, + + Directory: scriptDir, + Arguments: []string{printable}, + }); err != nil { logLeaf.Printlnf("couldn't run script: %s", err) return } diff --git a/tui/test.go b/tui/test.go deleted file mode 100644 index 9c4e12d..0000000 --- a/tui/test.go +++ /dev/null @@ -1,31 +0,0 @@ -package tui - -import ( - "path/filepath" - - "github.com/Liphium/magic/integration" - "github.com/tiemingo/greentea" -) - -// Command: test [path] -func TestCommand(fp string, console *greentea.StringLeaf) error { - - // set tests as dir - mDir, err := integration.GetMagicDirectory(5) // beacause cwd is inside ./magic/cache/config_default - if err != nil { - console.Printlnf("failed to get magic dir: %s", err) - return nil - } - fp = filepath.Join(mDir, "tests", fp) - - // verify filepath - _, filename, _, err := integration.EvaluatePath(fp) - if err != nil { - console.Printlnf("can't find %s: %s", fp, err.Error()) - return nil - } - - // run test - console.Printlnf("Starting test: %s", filename) - return nil -} From f297b2ee3afbc050f6e24365f0501685833abbf4 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:34:00 +0200 Subject: [PATCH 2/3] fix: Make sure containers are properly shut down in the test command --- mcli/mcli.go | 2 +- mcli/shutdown/shutdown.go | 8 ++--- mcli/test/test.go | 62 ++++++++++++++++++++++++++++----------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/mcli/mcli.go b/mcli/mcli.go index ec8fe5b..8631b6c 100644 --- a/mcli/mcli.go +++ b/mcli/mcli.go @@ -24,6 +24,6 @@ func RunCli() { } if err := cmd.Run(context.Background(), os.Args); err != nil { - log.Fatalln("ERROR:", err) + log.Println("ERROR:", err) } } diff --git a/mcli/shutdown/shutdown.go b/mcli/shutdown/shutdown.go index a11c681..5534503 100644 --- a/mcli/shutdown/shutdown.go +++ b/mcli/shutdown/shutdown.go @@ -83,8 +83,8 @@ func Hooks() map[string]func(os.Signal) { // Listen waits for provided OS signals. // It will wait for any signal if no signals provided. -func Listen(signals ...os.Signal) { - DefaultShutdown.Listen(signals...) +func Listen() { + DefaultShutdown.Listen() } // Remove cancels hook by identificator (key). @@ -142,9 +142,9 @@ func (s *Shutdown) Hooks() map[string]func(os.Signal) { // Listen waits for provided OS signals. // It will wait for any signal if no signals provided. -func (s *Shutdown) Listen(signals ...os.Signal) { +func (s *Shutdown) Listen() { ch := make(chan os.Signal, 1) - signal.Notify(ch, signals...) + signal.Notify(ch) sig := <-ch var wg sync.WaitGroup for _, fn := range s.Hooks() { diff --git a/mcli/test/test.go b/mcli/test/test.go index 8944085..0089db9 100644 --- a/mcli/test/test.go +++ b/mcli/test/test.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "github.com/Liphium/magic/integration" "github.com/Liphium/magic/mcli/shutdown" @@ -21,11 +22,12 @@ import ( func BuildCommand() *cli.Command { var testPath = "" var startConfig = "" + var watchMode = false return &cli.Command{ Name: "test", Description: "Magically test your project.", Action: func(ctx context.Context, c *cli.Command) error { - return runTestCommand(testPath, startConfig) + return runTestCommand(testPath, startConfig, watchMode) }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -35,6 +37,13 @@ func BuildCommand() *cli.Command { Destination: &startConfig, Usage: "The path to the config file that should be used.", }, + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Value: false, + Destination: &watchMode, + Usage: "Re-execute the test(s) and restart the app when anything changes.", + }, }, Arguments: []cli.Argument{ &cli.StringArg{ @@ -46,7 +55,7 @@ func BuildCommand() *cli.Command { } // Command: magic test [path] -func runTestCommand(path string, config string) error { +func runTestCommand(path string, config string, watch bool) error { mDir, err := integration.GetMagicDirectory(3) if err != nil { return err @@ -67,6 +76,9 @@ func runTestCommand(path string, config string) error { if path == "" { rel = false paths, err = discoverTestDirectories(factory.TestDirectory(".")) + if err != nil { + return fmt.Errorf("couldn't read test directory: %s", err) + } } // Convert all paths to relative paths @@ -78,7 +90,7 @@ func runTestCommand(path string, config string) error { } relativePath, err := filepath.Rel(factory.TestDirectory("."), startPath) if err != nil { - return fmt.Errorf("Couldn't convert relative (%s) to absolute path: %s", path, err) + return fmt.Errorf("couldn't convert relative (%s) to absolute path: %s", path, err) } relativePaths[i] = relativePath } @@ -89,7 +101,7 @@ func runTestCommand(path string, config string) error { } fmt.Println(" ") - log.Println("Successfully executed test.") + log.Println("Successfully executed.") return nil } @@ -166,12 +178,7 @@ func startTestRunner(mDir string, paths []string, config string, profile string) return } - // Only print logs when verbose logging - if !strings.HasPrefix(s, "ERROR") && !mconfig.VerboseLogging { - return - } - - log.Println(s) + log.Printf("[application] %s", s) }, Start: func(c *exec.Cmd) { @@ -181,22 +188,43 @@ func startTestRunner(mDir string, paths []string, config string, profile string) Directory: genDir, Arguments: []string{mod, config, profile, mDir}, }); err != nil { - log.Fatalln("couldn't run the app:", err) + log.Println("couldn't run the app:", err) } }() - // Wait for the signal from the SDK to run tests - <-finishedChan + // Add a shutdown hook to kill everything process := <-processChan - defer func() { - recover() - process.Process.Kill() + executed := false + shutdownMutex := &sync.Mutex{} + shutdownFunc := func(panic bool) { + shutdownMutex.Lock() + defer shutdownMutex.Unlock() + if executed { + return + } + executed = true - // Create a new runner from the current plan + // Clean up everything created + process.Process.Kill() runner, _ := mrunner.NewRunnerFromPlan(mconfig.CurrentPlan) runner.StopContainers() + if panic { + log.Fatalln("Stopped by user intervention.") + } + } + shutdown.Add(func() { + shutdownFunc(true) + }) + + // Recover from panics to prevent instant shutdown (make sure shutdown hook still runs) + defer func() { + recover() + shutdownFunc(false) }() + // Wait for the signal from the SDK to run tests + <-finishedChan + // Go back to the old working directory if err := os.Chdir(oldWd); err != nil { return fmt.Errorf("couldn't change to old working dir: %s", err) From dd8f461ca1675983946b42449736d63fe2157aa3 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:19:47 +0200 Subject: [PATCH 3/3] feat: Watch for tests (part 1) --- integration/watch_directory.go | 68 +++++++++++- mcli/start/start.go | 46 +++----- mcli/test/test.go | 194 ++++++++++++++++++++++----------- 3 files changed, 213 insertions(+), 95 deletions(-) diff --git a/integration/watch_directory.go b/integration/watch_directory.go index 561c69d..de1216e 100644 --- a/integration/watch_directory.go +++ b/integration/watch_directory.go @@ -1,12 +1,16 @@ package integration import ( + "errors" "fmt" "log" "os" "path/filepath" "slices" + "sync" + "time" + "github.com/Liphium/magic/mconfig" "github.com/fsnotify/fsnotify" ) @@ -51,7 +55,10 @@ func startWatchingRecursive(watcher *fsnotify.Watcher, dir string, cleanedExclus return fmt.Errorf("couldn't read directory %s: %s", dir, err) } - log.Println("Watching", dir) + if mconfig.VerboseLogging { + log.Printf("Watching %s...", dir) + } + if err := watcher.Add(dir); err != nil { return fmt.Errorf("couldn't watch directory %s: %s", dir, err) } @@ -70,3 +77,62 @@ func startWatchingRecursive(watcher *fsnotify.Watcher, dir string, cleanedExclus } return nil } + +type WatchContext[J any, C any] struct { + Print func(string) // Called for prints. + Error func(error) // Called when an error happens. + Start func(currentContext C, lastJob *J, retrievalChannel chan J) error // Gets called to start the process. + Stop func(J) error // Gets called to stop a job. + RetrievalChannel chan J // The channel a new job gets passed through (once ready) +} + +// Helper function for handling watching properly. Returns a function that can be called by multiple goroutines. None of the functions in context can be nil. +func HandleWatching[J any, C any](context WatchContext[J, C], startContext C) func(C, string) { + var debounceTimer *time.Timer + + // Create a waiting boolean for making sure we're not waiting for rebuilding twice + waitMutex := &sync.Mutex{} + waiting := false + + listener := func(ctx C, message string) { + if debounceTimer != nil { + debounceTimer.Stop() + } + + // Create new timer for 500ms + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + waitMutex.Lock() + defer waitMutex.Unlock() + if waiting { + if mconfig.VerboseLogging { + context.Print("Changes detected, but already trying rebuild.") + } + return + } + waiting = true + + // Print what the user wants us to say when the change is the one being accepted + context.Print(message) + + // Wait for the previous job to be cancellable and cancel it + job, ok := <-context.RetrievalChannel + if !ok { + context.Error(fmt.Errorf("couldn't get previous process")) + return + } + if err := context.Stop(job); err != nil && !errors.Is(err, os.ErrProcessDone) { + context.Error(fmt.Errorf("couldn't kill previous process: %w", err)) + return + } + + // Start the new job + if err := context.Start(ctx, &job, context.RetrievalChannel); err != nil { + context.Error(err) + } + }) + } + + // Start the first job + context.Start(startContext, nil, context.RetrievalChannel) + return listener +} diff --git a/mcli/start/start.go b/mcli/start/start.go index 60cc5fd..3392bba 100644 --- a/mcli/start/start.go +++ b/mcli/start/start.go @@ -9,7 +9,6 @@ import ( "os/exec" "path/filepath" "strings" - "time" "github.com/Liphium/magic/integration" "github.com/Liphium/magic/mconfig" @@ -121,7 +120,7 @@ func startCommand(config string, profile string, watch bool) error { // If we are in watch mode, only print the error to the command line if watch { - if err.Error() != "exit status 1" { + if !strings.Contains(err.Error(), "exit status") { logLeaf.Println("ERROR: failed to start config:", err) } } else { @@ -145,35 +144,26 @@ func startCommand(config string, profile string, watch bool) error { return } - // Create debounced listener function - var debounceTimer *time.Timer - debouncedListener := func() { - // Cancel previous timer if it exists - if debounceTimer != nil { - debounceTimer.Stop() - } - - // Create new timer for 500ms - debounceTimer = time.AfterFunc(500*time.Millisecond, func() { - logLeaf.Println("Changes detected, rebuilding...") - - // Get the previous process and kill it - process, ok := <-processChan - if !ok { - quitLeaf.Append(fmt.Errorf("couldn't get previous process")) - return - } - if err := process.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { - quitLeaf.Append(fmt.Errorf("couldn't kill previous process: %w", err)) - return - } - + // Create a listener for watching + listener := integration.HandleWatching(integration.WatchContext[*exec.Cmd, struct{}]{ + Print: func(s string) { + logLeaf.Println(s) + }, + Error: quitLeaf.Append, + Start: func(a struct{}, job **exec.Cmd, c chan *exec.Cmd) error { start() - }) - } + return nil + }, + Stop: func(c *exec.Cmd) error { + return c.Process.Kill() + }, + RetrievalChannel: processChan, + }, struct{}{}) // Start watching - if err := integration.WatchDirectory(modDir, debouncedListener, mDir); err != nil { + if err := integration.WatchDirectory(modDir, func() { + listener(struct{}{}, "Changes detected, rebuilding...") + }, mDir); err != nil { quitLeaf.Append(fmt.Errorf("couldn't watch: %w", err)) } } diff --git a/mcli/test/test.go b/mcli/test/test.go index 0089db9..0132d3b 100644 --- a/mcli/test/test.go +++ b/mcli/test/test.go @@ -2,6 +2,7 @@ package test_command import ( "context" + "errors" "fmt" "log" "os" @@ -95,6 +96,62 @@ func runTestCommand(path string, config string, watch bool) error { relativePaths[i] = relativePath } + jobChan := make(chan TestingJob) + + executed := false + shutdownMutex := &sync.Mutex{} + shutdownFunc := func(panic bool) { + shutdownMutex.Lock() + defer shutdownMutex.Unlock() + if executed { + return + } + executed = true + + // Clean up everything created + select { + case job := <-jobChan: + job.Stop() + default: + } + runner, _ := mrunner.NewRunnerFromPlan(mconfig.CurrentPlan) + runner.StopContainers() + if panic { + log.Fatalln("Stopped by user intervention.") + } + } + shutdown.Add(func() { + shutdownFunc(true) + }) + + // Recover from panics to prevent instant shutdown (make sure shutdown hook still runs) + defer func() { + recover() + shutdownFunc(false) + }() + + if watch { + modDir, err := mrunner.NewFactory(mDir).ModuleDirectory() + if err != nil { + quitLeaf.Append(fmt.Errorf("couldn't get module directory: %w", err)) + return + } + + listener := integration.HandleWatching(integration.WatchContext[*TestingJob, TestContext]{ + Print: func(s string) { + log.Println(s) + }, + Error: func(err error) { + log.Println("ERROR: ", err) + }, + + Stop: func(tj TestingJob) error { + return tj.Stop() + }, + RetrievalChannel: jobChan, + }) + } + // Start a test runner that goes through all the paths if err := startTestRunner(mDir, relativePaths, config, "test"); err != nil { return err @@ -141,8 +198,37 @@ func discoverTestDirectories(startDir string) ([]string, error) { return paths, nil } +type TestingJobBox = *TestingJob + +type TestingJob struct { + ApplicationProcess *exec.Cmd + CurrentTestingProcess *exec.Cmd +} + +func (job TestingJob) Stop() error { + if err := job.ApplicationProcess.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + if err := job.CurrentTestingProcess.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + return nil +} + +type TestContext struct { + MagicDirectory string + Config string + Profile string + StartApp bool // Whether or not to start the base application (that's being tested) + PathsToTest []string +} + // Helper function for starting a new test runner. Can't be run in parallel. -func startTestRunner(mDir string, paths []string, config string, profile string) error { +func startTestRunner(context TestContext, lastJob *TestingJob, jobChan chan TestingJob) error { + if lastJob == nil { + lastJob = &TestingJob{} + } + // Get the current working directory oldWd, err := os.Getwd() if err != nil { @@ -151,79 +237,53 @@ func startTestRunner(mDir string, paths []string, config string, profile string) // Create all the folders and stuff var mod, genDir string - config, _, mod, genDir, err = start_command.CreateStartEnvironment(config, profile, mDir, true) + context.Config, _, mod, genDir, err = start_command.CreateStartEnvironment(context.Config, context.Profile, context.MagicDirectory, true) if err != nil { return err } - // Start the app - processChan := make(chan *exec.Cmd) - finishedChan := make(chan struct{}) - go func() { - if err := integration.BuildThenRun(integration.RunConfig{ - Print: func(s string) { - - // Wait for a plan to be sent - if strings.HasPrefix(s, mrunner.PlanPrefix) { - mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) - if err != nil { - log.Fatalln("Couldn't parse plan:", err) + // Start the app (if desired) + if context.StartApp { + processChan := make(chan *exec.Cmd) + finishedChan := make(chan struct{}) + go func() { + if err := integration.BuildThenRun(integration.RunConfig{ + Print: func(s string) { + + // Wait for a plan to be sent + if strings.HasPrefix(s, mrunner.PlanPrefix) { + mconfig.CurrentPlan, err = mconfig.FromPrintable(strings.TrimLeft(s, mrunner.PlanPrefix)) + if err != nil { + log.Fatalln("Couldn't parse plan:", err) + } + return } - return - } - // Wait for the start signal from the SDK - if strings.HasPrefix(s, msdk.StartSignal) { - finishedChan <- struct{}{} - return - } + // Wait for the start signal from the SDK + if strings.HasPrefix(s, msdk.StartSignal) { + finishedChan <- struct{}{} + return + } - log.Printf("[application] %s", s) - }, + log.Printf("[application] %s", s) + }, - Start: func(c *exec.Cmd) { - processChan <- c - }, + Start: func(c *exec.Cmd) { + processChan <- c + }, - Directory: genDir, - Arguments: []string{mod, config, profile, mDir}, - }); err != nil { - log.Println("couldn't run the app:", err) - } - }() + Directory: genDir, + Arguments: []string{mod, context.Config, context.Profile, context.MagicDirectory}, + }); err != nil && !strings.Contains(err.Error(), "exit status") { + log.Println("couldn't run the app:", err) + } + }() - // Add a shutdown hook to kill everything - process := <-processChan - executed := false - shutdownMutex := &sync.Mutex{} - shutdownFunc := func(panic bool) { - shutdownMutex.Lock() - defer shutdownMutex.Unlock() - if executed { - return - } - executed = true + lastJob.ApplicationProcess = <-processChan - // Clean up everything created - process.Process.Kill() - runner, _ := mrunner.NewRunnerFromPlan(mconfig.CurrentPlan) - runner.StopContainers() - if panic { - log.Fatalln("Stopped by user intervention.") - } + // Wait for the signal from the SDK to run tests + <-finishedChan } - shutdown.Add(func() { - shutdownFunc(true) - }) - - // Recover from panics to prevent instant shutdown (make sure shutdown hook still runs) - defer func() { - recover() - shutdownFunc(false) - }() - - // Wait for the signal from the SDK to run tests - <-finishedChan // Go back to the old working directory if err := os.Chdir(oldWd); err != nil { @@ -231,10 +291,10 @@ func startTestRunner(mDir string, paths []string, config string, profile string) } // Create a factory for the test creation - factory := mrunner.NewFactory(mDir) + factory := mrunner.NewFactory(context.MagicDirectory) // Run the tests for each path - for _, path := range paths { + for _, path := range context.PathsToTest { loggablePath := path if loggablePath == "." || loggablePath == "" { loggablePath = "default directory" @@ -269,8 +329,10 @@ func startTestRunner(mDir string, paths []string, config string, profile string) } // Run go test with the arguments - if err := integration.ExecCmdWithFunc(func(s string) { - log.Println(s) + if err := integration.ExecCmdWithFuncStart(func(s string) { + log.Printf("[%s] %s", loggablePath, s) + }, func(c *exec.Cmd) { + }, "go", "test", "-args", "plan:"+printable); err != nil { return fmt.Errorf("test command failed: %s", err) }