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
6 changes: 5 additions & 1 deletion .agents/skills/uloop-compile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Execute Unity project compilation.
## Usage

```bash
uloop compile [--force-recompile] [--no-wait-for-domain-reload]
uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes]
```

## Parameters
Expand All @@ -20,6 +20,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload]
|-----------|------|---------|-------------|
| `--force-recompile` | flag | - | Use for broader validation, including warnings hidden by other asmdefs; much slower than normal compile |
| `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion |
| `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them |

## Global Options

Expand All @@ -35,6 +36,9 @@ uloop compile

# Start compilation without waiting for Domain Reload completion
uloop compile --no-wait-for-domain-reload

# Stop instead of auto-reloading externally changed open Scene files
uloop compile --stop-on-external-scene-changes
```

## Output
Expand Down
6 changes: 5 additions & 1 deletion .claude/skills/uloop-compile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Execute Unity project compilation.
## Usage

```bash
uloop compile [--force-recompile] [--no-wait-for-domain-reload]
uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes]
```

## Parameters
Expand All @@ -20,6 +20,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload]
|-----------|------|---------|-------------|
| `--force-recompile` | flag | - | Use for broader validation, including warnings hidden by other asmdefs; much slower than normal compile |
| `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion |
| `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them |

## Global Options

Expand All @@ -35,6 +36,9 @@ uloop compile

# Start compilation without waiting for Domain Reload completion
uloop compile --no-wait-for-domain-reload

# Stop instead of auto-reloading externally changed open Scene files
uloop compile --stop-on-external-scene-changes
```

## Output
Expand Down
8 changes: 4 additions & 4 deletions Assets/Tests/Editor/CliSetupApplicationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag()
}

[Test]
public void GetMinimumRequiredCliVersion_RequiresCompileRecoveryCliRelease()
public void GetMinimumRequiredCliVersion_RequiresExternalSceneChangeFlagCliRelease()
{
// Verifies this package release requires the CLI that polls stored compile status.
// Verifies this package release requires the CLI with external Scene change options.
CliSetupApplicationService service = new(
new FakeCliInstallationDetector(new string[] { null }),
new FakeNativeCliInstaller());

Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.23"));
Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.24"));
}

[Test]
Expand All @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag()
new FakeCliInstallationDetector(new string[] { null }),
new FakeNativeCliInstaller());

Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.23"));
Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.24"));
}

[Test]
Expand Down
32 changes: 32 additions & 0 deletions Assets/Tests/Editor/CompileSessionResultServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,37 @@ public void CreateCompileResult_WhenForceCompileHasOutcome_DropsCountsAndDetaile
Assert.That(response.Warnings, Is.Null);
Assert.That(response.Message, Is.Null);
}

[Test]
public void CreateCompileResult_WhenForceCompileHasPreservedFailure_MapsDetailedIssues()
{
// Verifies preflight failures keep actionable details even during force compile.
CompilerMessage error = new CompilerMessage
{
type = CompilerMessageType.Error,
message = "external Scene changed",
file = "Assets/Scenes/SampleScene.unity",
line = 0
};
CompileResult result = new CompileResult(
success: false,
errorCount: 1,
warningCount: 0,
completedAt: DateTime.Now,
messages: new[] { error },
errors: new[] { error },
warnings: Array.Empty<CompilerMessage>(),
message: "Compilation stopped because open Scene files changed externally.",
preserveDetailsWhenForceRecompile: true);

UnityCliLoopCompileResult response =
CompileSessionResultService.CreateCompileResult(result, forceRecompile: true);

Assert.That(response.Success, Is.False);
Assert.That(response.ErrorCount, Is.EqualTo(1));
Assert.That(response.Errors, Has.Length.EqualTo(1));
Assert.That(response.Errors[0].File, Is.EqualTo("Assets/Scenes/SampleScene.unity"));
Assert.That(response.Message, Does.Contain("externally"));
}
}
}
201 changes: 201 additions & 0 deletions Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Tests external Scene change resolution without invoking Unity's real Scene loading APIs.
/// </summary>
public sealed class ExternalSceneChangeResolverTests
{
private const string ScenePath = "Assets/Scenes/SampleScene.unity";
private static readonly DateTime SavedTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ChangedTime = new DateTime(2026, 1, 1, 0, 1, 0, DateTimeKind.Utc);

[Test]
public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_ReloadsSceneSetup()
{
// Verifies default compile behavior reloads clean externally changed Scenes.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: false) },
_ => (true, ChangedTime, 20),
() => Array.Empty<string>(),
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: true);

Assert.That(result.CanProceed, Is.True);
Assert.That(reloadWasCalled, Is.True);
}

[Test]
public void ResolveExternalSceneChanges_WhenDirtySceneChangedAndReloadEnabled_ReturnsFailureWithoutMutation()
{
// Verifies dirty externally changed Scenes are not overwritten by automatic compile preflight.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool saveDirtyOpenScenesWasCalled = false;
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: true) },
_ => (true, ChangedTime, 20),
() =>
{
saveDirtyOpenScenesWasCalled = true;
return Array.Empty<string>();
},
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: true);

Assert.That(result.CanProceed, Is.False);
Assert.That(result.Message, Does.Contain("not overwritten automatically"));
Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath }));
Assert.That(saveDirtyOpenScenesWasCalled, Is.False);
Assert.That(reloadWasCalled, Is.False);
}

[Test]
public void ResolveExternalSceneChanges_WhenStopPolicyIsEnabled_ReturnsFailureWithoutMutation()
{
// Verifies stop-on-external-scene-changes prevents automatic save or reload.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool saveDirtyOpenScenesWasCalled = false;
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: true) },
_ => (true, ChangedTime, 20),
() =>
{
saveDirtyOpenScenesWasCalled = true;
return Array.Empty<string>();
},
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: false);

Assert.That(result.CanProceed, Is.False);
Assert.That(result.Message, Does.Contain("--stop-on-external-scene-changes"));
Assert.That(result.ScenePaths, Is.EqualTo(new[] { ScenePath }));
Assert.That(saveDirtyOpenScenesWasCalled, Is.False);
Assert.That(reloadWasCalled, Is.False);
}

[Test]
public void ResolveExternalSceneChanges_WhenCleanSceneChangedAndReloadEnabled_SavesDirtyScenesBeforeReload()
{
// Verifies reload preflight saves other dirty open Scenes before restoring Scene setup.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool saveDirtyOpenScenesWasCalled = false;
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: false) },
_ => (true, ChangedTime, 20),
() =>
{
saveDirtyOpenScenesWasCalled = true;
return Array.Empty<string>();
},
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: true);

Assert.That(result.CanProceed, Is.True);
Assert.That(saveDirtyOpenScenesWasCalled, Is.True);
Assert.That(reloadWasCalled, Is.True);
}

[Test]
public void ResolveExternalSceneChanges_WhenDirtyScenesBeforeReloadCannotBeSaved_ReturnsFailure()
{
// Verifies reload does not proceed when any dirty open Scene cannot be saved first.
const string DirtyScenePath = "Assets/Scenes/DirtyScene.unity";
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: false) },
_ => (true, ChangedTime, 20),
() => new[] { DirtyScenePath },
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: true);

Assert.That(result.CanProceed, Is.False);
Assert.That(result.Message, Does.Contain("cannot save dirty Scene files"));
Assert.That(result.ScenePaths, Is.EqualTo(new[] { DirtyScenePath }));
Assert.That(reloadWasCalled, Is.False);
}

[Test]
public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload()
{
// Verifies unchanged Scene fingerprints do not trigger compile preflight work.
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots = CreateSnapshots();
bool saveDirtyOpenScenesWasCalled = false;
bool reloadWasCalled = false;
ExternalSceneChangeResolver resolver = new ExternalSceneChangeResolver(
snapshots,
() => new[] { (AssetPath: ScenePath, IsDirty: true) },
_ => (true, SavedTime, 10),
() =>
{
saveDirtyOpenScenesWasCalled = true;
return Array.Empty<string>();
},
() =>
{
reloadWasCalled = true;
return true;
});

(bool CanProceed, string Message, string[] ScenePaths) result = resolver.ResolveExternalSceneChanges(
reloadExternalSceneChanges: true);

Assert.That(result.CanProceed, Is.True);
Assert.That(saveDirtyOpenScenesWasCalled, Is.False);
Assert.That(reloadWasCalled, Is.False);
}

private static Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> CreateSnapshots()
{
Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)> snapshots =
new Dictionary<string, (bool Exists, DateTime LastWriteTimeUtc, long Length)>(StringComparer.Ordinal);
snapshots[ScenePath] = (true, SavedTime, 10);
return snapshots;
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Packages/src/Editor/Domain/CliConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain
public static class CliConstants
{
public const string EXECUTABLE_NAME = "uloop";
public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.23";
public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.24";
public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION;
public const string VERSION_FLAG = "--version";
public const string SHORT_VERSION_FLAG = "-v";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public async Task<CompileResult> ExecuteCompilationAsync(UnityCliLoopCompileRequ

using CompileController compileController = new();
compileController.SetResultRecordingContext(CompileResultRecordingContext.Create(request));
compileController.SetExternalSceneChangePolicy(request.ReloadExternalSceneChanges);
return await compileController.TryCompileAsync(request.ForceRecompile, ct);
}
}
Expand Down
Loading
Loading