diff --git a/assets/vendor/toolrelay.cpp b/assets/vendor/toolrelay.cpp
index 6939f24..73f9970 100644
--- a/assets/vendor/toolrelay.cpp
+++ b/assets/vendor/toolrelay.cpp
@@ -79,8 +79,16 @@ HANDLE MakeKillOnCloseJob() {
}
bool IsMtExe(const wchar_t *path) {
- const wchar_t *name = wcsrchr(path, L'\\');
- name = name ? name + 1 : path;
+ // vintner passes toolExePath straight through from Go's filepath.Join,
+ // which uses forward slashes even for a path that's about to be handed
+ // to a native Windows process - so the separator here isn't reliably
+ // '\\'. Check both; using whichever comes later in the string covers a
+ // mixed-separator path too.
+ const wchar_t *back = wcsrchr(path, L'\\');
+ const wchar_t *fwd = wcsrchr(path, L'/');
+ const wchar_t *sep = back;
+ if (fwd && (!sep || fwd > sep)) sep = fwd;
+ const wchar_t *name = sep ? sep + 1 : path;
return _wcsicmp(name, L"mt.exe") == 0;
}
diff --git a/internal/download/extract_test.go b/internal/download/extract_test.go
new file mode 100644
index 0000000..841e4b2
--- /dev/null
+++ b/internal/download/extract_test.go
@@ -0,0 +1,152 @@
+package download
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCombineDirTreesNonexistentSrcIsNoop(t *testing.T) {
+ dest := t.TempDir()
+ if err := combineDirTrees(filepath.Join(t.TempDir(), "does-not-exist"), dest); err != nil {
+ t.Fatalf("combineDirTrees with a nonexistent src returned an error: %v", err)
+ }
+}
+
+func TestCombineDirTreesRenamesWholesaleWhenDestMissing(t *testing.T) {
+ root := t.TempDir()
+ src := filepath.Join(root, "src")
+ dest := filepath.Join(root, "nested", "dest")
+ writeFile(t, filepath.Join(src, "file.txt"), "hello")
+
+ if err := combineDirTrees(src, dest); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.ReadFile(filepath.Join(dest, "file.txt")); err != nil {
+ t.Errorf("expected %s/file.txt to exist after combine: %v", dest, err)
+ }
+ if isDir(src) {
+ t.Error("src should have been moved (renamed), not copied")
+ }
+}
+
+func TestCombineDirTreesMergesNewSubdir(t *testing.T) {
+ root := t.TempDir()
+ src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
+ writeFile(t, filepath.Join(src, "NewDir", "a.txt"), "a")
+ writeFile(t, filepath.Join(dest, "Existing.txt"), "keep me")
+
+ if err := combineDirTrees(src, dest); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.ReadFile(filepath.Join(dest, "NewDir", "a.txt")); err != nil {
+ t.Errorf("expected merged NewDir/a.txt: %v", err)
+ }
+ if _, err := os.ReadFile(filepath.Join(dest, "Existing.txt")); err != nil {
+ t.Errorf("pre-existing dest file was lost: %v", err)
+ }
+}
+
+func TestCombineDirTreesMergesCaseInsensitiveCollision(t *testing.T) {
+ root := t.TempDir()
+ src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
+ // src has "Include" (capital I), dest already has "include" (lowercase) -
+ // this is exactly the MSVC/WinSDK casing-inconsistency scenario the
+ // function's doc comment describes.
+ writeFile(t, filepath.Join(src, "Include", "new.h"), "new")
+ writeFile(t, filepath.Join(dest, "include", "old.h"), "old")
+
+ if err := combineDirTrees(src, dest); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.ReadFile(filepath.Join(dest, "include", "new.h")); err != nil {
+ t.Errorf("new.h should have merged into the existing lowercase 'include' dir: %v", err)
+ }
+ if _, err := os.ReadFile(filepath.Join(dest, "include", "old.h")); err != nil {
+ t.Errorf("old.h should still be there: %v", err)
+ }
+ if isDir(filepath.Join(dest, "Include")) {
+ t.Error("a separate capital-I 'Include' dir should not have been created")
+ }
+}
+
+func TestCombineDirTreesRecursesIntoExactNameMatch(t *testing.T) {
+ root := t.TempDir()
+ src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
+ writeFile(t, filepath.Join(src, "lib", "x64", "new.lib"), "new")
+ writeFile(t, filepath.Join(dest, "lib", "x64", "old.lib"), "old")
+
+ if err := combineDirTrees(src, dest); err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range []string{"new.lib", "old.lib"} {
+ if _, err := os.ReadFile(filepath.Join(dest, "lib", "x64", f)); err != nil {
+ t.Errorf("expected lib/x64/%s to survive the merge: %v", f, err)
+ }
+ }
+}
+
+func TestCopyRedirectedAssembliesNoConfigIsNoop(t *testing.T) {
+ dir := t.TempDir()
+ app := filepath.Join(dir, "MSBuild.exe")
+ if err := CopyRedirectedAssemblies(app); err != nil {
+ t.Fatalf("with no .config file present, expected no error, got: %v", err)
+ }
+}
+
+func TestCopyRedirectedAssembliesCopiesReferencedDLL(t *testing.T) {
+ dir := t.TempDir()
+ app := filepath.Join(dir, "MSBuild.exe")
+ writeFile(t, app+".config", `
+
+
+
+
+
+
+
+
+`)
+ writeFile(t, filepath.Join(dir, "amd64", "Some.Assembly.dll"), "binary-content")
+
+ if err := CopyRedirectedAssemblies(app); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := os.ReadFile(filepath.Join(dir, "Some.Assembly.dll"))
+ if err != nil {
+ t.Fatalf("expected Some.Assembly.dll copied next to MSBuild.exe: %v", err)
+ }
+ if string(got) != "binary-content" {
+ t.Errorf("copied file content = %q, want %q", got, "binary-content")
+ }
+}
+
+func TestCopyRedirectedAssembliesSkipsMissingTarget(t *testing.T) {
+ dir := t.TempDir()
+ app := filepath.Join(dir, "MSBuild.exe")
+ writeFile(t, app+".config", `
+
+
+
+
+
+
+
+
+`)
+
+ if err := CopyRedirectedAssemblies(app); err != nil {
+ t.Fatalf("a redirect pointing at a nonexistent file should be silently skipped, got: %v", err)
+ }
+}
diff --git a/internal/download/fetch_test.go b/internal/download/fetch_test.go
new file mode 100644
index 0000000..cf2a715
--- /dev/null
+++ b/internal/download/fetch_test.go
@@ -0,0 +1,59 @@
+package download
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestRetryBackoff(t *testing.T) {
+ for _, tc := range []struct {
+ attempt int
+ want time.Duration
+ }{
+ {1, 1 * time.Second},
+ {2, 2 * time.Second},
+ {3, 4 * time.Second},
+ {4, 8 * time.Second},
+ {5, 10 * time.Second}, // capped
+ {10, 10 * time.Second},
+ } {
+ if got := retryBackoff(tc.attempt); got != tc.want {
+ t.Errorf("retryBackoff(%d) = %v, want %v", tc.attempt, got, tc.want)
+ }
+ }
+}
+
+func TestSHA256File(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "f")
+ if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := sha256File(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // echo -n hello | sha256sum
+ want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+ if got != want {
+ t.Errorf("sha256File(hello) = %q, want %q", got, want)
+ }
+}
+
+func TestEqualFoldHex(t *testing.T) {
+ for _, tc := range []struct {
+ a, b string
+ want bool
+ }{
+ {"ABCDEF", "abcdef", true},
+ {"abc123", "ABC123", true},
+ {"abc123", "abc124", false},
+ {"abc", "abcd", false},
+ {"", "", true},
+ } {
+ if got := equalFoldHex(tc.a, tc.b); got != tc.want {
+ t.Errorf("equalFoldHex(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+ }
+ }
+}
diff --git a/internal/download/manifest_test.go b/internal/download/manifest_test.go
new file mode 100644
index 0000000..6042854
--- /dev/null
+++ b/internal/download/manifest_test.go
@@ -0,0 +1,134 @@
+package download
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestPayloadName(t *testing.T) {
+ for _, tc := range []struct {
+ fileName string
+ want string
+ }{
+ {"payload.msi", "payload.msi"},
+ {"folder/payload.msi", "payload.msi"},
+ {`folder\payload.msi`, "payload.msi"},
+ {`a\b/c\payload.msi`, "payload.msi"},
+ {"", ""},
+ } {
+ p := Payload{FileName: tc.fileName}
+ if got := p.Name(); got != tc.want {
+ t.Errorf("Payload{FileName: %q}.Name() = %q, want %q", tc.fileName, got, tc.want)
+ }
+ }
+}
+
+func TestPackageKey(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ p Package
+ want string
+ }{
+ {"id only", Package{ID: "Foo"}, "Foo"},
+ {"id+version", Package{ID: "Foo", Version: "1.0"}, "Foo-1.0"},
+ {
+ "id+version+all arches",
+ Package{ID: "Foo", Version: "1.0", Chip: "x64", MachineArch: "x86", ProductArch: "neutral"},
+ "Foo-1.0-chip.x64-machineArch.x86-productArch.neutral",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.p.Key(); got != tc.want {
+ t.Errorf("Key() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPackageLocalized(t *testing.T) {
+ noResources := Package{}
+ if got := noResources.Localized("en"); got != nil {
+ t.Errorf("Localized() on a package with no LocalizedResources = %v, want nil", got)
+ }
+
+ p := Package{LocalizedResources: []LocalizedResource{
+ {Language: "de-DE", Title: "Deutsch"},
+ {Language: "en-US", Title: "English"},
+ {Language: "ru-RU", Title: "Русский"},
+ }}
+
+ for _, tc := range []struct {
+ lang string
+ wantTitle string
+ }{
+ {"ru", "Русский"},
+ {"ru-RU", "Русский"},
+ {"en", "English"},
+ {"", "English"}, // "" defaults to "en"
+ {"fr", "English"}, // no fr variant, falls back to the en-* one
+ } {
+ t.Run("lang="+tc.lang, func(t *testing.T) {
+ got := p.Localized(tc.lang)
+ if got == nil {
+ t.Fatalf("Localized(%q) = nil", tc.lang)
+ }
+ if got.Title != tc.wantTitle {
+ t.Errorf("Localized(%q).Title = %q, want %q", tc.lang, got.Title, tc.wantTitle)
+ }
+ })
+ }
+}
+
+func TestPackageSizes(t *testing.T) {
+ p := Package{
+ InstallSizes: map[string]int64{"x86": 100, "x64": 200},
+ Payloads: []Payload{{Size: 10}, {Size: 20}, {Size: 30}},
+ }
+ if got := p.InstalledSize(); got != 300 {
+ t.Errorf("InstalledSize() = %d, want 300", got)
+ }
+ if got := p.DownloadSize(); got != 60 {
+ t.Errorf("DownloadSize() = %d, want 60", got)
+ }
+}
+
+func TestPackageDependenciesNormalizesBothShapes(t *testing.T) {
+ p := Package{DependenciesRaw: map[string]json.RawMessage{
+ "Bare.Version": json.RawMessage(`"1.0"`),
+ "Full.Object": json.RawMessage(`{"version":"2.0","type":"Optional","id":"Real.Target"}`),
+ "Recommended.Dep": json.RawMessage(`{"version":"3.0","type":"Recommended"}`),
+ }}
+ deps := p.Dependencies()
+
+ if d := deps["Bare.Version"]; d.Version != "1.0" || d.TargetID != "" || d.Type != "" {
+ t.Errorf("Bare.Version = %+v, want Version=1.0 TargetID='' Type=''", d)
+ }
+ if d := deps["Full.Object"]; d.Version != "2.0" || d.TargetID != "Real.Target" || d.Type != "Optional" {
+ t.Errorf("Full.Object = %+v, want Version=2.0 TargetID=Real.Target Type=Optional", d)
+ }
+ if d := deps["Recommended.Dep"]; d.Version != "3.0" || d.Type != "Recommended" {
+ t.Errorf("Recommended.Dep = %+v, want Version=3.0 Type=Recommended", d)
+ }
+
+ // Calling Dependencies() again must return the same cached map, not
+ // re-parse (and must not panic on the second call).
+ if d2 := p.Dependencies(); len(d2) != len(deps) {
+ t.Errorf("second Dependencies() call returned a different map: %v vs %v", d2, deps)
+ }
+}
+
+func TestHumanizeBytes(t *testing.T) {
+ for _, tc := range []struct {
+ size int64
+ want string
+ }{
+ {500, "500 bytes"},
+ {2048, "2.0 KB"},
+ {5 * 1024 * 1024, "5.0 MB"},
+ {2 * 1024 * 1024 * 1024, "2.0 GB"},
+ } {
+ if got := HumanizeBytes(tc.size); got != tc.want {
+ t.Errorf("HumanizeBytes(%d) = %q, want %q", tc.size, got, tc.want)
+ }
+ }
+}
diff --git a/internal/download/wdk_test.go b/internal/download/wdk_test.go
new file mode 100644
index 0000000..67c7107
--- /dev/null
+++ b/internal/download/wdk_test.go
@@ -0,0 +1,118 @@
+package download
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestWDKNuGetID(t *testing.T) {
+ for _, tc := range []struct{ arch, want string }{
+ {"x64", "Microsoft.Windows.WDK.x64"},
+ {"x86", "Microsoft.Windows.WDK.x64"}, // no 32-bit package exists
+ {"arm64", "Microsoft.Windows.WDK.ARM64"},
+ } {
+ if got := WDKNuGetID(tc.arch); got != tc.want {
+ t.Errorf("WDKNuGetID(%q) = %q, want %q", tc.arch, got, tc.want)
+ }
+ }
+}
+
+func TestSDKBuildPrefix(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ selected []*Package
+ want string
+ }{
+ {"no SDK package", []*Package{{ID: "Something.Else"}}, ""},
+ {"win10sdk", []*Package{{ID: "Win10SDK_10.0.26100", Version: "10.0.26100.1742"}}, "10.0.26100"},
+ {"win11sdk case-insensitive id", []*Package{{ID: "WIN11SDK_10.0.22621", Version: "10.0.22621.5"}}, "10.0.22621"},
+ {"short version", []*Package{{ID: "Win10SDK_x", Version: "10.0"}}, ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := SDKBuildPrefix(tc.selected); got != tc.want {
+ t.Errorf("SDKBuildPrefix(...) = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFillMissingHostToolsCopiesWithoutOverwriting(t *testing.T) {
+ cDir := t.TempDir()
+ writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "stampinf.exe"), "x64-stampinf")
+ writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "inf2cat.exe"), "x64-inf2cat")
+ // x86 already ships its own real inf2cat.exe - must not be clobbered.
+ writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x86", "inf2cat.exe"), "real-x86-inf2cat")
+
+ if err := fillMissingHostTools(cDir); err != nil {
+ t.Fatal(err)
+ }
+
+ x86Dir := filepath.Join(cDir, "bin", "10.0.26100.0", "x86")
+ stampinf, err := os.ReadFile(filepath.Join(x86Dir, "stampinf.exe"))
+ if err != nil {
+ t.Fatalf("expected stampinf.exe to be copied into x86: %v", err)
+ }
+ if string(stampinf) != "x64-stampinf" {
+ t.Errorf("copied stampinf.exe content = %q, want the x64 copy's content", stampinf)
+ }
+
+ inf2cat, err := os.ReadFile(filepath.Join(x86Dir, "inf2cat.exe"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(inf2cat) != "real-x86-inf2cat" {
+ t.Errorf("inf2cat.exe = %q, want the original x86 file preserved (not overwritten by the x64 copy)", inf2cat)
+ }
+}
+
+func TestFillMissingHostToolsNoBinDirIsNoop(t *testing.T) {
+ if err := fillMissingHostTools(t.TempDir()); err != nil {
+ t.Fatalf("missing bin/ dir should be a no-op, got: %v", err)
+ }
+}
+
+func TestDuplicateVersionedBuildTaskAssemblies(t *testing.T) {
+ cDir := t.TempDir()
+ buildDir := filepath.Join(cDir, "build")
+ writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.17.0.dll"), "task-dll-bytes")
+ writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"), "already-there")
+ writeFile(t, filepath.Join(buildDir, "unrelated.dll"), "unrelated")
+
+ if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
+ t.Fatal(err)
+ }
+
+ // Already had an 18.0 copy - must not have been overwritten.
+ got, err := os.ReadFile(filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "already-there" {
+ t.Errorf("pre-existing 18.0 dll was overwritten: got %q", got)
+ }
+}
+
+func TestDuplicateVersionedBuildTaskAssembliesCreatesMissingCopy(t *testing.T) {
+ cDir := t.TempDir()
+ buildDir := filepath.Join(cDir, "build")
+ writeFile(t, filepath.Join(buildDir, "sub", "Foo.Bar.17.0.dll"), "bytes")
+
+ if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := os.ReadFile(filepath.Join(buildDir, "sub", "Foo.Bar.18.0.dll"))
+ if err != nil {
+ t.Fatalf("expected a Foo.Bar.18.0.dll duplicate: %v", err)
+ }
+ if string(got) != "bytes" {
+ t.Errorf("duplicated dll content = %q, want %q", got, "bytes")
+ }
+}
+
+func TestDuplicateVersionedBuildTaskAssembliesNoBuildDirIsNoop(t *testing.T) {
+ if err := duplicateVersionedBuildTaskAssemblies(t.TempDir(), "18.0"); err != nil {
+ t.Fatalf("missing build/ dir should be a no-op, got: %v", err)
+ }
+}
diff --git a/internal/wrapper/clpost_test.go b/internal/wrapper/clpost_test.go
new file mode 100644
index 0000000..aca1887
--- /dev/null
+++ b/internal/wrapper/clpost_test.go
@@ -0,0 +1,61 @@
+package wrapper
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestClPostProcessRewritesLineDirectivesInPreprocessedOutput(t *testing.T) {
+ dir := t.TempDir()
+ fi := filepath.Join(dir, "out.i")
+ // A #line directive as cl.exe's /P emits it: z:-prefixed, backslash
+ // path, doubled ("escaped") backslashes, CRLF line ending.
+ input := "#line 1 \"z:\\\\home\\\\user\\\\src\\\\hello.c\"\r\n" +
+ "int main(void) { return 0; }\r\n"
+ if err := os.WriteFile(fi, []byte(input), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ clPostProcess([]string{"/P", "/Fi" + fi, "hello.c"})
+
+ got, err := os.ReadFile(fi)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "#line 1 \"/home/user/src/hello.c\"\n" +
+ "int main(void) { return 0; }\n"
+ if string(got) != want {
+ t.Errorf("clPostProcess output = %q, want %q", got, want)
+ }
+}
+
+func TestClPostProcessNoopWithoutP(t *testing.T) {
+ dir := t.TempDir()
+ fi := filepath.Join(dir, "out.i")
+ original := "#line 1 \"z:\\\\foo.c\"\r\n"
+ if err := os.WriteFile(fi, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // No "/P" flag - should leave the file untouched even though -Fi is present.
+ clPostProcess([]string{"/Fi" + fi, "foo.c"})
+
+ got, err := os.ReadFile(fi)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != original {
+ t.Errorf("file was modified without /P: got %q, want unchanged %q", got, original)
+ }
+}
+
+func TestClPostProcessNoopWithoutFi(t *testing.T) {
+ // Must not panic or error when -Fi wasn't passed - just silently skip.
+ clPostProcess([]string{"/P", "foo.c"})
+}
+
+func TestClPostProcessMissingFileIsSilent(t *testing.T) {
+ // The referenced -Fi file doesn't exist - clPostProcess must not panic.
+ clPostProcess([]string{"/P", "/Fi" + filepath.Join(t.TempDir(), "missing.i"), "foo.c"})
+}
diff --git a/internal/wrapper/msbuildenv_test.go b/internal/wrapper/msbuildenv_test.go
new file mode 100644
index 0000000..3bbd568
--- /dev/null
+++ b/internal/wrapper/msbuildenv_test.go
@@ -0,0 +1,117 @@
+package wrapper
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Cheviiot/vintner/internal/wineenv"
+)
+
+func TestMsbuildPlatform(t *testing.T) {
+ for _, tc := range []struct{ arch, want string }{
+ {"x86", "Win32"},
+ {"x64", "x64"},
+ {"arm", "ARM"},
+ {"arm64", "ARM64"},
+ } {
+ if got := msbuildPlatform(tc.arch); got != tc.want {
+ t.Errorf("msbuildPlatform(%q) = %q, want %q", tc.arch, got, tc.want)
+ }
+ }
+}
+
+func newTestPaths(t *testing.T, cfg *wineenv.Config) (*wineenv.Paths, string) {
+ t.Helper()
+ base := t.TempDir()
+ return wineenv.NewPaths(cfg, base), base
+}
+
+func TestMsbuildEnvBasics(t *testing.T) {
+ cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
+ paths, _ := newTestPaths(t, cfg)
+
+ env := msbuildEnv(cfg, paths)
+
+ if env["TZ"] != "UTC" {
+ t.Errorf(`env["TZ"] = %q, want "UTC"`, env["TZ"])
+ }
+ if env["DisableRegistryUse"] != "true" {
+ t.Errorf(`env["DisableRegistryUse"] = %q, want "true"`, env["DisableRegistryUse"])
+ }
+ if env["VCToolsVersion"] != cfg.MSVCVer {
+ t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer)
+ }
+ if env["WindowsTargetPlatformVersion"] != cfg.SDKVer {
+ t.Errorf(`env["WindowsTargetPlatformVersion"] = %q, want %q`, env["WindowsTargetPlatformVersion"], cfg.SDKVer)
+ }
+ if env["Platform"] != "x64" {
+ t.Errorf(`env["Platform"] = %q, want "x64"`, env["Platform"])
+ }
+ if env["SignMode"] != "off" {
+ t.Errorf(`env["SignMode"] = %q, want "off" (driver builds must not attempt real signing)`, env["SignMode"])
+ }
+ // No WDK content on disk in this test - must not claim otherwise.
+ if _, ok := env["WDKContentRoot"]; ok {
+ t.Error(`env["WDKContentRoot"] set even though no wdk//c directory exists`)
+ }
+}
+
+func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
+ cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
+ paths, base := newTestPaths(t, cfg)
+
+ for _, v := range []string{"v145", "v180", "not-a-version"} {
+ if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", v), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ env := msbuildEnv(cfg, paths)
+
+ for _, n := range []string{"145", "180"} {
+ if _, ok := env["VCInstallDir_"+n]; !ok {
+ t.Errorf("expected VCInstallDir_%s to be set", n)
+ }
+ if _, ok := env["VCToolsInstallDir_"+n]; !ok {
+ t.Errorf("expected VCToolsInstallDir_%s to be set", n)
+ }
+ }
+ if _, ok := env["VCInstallDir_not-a-version"]; ok {
+ t.Error("a directory not matching v should not have produced a VCInstallDir_ entry")
+ }
+}
+
+func TestMsbuildEnvDetectsWDKContentRoot(t *testing.T) {
+ cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
+ paths, base := newTestPaths(t, cfg)
+
+ if err := os.MkdirAll(filepath.Join(base, "wdk", "x64", "c"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ env := msbuildEnv(cfg, paths)
+
+ if env["WDKContentRoot"] == "" {
+ t.Error("expected WDKContentRoot to be set once wdk/x64/c exists on disk")
+ }
+ if env["WDKBuildFolder"] != cfg.SDKVer {
+ t.Errorf(`env["WDKBuildFolder"] = %q, want %q`, env["WDKBuildFolder"], cfg.SDKVer)
+ }
+}
+
+func TestMsbuildEnvPreferredToolArchitecture(t *testing.T) {
+ // PreferredToolArchitecture should only be set when the host toolset
+ // bin dir is the 64-bit ("amd64") .NET host - not for arm64.
+ cfg64 := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "1", SDKVer: "1"}
+ paths64, _ := newTestPaths(t, cfg64)
+ if env := msbuildEnv(cfg64, paths64); env["PreferredToolArchitecture"] != "x64" {
+ t.Errorf(`with DotnetHost=amd64, PreferredToolArchitecture = %q, want "x64"`, env["PreferredToolArchitecture"])
+ }
+
+ cfgARM := &wineenv.Config{Arch: "arm64", Host: "arm64", DotnetHost: "arm64", MSVCVer: "1", SDKVer: "1"}
+ pathsARM, _ := newTestPaths(t, cfgARM)
+ if env := msbuildEnv(cfgARM, pathsARM); env["PreferredToolArchitecture"] != "" {
+ t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"])
+ }
+}
diff --git a/internal/wrapper/tools_test.go b/internal/wrapper/tools_test.go
new file mode 100644
index 0000000..1b5dedd
--- /dev/null
+++ b/internal/wrapper/tools_test.go
@@ -0,0 +1,45 @@
+package wrapper
+
+import (
+ "testing"
+
+ "github.com/Cheviiot/vintner/internal/wineenv"
+)
+
+func TestSpecExeDir(t *testing.T) {
+ paths := &wineenv.Paths{
+ BinDir: "/bin-dir",
+ SDKBinDir: "/sdk-bin-dir",
+ MSBuildBinDir: "/msbuild-bin-dir",
+ }
+ for _, tc := range []struct {
+ name string
+ dir dirKind
+ want string
+ }{
+ {"dirBin", dirBin, "/bin-dir"},
+ {"dirSDK", dirSDK, "/sdk-bin-dir"},
+ {"dirMSBuild", dirMSBuild, "/msbuild-bin-dir"},
+ } {
+ s := spec{dir: tc.dir}
+ if got := s.exeDir(paths); got != tc.want {
+ t.Errorf("%s: exeDir() = %q, want %q", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestToolsAndNativeToolsAreDisjoint(t *testing.T) {
+ for name := range Tools {
+ if nativeTools[name] {
+ t.Errorf("%q is in both Tools and nativeTools", name)
+ }
+ }
+}
+
+func TestEveryToolHasAnExeName(t *testing.T) {
+ for name, s := range Tools {
+ if s.exeName == "" {
+ t.Errorf("Tools[%q] has no exeName", name)
+ }
+ }
+}