blob: 85fe554ded68c893124038db42e5aff24ac9f71c [file]
// 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)
}
}