From 1265dc158bbb5fca9e8a8efbc120f3e559f454c7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 18:58:25 +0000 Subject: [PATCH] feat(build): add Wails frontend prebuild hook Co-Authored-By: Virgil --- pkg/build/builders/wails.go | 109 ++++++++++++++++++++++++ pkg/build/builders/wails_test.go | 140 +++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/pkg/build/builders/wails.go b/pkg/build/builders/wails.go index b15a4bc..00b1ad3 100644 --- a/pkg/build/builders/wails.go +++ b/pkg/build/builders/wails.go @@ -66,6 +66,10 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b } // Wails v2 strategy: Use 'wails build' + if err := b.PreBuild(ctx, cfg); err != nil { + return nil, err + } + // Ensure output directory exists if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil { return nil, coreerr.E("WailsBuilder.Build", "failed to create output directory", err) @@ -86,6 +90,30 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b return artifacts, nil } +// PreBuild runs the frontend build step before Wails compiles the desktop app. +// +// err := b.PreBuild(ctx, cfg) // runs `deno task build` or `npm run build` +func (b *WailsBuilder) PreBuild(ctx context.Context, cfg *build.Config) error { + if cfg == nil { + return coreerr.E("WailsBuilder.PreBuild", "config is nil", nil) + } + + frontendDir, command, args, err := b.resolveFrontendBuild(cfg.FS, cfg.ProjectDir) + if err != nil { + return err + } + if command == "" { + return nil + } + + output, err := ax.CombinedOutput(ctx, frontendDir, nil, command, args...) + if err != nil { + return coreerr.E("WailsBuilder.PreBuild", command+" build failed: "+output, err) + } + + return nil +} + // isWailsV3 checks if the project uses Wails v3 by inspecting go.mod. func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool { goModPath := ax.Join(dir, "go.mod") @@ -96,6 +124,53 @@ func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool { return core.Contains(content, "github.com/wailsapp/wails/v3") } +// resolveFrontendBuild selects the frontend directory and build command. +// +// dir, command, args, err := b.resolveFrontendBuild(io.Local, ".") +func (b *WailsBuilder) resolveFrontendBuild(fs io.Medium, projectDir string) (string, string, []string, error) { + frontendDir := b.resolveFrontendDir(fs, projectDir) + if frontendDir == "" { + return "", "", nil, nil + } + + if b.hasDenoConfig(fs, frontendDir) { + command, err := b.resolveDenoCli() + if err != nil { + return "", "", nil, err + } + return frontendDir, command, []string{"task", "build"}, nil + } + + if fs.IsFile(ax.Join(frontendDir, "package.json")) { + command, err := b.resolveNpmCli() + if err != nil { + return "", "", nil, err + } + return frontendDir, command, []string{"run", "build"}, nil + } + + return "", "", nil, nil +} + +// resolveFrontendDir returns the directory that contains the frontend build manifest. +func (b *WailsBuilder) resolveFrontendDir(fs io.Medium, projectDir string) string { + frontendDir := ax.Join(projectDir, "frontend") + if fs.IsDir(frontendDir) && (b.hasDenoConfig(fs, frontendDir) || fs.IsFile(ax.Join(frontendDir, "package.json"))) { + return frontendDir + } + + if b.hasDenoConfig(fs, projectDir) || fs.IsFile(ax.Join(projectDir, "package.json")) { + return projectDir + } + + return "" +} + +// hasDenoConfig reports whether the frontend directory contains a Deno manifest. +func (b *WailsBuilder) hasDenoConfig(fs io.Medium, dir string) bool { + return fs.IsFile(ax.Join(dir, "deno.json")) || fs.IsFile(ax.Join(dir, "deno.jsonc")) +} + // buildV2Target compiles for a single target platform using wails (v2). func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, target build.Target) (build.Artifact, error) { wailsCommand, err := b.resolveWailsCli() @@ -258,6 +333,40 @@ func (b *WailsBuilder) resolveWailsCli(paths ...string) (string, error) { return command, nil } +// resolveDenoCli returns the executable path for the deno CLI. +func (b *WailsBuilder) resolveDenoCli(paths ...string) (string, error) { + if len(paths) == 0 { + paths = []string{ + "/usr/local/bin/deno", + "/opt/homebrew/bin/deno", + } + } + + command, err := ax.ResolveCommand("deno", paths...) + if err != nil { + return "", coreerr.E("WailsBuilder.resolveDenoCli", "deno CLI not found. Install it from https://deno.com/runtime", err) + } + + return command, nil +} + +// resolveNpmCli returns the executable path for the npm CLI. +func (b *WailsBuilder) resolveNpmCli(paths ...string) (string, error) { + if len(paths) == 0 { + paths = []string{ + "/usr/local/bin/npm", + "/opt/homebrew/bin/npm", + } + } + + command, err := ax.ResolveCommand("npm", paths...) + if err != nil { + return "", coreerr.E("WailsBuilder.resolveNpmCli", "npm CLI not found. Install Node.js from https://nodejs.org/", err) + } + + return command, nil +} + // detectPackageManager detects the frontend package manager based on lock files. // Returns "bun", "pnpm", "yarn", or "npm" (default). func detectPackageManager(fs io.Medium, dir string) string { diff --git a/pkg/build/builders/wails_test.go b/pkg/build/builders/wails_test.go index f48afd5..e7444b7 100644 --- a/pkg/build/builders/wails_test.go +++ b/pkg/build/builders/wails_test.go @@ -92,6 +92,12 @@ if [ -n "$log_file" ]; then printf '%s\n' "$@" > "$log_file" fi +sequence_file="${BUILD_SEQUENCE_FILE:-}" +if [ -n "$sequence_file" ]; then + printf '%s\n' "wails" >> "$sequence_file" + printf '%s\n' "$@" >> "$sequence_file" +fi + output_dir="build/bin" binary_name="testapp" mkdir -p "$output_dir" @@ -103,6 +109,22 @@ chmod +x "$output_dir/$binary_name" require.NoError(t, err) } +func setupFakeFrontendCommand(t *testing.T, binDir, name string) { + t.Helper() + + script := strings.ReplaceAll(`#!/bin/sh +set -eu + +sequence_file="${BUILD_SEQUENCE_FILE:-}" +if [ -n "$sequence_file" ]; then + printf '%s\n' "__NAME__" >> "$sequence_file" + printf '%s\n' "$@" >> "$sequence_file" +fi +`, "__NAME__", name) + + require.NoError(t, ax.WriteFile(ax.Join(binDir, name), []byte(script), 0o755)) +} + func TestWails_WailsBuilderBuildTaskfile_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") @@ -227,6 +249,124 @@ func TestWails_WailsBuilderBuildV2Flags_Good(t *testing.T) { assert.Contains(t, args, "embed") } +func TestWails_WailsBuilderPreBuild_Good(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + t.Run("uses deno when deno manifest exists", func(t *testing.T) { + binDir := t.TempDir() + setupFakeFrontendCommand(t, binDir, "deno") + setupFakeFrontendCommand(t, binDir, "npm") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := setupWailsTestProject(t) + frontendDir := ax.Join(projectDir, "frontend") + require.NoError(t, ax.MkdirAll(frontendDir, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "deno.json"), []byte(`{}`), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "package.json"), []byte(`{}`), 0o644)) + + logPath := ax.Join(t.TempDir(), "frontend.log") + t.Setenv("BUILD_SEQUENCE_FILE", logPath) + + builder := NewWailsBuilder() + cfg := &build.Config{ + FS: io.Local, + ProjectDir: projectDir, + } + + require.NoError(t, builder.PreBuild(context.Background(), cfg)) + + content, err := ax.ReadFile(logPath) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + require.Len(t, lines, 3) + assert.Equal(t, "deno", lines[0]) + assert.Equal(t, "task", lines[1]) + assert.Equal(t, "build", lines[2]) + }) + + t.Run("falls back to npm when only package.json exists", func(t *testing.T) { + binDir := t.TempDir() + setupFakeFrontendCommand(t, binDir, "deno") + setupFakeFrontendCommand(t, binDir, "npm") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := setupWailsTestProject(t) + require.NoError(t, ax.WriteFile(ax.Join(projectDir, "package.json"), []byte(`{}`), 0o644)) + + logPath := ax.Join(t.TempDir(), "frontend.log") + t.Setenv("BUILD_SEQUENCE_FILE", logPath) + + builder := NewWailsBuilder() + cfg := &build.Config{ + FS: io.Local, + ProjectDir: projectDir, + } + + require.NoError(t, builder.PreBuild(context.Background(), cfg)) + + content, err := ax.ReadFile(logPath) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + require.Len(t, lines, 3) + assert.Equal(t, "npm", lines[0]) + assert.Equal(t, "run", lines[1]) + assert.Equal(t, "build", lines[2]) + }) +} + +func TestWails_WailsBuilderBuildV2PreBuild_Good(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + binDir := t.TempDir() + setupFakeFrontendCommand(t, binDir, "deno") + setupFakeFrontendCommand(t, binDir, "npm") + setupFakeWailsToolchain(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := setupWailsV2TestProject(t) + frontendDir := ax.Join(projectDir, "frontend") + require.NoError(t, ax.MkdirAll(frontendDir, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "deno.json"), []byte(`{}`), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "package.json"), []byte(`{}`), 0o644)) + + outputDir := t.TempDir() + sequencePath := ax.Join(t.TempDir(), "build-sequence.log") + wailsLogPath := ax.Join(t.TempDir(), "wails.log") + t.Setenv("BUILD_SEQUENCE_FILE", sequencePath) + t.Setenv("WAILS_BUILD_LOG_FILE", wailsLogPath) + + builder := NewWailsBuilder() + cfg := &build.Config{ + FS: io.Local, + ProjectDir: projectDir, + OutputDir: outputDir, + Name: "testapp", + } + targets := []build.Target{ + {OS: runtime.GOOS, Arch: runtime.GOARCH}, + } + + artifacts, err := builder.Build(context.Background(), cfg, targets) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + content, err := ax.ReadFile(sequencePath) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + require.GreaterOrEqual(t, len(lines), 4) + assert.Equal(t, "deno", lines[0]) + assert.Equal(t, "task", lines[1]) + assert.Equal(t, "build", lines[2]) + assert.Equal(t, "wails", lines[3]) +} + func TestWails_WailsBuilderResolveWailsCli_Good(t *testing.T) { builder := NewWailsBuilder() fallbackDir := t.TempDir()