From 78097710b5cfef58c97c459993e1c81e45c84361 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Sat, 18 Jul 2026 15:46:08 +0200 Subject: [PATCH 1/3] feat(dev): improve frontend dev server readiness checks with retry logic and test coverage --- go.work.sum | 41 +++++++++++ v3/pkg/application/application_dev.go | 57 +++++++++++---- v3/pkg/application/application_dev_test.go | 82 ++++++++++++++++++++++ 3 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 v3/pkg/application/application_dev_test.go diff --git a/go.work.sum b/go.work.sum index 91461c0a1f5..7550cba87af 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,41 +1,82 @@ +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/caarlos0/go-version v0.2.2 h1:5r+nlrg4H2wOVwWjqRqRRIRbZ7ytRmjC9xoMIP0a5kQ= github.com/caarlos0/go-version v0.2.2/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +mvdan.cc/editorconfig v0.3.0 h1:D1D2wLYEYGpawWT5SpM5pRivgEgXjtEXwC9MWhEY0gQ= mvdan.cc/editorconfig v0.3.0/go.mod h1:NcJHuDtNOTEJ6251indKiWuzK6+VcrMuLzGMLKBFupQ= 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 7cb3e5638c1dbb9068586716a1b6f1fec2fce52d Mon Sep 17 00:00:00 2001 From: Krasovskiy Saveliy Igorevich Date: Mon, 20 Jul 2026 15:07:15 +0200 Subject: [PATCH 2/3] 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 | 63 +++++++++++++++ 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 | 19 ++--- 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(+), 175 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 4dc8a329e74..b37c8fbe1da 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -95,6 +95,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..709e3116273 --- /dev/null +++ b/v3/internal/commands/tool_waitport.go @@ -0,0 +1,63 @@ +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) + } + } + + 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 8097a4b7aa9..aba26f6f235 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -571,12 +571,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. @@ -602,13 +596,12 @@ func (a *App) Run() error { a.options.Services = services[:i+1] } - - // Start the MCP server when the application is built with -tags mcp. - // All configuration is read from environment variables (WAILS_MCP_HOST, - // WAILS_MCP_PORT, WAILS_MCP_TIMEOUT, WAILS_MCP_HIDE_CURSOR). - if err := startMCPServer(a); err != nil { - return fmt.Errorf("mcp: %w", err) - } + // Start the MCP server when the application is built with -tags mcp. + // All configuration is read from environment variables (WAILS_MCP_HOST, + // WAILS_MCP_PORT, WAILS_MCP_TIMEOUT, WAILS_MCP_HIDE_CURSOR). + if err := startMCPServer(a); err != nil { + return fmt.Errorf("mcp: %w", err) + } go func() { for { 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 ecb7cd304e1eceae897455f9ece546529685bd96 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Wed, 22 Jul 2026 16:37:22 +0200 Subject: [PATCH 3/3] 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) -}