From d702535cbcdc9f31a637f8d955cad023f16c919b Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sun, 26 Jul 2026 15:54:11 +0200 Subject: [PATCH 1/5] feat(dev): improve frontend dev server readiness checks with retry logic and test coverage --- v3/pkg/application/application_dev.go | 57 +++++++++++---- v3/pkg/application/application_dev_test.go | 82 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 v3/pkg/application/application_dev_test.go diff --git a/v3/pkg/application/application_dev.go b/v3/pkg/application/application_dev.go index e12033e33e8..ea7bc7b0894 100644 --- a/v3/pkg/application/application_dev.go +++ b/v3/pkg/application/application_dev.go @@ -3,6 +3,8 @@ package application import ( + "context" + "fmt" "net/http" "time" @@ -11,30 +13,55 @@ import ( var devMode = false +const ( + frontendDevServerRetryInterval = 500 * time.Millisecond + frontendDevServerProbeTimeout = 2 * time.Second +) + +func waitForFrontendDevServer(ctx context.Context, client *http.Client, frontendURL string, retry func()) error { + request, err := http.NewRequest(http.MethodGet, frontendURL, nil) + if err != nil { + return fmt.Errorf("invalid frontend dev server URL: %w", err) + } + + for { + response, err := client.Do(request.Clone(ctx)) + if err == nil { + response.Body.Close() + return nil + } + + timer := time.NewTimer(frontendDevServerRetryInterval) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + if retry != nil { + retry() + } + } + } +} + func (a *App) preRun() error { // Check for frontend server url frontendURL := assetserver.GetDevServerURL() if frontendURL != "" { devMode = true - // We want to check if the frontend server is running by trying to http get the url - // and if it is not, we wait 500ms and try again for a maximum of 10 times. If it is - // still not available, we return an error. - // This is to allow the frontend server to start up before the backend server. - client := http.Client{} + client := &http.Client{Timeout: frontendDevServerProbeTimeout} a.Logger.Info("Waiting for frontend dev server to start...", "url", frontendURL) - for i := 0; i < 10; i++ { - _, err := client.Get(frontendURL) - if err == nil { - a.Logger.Info("Connected to frontend dev server!") - return nil - } - // Wait 500ms - time.Sleep(500 * time.Millisecond) - if i%2 == 0 { + retries := 0 + err := waitForFrontendDevServer(a.Context(), client, frontendURL, func() { + retries++ + if retries%2 == 1 { a.Logger.Info("Retrying...") } + }) + if err != nil { + return fmt.Errorf("unable to connect to frontend server at FRONTEND_DEVSERVER_URL=%q: %w", frontendURL, err) } - a.fatal("unable to connect to frontend server. Please check it is running - FRONTEND_DEVSERVER_URL='%s'", frontendURL) + a.Logger.Info("Connected to frontend dev server!") } return nil } diff --git a/v3/pkg/application/application_dev_test.go b/v3/pkg/application/application_dev_test.go new file mode 100644 index 00000000000..c4ce8d9c61a --- /dev/null +++ b/v3/pkg/application/application_dev_test.go @@ -0,0 +1,82 @@ +//go:build !production + +package application + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +type trackingReadCloser struct { + closed bool +} + +func (*trackingReadCloser) Read([]byte) (int, error) { return 0, io.EOF } + +func (body *trackingReadCloser) Close() error { + body.closed = true + return nil +} + +func TestWaitForFrontendDevServerRetriesUntilReady(t *testing.T) { + body := &trackingReadCloser{} + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + if attempts < 3 { + return nil, errors.New("server is still starting") + } + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Body: body, + Header: make(http.Header), + }, nil + })} + + retries := 0 + err := waitForFrontendDevServer(context.Background(), client, "http://localhost:9245", func() { + retries++ + }) + if err != nil { + t.Fatalf("waitForFrontendDevServer returned an error: %v", err) + } + if attempts != 3 { + t.Fatalf("attempt count = %d, want 3", attempts) + } + if retries != 2 { + t.Fatalf("retry count = %d, want 2", retries) + } + if !body.closed { + t.Fatal("successful probe response body was not closed") + } +} + +func TestWaitForFrontendDevServerStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + cancel() + return nil, errors.New("server is unavailable") + })} + + err := waitForFrontendDevServer(ctx, client, "http://localhost:9245", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("waitForFrontendDevServer error = %v, want context.Canceled", err) + } +} + +func TestWaitForFrontendDevServerRejectsInvalidURL(t *testing.T) { + err := waitForFrontendDevServer(context.Background(), http.DefaultClient, "://not-a-url", nil) + if err == nil || !strings.Contains(err.Error(), "invalid frontend dev server URL") { + t.Fatalf("waitForFrontendDevServer error = %v, want invalid URL error", err) + } +} From 2c6b8fabf8191a7390edfdaa4c32a6a5940895c7 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sun, 26 Jul 2026 15:54:17 +0200 Subject: [PATCH 2/5] feat: ensure frontend readiness checks are consistently integrated across dev tasks --- v3/cmd/wails3/main.go | 1 + v3/internal/commands/build_assets/config.yml | 4 +- v3/internal/commands/dev_config_test.go | 27 +++++++ v3/internal/commands/tool_waitport.go | 69 ++++++++++++++++ v3/internal/commands/tool_waitport_test.go | 36 +++++++++ v3/internal/commands/watcher.go | 28 +++++++ v3/internal/commands/watcher_test.go | 55 +++++++++++-- v3/pkg/application/application.go | 6 -- v3/pkg/application/application_dev.go | 74 ++---------------- v3/pkg/application/application_dev_test.go | 82 -------------------- v3/pkg/application/application_production.go | 4 +- 11 files changed, 218 insertions(+), 168 deletions(-) create mode 100644 v3/internal/commands/dev_config_test.go create mode 100644 v3/internal/commands/tool_waitport.go create mode 100644 v3/internal/commands/tool_waitport_test.go delete mode 100644 v3/pkg/application/application_dev_test.go diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index 1badfe24ee3..f54b973a1b2 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -99,6 +99,7 @@ func main() { tool := app.NewSubCommand("tool", "Various tools") tool.NewSubCommandFunction("checkport", "Checks if a port is open. Useful for testing if vite is running.", commands.ToolCheckPort) + tool.NewSubCommandFunction("waitport", "Waits for a port to open. Useful for gating dependent development tasks.", commands.ToolWaitPort) tool.NewSubCommandFunction("watcher", "Watches files and runs a command when they change", commands.Watcher) tool.NewSubCommandFunction("cp", "Copy files", commands.Cp) tool.NewSubCommandFunction("buildinfo", "Show Build Info", commands.BuildInfo) diff --git a/v3/internal/commands/build_assets/config.yml b/v3/internal/commands/build_assets/config.yml index 9d49291fdff..834c3ce80cb 100644 --- a/v3/internal/commands/build_assets/config.yml +++ b/v3/internal/commands/build_assets/config.yml @@ -56,6 +56,8 @@ dev_mode: type: blocking - cmd: wails3 task common:dev:frontend type: background + - cmd: wails3 tool waitport --timeout 60 + type: once - cmd: wails3 task run type: primary @@ -76,4 +78,4 @@ fileAssociations: # Other data other: - - name: My Other Data \ No newline at end of file + - name: My Other Data diff --git a/v3/internal/commands/dev_config_test.go b/v3/internal/commands/dev_config_test.go new file mode 100644 index 00000000000..7230a4d1f65 --- /dev/null +++ b/v3/internal/commands/dev_config_test.go @@ -0,0 +1,27 @@ +package commands + +import ( + "testing" + + "github.com/atterpac/refresh/process" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDevConfigGatesApplicationOnFrontendReadiness(t *testing.T) { + data, err := buildAssets.ReadFile("build_assets/config.yml") + require.NoError(t, err) + + var config struct { + DevMode struct { + Executes []process.Execute `yaml:"executes"` + } `yaml:"dev_mode"` + } + require.NoError(t, yaml.Unmarshal(data, &config)) + + require.Len(t, config.DevMode.Executes, 4) + require.Equal(t, process.Background, config.DevMode.Executes[1].Type) + require.Equal(t, frontendDevServerReadyCommand, config.DevMode.Executes[2].Cmd) + require.Equal(t, process.Once, config.DevMode.Executes[2].Type) + require.Equal(t, process.Primary, config.DevMode.Executes[3].Type) +} diff --git a/v3/internal/commands/tool_waitport.go b/v3/internal/commands/tool_waitport.go new file mode 100644 index 00000000000..e7a533a1fb0 --- /dev/null +++ b/v3/internal/commands/tool_waitport.go @@ -0,0 +1,69 @@ +package commands + +import ( + "fmt" + "os" + "strconv" + "time" +) + +const portWaitInterval = 100 * time.Millisecond + +type ToolWaitPortOptions struct { + Host string `name:"h" description:"Host to check" default:"localhost"` + Port int `name:"p" description:"Port to check; defaults to WAILS_VITE_PORT when set"` + Timeout int `name:"timeout" description:"Maximum number of seconds to wait for the port to open" default:"60"` +} + +func waitForPort(check func() bool, timeout time.Duration) bool { + if check() { + return true + } + if timeout <= 0 { + return false + } + + ticker := time.NewTicker(portWaitInterval) + defer ticker.Stop() + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case <-ticker.C: + if check() { + return true + } + case <-timer.C: + return false + } + } +} + +func ToolWaitPort(options *ToolWaitPortOptions) error { + DisableFooter = true + + if options.Port == 0 { + port := os.Getenv(wailsVitePort) + if port == "" { + return fmt.Errorf("please use the -p flag to specify a port or set %s", wailsVitePort) + } + var err error + options.Port, err = strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid %s value %q: %w", wailsVitePort, port, err) + } + } + if options.Port < 1 || options.Port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + if options.Timeout <= 0 { + return fmt.Errorf("timeout must be greater than zero") + } + + timeout := time.Duration(options.Timeout) * time.Second + if !waitForPort(func() bool { return isPortOpen(options.Host, options.Port) }, timeout) { + return fmt.Errorf("timed out after %s waiting for port %d to open on %s", timeout, options.Port, options.Host) + } + return nil +} diff --git a/v3/internal/commands/tool_waitport_test.go b/v3/internal/commands/tool_waitport_test.go new file mode 100644 index 00000000000..b66210340e6 --- /dev/null +++ b/v3/internal/commands/tool_waitport_test.go @@ -0,0 +1,36 @@ +package commands + +import ( + "testing" + "time" +) + +func TestWaitForPortRetriesUntilReady(t *testing.T) { + attempts := 0 + ready := waitForPort(func() bool { + attempts++ + return attempts == 3 + }, time.Second) + + if !ready { + t.Fatal("waitForPort reported that the port was unavailable") + } + if attempts != 3 { + t.Fatalf("attempt count = %d, want 3", attempts) + } +} + +func TestWaitForPortDoesNotRetryWithoutTimeout(t *testing.T) { + attempts := 0 + ready := waitForPort(func() bool { + attempts++ + return false + }, 0) + + if ready { + t.Fatal("waitForPort reported that the port was available") + } + if attempts != 1 { + t.Fatalf("attempt count = %d, want 1", attempts) + } +} diff --git a/v3/internal/commands/watcher.go b/v3/internal/commands/watcher.go index fa0a4cd86b0..56694369b26 100644 --- a/v3/internal/commands/watcher.go +++ b/v3/internal/commands/watcher.go @@ -4,10 +4,13 @@ import ( "os" "github.com/atterpac/refresh/engine" + "github.com/atterpac/refresh/process" "github.com/wailsapp/wails/v3/internal/signal" "gopkg.in/yaml.v3" ) +const frontendDevServerReadyCommand = "wails3 tool waitport --timeout 60" + func ensureIgnored(list *[]string, pattern string) { for _, item := range *list { if item == pattern { @@ -21,6 +24,30 @@ type WatcherOptions struct { Config string `description:"The config file including path" default:"."` } +func ensureFrontendDevServerReadyTask(config *engine.Config) { + if os.Getenv("FRONTEND_DEVSERVER_URL") == "" { + return + } + + for _, execute := range config.ExecStruct { + if execute.Cmd == frontendDevServerReadyCommand { + return + } + } + + for index, execute := range config.ExecStruct { + if execute.Type != process.Primary { + continue + } + + ready := process.Execute{Cmd: frontendDevServerReadyCommand, Type: process.Once} + config.ExecStruct = append(config.ExecStruct, process.Execute{}) + copy(config.ExecStruct[index+1:], config.ExecStruct[index:]) + config.ExecStruct[index] = ready + return + } +} + func Watcher(options *WatcherOptions) error { stopChan := make(chan struct{}) @@ -42,6 +69,7 @@ func Watcher(options *WatcherOptions) error { } ensureIgnored(&devconfig.Config.Ignore.File, "*_test.go") + ensureFrontendDevServerReadyTask(&devconfig.Config) watcherEngine, err := engine.NewEngineFromConfig(devconfig.Config) if err != nil { diff --git a/v3/internal/commands/watcher_test.go b/v3/internal/commands/watcher_test.go index 0f48f0d8523..47b76f75060 100644 --- a/v3/internal/commands/watcher_test.go +++ b/v3/internal/commands/watcher_test.go @@ -3,28 +3,69 @@ package commands import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/atterpac/refresh/engine" + "github.com/atterpac/refresh/process" + "github.com/stretchr/testify/require" ) func TestEnsureIgnored(t *testing.T) { t.Run("adds pattern when not present", func(t *testing.T) { list := []string{".gitignore", ".DS_Store"} ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 3) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 3) }) t.Run("does not duplicate pattern when already present", func(t *testing.T) { list := []string{".gitignore", "*_test.go"} ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 2) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 2) }) t.Run("adds to empty list", func(t *testing.T) { var list []string ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 1) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 1) }) } + +func TestEnsureFrontendDevServerReadyTask(t *testing.T) { + t.Setenv("FRONTEND_DEVSERVER_URL", "http://localhost:9245") + config := engine.Config{ExecStruct: []process.Execute{ + {Cmd: "frontend", Type: process.Background}, + {Cmd: "application", Type: process.Primary}, + }} + + ensureFrontendDevServerReadyTask(&config) + + require.Equal(t, []process.Execute{ + {Cmd: "frontend", Type: process.Background}, + {Cmd: frontendDevServerReadyCommand, Type: process.Once}, + {Cmd: "application", Type: process.Primary}, + }, config.ExecStruct) +} + +func TestEnsureFrontendDevServerReadyTaskIsIdempotent(t *testing.T) { + t.Setenv("FRONTEND_DEVSERVER_URL", "http://localhost:9245") + config := engine.Config{ExecStruct: []process.Execute{ + {Cmd: frontendDevServerReadyCommand, Type: process.Once}, + {Cmd: "application", Type: process.Primary}, + }} + + ensureFrontendDevServerReadyTask(&config) + + require.Len(t, config.ExecStruct, 2) +} + +func TestEnsureFrontendDevServerReadyTaskRequiresDevServer(t *testing.T) { + t.Setenv("FRONTEND_DEVSERVER_URL", "") + config := engine.Config{ExecStruct: []process.Execute{ + {Cmd: "application", Type: process.Primary}, + }} + + ensureFrontendDevServerReadyTask(&config) + + require.Len(t, config.ExecStruct, 1) +} diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index cd801a1bb94..a7d383b6575 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -650,12 +650,6 @@ func (a *App) Run() error { // Ensure application context is cancelled in case of failures. defer a.cancel() - // Call post-create hooks - err := a.preRun() - if err != nil { - return err - } - a.impl = newPlatformApp(a) // Ensure services are shut down in case of failures. diff --git a/v3/pkg/application/application_dev.go b/v3/pkg/application/application_dev.go index ea7bc7b0894..7f890eb1c37 100644 --- a/v3/pkg/application/application_dev.go +++ b/v3/pkg/application/application_dev.go @@ -2,77 +2,13 @@ package application -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/wailsapp/wails/v3/internal/assetserver" -) - -var devMode = false - -const ( - frontendDevServerRetryInterval = 500 * time.Millisecond - frontendDevServerProbeTimeout = 2 * time.Second -) - -func waitForFrontendDevServer(ctx context.Context, client *http.Client, frontendURL string, retry func()) error { - request, err := http.NewRequest(http.MethodGet, frontendURL, nil) - if err != nil { - return fmt.Errorf("invalid frontend dev server URL: %w", err) - } - - for { - response, err := client.Do(request.Clone(ctx)) - if err == nil { - response.Body.Close() - return nil - } - - timer := time.NewTimer(frontendDevServerRetryInterval) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - if retry != nil { - retry() - } - } - } -} - -func (a *App) preRun() error { - // Check for frontend server url - frontendURL := assetserver.GetDevServerURL() - if frontendURL != "" { - devMode = true - client := &http.Client{Timeout: frontendDevServerProbeTimeout} - a.Logger.Info("Waiting for frontend dev server to start...", "url", frontendURL) - retries := 0 - err := waitForFrontendDevServer(a.Context(), client, frontendURL, func() { - retries++ - if retries%2 == 1 { - a.Logger.Info("Retrying...") - } - }) - if err != nil { - return fmt.Errorf("unable to connect to frontend server at FRONTEND_DEVSERVER_URL=%q: %w", frontendURL, err) - } - a.Logger.Info("Connected to frontend dev server!") - } - return nil -} +import "github.com/wailsapp/wails/v3/internal/assetserver" func (a *App) postQuit() { - if devMode { - a.Logger.Info("The application has terminated, but the watcher is still running.") - a.Logger.Info("To terminate the watcher, press CTRL+C") + if assetserver.GetDevServerURL() == "" { + return } -} - -func (a *App) enableDevTools() { + a.Logger.Info("The application has terminated, but the watcher is still running.") + a.Logger.Info("To terminate the watcher, press CTRL+C") } diff --git a/v3/pkg/application/application_dev_test.go b/v3/pkg/application/application_dev_test.go deleted file mode 100644 index c4ce8d9c61a..00000000000 --- a/v3/pkg/application/application_dev_test.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build !production - -package application - -import ( - "context" - "errors" - "io" - "net/http" - "strings" - "testing" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { - return fn(request) -} - -type trackingReadCloser struct { - closed bool -} - -func (*trackingReadCloser) Read([]byte) (int, error) { return 0, io.EOF } - -func (body *trackingReadCloser) Close() error { - body.closed = true - return nil -} - -func TestWaitForFrontendDevServerRetriesUntilReady(t *testing.T) { - body := &trackingReadCloser{} - attempts := 0 - client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - attempts++ - if attempts < 3 { - return nil, errors.New("server is still starting") - } - return &http.Response{ - StatusCode: http.StatusServiceUnavailable, - Body: body, - Header: make(http.Header), - }, nil - })} - - retries := 0 - err := waitForFrontendDevServer(context.Background(), client, "http://localhost:9245", func() { - retries++ - }) - if err != nil { - t.Fatalf("waitForFrontendDevServer returned an error: %v", err) - } - if attempts != 3 { - t.Fatalf("attempt count = %d, want 3", attempts) - } - if retries != 2 { - t.Fatalf("retry count = %d, want 2", retries) - } - if !body.closed { - t.Fatal("successful probe response body was not closed") - } -} - -func TestWaitForFrontendDevServerStopsWhenCancelled(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - cancel() - return nil, errors.New("server is unavailable") - })} - - err := waitForFrontendDevServer(ctx, client, "http://localhost:9245", nil) - if !errors.Is(err, context.Canceled) { - t.Fatalf("waitForFrontendDevServer error = %v, want context.Canceled", err) - } -} - -func TestWaitForFrontendDevServerRejectsInvalidURL(t *testing.T) { - err := waitForFrontendDevServer(context.Background(), http.DefaultClient, "://not-a-url", nil) - if err == nil || !strings.Contains(err.Error(), "invalid frontend dev server URL") { - t.Fatalf("waitForFrontendDevServer error = %v, want invalid URL error", err) - } -} diff --git a/v3/pkg/application/application_production.go b/v3/pkg/application/application_production.go index 75f86b44d9c..4cbe5a79848 100644 --- a/v3/pkg/application/application_production.go +++ b/v3/pkg/application/application_production.go @@ -13,6 +13,4 @@ func newApplication(options Options) *App { func (a *App) logStartup() {} -func (a *App) preRun() error { return nil } - -func (a *App) postQuit() error { return nil } +func (a *App) postQuit() {} From ab6da519a0a8b61371e0753304b33223b0a470cf Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sun, 26 Jul 2026 15:54:21 +0200 Subject: [PATCH 3/5] refactor: simplify frontend readiness integration and remove redundant test logic --- .../commands/build_assets/Taskfile.tmpl.yml | 7 ++++ v3/internal/commands/build_assets/config.yml | 2 +- v3/internal/commands/dev_config_test.go | 22 +++++++++- v3/internal/commands/watcher.go | 28 ------------- v3/internal/commands/watcher_test.go | 41 ------------------- 5 files changed, 29 insertions(+), 71 deletions(-) diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 65e1eb78a8e..2ffe9f327a2 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -210,6 +210,13 @@ tasks: cmds: - task: frontend:dev:{{.Opn}}.PACKAGE_MANAGER{{.Cls}} + dev:wait: + summary: Waits for the frontend development server + vars: + TIMEOUT: 60 + cmds: + - wails3 tool waitport --timeout {{.Opn}}.TIMEOUT{{.Cls}} + frontend:dev:npm: dir: frontend cmds: diff --git a/v3/internal/commands/build_assets/config.yml b/v3/internal/commands/build_assets/config.yml index 834c3ce80cb..9359e3e8834 100644 --- a/v3/internal/commands/build_assets/config.yml +++ b/v3/internal/commands/build_assets/config.yml @@ -56,7 +56,7 @@ dev_mode: type: blocking - cmd: wails3 task common:dev:frontend type: background - - cmd: wails3 tool waitport --timeout 60 + - cmd: wails3 task common:dev:wait type: once - cmd: wails3 task run type: primary diff --git a/v3/internal/commands/dev_config_test.go b/v3/internal/commands/dev_config_test.go index 7230a4d1f65..1e2444756ee 100644 --- a/v3/internal/commands/dev_config_test.go +++ b/v3/internal/commands/dev_config_test.go @@ -21,7 +21,27 @@ func TestDevConfigGatesApplicationOnFrontendReadiness(t *testing.T) { require.Len(t, config.DevMode.Executes, 4) require.Equal(t, process.Background, config.DevMode.Executes[1].Type) - require.Equal(t, frontendDevServerReadyCommand, config.DevMode.Executes[2].Cmd) + require.Equal(t, "wails3 task common:dev:wait", config.DevMode.Executes[2].Cmd) require.Equal(t, process.Once, config.DevMode.Executes[2].Type) require.Equal(t, process.Primary, config.DevMode.Executes[3].Type) } + +func TestFrontendDevServerTimeoutIsTaskLocal(t *testing.T) { + data, err := buildAssets.ReadFile("build_assets/Taskfile.tmpl.yml") + require.NoError(t, err) + + var taskfile struct { + Tasks map[string]yaml.Node `yaml:"tasks"` + } + require.NoError(t, yaml.Unmarshal(data, &taskfile)) + + waitTaskNode, exists := taskfile.Tasks["dev:wait"] + require.True(t, exists) + var waitTask struct { + Vars map[string]int `yaml:"vars"` + Cmds []string `yaml:"cmds"` + } + require.NoError(t, waitTaskNode.Decode(&waitTask)) + require.Equal(t, 60, waitTask.Vars["TIMEOUT"]) + require.Equal(t, []string{"wails3 tool waitport --timeout {{.Opn}}.TIMEOUT{{.Cls}}"}, waitTask.Cmds) +} diff --git a/v3/internal/commands/watcher.go b/v3/internal/commands/watcher.go index 56694369b26..fa0a4cd86b0 100644 --- a/v3/internal/commands/watcher.go +++ b/v3/internal/commands/watcher.go @@ -4,13 +4,10 @@ import ( "os" "github.com/atterpac/refresh/engine" - "github.com/atterpac/refresh/process" "github.com/wailsapp/wails/v3/internal/signal" "gopkg.in/yaml.v3" ) -const frontendDevServerReadyCommand = "wails3 tool waitport --timeout 60" - func ensureIgnored(list *[]string, pattern string) { for _, item := range *list { if item == pattern { @@ -24,30 +21,6 @@ type WatcherOptions struct { Config string `description:"The config file including path" default:"."` } -func ensureFrontendDevServerReadyTask(config *engine.Config) { - if os.Getenv("FRONTEND_DEVSERVER_URL") == "" { - return - } - - for _, execute := range config.ExecStruct { - if execute.Cmd == frontendDevServerReadyCommand { - return - } - } - - for index, execute := range config.ExecStruct { - if execute.Type != process.Primary { - continue - } - - ready := process.Execute{Cmd: frontendDevServerReadyCommand, Type: process.Once} - config.ExecStruct = append(config.ExecStruct, process.Execute{}) - copy(config.ExecStruct[index+1:], config.ExecStruct[index:]) - config.ExecStruct[index] = ready - return - } -} - func Watcher(options *WatcherOptions) error { stopChan := make(chan struct{}) @@ -69,7 +42,6 @@ func Watcher(options *WatcherOptions) error { } ensureIgnored(&devconfig.Config.Ignore.File, "*_test.go") - ensureFrontendDevServerReadyTask(&devconfig.Config) watcherEngine, err := engine.NewEngineFromConfig(devconfig.Config) if err != nil { diff --git a/v3/internal/commands/watcher_test.go b/v3/internal/commands/watcher_test.go index 47b76f75060..635060ec7a5 100644 --- a/v3/internal/commands/watcher_test.go +++ b/v3/internal/commands/watcher_test.go @@ -3,8 +3,6 @@ package commands import ( "testing" - "github.com/atterpac/refresh/engine" - "github.com/atterpac/refresh/process" "github.com/stretchr/testify/require" ) @@ -30,42 +28,3 @@ func TestEnsureIgnored(t *testing.T) { require.Len(t, list, 1) }) } - -func TestEnsureFrontendDevServerReadyTask(t *testing.T) { - t.Setenv("FRONTEND_DEVSERVER_URL", "http://localhost:9245") - config := engine.Config{ExecStruct: []process.Execute{ - {Cmd: "frontend", Type: process.Background}, - {Cmd: "application", Type: process.Primary}, - }} - - ensureFrontendDevServerReadyTask(&config) - - require.Equal(t, []process.Execute{ - {Cmd: "frontend", Type: process.Background}, - {Cmd: frontendDevServerReadyCommand, Type: process.Once}, - {Cmd: "application", Type: process.Primary}, - }, config.ExecStruct) -} - -func TestEnsureFrontendDevServerReadyTaskIsIdempotent(t *testing.T) { - t.Setenv("FRONTEND_DEVSERVER_URL", "http://localhost:9245") - config := engine.Config{ExecStruct: []process.Execute{ - {Cmd: frontendDevServerReadyCommand, Type: process.Once}, - {Cmd: "application", Type: process.Primary}, - }} - - ensureFrontendDevServerReadyTask(&config) - - require.Len(t, config.ExecStruct, 2) -} - -func TestEnsureFrontendDevServerReadyTaskRequiresDevServer(t *testing.T) { - t.Setenv("FRONTEND_DEVSERVER_URL", "") - config := engine.Config{ExecStruct: []process.Execute{ - {Cmd: "application", Type: process.Primary}, - }} - - ensureFrontendDevServerReadyTask(&config) - - require.Len(t, config.ExecStruct, 1) -} From ec9f2548c186d3bc1426fa10c042b84b57c68b41 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sun, 26 Jul 2026 15:54:25 +0200 Subject: [PATCH 4/5] build(deps): update refresh to v1.1.2 --- v3/go.mod | 2 +- v3/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/v3/go.mod b/v3/go.mod index c2e637ed48c..aecc386a005 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -6,7 +6,7 @@ require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/adrg/xdg v0.5.3 github.com/atotto/clipboard v0.1.4 - github.com/atterpac/refresh v1.0.0 + github.com/atterpac/refresh v1.1.2 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 diff --git a/v3/go.sum b/v3/go.sum index f466e9964b6..e79a446e3f2 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -61,8 +61,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/atterpac/refresh v1.0.0 h1:IK/rh3w5cD7nb6GuqzfScIdNuAz/E0sZz10k1pioIFE= -github.com/atterpac/refresh v1.0.0/go.mod h1:+vQ8OHgGmZ7wwoZfxxkT6Nr/gKA8j78Rbt+qcLLDEoc= +github.com/atterpac/refresh v1.1.2 h1:NvukuugqyZZ/vCArMVssBWSfg3dwfDbbTdav97d+leM= +github.com/atterpac/refresh v1.1.2/go.mod h1:+vQ8OHgGmZ7wwoZfxxkT6Nr/gKA8j78Rbt+qcLLDEoc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= From 90a52f993ed017f617c3d35d4130cf61f6012453 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sun, 26 Jul 2026 15:54:30 +0200 Subject: [PATCH 5/5] chore(changelog): document changes for frontend dev server readiness in `wails3 dev` --- v3/UNRELEASED_CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 33638e7fc86..52686f598ce 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -20,6 +20,27 @@ After processing, the content will be moved to the main changelog and this file ## Changed +- Start the native application in `wails3 dev` only after the frontend development server is accepting connections. Existing alpha projects must add a `dev:wait` task to `build/Taskfile.yml`: + + ```yaml + dev:wait: + summary: Waits for the frontend development server + vars: + TIMEOUT: 60 + cmds: + - wails3 tool waitport --timeout {{.TIMEOUT}} + ``` + + Then insert the readiness step in `build/config.yml` after the background frontend task and before the primary application task: + + ```yaml + - cmd: wails3 task common:dev:frontend + type: background + - cmd: wails3 task common:dev:wait + type: once + - cmd: wails3 task run + type: primary + ``` ## Fixed