| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package project_test |
| |
| import ( |
| "os" |
| "path/filepath" |
| "strings" |
| "syscall" |
| "testing" |
| "time" |
| |
| "go.fuchsia.dev/jiri" |
| "go.fuchsia.dev/jiri/jiritest" |
| "go.fuchsia.dev/jiri/osutil" |
| "go.fuchsia.dev/jiri/project" |
| ) |
| |
| const ( |
| gnPkgName = "gn/gn/${platform}" |
| gnPkgPath = "prebuilt/third_party/gn" |
| gnV1 = "git_revision:f9cea148d88f2a3570fc2f81a5931f96d20194f1" |
| gnV2 = "git_revision:925dd782d25df9a60d6402a98a1c22a5c54149a2" |
| gnV3 = "git_revision:1c73f62240db1517dfd60ff193f53f2e4a52f3a5" |
| ) |
| |
| // ----------------------------------------------------------------------------- |
| // Section 1: End-to-End Transitions & Worktrees |
| // ----------------------------------------------------------------------------- |
| |
| // TestPackageCache_Transition tests toggling package-cache: |
| // false -> true (opt-in) -> false (opt-out). |
| func TestPackageCache_Transition(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| |
| pkg := project.Package{ |
| Name: gnPkgName, |
| Path: gnPkgPath, |
| Version: gnV1, |
| } |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 1. Start with cache disabled |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| |
| // Verify it is a directory, not a symlink |
| fi, err := os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 { |
| t.Fatalf("Expected directory, got symlink at %s", destDir) |
| } |
| |
| // Create a marker file to verify it is preserved |
| markerFile := filepath.Join(destDir, "test_marker") |
| markerContent := "preserved_content" |
| if err := os.WriteFile(markerFile, []byte(markerContent), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 2. Opt-in: Enable cache |
| fake.X.PackageCacheEnabled = true |
| configPath := filepath.Join(fake.X.Root, jiri.RootMetaDir, jiri.ConfigFile) |
| configContent := `<config><package_cache><enabled>true</enabled></package_cache></config>` |
| if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Verify destDir is now a directory (hard links) |
| fi, err = os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { |
| t.Fatalf("Expected directory, got symlink or non-dir at %s", destDir) |
| } |
| |
| // Verify .jiri_cache_hash file exists |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Errorf("Expected .jiri_cache_hash to exist, got error: %v", err) |
| } |
| |
| // Verify marker file is still there and has correct content |
| markerInDest := filepath.Join(destDir, "test_marker") |
| gotContent, err := os.ReadFile(markerInDest) |
| if err != nil { |
| t.Fatalf("Marker file lost during opt-in: %v", err) |
| } |
| if string(gotContent) != markerContent { |
| t.Errorf("Marker content mismatch: got %q, want %q", string(gotContent), markerContent) |
| } |
| |
| // 3. Opt-out: Disable cache |
| fake.X.PackageCacheEnabled = false |
| configContent = `<config><package_cache><enabled>false</enabled></package_cache></config>` |
| if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Verify destDir is a directory again |
| fi, err = os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 { |
| t.Fatalf("Expected directory, got symlink at %s after opt-out", destDir) |
| } |
| |
| // Verify marker file is still there |
| gotContent, err = os.ReadFile(markerFile) |
| if err != nil { |
| t.Fatalf("Marker file lost during opt-out: %v", err) |
| } |
| if string(gotContent) != markerContent { |
| t.Errorf("Marker content mismatch after opt-out: got %q, want %q", string(gotContent), markerContent) |
| } |
| } |
| |
| // TestPackageCache_TransitionInWorktree tests package cache behavior in a worktree. |
| func TestPackageCache_TransitionInWorktree(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| |
| parentRoot := t.TempDir() |
| fake.X.WorktreeParentRoot = parentRoot |
| |
| pkg := project.Package{ |
| Name: gnPkgName, |
| Path: gnPkgPath, |
| Version: gnV1, |
| } |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| |
| // 1. Start with cache disabled (worktree forces cache enabled) |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Verify destDir is a directory and has hash file |
| fi, err := os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { |
| t.Fatalf("Expected directory, got %s at %s", fi.Mode(), destDir) |
| } |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Fatalf("Expected .jiri_cache_hash file to exist: %v", err) |
| } |
| |
| // 2. Enable cache explicitly |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| fi, err = os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { |
| t.Fatalf("Expected directory, got %s at %s", fi.Mode(), destDir) |
| } |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Fatalf("Expected .jiri_cache_hash file to exist: %v", err) |
| } |
| |
| // 3. Disable cache again (still in worktree, remains cached) |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| fi, err = os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { |
| t.Fatalf("Expected directory, got %s at %s after disabling cache in worktree", fi.Mode(), destDir) |
| } |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Fatalf("Expected .jiri_cache_hash file to exist: %v", err) |
| } |
| } |
| |
| // TestPackageCache_MigrationFromSymlinkToHardLink tests migrating a legacy symlink |
| // destination to a modern hard-linked directory. |
| func TestPackageCache_MigrationFromSymlinkToHardLink(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| |
| pkg := project.Package{ |
| Name: gnPkgName, |
| Path: gnPkgPath, |
| Version: gnV1, |
| } |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 1. Enable cache and sync to create hard links |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| hash := strings.TrimSpace(readFile(t, hashFile)) |
| |
| // 2. Simulate old style worktree: replace destDir with a symlink to cache |
| if err := os.RemoveAll(destDir); err != nil { |
| t.Fatal(err) |
| } |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), hash) |
| if err := os.Symlink(cacheDir, destDir); err != nil { |
| t.Fatal(err) |
| } |
| |
| fi, err := os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink == 0 { |
| t.Fatalf("Expected symlink, got %s at %s", fi.Mode(), destDir) |
| } |
| |
| // 3. Sync again: should migrate symlink to hard links |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| fi, err = os.Lstat(destDir) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { |
| t.Fatalf("Expected directory after migration, got %s at %s", fi.Mode(), destDir) |
| } |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Fatalf("Expected .jiri_cache_hash file to exist after migration: %v", err) |
| } |
| } |
| |
| // ----------------------------------------------------------------------------- |
| // Section 2: Systematic State Machine Tests |
| // Group A: Package Cache Enabled (package-cache=true) |
| // ----------------------------------------------------------------------------- |
| |
| // Case S1: Cache Missing, Dest Missing |
| // Fresh fetch with package-cache enabled. |
| // Expected: Package is downloaded from CIPD into cache, hardlinked to destDir, and .jiri_cache_hash is written. |
| func TestPackageCacheState_CacheEnabled_FreshDownload(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkg := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Errorf("Expected .jiri_cache_hash to exist: %v", err) |
| } |
| |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), strings.TrimSpace(readFile(t, hashFile))) |
| if getInode(t, filepath.Join(destDir, "gn")) != getInode(t, filepath.Join(cacheDir, "gn")) { |
| t.Errorf("Expected destDir/gn to be hardlinked to cacheDir/gn") |
| } |
| } |
| |
| // Case S2: Cache Valid, Dest Valid Hardlink (matching hash) |
| // Steady-state no-op update when everything is already up to date. |
| // Expected: Fast path succeeds without modifications. |
| func TestPackageCacheState_CacheEnabled_NoOpSteadyState(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkg := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Run update again |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| if _, err := os.Stat(filepath.Join(destDir, ".jiri_cache_hash")); err != nil { |
| t.Errorf("Expected .jiri_cache_hash to exist: %v", err) |
| } |
| } |
| |
| // Case S3: Cache Valid, Dest Missing |
| // Worktree creation or deleted destDir when package is already in cache. |
| // Expected: Instant hardlink from cache without network download. |
| func TestPackageCacheState_CacheEnabled_WorktreeCacheHit(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkg := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| savedHash := readFile(t, hashFile) |
| |
| // Delete destDir to simulate a missing destination |
| if err := os.RemoveAll(destDir); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Re-run update |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Errorf("Expected .jiri_cache_hash to be recreated: %v", err) |
| } |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), strings.TrimSpace(savedHash)) |
| if getInode(t, filepath.Join(destDir, "gn")) != getInode(t, filepath.Join(cacheDir, "gn")) { |
| t.Errorf("Expected destDir/gn to be hardlinked to cacheDir/gn") |
| } |
| } |
| |
| // Case S4: Cache Valid, Dest Stale Hardlink (pointing to older version) |
| // Version roll where the target version was already cached (e.g. by another worktree). |
| // Expected: destDir is replaced with hardlinks to the target version's cache. |
| func TestPackageCacheState_CacheEnabled_RollWithPrecachedTarget(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| fake.X.PackageCacheEnabled = true |
| |
| // Step 1: Fetch v2 first into cache |
| pkgV2 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV2} |
| if err := fake.AddPackage(pkgV2); err != nil { |
| t.Fatal(err) |
| } |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| v2Hash := strings.TrimSpace(readFile(t, filepath.Join(destDir, ".jiri_cache_hash"))) |
| |
| // Step 2: Switch to v1 so destDir is on v1 hardlinks |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV1) |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| v1Hash := strings.TrimSpace(readFile(t, filepath.Join(destDir, ".jiri_cache_hash"))) |
| if v1Hash == v2Hash { |
| t.Fatalf("Expected different hashes for v1 and v2") |
| } |
| |
| // Step 3: Roll back to v2 (which is already valid in cache) |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV2) |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| currentHash := strings.TrimSpace(readFile(t, filepath.Join(destDir, ".jiri_cache_hash"))) |
| if currentHash != v2Hash { |
| t.Errorf("Expected destDir to be updated to v2 hash %s, got %s", v2Hash, currentHash) |
| } |
| } |
| |
| // Case S6: Cache Missing, Dest Stale Hardlink (contains .jiri_cache_hash for v1) |
| // Version roll when package-cache is enabled. |
| // Expected: Do NOT migrate stale destDir to v2 cache; download v2 from CIPD into cache and link to dest. |
| func TestPackageCacheState_CacheEnabled_RollWithCacheEnabled(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| markerFile := filepath.Join(destDir, "v1_marker") |
| if err := os.WriteFile(markerFile, []byte("v1"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| v1Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| |
| // Roll to v2 |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV2) |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Failure condition: if stale destDir was migrated to v2 cache, v1_marker still exists |
| if _, err := os.Stat(markerFile); err == nil { |
| t.Errorf("Stale destDir was migrated to new version cache! Marker from v1 still exists in v2 destDir.") |
| } |
| |
| v2Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| if v1Version == v2Version { |
| t.Errorf("Package version was not updated! Still on v1: %s", v1Version) |
| } |
| } |
| |
| // Case S7: Cache Missing, Dest Valid Standalone (no .jiri_cache_hash) |
| // User transitions from package-cache=false to package-cache=true when destDir is already valid. |
| // Expected: destDir is migrated to cacheDir without network re-download. |
| // Inodes should be preserved in cache, hardlinks established, and .jiri_cache_hash written. |
| func TestPackageCacheState_CacheEnabled_OptInValidStandalone(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 1. Initial fetch without package cache |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| originalInode := getInode(t, filepath.Join(destDir, "gn")) |
| |
| // 2. Opt-in to package cache on the SAME manifest version |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| if _, err := os.Stat(hashFile); err != nil { |
| t.Fatalf("Expected .jiri_cache_hash to exist in destDir: %v", err) |
| } |
| |
| hash := strings.TrimSpace(readFile(t, hashFile)) |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), hash) |
| if _, err := os.Stat(cacheDir); err != nil { |
| t.Fatalf("Expected cacheDir %s to exist: %v", cacheDir, err) |
| } |
| |
| // Verify hardlink between cache and dest |
| cacheInode := getInode(t, filepath.Join(cacheDir, "gn")) |
| destInode := getInode(t, filepath.Join(destDir, "gn")) |
| if cacheInode != destInode { |
| t.Errorf("Expected destDir/gn and cacheDir/gn to be hardlinked (got dest=%d, cache=%d)", destInode, cacheInode) |
| } |
| |
| // Verify that the original files were migrated rather than re-downloaded |
| if cacheInode != originalInode { |
| t.Errorf("Expected original files to be migrated into cache without re-downloading (got cache inode %d, original inode %d)", cacheInode, originalInode) |
| } |
| } |
| |
| // Case S8: Cache Missing, Dest Stale Standalone (no .jiri_cache_hash) |
| // Transition from package-cache=false to package-cache=true during or after a roll. |
| // Expected: Do NOT migrate stale destDir to v2 cache; download v2 from CIPD into cache and link to dest. |
| func TestPackageCacheState_CacheEnabled_RollWhileOptingIn(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| // Ensure CIPD has an instance cache configured with v2 pre-cached. |
| // This simulates a real development workstation or infra environment where |
| // package instances are already downloaded in the instance cache, verifying |
| // that Jiri's standalone package checks are strictly non-mutative. |
| cipdCacheDir := t.TempDir() |
| t.Setenv("CIPD_CACHE_DIR", cipdCacheDir) |
| |
| scratchFake := jiritest.NewFakeJiriRoot(t) |
| if err := scratchFake.AddPackage(project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV2}); err != nil { |
| t.Fatal(err) |
| } |
| if err := scratchFake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Fetch v1 without cache |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| markerFile := filepath.Join(destDir, "v1_marker") |
| if err := os.WriteFile(markerFile, []byte("v1"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| v1Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| |
| // Roll to v2 and opt into cache |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV2) |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Failure condition: stale destDir migrated to v2 cache |
| if _, err := os.Stat(markerFile); err == nil { |
| t.Errorf("Stale standalone destDir was migrated to new version cache! Marker from v1 still exists in v2 destDir.") |
| } |
| |
| v2Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| if v1Version == v2Version { |
| t.Errorf("Package version was not updated! Still on v1: %s", v1Version) |
| } |
| } |
| |
| // Case S9: Cache Missing, Dest Dirty Standalone (untracked / build artifact files) |
| // Pre-existing dirty directory without .jiri_cache_hash when package-cache is enabled. |
| // Expected: Do NOT migrate dirty files to cache; clean CIPD package downloaded. |
| func TestPackageCacheState_CacheEnabled_PreexistingDirtyDestDir(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| |
| // Simulate dirty pre-existing destDir |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| if err := os.MkdirAll(destDir, 0755); err != nil { |
| t.Fatal(err) |
| } |
| dirtyFile := filepath.Join(destDir, "dirty_build_artifact") |
| if err := os.WriteFile(dirtyFile, []byte("dirty"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Failure condition: dirty file was moved into cache |
| if _, err := os.Stat(dirtyFile); err == nil { |
| t.Errorf("Dirty file was migrated into package cache instead of fetching clean package.") |
| } |
| if _, err := os.Stat(filepath.Join(destDir, ".versions", "gn.cipd_version")); err != nil { |
| t.Errorf("Clean CIPD package was not installed: %v", err) |
| } |
| } |
| |
| // Case S10: Cache Invalid (corrupted as regular file), Dest Missing |
| // Cache directory is corrupted into a non-directory. |
| // Expected: Detects invalid cache, removes it, downloads clean package into cache. |
| func TestPackageCacheState_CacheEnabled_CorruptCacheEntry(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| |
| // Run update once to create normal setup, then corrupt cache |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| hash := strings.TrimSpace(readFile(t, filepath.Join(destDir, ".jiri_cache_hash"))) |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), hash) |
| if err := os.RemoveAll(cacheDir); err != nil { |
| t.Fatal(err) |
| } |
| if err := os.RemoveAll(destDir); err != nil { |
| t.Fatal(err) |
| } |
| // Write a file instead of directory |
| if err := os.WriteFile(cacheDir, []byte("corrupted_cache_not_a_dir"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Re-run update: should remove corrupt file and recreate valid cache directory |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| fi, err := os.Lstat(cacheDir) |
| if err != nil || !fi.IsDir() { |
| t.Errorf("Expected cacheDir to be restored as a valid directory, got err=%v", err) |
| } |
| } |
| |
| // ----------------------------------------------------------------------------- |
| // Section 2: Systematic State Machine Tests |
| // Group B: Package Cache Disabled (package-cache=false) |
| // ----------------------------------------------------------------------------- |
| |
| // Case S11: Fresh download with package cache disabled. |
| // Expected: CIPD downloads standalone package to destDir, no .jiri_cache_hash. |
| func TestPackageCacheState_CacheDisabled_FreshDownload(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| if _, err := os.Stat(filepath.Join(destDir, ".jiri_cache_hash")); err == nil { |
| t.Errorf("Expected NO .jiri_cache_hash in package-cache=false mode") |
| } |
| if _, err := os.Stat(filepath.Join(destDir, "gn")); err != nil { |
| t.Errorf("Expected binary gn to exist in destDir: %v", err) |
| } |
| } |
| |
| // Case S13: Roll across versions with package cache disabled. |
| // Expected: CIPD updates destDir to new version, no .jiri_cache_hash. |
| func TestPackageCacheState_CacheDisabled_RollAcrossVersions(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| v1Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV2) |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| v2Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| if v1Version == v2Version { |
| t.Errorf("Expected version to change from v1 to v2") |
| } |
| if _, err := os.Stat(filepath.Join(destDir, ".jiri_cache_hash")); err == nil { |
| t.Errorf("Expected NO .jiri_cache_hash in package-cache=false mode") |
| } |
| } |
| |
| // Case S14: Opt-out of cache (package-cache=true -> false) on the SAME version. |
| // Expected: destDir hardlinks are preserved without copying, .jiri_cache_hash is removed, |
| // and no network download occurs. |
| func TestPackageCacheState_CacheDisabled_OptOutPreservesHardLinks(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 1. Fetch with cache enabled |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| hash := strings.TrimSpace(readFile(t, hashFile)) |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), hash) |
| |
| // Verify they are initially hardlinked |
| if getInode(t, filepath.Join(destDir, "gn")) != getInode(t, filepath.Join(cacheDir, "gn")) { |
| t.Fatalf("Precondition failed: destDir should be hardlinked to cacheDir") |
| } |
| |
| // Add a canary file to cacheDir so we can verify destDir was preserved in-place without re-linking/copying |
| canaryFile := filepath.Join(cacheDir, "cache_canary") |
| if err := os.WriteFile(canaryFile, []byte("canary"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 2. Opt-out: package-cache=false |
| // Disable CIPD execution to strictly guarantee no remote download occurs |
| cipdBackup := fake.X.CIPDPath() + ".bak" |
| if err := os.Rename(fake.X.CIPDPath(), cipdBackup); err != nil { |
| t.Fatal(err) |
| } |
| defer func() { |
| if _, err := os.Stat(cipdBackup); err == nil { |
| os.Rename(cipdBackup, fake.X.CIPDPath()) |
| } |
| }() |
| |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatalf("UpdateUniverse failed when CIPD is unavailable; should have restored from cache without network: %v", err) |
| } |
| |
| // Restore CIPD binary |
| if err := os.Rename(cipdBackup, fake.X.CIPDPath()); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Failure condition 1: .jiri_cache_hash still exists |
| if _, err := os.Stat(hashFile); err == nil { |
| t.Errorf(".jiri_cache_hash still exists after opting out of package cache") |
| } |
| |
| // Failure condition 2: inodes should remain hard-linked to cacheDir (fast-path opt-out without copy) |
| if getInode(t, filepath.Join(destDir, "gn")) != getInode(t, filepath.Join(cacheDir, "gn")) { |
| t.Errorf("destDir files should remain hard-linked to cacheDir without copying") |
| } |
| |
| // Failure condition 3: destDir was unnecessarily re-created/copied (canary found in destDir) |
| if _, err := os.Stat(filepath.Join(destDir, "cache_canary")); err == nil { |
| t.Errorf("destDir was unnecessarily recreated or copied; canary file from cacheDir should not be present in destDir") |
| } |
| } |
| |
| // Case S16: Opt-out of cache across a version roll. |
| // User had hardlinks on v1, disables cache and updates to v2. |
| // Expected: destDir is cleanly updated to standalone v2, no .jiri_cache_hash. |
| func TestPackageCacheState_CacheDisabled_OptOutAcrossRoll(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Fetch v1 with cache |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| v1Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| |
| // Opt out and roll to v2 |
| fake.X.PackageCacheEnabled = false |
| updateRemoteManifestPackageVersion(t, fake, gnPkgName, gnV2) |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| v2Version := readFile(t, filepath.Join(destDir, ".versions", "gn.cipd_version")) |
| if v1Version == v2Version { |
| t.Errorf("Expected version to update to v2") |
| } |
| if _, err := os.Stat(filepath.Join(destDir, ".jiri_cache_hash")); err == nil { |
| t.Errorf("Expected NO .jiri_cache_hash in package-cache=false mode") |
| } |
| } |
| |
| // Case S15: Cache Valid, Dest Missing, Package Cache Disabled |
| // The package cache already contains the requested package (e.g. from another worktree |
| // or previous run), but package-cache=false is set and destDir does not exist. |
| // Expected: Jiri should restore the package from cacheDir into destDir via hard links |
| // to avoid downloading it from CIPD over the network, without writing .jiri_cache_hash. |
| func TestPackageCacheState_CacheDisabled_RestoreFromCacheWhenDestMissing(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| pkgV1 := project.Package{Name: gnPkgName, Path: gnPkgPath, Version: gnV1} |
| if err := fake.AddPackage(pkgV1); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 1. Populate cache with v1 using package-cache=true |
| fake.X.PackageCacheEnabled = true |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatal(err) |
| } |
| |
| destDir := filepath.Join(fake.X.Root, gnPkgPath) |
| hashFile := filepath.Join(destDir, ".jiri_cache_hash") |
| hash := strings.TrimSpace(readFile(t, hashFile)) |
| cacheDir := filepath.Join(fake.X.PackageCacheDir(), hash) |
| |
| // Add a canary file to cacheDir so we can detect whether destDir was restored from cache |
| canaryFile := filepath.Join(cacheDir, "cache_canary") |
| if err := os.WriteFile(canaryFile, []byte("canary"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 2. Remove destDir to simulate a fresh/missing destination in a cache-disabled repo |
| if err := os.RemoveAll(destDir); err != nil { |
| t.Fatal(err) |
| } |
| |
| // 3. Update with package-cache=false |
| // Disable CIPD execution to strictly guarantee no remote download occurs |
| cipdBackup := fake.X.CIPDPath() + ".bak" |
| if err := os.Rename(fake.X.CIPDPath(), cipdBackup); err != nil { |
| t.Fatal(err) |
| } |
| defer func() { |
| if _, err := os.Stat(cipdBackup); err == nil { |
| os.Rename(cipdBackup, fake.X.CIPDPath()) |
| } |
| }() |
| |
| fake.X.PackageCacheEnabled = false |
| if err := fake.UpdateUniverse(false); err != nil { |
| t.Fatalf("UpdateUniverse failed when CIPD is unavailable; should have restored from cache without network: %v", err) |
| } |
| |
| // Restore CIPD binary |
| if err := os.Rename(cipdBackup, fake.X.CIPDPath()); err != nil { |
| t.Fatal(err) |
| } |
| |
| // If destDir was restored from cacheDir, canaryFile must exist in destDir |
| if _, err := os.Stat(filepath.Join(destDir, "cache_canary")); err != nil { |
| t.Errorf("destDir was not restored from existing cacheDir; re-downloaded from network instead: %v", err) |
| } |
| |
| // Must be hardlinked to cache |
| if getInode(t, filepath.Join(destDir, "gn")) != getInode(t, filepath.Join(cacheDir, "gn")) { |
| t.Errorf("Expected destDir to be hardlinked to cache") |
| } |
| if _, err := os.Stat(filepath.Join(destDir, ".jiri_cache_hash")); err == nil { |
| t.Errorf("Expected NO .jiri_cache_hash in package-cache=false mode") |
| } |
| } |
| |
| // TestPackageCache_WorktreeClangRoll_GCPreservationAndPrebuiltCleanup tests the scenario where: |
| // 1. package-cache was enabled, and worktrees were created using Clang 23. |
| // 2. Main tree turns off package-cache and updates to Clang 24 (and removes obsolete packages). |
| // 3. GC runs. |
| // Assertions: |
| // - Packages in the cache that are still used by worktrees (Clang 23) MUST NOT be garbage collected. |
| // - Unused packages in the cache (e.g. old packages not used by any tree) MUST be garbage collected. |
| // - The /prebuilt directory of the main tree MUST ALWAYS reflect the packages currently needed by the main tree (Clang 24), and none of the old ones (obsolete packages/hardlinks/symlinks in /prebuilt are cleaned up). |
| // - The /prebuilt directory of the worktree MUST reflect the packages currently needed by the worktree (Clang 23). |
| func TestPackageCache_WorktreeClangRoll_GCPreservationAndPrebuiltCleanup(t *testing.T) { |
| if testing.Short() { |
| t.Skip("Skipping test in short mode.") |
| } |
| |
| fake := jiritest.NewFakeJiriRoot(t) |
| |
| // Step 1: Manifest initially has: |
| // - clangPkgV1 (Clang 23) at prebuilt/third_party/clang |
| // - oldToolPkg (obsolete tool) at prebuilt/third_party/old_tool |
| clangPkgPath := "prebuilt/third_party/clang" |
| oldToolPkgPath := "prebuilt/third_party/old_tool" |
| |
| clangPkgV1 := project.Package{Name: gnPkgName, Path: clangPkgPath, Version: gnV1} |
| oldToolPkg := project.Package{Name: gnPkgName, Path: oldToolPkgPath, Version: gnV3} |
| |
| if err := fake.AddPackage(clangPkgV1); err != nil { |
| t.Fatal(err) |
| } |
| if err := fake.AddPackage(oldToolPkg); err != nil { |
| t.Fatal(err) |
| } |
| |
| fake.X.PackageCacheEnabled = true |
| |
| // Initial fetch with package cache enabled |
| if err := fake.UpdateUniverse(true); err != nil { |
| t.Fatal(err) |
| } |
| |
| mainClangDir := filepath.Join(fake.X.Root, clangPkgPath) |
| mainOldToolDir := filepath.Join(fake.X.Root, oldToolPkgPath) |
| |
| v1Version := readFile(t, filepath.Join(mainClangDir, ".versions", "gn.cipd_version")) |
| clangV1Hash := strings.TrimSpace(readFile(t, filepath.Join(mainClangDir, ".jiri_cache_hash"))) |
| oldToolHash := strings.TrimSpace(readFile(t, filepath.Join(mainOldToolDir, ".jiri_cache_hash"))) |
| |
| cacheDir := fake.X.PackageCacheDir() |
| clangV1CacheDir := filepath.Join(cacheDir, clangV1Hash) |
| oldToolCacheDir := filepath.Join(cacheDir, oldToolHash) |
| |
| // Verify both packages exist in cache and in main prebuilt |
| if _, err := os.Stat(clangV1CacheDir); err != nil { |
| t.Fatalf("clangV1CacheDir should exist: %v", err) |
| } |
| if _, err := os.Stat(oldToolCacheDir); err != nil { |
| t.Fatalf("oldToolCacheDir should exist: %v", err) |
| } |
| |
| // Step 2: Create a worktree "wt1" that uses Clang 23 |
| wtRoot := filepath.Join(fake.X.Root, ".jiri_root", "worktrees", "wt1") |
| if err := os.MkdirAll(wtRoot, 0755); err != nil { |
| t.Fatal(err) |
| } |
| regPath := filepath.Join(fake.X.Root, ".jiri_root", "worktrees_registry") |
| if err := os.WriteFile(regPath, []byte(wtRoot+"\n"), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Link clang 23 into wtRoot's prebuilt |
| wtClangDir := filepath.Join(wtRoot, clangPkgPath) |
| if err := os.MkdirAll(filepath.Dir(wtClangDir), 0755); err != nil { |
| t.Fatal(err) |
| } |
| if err := osutil.LinkPath(clangV1CacheDir, wtClangDir); err != nil { |
| t.Fatal(err) |
| } |
| if err := os.WriteFile(filepath.Join(wtClangDir, ".jiri_cache_hash"), []byte(clangV1Hash), 0644); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Step 3: In main tree, turn off package cache and update to Clang 24. |
| // Also remove old_tool from the manifest. |
| fake.X.PackageCacheEnabled = false |
| |
| manifest, err := fake.ReadRemoteManifest() |
| if err != nil { |
| t.Fatal(err) |
| } |
| var newPkgs []project.Package |
| for _, p := range manifest.Packages { |
| if p.Path == oldToolPkgPath { |
| continue // remove obsolete tool |
| } |
| if p.Path == clangPkgPath { |
| p.Version = gnV2 // update to Clang 24 |
| } |
| newPkgs = append(newPkgs, p) |
| } |
| manifest.Packages = newPkgs |
| if err := fake.WriteRemoteManifest(manifest); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Set mtimes on cache packages to be old so that unused ones are eligible for GC |
| oldTime := time.Now().Add(-2 * time.Hour) |
| if err := os.Chtimes(clangV1CacheDir, oldTime, oldTime); err != nil { |
| t.Fatal(err) |
| } |
| if err := os.Chtimes(oldToolCacheDir, oldTime, oldTime); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Step 4: Run update and GC in the main tree |
| if err := fake.UpdateUniverse(true); err != nil { |
| t.Fatal(err) |
| } |
| if err := project.PackageGC(fake.X, 1*time.Hour); err != nil { |
| t.Fatal(err) |
| } |
| |
| // Assertions: |
| |
| // 1. Cache preservation: Clang 23 cache dir MUST NOT be GC'd because worktree wt1 is using it! |
| if _, err := os.Stat(clangV1CacheDir); os.IsNotExist(err) { |
| t.Errorf("clangV1CacheDir (%s) was garbage collected from cache even though worktree wt1 is using it!", clangV1CacheDir) |
| } |
| |
| // 2. Cache cleanup: old_tool cache dir MUST be GC'd because it is no longer used by any tree |
| if _, err := os.Stat(oldToolCacheDir); !os.IsNotExist(err) { |
| t.Errorf("oldToolCacheDir (%s) was NOT garbage collected from cache even though it is completely unused!", oldToolCacheDir) |
| } |
| |
| // 3. Main tree /prebuilt: MUST reflect ONLY the packages currently needed by main (Clang 24) |
| v2Version := readFile(t, filepath.Join(mainClangDir, ".versions", "gn.cipd_version")) |
| if v2Version == v1Version { |
| t.Errorf("main tree clang was not updated to v2 version!") |
| } |
| if _, err := os.Stat(filepath.Join(mainClangDir, ".jiri_cache_hash")); err == nil { |
| t.Errorf("main tree clang still has .jiri_cache_hash after turning off package cache!") |
| } |
| |
| // Obsolete package prebuilt/third_party/old_tool MUST be removed from main /prebuilt! |
| if _, err := os.Stat(mainOldToolDir); !os.IsNotExist(err) { |
| t.Errorf("obsolete package %s was NOT garbage collected from main prebuilt!", mainOldToolDir) |
| } |
| |
| // 4. Worktree /prebuilt: MUST reflect the packages currently needed by wt1 (Clang 23) |
| if readFile(t, filepath.Join(wtClangDir, ".versions", "gn.cipd_version")) != v1Version { |
| t.Errorf("worktree clang version changed unexpectedly!") |
| } |
| if _, err := os.Stat(filepath.Join(wtClangDir, ".jiri_cache_hash")); err != nil { |
| t.Errorf("worktree clang lost .jiri_cache_hash: %v", err) |
| } |
| } |
| |
| // ----------------------------------------------------------------------------- |
| // Section 3: Helpers |
| // ----------------------------------------------------------------------------- |
| |
| func getInode(t *testing.T, path string) uint64 { |
| t.Helper() |
| fi, err := os.Stat(path) |
| if err != nil { |
| t.Fatalf("failed to stat %s: %v", path, err) |
| } |
| stat, ok := fi.Sys().(*syscall.Stat_t) |
| if !ok { |
| t.Fatalf("failed to get syscall.Stat_t for %s", path) |
| } |
| return stat.Ino |
| } |
| |
| func readFile(t *testing.T, path string) string { |
| t.Helper() |
| data, err := os.ReadFile(path) |
| if err != nil { |
| t.Fatalf("failed to read file %s: %v", path, err) |
| } |
| return string(data) |
| } |
| |
| func updateRemoteManifestPackageVersion(t *testing.T, fake *jiritest.FakeJiriRoot, pkgName, newVersion string) { |
| t.Helper() |
| manifest, err := fake.ReadRemoteManifest() |
| if err != nil { |
| t.Fatal(err) |
| } |
| found := false |
| for i := range manifest.Packages { |
| if manifest.Packages[i].Name == pkgName { |
| manifest.Packages[i].Version = newVersion |
| found = true |
| break |
| } |
| } |
| if !found { |
| t.Fatalf("Package %s not found in remote manifest", pkgName) |
| } |
| if err := fake.WriteRemoteManifest(manifest); err != nil { |
| t.Fatal(err) |
| } |
| } |