| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "os/exec" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func fakeLookPath(found map[string]string) func(string) (string, error) { |
| 14 | return func(name string) (string, error) { |
| 15 | if p, ok := found[name]; ok { |
| 16 | return p, nil |
| 17 | } |
| 18 | return "", exec.ErrNotFound |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // TestBashCandidatesFromGitBinary pins the git.exe → bash.exe derivation: the |
| 23 | // install root's bin\bash.exe and usr\bin\bash.exe, walking up from the binary |
| 24 | // directory so cmd\, mingw64\bin, and root-level layouts all resolve. |
| 25 | // filepath.Join uses the host separator, so compare with separators |
| 26 | // normalized — the derivation itself only runs on Windows in production. |
| 27 | func TestBashCandidatesFromGitBinary(t *testing.T) { |
| 28 | got := bashCandidatesFromGitBinary(`C:\Program Files\Git\cmd\git.exe`) |
| 29 | want := []string{ |
| 30 | `C:\Program Files\Git\cmd\bin\bash.exe`, |
| 31 | `C:\Program Files\Git\cmd\usr\bin\bash.exe`, |
| 32 | `C:\Program Files\Git\bin\bash.exe`, |
| 33 | `C:\Program Files\Git\usr\bin\bash.exe`, |
| 34 | `C:\Program Files\bin\bash.exe`, |
| 35 | `C:\Program Files\usr\bin\bash.exe`, |
| 36 | } |
| 37 | norm := func(p string) string { return strings.ReplaceAll(filepath.Clean(p), "/", `\`) } |
| 38 | if len(got) != len(want) { |
| 39 | t.Fatalf("derived %d candidates, want %d: %v", len(got), len(want), got) |
| 40 | } |
| 41 | for i := range want { |
| 42 | if norm(got[i]) != want[i] { |
| 43 | t.Errorf("candidate[%d] = %q, want %q", i, norm(got[i]), want[i]) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // TestWindowsBashCandidateOrder pins discovery priority: the configured path |
| 49 | // first (with git-bash.exe rewritten to bin\bash.exe), then candidates derived |
| 50 | // from a PATH-visible git.exe, deduplicated case-insensitively. PATH bash.exe |
| 51 | // itself is resolved by resolveShell ahead of every candidate, so it must not |
| 52 | // appear in the list. |
| 53 | func TestWindowsBashCandidateOrder(t *testing.T) { |
| 54 | lookPath := fakeLookPath(map[string]string{ |
| 55 | "bash": `C:\Windows\System32\bash.exe`, |
| 56 | "git.exe": `D:\Tools\Git\mingw64\bin\git.exe`, |
| 57 | }) |
| 58 | // The configured git-bash.exe rewrites to a sibling bin\bash.exe that |
| 59 | // "exists", so the config candidate survives sanitization. |
| 60 | exists := func(p string) bool { return strings.EqualFold(p, `E:\Portable\Git\bin\bash.exe`) } |
| 61 | |
| 62 | got, sources := windowsBashCandidateSources("auto", `E:\Portable\Git\git-bash.exe`, lookPath, exists) |
| 63 | if len(got) == 0 { |
| 64 | t.Fatal("expected candidates") |
| 65 | } |
| 66 | if got[0] != `E:\Portable\Git\bin\bash.exe` { |
| 67 | t.Errorf("first candidate = %q, want the rewritten config path", got[0]) |
| 68 | } |
| 69 | if sources[strings.ToLower(got[0])] != ShellSourceConfig { |
| 70 | t.Errorf("first candidate source = %q, want %q", sources[strings.ToLower(got[0])], ShellSourceConfig) |
| 71 | } |
| 72 | gitRoot := filepath.Clean(`D:\Tools\Git`) |
| 73 | wantGitDerived := filepath.Join(gitRoot, "bin", "bash.exe") |
| 74 | found := false |
| 75 | for _, p := range got { |
| 76 | if p == wantGitDerived && sources[strings.ToLower(p)] == ShellSourceGitDerived { |
| 77 | found = true |
| 78 | } |
| 79 | } |
| 80 | if !found { |
| 81 | t.Errorf("derived candidate %q with source %q missing from %v", wantGitDerived, ShellSourceGitDerived, got) |
| 82 | } |
| 83 | for _, p := range got { |
| 84 | if strings.EqualFold(p, `C:\Windows\System32\bash.exe`) { |
| 85 | t.Errorf("PATH bash must not be listed as a candidate: %v", got) |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestWindowsShellCapabilitiesReportGitBashAndPowerShellRuntimes(t *testing.T) { |
| 91 | const pwsh = `C:\Program Files\PowerShell\7\pwsh.exe` |
| 92 | snap := &shellSnapshot{ |
| 93 | lookPath: fakeLookPath(map[string]string{"pwsh": pwsh}), |
| 94 | exists: func(path string) bool { return path == pwsh }, |
| 95 | isWSL: func(string) bool { return false }, |
| 96 | psCands: []string{pwsh}, |
| 97 | sources: map[string]string{}, |
| 98 | probeFunc: func(string) bool { return true }, |
| 99 | probeCache: map[string]bool{}, |
| 100 | } |
| 101 | caps := windowsShellCapabilities(snap) |
| 102 | if len(caps) != 3 || caps[0].ID != ShellCapabilityGitBash || caps[1].ID != ShellCapabilityPwsh || caps[2].ID != ShellCapabilityPowerShell { |
| 103 | t.Fatalf("Windows shell capabilities = %+v, want Git Bash, pwsh and powershell", caps) |
| 104 | } |
| 105 | if caps[0].Available || caps[0].Reason != "not-installed" { |
| 106 | t.Fatalf("missing Git Bash must report not-installed: %+v", caps[0]) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestConfiguredWindowsBashPathMatchesPreference(t *testing.T) { |
| 111 | exists := func(path string) bool { |
| 112 | return strings.EqualFold(path, `E:\Portable\Git\bin\bash.exe`) |
| 113 | } |
| 114 | tests := []struct { |
| 115 | name string |
| 116 | prefer string |
| 117 | path string |
| 118 | want string |
| 119 | }{ |
| 120 | {"auto accepts bash", "auto", `E:\Portable\Git\bin\bash.exe`, `E:\Portable\Git\bin\bash.exe`}, |
| 121 | {"auto rewrites git bash", "auto", `E:\Portable\Git\git-bash.exe`, `E:\Portable\Git\bin\bash.exe`}, |
| 122 | {"auto rejects stale pwsh", "auto", `C:\Program Files\PowerShell\7\pwsh.exe`, ""}, |
| 123 | {"powershell rejects stale bash", "powershell", `E:\Portable\Git\bin\bash.exe`, ""}, |
| 124 | {"forced bash accepts custom wrapper", "bash", `E:\Custom\shell-wrapper.exe`, `E:\Custom\shell-wrapper.exe`}, |
| 125 | } |
| 126 | for _, test := range tests { |
| 127 | t.Run(test.name, func(t *testing.T) { |
| 128 | if got := configuredWindowsBashPath(test.prefer, test.path, exists); got != test.want { |
| 129 | t.Fatalf("configuredWindowsBashPath(%q, %q) = %q, want %q", test.prefer, test.path, got, test.want) |
| 130 | } |
| 131 | }) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestWindowsGitBashDetectionMatchesExplicitResolution(t *testing.T) { |
| 136 | const configured = `E:\Portable\Git\bin\bash.exe` |
| 137 | const onPath = `C:\Program Files\Git\bin\bash.exe` |
| 138 | const installed = `D:\Git\bin\bash.exe` |
| 139 | const wsl = `C:\Windows\System32\bash.exe` |
| 140 | for _, tc := range []struct { |
| 141 | name, config, path, failed, want, source string |
| 142 | }{ |
| 143 | {"configured before PATH", configured, onPath, "", configured, ShellSourceConfig}, |
| 144 | {"PATH before install locations", "", onPath, "", onPath, ShellSourcePath}, |
| 145 | {"broken configured runtime", configured, onPath, configured, onPath, ShellSourcePath}, |
| 146 | {"broken PATH runtime", "", onPath, onPath, installed, ShellSourceStandard}, |
| 147 | {"WSL launcher excluded", "", wsl, "", installed, ShellSourceStandard}, |
| 148 | } { |
| 149 | t.Run(tc.name, func(t *testing.T) { |
| 150 | snap := &shellSnapshot{ |
| 151 | goos: "windows", lookPath: fakeLookPath(map[string]string{"bash": tc.path}), |
| 152 | exists: func(string) bool { return true }, |
| 153 | isWSL: func(path string) bool { return path == wsl }, |
| 154 | bashCands: []string{installed}, sources: map[string]string{strings.ToLower(installed): ShellSourceStandard}, |
| 155 | probeFunc: func(path string) bool { return path != tc.failed }, probeCache: map[string]bool{}, |
| 156 | } |
| 157 | if tc.config != "" { |
| 158 | snap.bashCands = append([]string{tc.config}, snap.bashCands...) |
| 159 | snap.sources[strings.ToLower(tc.config)] = ShellSourceConfig |
| 160 | } |
| 161 | cap := windowsShellCapabilities(snap)[0] |
| 162 | sh := resolveShell("bash", tc.config, nil, snap.goos, snap.lookPath, snap.exists, snap.bashCands, nil, snap.probe, snap.isWSL) |
| 163 | if !cap.Available || cap.Path != tc.want || cap.Source != tc.source || sh.Kind != ShellBash || sh.Path != cap.Path { |
| 164 | t.Fatalf("capability=%+v resolved=%+v, want %s (%s)", cap, sh, tc.want, tc.source) |
| 165 | } |
| 166 | }) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestMissingWindowsGitBashFallsBackToPowerShellWithWarning(t *testing.T) { |
| 171 | var warn strings.Builder |
| 172 | got := resolveShell("bash", "", &warn, "windows", fakeLookPath(map[string]string{"pwsh": "pwsh.exe"}), |
| 173 | func(string) bool { return false }, nil, nil, func(string) bool { return false }, func(string) bool { return false }) |
| 174 | if got.Kind != ShellPowerShell || got.Path != "pwsh.exe" || !strings.Contains(warn.String(), "not found") { |
| 175 | t.Fatalf("missing Bash resolution=%+v warning=%q", got, warn.String()) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // TestWindowsBashCandidatesDedupeAndWSLExclusion exercises dedupe (the same |
| 180 | // path reachable through two buckets) and the %SystemRoot% WSL launcher |
| 181 | // exclusion, both of which need a Windows host to observe. |
| 182 | func TestWindowsBashCandidatesDedupeAndWSLExclusion(t *testing.T) { |
| 183 | if runtime.GOOS != "windows" { |
| 184 | t.Skip("WSL launcher detection reads %SystemRoot% and only fires on Windows") |
| 185 | } |
| 186 | t.Setenv("SystemRoot", `C:\Windows`) |
| 187 | lookPath := fakeLookPath(map[string]string{ |
| 188 | "git.exe": `C:\Program Files\Git\cmd\git.exe`, |
| 189 | }) |
| 190 | got, _ := windowsBashCandidateSources("auto", "", lookPath, fileExists) |
| 191 | seen := map[string]bool{} |
| 192 | for _, p := range got { |
| 193 | key := strings.ToLower(p) |
| 194 | if seen[key] { |
| 195 | t.Errorf("duplicate candidate %q", p) |
| 196 | } |
| 197 | seen[key] = true |
| 198 | if isWindowsWSLBash(p) { |
| 199 | t.Errorf("WSL launcher %q must be excluded", p) |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // TestShellInventoryCache covers the singleflight cache contract: concurrent |
| 205 | // lookups share one build, the entry survives within the TTL, a different |
| 206 | // config path rebuilds, and InvalidateShellInventory forces a rebuild while a |
| 207 | // stale in-flight build cannot republish itself. |
| 208 | func TestShellInventoryCache(t *testing.T) { |
| 209 | inv := newShellInventory() |
| 210 | var builds sync.Mutex |
| 211 | buildCount := 0 |
| 212 | release := make(chan struct{}) |
| 213 | inv.build = func(goos, prefer, configPath string) *shellSnapshot { |
| 214 | builds.Lock() |
| 215 | buildCount++ |
| 216 | builds.Unlock() |
| 217 | if configPath == "slow" { |
| 218 | <-release |
| 219 | } |
| 220 | return &shellSnapshot{key: shellInventoryKey(goos, prefer, configPath), builtAt: time.Now(), probeCache: map[string]bool{}} |
| 221 | } |
| 222 | |
| 223 | // Singleflight: concurrent snapshot() calls for one key build once. |
| 224 | var wg sync.WaitGroup |
| 225 | for range 8 { |
| 226 | wg.Go(func() { |
| 227 | _ = inv.snapshot("windows", "auto", "") |
| 228 | }) |
| 229 | } |
| 230 | // The first build ("") does not block, so wait for the burst to finish. |
| 231 | wg.Wait() |
| 232 | builds.Lock() |
| 233 | if buildCount != 1 { |
| 234 | builds.Unlock() |
| 235 | t.Fatalf("concurrent lookups built %d times, want 1", buildCount) |
| 236 | } |
| 237 | builds.Unlock() |
| 238 | |
| 239 | // Within the TTL the cached entry is served without a rebuild. |
| 240 | _ = inv.snapshot("windows", "auto", "") |
| 241 | builds.Lock() |
| 242 | count := buildCount |
| 243 | builds.Unlock() |
| 244 | if count != 1 { |
| 245 | t.Fatalf("TTL hit rebuilt: %d", count) |
| 246 | } |
| 247 | |
| 248 | // A different preference is a different cache key even when the configured |
| 249 | // path stays the same, because only Bash preferences may consume that path. |
| 250 | _ = inv.snapshot("windows", "bash", "") |
| 251 | builds.Lock() |
| 252 | count = buildCount |
| 253 | builds.Unlock() |
| 254 | if count != 2 { |
| 255 | t.Fatalf("different preference should rebuild: %d", count) |
| 256 | } |
| 257 | |
| 258 | // A different config path is also a different cache key. |
| 259 | _ = inv.snapshot("windows", "auto", "other") |
| 260 | builds.Lock() |
| 261 | count = buildCount |
| 262 | builds.Unlock() |
| 263 | if count != 3 { |
| 264 | t.Fatalf("different path should rebuild: %d", count) |
| 265 | } |
| 266 | |
| 267 | // Invalidation forces a rebuild and drops a stale in-flight result. |
| 268 | var inflight sync.WaitGroup |
| 269 | inflight.Go(func() { |
| 270 | _ = inv.snapshot("windows", "auto", "slow") |
| 271 | }) |
| 272 | // Wait until the slow build has started, then invalidate under it. |
| 273 | deadline := time.Now().Add(2 * time.Second) |
| 274 | for time.Now().Before(deadline) { |
| 275 | inv.mu.Lock() |
| 276 | refreshing := inv.refreshing != nil |
| 277 | inv.mu.Unlock() |
| 278 | if refreshing { |
| 279 | break |
| 280 | } |
| 281 | time.Sleep(time.Millisecond) |
| 282 | } |
| 283 | inv.invalidate() |
| 284 | close(release) |
| 285 | inflight.Wait() |
| 286 | |
| 287 | inv.mu.Lock() |
| 288 | current := inv.current |
| 289 | inv.mu.Unlock() |
| 290 | if current != nil { |
| 291 | t.Fatalf("stale in-flight build was cached under key %q after invalidation", current.key) |
| 292 | } |
| 293 | |
| 294 | // And the next lookup builds fresh. |
| 295 | _ = inv.snapshot("windows", "auto", "") |
| 296 | builds.Lock() |
| 297 | count = buildCount |
| 298 | builds.Unlock() |
| 299 | if count != 5 { |
| 300 | t.Fatalf("post-invalidation lookup should rebuild: %d builds", count) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // TestSnapshotProbeCachesResults proves repeated resolutions inside one |
| 305 | // snapshot do not re-probe the same path. |
| 306 | func TestSnapshotProbeCachesResults(t *testing.T) { |
| 307 | snap := &shellSnapshot{probeFunc: func(string) bool { return true }, probeCache: map[string]bool{}} |
| 308 | if !snap.probe(`C:\Git\bin\bash.exe`) { |
| 309 | t.Fatal("probe on a non-Windows host always succeeds") |
| 310 | } |
| 311 | snap.probeMu.Lock() |
| 312 | cached, ok := snap.probeCache[`C:\Git\bin\bash.exe`] |
| 313 | snap.probeMu.Unlock() |
| 314 | if !ok || !cached { |
| 315 | t.Fatalf("probe result not cached: %v", snap.probeCache) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestUnixShellCapabilitiesReportsBashZshAndSh(t *testing.T) { |
| 320 | snap := &shellSnapshot{ |
| 321 | lookPath: fakeLookPath(map[string]string{"zsh": "/opt/homebrew/bin/zsh"}), |
| 322 | exists: func(path string) bool { |
| 323 | return path == "/bin/sh" |
| 324 | }, |
| 325 | } |
| 326 | caps := unixShellCapabilities(snap) |
| 327 | if len(caps) != 3 { |
| 328 | t.Fatalf("capabilities = %+v, want bash, zsh, and sh", caps) |
| 329 | } |
| 330 | byID := map[string]ShellCapability{} |
| 331 | for _, capability := range caps { |
| 332 | byID[capability.ID] = capability |
| 333 | } |
| 334 | if bash := byID[ShellCapabilityBash]; bash.Available || bash.Reason != "not-found" { |
| 335 | t.Fatalf("bash = %+v, want unavailable with a reason", bash) |
| 336 | } |
| 337 | if zsh := byID[ShellCapabilityZsh]; !zsh.Available || zsh.Path != "/opt/homebrew/bin/zsh" || zsh.Source != ShellSourcePath { |
| 338 | t.Fatalf("zsh = %+v, want PATH capability", zsh) |
| 339 | } |
| 340 | if sh := byID[ShellCapabilitySh]; !sh.Available || sh.Path != "/bin/sh" || sh.Source != ShellSourceStandard { |
| 341 | t.Fatalf("sh = %+v, want standard-path capability", sh) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestDiscoverGitCapabilityIsIndependentFromShells(t *testing.T) { |
| 346 | snap := &shellSnapshot{ |
| 347 | goos: "darwin", |
| 348 | lookPath: fakeLookPath(map[string]string{}), |
| 349 | exists: func(path string) bool { |
| 350 | return path == "/opt/homebrew/bin/git" |
| 351 | }, |
| 352 | } |
| 353 | got := discoverGitCapability(snap) |
| 354 | if !got.Available || got.ID != HostCapabilityGit || got.Path != "/opt/homebrew/bin/git" || got.Source != ShellSourceStandard { |
| 355 | t.Fatalf("Git capability = %+v, want independent Homebrew standard path", got) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | func TestDiscoverGitCapabilityRejectsUnusableShim(t *testing.T) { |
| 360 | snap := &shellSnapshot{ |
| 361 | goos: "darwin", |
| 362 | lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}), |
| 363 | exists: func(path string) bool { return path == "/usr/bin/git" }, |
| 364 | gitProbe: func(string) bool { return false }, |
| 365 | } |
| 366 | got := discoverGitCapability(snap) |
| 367 | if got.Available || got.Reason != "not-usable" { |
| 368 | t.Fatalf("Git capability = %+v, want unusable shim rejected", got) |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | func TestDiscoverGitCapabilityPreflightRejectsAppleShimWithoutRunningIt(t *testing.T) { |
| 373 | preflightCalls := 0 |
| 374 | probeCalls := 0 |
| 375 | snap := &shellSnapshot{ |
| 376 | goos: "darwin", |
| 377 | lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}), |
| 378 | exists: func(path string) bool { return path == "/usr/bin/git" }, |
| 379 | gitPreflight: func(path string) bool { |
| 380 | preflightCalls++ |
| 381 | return false |
| 382 | }, |
| 383 | gitProbe: func(string) bool { |
| 384 | probeCalls++ |
| 385 | return true |
| 386 | }, |
| 387 | } |
| 388 | got := discoverGitCapability(snap) |
| 389 | if got.Available || got.Reason != "not-usable" { |
| 390 | t.Fatalf("Git capability = %+v, want inactive Apple shim rejected", got) |
| 391 | } |
| 392 | if preflightCalls != 1 { |
| 393 | t.Fatalf("Apple shim preflight calls = %d, want one cached decision", preflightCalls) |
| 394 | } |
| 395 | if probeCalls != 0 { |
| 396 | t.Fatalf("git --version probe ran %d times after preflight rejection", probeCalls) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | func TestGitCandidatesFromWindowsBashFindInstallRoot(t *testing.T) { |
| 401 | got := gitCandidatesFromWindowsBash(`C:\Program Files\Git\bin\bash.exe`) |
| 402 | norm := func(path string) string { |
| 403 | return strings.ToLower(strings.ReplaceAll(filepath.Clean(path), `\`, "/")) |
| 404 | } |
| 405 | want := norm(`C:\Program Files\Git\cmd\git.exe`) |
| 406 | found := false |
| 407 | for _, candidate := range got { |
| 408 | if norm(candidate) == want { |
| 409 | found = true |
| 410 | break |
| 411 | } |
| 412 | } |
| 413 | if !found { |
| 414 | t.Fatalf("candidates = %v, missing %q", got, want) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // TestShellCapabilitiesShape ensures the exported capability report matches |
| 419 | // the platform: Git Bash and both native PowerShells on Windows, and bash/zsh/sh on |
| 420 | // Unix — with unavailable entries carrying a reason, never an error. |
| 421 | func TestShellCapabilitiesShape(t *testing.T) { |
| 422 | caps := ShellCapabilities() |
| 423 | if len(caps) == 0 { |
| 424 | t.Fatal("ShellCapabilities returned no entries") |
| 425 | } |
| 426 | ids := map[string]bool{} |
| 427 | for _, cap := range caps { |
| 428 | ids[cap.ID] = true |
| 429 | if cap.Available && cap.Path == "" { |
| 430 | t.Errorf("capability %q is available without a path", cap.ID) |
| 431 | } |
| 432 | if !cap.Available && cap.Reason == "" { |
| 433 | t.Errorf("unavailable capability %q must carry a reason", cap.ID) |
| 434 | } |
| 435 | } |
| 436 | if runtime.GOOS == "windows" { |
| 437 | for _, id := range []string{ShellCapabilityGitBash, ShellCapabilityPowerShell, ShellCapabilityPwsh} { |
| 438 | if !ids[id] { |
| 439 | t.Errorf("Windows report missing %q: %v", id, caps) |
| 440 | } |
| 441 | } |
| 442 | for _, id := range []string{ShellCapabilityBash} { |
| 443 | if ids[id] { |
| 444 | t.Errorf("Windows report must not advertise %q: %v", id, caps) |
| 445 | } |
| 446 | } |
| 447 | for _, id := range []string{ShellCapabilityZsh, ShellCapabilitySh} { |
| 448 | if ids[id] { |
| 449 | t.Errorf("Windows report must not advertise %q: %v", id, caps) |
| 450 | } |
| 451 | } |
| 452 | } else { |
| 453 | for _, id := range []string{ShellCapabilityBash, ShellCapabilityZsh, ShellCapabilitySh} { |
| 454 | if !ids[id] { |
| 455 | t.Errorf("non-Windows report missing %q: %v", id, caps) |
| 456 | } |
| 457 | } |
| 458 | for _, id := range []string{ShellCapabilityGitBash, ShellCapabilityPowerShell, ShellCapabilityPwsh} { |
| 459 | if ids[id] { |
| 460 | t.Errorf("non-Windows report must not advertise %q: %v", id, caps) |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | } |
| 465 |