Files
Cheviiot 0a4f05c673 Add regression test for deterministic dependency resolution order
Loops ExpandSelection 50 times and checks the result order never
changes, guarding against the map-iteration-order bug fixed in the
previous commit ever coming back unnoticed. Verified this actually
catches the regression by temporarily reverting the fix locally.
2026-07-25 04:17:57 +10:00

256 lines
7.5 KiB
Go

package download
import (
"encoding/json"
"testing"
)
// fixtureManifest is a trimmed-down stand-in for a real installer manifest,
// covering: a workload -> component -> {required, Optional, Recommended}
// dependency chain, host-arch variants of the same package id, and
// language variants - enough to exercise BuildIndex/collectDependencyClosure
// offline, without ever hitting the network.
const fixtureManifestJSON = `{
"info": {"productDisplayVersion": "test"},
"packages": [
{
"id": "Microsoft.VisualStudio.Workload.VCTools",
"type": "Workload",
"dependencies": {
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64": "1.0"
}
},
{
"id": "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"type": "Component",
"dependencies": {
"Microsoft.VC.Tools.Core": "1.0",
"Microsoft.VC.Tools.Optional": {"version": "1.0", "type": "Optional"},
"Microsoft.VC.Tools.Recommended": {"version": "1.0", "type": "Recommended"}
}
},
{
"id": "Microsoft.VC.Tools.Core",
"type": "Msi",
"machineArch": "x86",
"payloads": [{"fileName": "core-x86.msi", "url": "https://example.invalid/core-x86.msi", "sha256": "aa", "size": 100}]
},
{
"id": "Microsoft.VC.Tools.Core",
"type": "Msi",
"machineArch": "arm64",
"payloads": [{"fileName": "core-arm64.msi", "url": "https://example.invalid/core-arm64.msi", "sha256": "bb", "size": 200}]
},
{
"id": "Microsoft.VC.Tools.Optional",
"type": "Msi",
"payloads": [{"fileName": "optional.msi", "url": "https://example.invalid/optional.msi", "size": 10}]
},
{
"id": "Microsoft.VC.Tools.Recommended",
"type": "Msi",
"payloads": [{"fileName": "recommended.msi", "url": "https://example.invalid/recommended.msi", "size": 20}]
},
{
"id": "Microsoft.VisualStudio.Resources",
"type": "Msi",
"language": "en-US",
"payloads": [{"fileName": "res-en.msi", "url": "https://example.invalid/res-en.msi", "size": 1}]
},
{
"id": "Microsoft.VisualStudio.Resources",
"type": "Msi",
"language": "de-DE",
"payloads": [{"fileName": "res-de.msi", "url": "https://example.invalid/res-de.msi", "size": 1}]
}
]
}`
func fixtureIndex(t *testing.T, hostArch string) Index {
t.Helper()
var m Manifest
if err := json.Unmarshal([]byte(fixtureManifestJSON), &m); err != nil {
t.Fatalf("parsing fixture manifest: %v", err)
}
return BuildIndex(&m, hostArch, "en")
}
func TestBuildIndexPrioritizesHostArch(t *testing.T) {
idx := fixtureIndex(t, "x86")
p := idx.Find("Microsoft.VC.Tools.Core", nil)
if p == nil {
t.Fatal("expected to find Microsoft.VC.Tools.Core")
}
if p.MachineArch != "x86" {
t.Errorf("expected x86 variant to sort first for host x86, got machineArch=%q", p.MachineArch)
}
idx64 := fixtureIndex(t, "arm64")
p64 := idx64.Find("Microsoft.VC.Tools.Core", nil)
if p64.MachineArch != "arm64" {
t.Errorf("expected arm64 variant to sort first for host arm64, got machineArch=%q", p64.MachineArch)
}
}
func TestBuildIndexPrioritizesLanguage(t *testing.T) {
idx := fixtureIndex(t, "x86")
p := idx.Find("Microsoft.VisualStudio.Resources", nil)
if p == nil {
t.Fatal("expected to find Microsoft.VisualStudio.Resources")
}
if p.Language != "en-US" {
t.Errorf("expected en-US variant to sort first for language=en, got %q", p.Language)
}
}
func TestAggregateDependsDefaultExcludesOptionalOnly(t *testing.T) {
// Optional deps are skipped unless --include-optional is passed, but
// Recommended deps are pulled in unless --skip-recommended is passed -
// it's "recommended", not "extra".
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustContain(t, ids, "Microsoft.VisualStudio.Workload.VCTools")
mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64")
mustContain(t, ids, "Microsoft.VC.Tools.Core")
mustContain(t, ids, "Microsoft.VC.Tools.Recommended")
mustNotContain(t, ids, "Microsoft.VC.Tools.Optional")
}
func TestAggregateDependsIncludeOptional(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
IncludeOptional: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustContain(t, ids, "Microsoft.VC.Tools.Optional")
// Recommended is included by default (only --skip-recommended excludes it).
mustContain(t, ids, "Microsoft.VC.Tools.Recommended")
}
func TestAggregateDependsSkipRecommended(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
SkipRecommended: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustNotContain(t, ids, "Microsoft.VC.Tools.Recommended")
}
func TestAggregateDependsRespectsIgnore(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
Ignore: []string{"microsoft.vc.tools.core"},
HostArch: "x86",
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustNotContain(t, ids, "Microsoft.VC.Tools.Core")
mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64")
}
func TestAggregateDependsHostArchMismatchExcludes(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VC.Tools.Core"},
HostArch: "arm", // no arm variant exists, only x86/arm64
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
if len(selected) != 0 {
t.Errorf("expected no packages selected for mismatched host arch, got %v", idsOf(selected))
}
}
// TestExpandSelectionDeterministic guards against collectDependencyClosure
// iterating a package's dependency map directly (Go map iteration order is
// randomized per range statement, so a regression here wouldn't necessarily
// show up on the first run - looping catches it reliably in practice).
func TestExpandSelectionDeterministic(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
IncludeOptional: true,
}
first, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
want := idsOf(first)
for i := 0; i < 50; i++ {
got, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
gotIDs := idsOf(got)
if len(gotIDs) != len(want) {
t.Fatalf("run %d: got %v, want %v", i, gotIDs, want)
}
for j := range want {
if gotIDs[j] != want[j] {
t.Fatalf("run %d: order changed: got %v, want %v", i, gotIDs, want)
}
}
}
}
func idsOf(pkgs []*Package) []string {
var ids []string
for _, p := range pkgs {
ids = append(ids, p.ID)
}
return ids
}
func mustContain(t *testing.T, ids []string, want string) {
t.Helper()
for _, id := range ids {
if id == want {
return
}
}
t.Errorf("expected %v to contain %q", ids, want)
}
func mustNotContain(t *testing.T, ids []string, unwanted string) {
t.Helper()
for _, id := range ids {
if id == unwanted {
t.Errorf("expected %v to NOT contain %q", ids, unwanted)
}
}
}