Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Assets/Tests/Editor/CliPinSynchronizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,43 @@ public void SyncProjectPinFile_WhenPackagePinChanges_ShouldUpdateProjectPin()
}
}

[Test]
public void SyncProjectPinFile_WhenSourcePinMissing_ShouldLogWarningAndSkip()
{
// Tests that a missing package source pin now emits a warning instead of silently returning false.
string root = CreateTestRoot();
string packageRoot = Path.Combine(root, "package");
string projectRoot = Path.Combine(root, "project");

try
{
Directory.CreateDirectory(packageRoot);
Directory.CreateDirectory(projectRoot);
string sourcePath = Path.Combine(
packageRoot,
UnityCliLoopConstants.ULOOP_PROJECT_RUNNER_PIN_FILE_NAME);
LogAssert.Expect(
LogType.Warning,
$"Unity CLI Loop skipped {UnityCliLoopConstants.ULOOP_PROJECT_RUNNER_PIN_FILE_NAME} synchronization because the package source pin was not found at {sourcePath}.");

bool changed = CliPinSynchronizer.SyncProjectPinFile(packageRoot, projectRoot);

Assert.That(changed, Is.False);
Assert.That(File.Exists(sourcePath), Is.False);
Assert.That(
File.Exists(
Path.Combine(
projectRoot,
UnityCliLoopConstants.ULOOP_DIR,
UnityCliLoopConstants.ULOOP_PROJECT_RUNNER_PIN_FILE_NAME)),
Is.False);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Test]
public void SyncProjectPinFile_WhenPackageRootMissing_ShouldSkipWrite()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ internal UnityCliLoopEditorBootstrapper()

internal void Initialize()
{
CliPinSynchronizer.SyncCurrentProjectPin();
// Why: pin synchronization is now mandatory for the dispatcher, but a false result (missing
// source pin, or destination already matches) is signalled via the return value and must not
// abort the remaining startup steps. CliPinSynchronizer logs its own warnings on failure.
_ = CliPinSynchronizer.SyncCurrentProjectPin();
UnityCliLoopApplicationServices applicationServices = _applicationRegistration.Register();
ApplicationEditorStartup.Initialize(applicationServices.DomainReloadDetectionService);
FirstPartyToolsEditorStartup.Initialize();
Expand Down
8 changes: 6 additions & 2 deletions Packages/src/Editor/Infrastructure/CLI/CliPinSynchronizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure
/// </summary>
internal static class CliPinSynchronizer
{
internal static void SyncCurrentProjectPin()
internal static bool SyncCurrentProjectPin()
{
SyncProjectPinFile(UnityCliLoopConstants.PackageResolvedPath, ResolveCurrentProjectRoot(UnityEngine.Application.dataPath));
return SyncProjectPinFile(UnityCliLoopConstants.PackageResolvedPath, ResolveCurrentProjectRoot(UnityEngine.Application.dataPath));
}

internal static string ResolveCurrentProjectRoot(string assetsPath)
Expand Down Expand Up @@ -61,6 +61,10 @@ private static bool SyncProjectPinFileByName(string packageRoot, string projectR

if (!File.Exists(sourcePath))
{
// Why: the pin JSON is the only runtime source for the dispatcher's project runner version,
// so a missing package source must be visible instead of silently skipping synchronization.
Debug.LogWarning(
$"Unity CLI Loop skipped {pinFileName} synchronization because the package source pin was not found at {sourcePath}.");
return false;
}

Expand Down
75 changes: 1 addition & 74 deletions cli/dispatcher/internal/dispatcher/dispatcher_pin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import (
"strings"
)

var (
dispatcherMinimumProjectRunnerVersionPattern = regexp.MustCompile(`MINIMUM_REQUIRED_PROJECT_RUNNER_VERSION\s*=\s*"([^"]+)"`)
dispatcherMinimumVersionPattern = regexp.MustCompile(`MINIMUM_REQUIRED_DISPATCHER_VERSION\s*=\s*"([^"]+)"`)
dispatcherRequiredProtocolVersionPattern = regexp.MustCompile(`REQUIRED_CLI_PROTOCOL_VERSION\s*=\s*(\d+)`)
dispatcherProjectRunnerVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$`)
)
var dispatcherProjectRunnerVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$`)

type dispatcherPin struct {
SchemaVersion int `json:"schemaVersion"`
Expand Down Expand Up @@ -51,16 +46,6 @@ func loadDispatcherPin(projectRoot string) (dispatcherPin, error) {
}
}

for _, constantsPath := range dispatcherCliConstantsCandidatePaths(projectRoot) {
pin, err := readDispatcherPinFromCliConstants(constantsPath)
if err == nil {
return pin, nil
}
if !errors.Is(err, os.ErrNotExist) {
return dispatcherPin{}, err
}
}

if invalidPackagePinError != nil {
return dispatcherPin{}, invalidPackagePinError
}
Expand Down Expand Up @@ -88,26 +73,6 @@ func dispatcherPinCandidatePaths(projectRoot string) []dispatcherPinCandidatePat
return paths
}

func dispatcherCliConstantsCandidatePaths(projectRoot string) []string {
paths := []string{
filepath.Join(projectRoot, "Packages", "src", "Editor", "Domain", "CliConstants.cs"),
filepath.Join(projectRoot, "Packages", dispatcherUnityPackageName, "Editor", "Domain", "CliConstants.cs"),
}
packageCachePattern := filepath.Join(
projectRoot,
"Library",
"PackageCache",
dispatcherUnityPackageName+"@*",
"Editor",
"Domain",
"CliConstants.cs")
matches, err := filepath.Glob(packageCachePattern)
if err == nil {
paths = append(paths, matches...)
}
return paths
}

func readDispatcherPin(pinPath string) (dispatcherPin, error) {
content, err := os.ReadFile(pinPath)
if err != nil {
Expand Down Expand Up @@ -138,44 +103,6 @@ func readDispatcherPin(pinPath string) (dispatcherPin, error) {
return pin, nil
}

func readDispatcherPinFromCliConstants(constantsPath string) (dispatcherPin, error) {
content, err := os.ReadFile(constantsPath)
if err != nil {
return dispatcherPin{}, err
}
text := string(content)
versionMatch := dispatcherMinimumProjectRunnerVersionPattern.FindStringSubmatch(text)
if len(versionMatch) != 2 {
return dispatcherPin{}, fmt.Errorf("%s does not define MINIMUM_REQUIRED_PROJECT_RUNNER_VERSION", constantsPath)
}
projectRunnerVersion := normalizeDispatcherVersion(versionMatch[1])
if err := validateDispatcherProjectRunnerVersion(projectRunnerVersion); err != nil {
return dispatcherPin{}, fmt.Errorf("%s defines invalid MINIMUM_REQUIRED_PROJECT_RUNNER_VERSION: %w", constantsPath, err)
}
dispatcherVersionMatch := dispatcherMinimumVersionPattern.FindStringSubmatch(text)
if len(dispatcherVersionMatch) != 2 {
return dispatcherPin{}, fmt.Errorf("%s does not define MINIMUM_REQUIRED_DISPATCHER_VERSION", constantsPath)
}
minimumDispatcherVersion := normalizeDispatcherVersion(dispatcherVersionMatch[1])
if err := validateDispatcherProjectRunnerVersion(minimumDispatcherVersion); err != nil {
return dispatcherPin{}, fmt.Errorf("%s defines invalid MINIMUM_REQUIRED_DISPATCHER_VERSION: %w", constantsPath, err)
}
protocolVersion := 0
protocolMatch := dispatcherRequiredProtocolVersionPattern.FindStringSubmatch(text)
if len(protocolMatch) == 2 {
_, _ = fmt.Sscanf(protocolMatch[1], "%d", &protocolVersion)
}

return dispatcherPin{
SchemaVersion: 1,
PackageName: dispatcherUnityPackageName,
ProjectRunnerVersion: projectRunnerVersion,
RequiredProtocolVersion: protocolVersion,
MinimumDispatcherVersion: minimumDispatcherVersion,
SourcePath: constantsPath,
}, nil
}

func normalizeDispatcherVersion(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "v") || strings.HasPrefix(trimmed, "V") {
Expand Down
58 changes: 26 additions & 32 deletions cli/dispatcher/internal/dispatcher/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,8 +770,8 @@ func TestLoadDispatcherPinRejectsInvalidMinimumDispatcherVersion(t *testing.T) {
}
}

func TestLoadDispatcherPinFallsBackToCliConstants(t *testing.T) {
// Verifies old package layouts can still resolve a CLI version from CliConstants.cs.
func TestLoadDispatcherPinFailsWhenPinFileMissing(t *testing.T) {
// Verifies loadDispatcherPin now requires a pin JSON and does not fall back to CliConstants.cs.
projectRoot := createDispatcherUnityProject(t)
constantsPath := filepath.Join(projectRoot, "Packages", "src", "Editor", "Domain", "CliConstants.cs")
if err := os.MkdirAll(filepath.Dir(constantsPath), 0o755); err != nil {
Expand All @@ -784,44 +784,38 @@ public const string MINIMUM_REQUIRED_DISPATCHER_VERSION = "1.0.0";`
t.Fatalf("failed to write constants: %v", err)
}

pin, err := loadDispatcherPin(projectRoot)
if err != nil {
t.Fatalf("loadDispatcherPin failed: %v", err)
}
if pin.ProjectRunnerVersion != "3.0.0-beta.56" {
t.Fatalf("projectRunnerVersion mismatch: %s", pin.ProjectRunnerVersion)
}
if pin.RequiredProtocolVersion != 3 {
t.Fatalf("protocol mismatch: %d", pin.RequiredProtocolVersion)
_, err := loadDispatcherPin(projectRoot)

if err == nil {
t.Fatal("expected pin resolution to fail when no pin JSON is present")
}
if pin.MinimumDispatcherVersion != "1.0.0" {
t.Fatalf("minimumDispatcherVersion mismatch: %s", pin.MinimumDispatcherVersion)
if !strings.Contains(err.Error(), "project runner pin not found") {
t.Fatalf("expected 'project runner pin not found' message, got: %v", err)
}
}

func TestLoadDispatcherPinFromCliConstantsNormalizesVersionPrefix(t *testing.T) {
// Verifies v-prefixed fallback constants are normalized before dispatcher resolution.
func TestRunDispatcherMissingPinEmitsPinResolutionGuidance(t *testing.T) {
// Verifies the dispatcher surfaces the pin-resolution error envelope with NextActions guidance when the pin is missing.
projectRoot := createDispatcherUnityProject(t)
constantsPath := filepath.Join(projectRoot, "Packages", "src", "Editor", "Domain", "CliConstants.cs")
if err := os.MkdirAll(filepath.Dir(constantsPath), 0o755); err != nil {
t.Fatalf("failed to create constants directory: %v", err)
}
content := `public const int REQUIRED_CLI_PROTOCOL_VERSION = 3;
public const string MINIMUM_REQUIRED_PROJECT_RUNNER_VERSION = "v3.0.0-beta.59";
public const string MINIMUM_REQUIRED_DISPATCHER_VERSION = "v1.0.0";`
if err := os.WriteFile(constantsPath, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write constants: %v", err)
}
t.Setenv(nativepath.CacheDirEnvName, t.TempDir())
t.Setenv(dispatcherDisableSelfUpdateEnvName, "1")
t.Chdir(projectRoot)

pin, err := loadDispatcherPin(projectRoot)
if err != nil {
t.Fatalf("loadDispatcherPin failed: %v", err)
deps := defaultDispatcherRunDeps()

var stdout bytes.Buffer
var stderr bytes.Buffer
code := runDispatcherWithDeps(context.Background(), []string{"compile"}, &stdout, &stderr, deps)

if code == 0 {
t.Fatalf("expected non-zero exit when pin is missing, stderr=%s", stderr.String())
}
if pin.ProjectRunnerVersion != "3.0.0-beta.59" {
t.Fatalf("projectRunnerVersion mismatch: %s", pin.ProjectRunnerVersion)
stderrText := stderr.String()
if !strings.Contains(stderrText, "Could not resolve the required uloop project runner") {
t.Fatalf("expected pin resolution error message, got: %s", stderrText)
}
if pin.MinimumDispatcherVersion != "1.0.0" {
t.Fatalf("minimumDispatcherVersion mismatch: %s", pin.MinimumDispatcherVersion)
if !strings.Contains(stderrText, "project-runner-pin.json") {
t.Fatalf("expected NextActions to reference project-runner-pin.json, got: %s", stderrText)
}
}

Expand Down
Loading