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
14 changes: 12 additions & 2 deletions Assets/Editor/CaptureTest/EditorWindowCaptureTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace io.github.hatayama.UnityCliLoop.Dev
/// </summary>
public class EditorWindowCaptureTest : EditorWindow
{
private const int FRAME_WAIT_TIMEOUT_MILLISECONDS = 5000;

private string _windowName = "Console";
private WindowMatchMode _matchMode = WindowMatchMode.exact;
private string _lastResult = "";
Expand Down Expand Up @@ -136,7 +138,16 @@ private async void CaptureWindowAsync()
for (int i = 0; i < windows.Length; i++)
{
EditorWindow window = windows[i];
Texture2D texture = await EditorWindowCaptureUtility.CaptureWindowAsync(window, 1.0f, CancellationToken.None);
(Texture2D texture, bool timedOut) = await EditorWindowCaptureUtility.CaptureWindowAsync(
window,
1.0f,
FRAME_WAIT_TIMEOUT_MILLISECONDS,
CancellationToken.None);
if (timedOut)
{
sb.AppendLine($" [{i + 1}] Timed out while waiting for Editor frames");
continue;
}

if (texture == null)
{
Expand Down Expand Up @@ -193,4 +204,3 @@ private void OpenOutputFolder()
}
}
}

34 changes: 25 additions & 9 deletions Assets/Editor/EditorDelayManualTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ public static class EditorFrameWaiterManualTests
{
private static int testFrameStart;
private static int testCounter = 0;

private static async Task WaitFramesForManualTestAsync(int frameCount, CancellationToken ct = default)
{
bool completed = await EditorFrameWaiter.WaitFramesOrTimeoutAsync(
frameCount,
UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS,
ct);
if (completed)
{
return;
}

Debug.LogWarning($"[WaitFramesForManualTestAsync] Timed out after {UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS}ms waiting for {frameCount} frame(s).");
throw new TimeoutException(
$"Timed out after {UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS}ms waiting for {frameCount} frame(s).");
}

[MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Basic Frame Wait Tests")]
public static void TestBasicDelays()
Expand All @@ -37,7 +53,7 @@ private static async Task TestZeroFrameDelayAsync()
int currentFrame = Time.frameCount;
Debug.Log($"[Test {++testCounter}] Zero Frame Delay - Start (Frame: {currentFrame})");

await EditorFrameWaiter.WaitFramesAsync(0);
await WaitFramesForManualTestAsync(0);

int completionFrame = Time.frameCount;
Debug.Log($"[Test {testCounter}] Zero Frame Delay - Complete (Frame: {completionFrame}) - Immediate: {currentFrame == completionFrame}");
Expand All @@ -48,7 +64,7 @@ private static async Task TestSingleFrameDelayAsync()
int currentFrame = Time.frameCount;
Debug.Log($"[Test {++testCounter}] Single Frame Delay - Start (Frame: {currentFrame})");

await EditorFrameWaiter.WaitFramesAsync(1);
await WaitFramesForManualTestAsync(1);

int completionFrame = Time.frameCount;
int framesDiff = completionFrame - currentFrame;
Expand All @@ -61,7 +77,7 @@ private static async Task TestMultipleFrameDelayAsync()
int currentFrame = Time.frameCount;
Debug.Log($"[Test {++testCounter}] Multiple Frame Delay ({delayFrames}) - Start (Frame: {currentFrame})");

await EditorFrameWaiter.WaitFramesAsync(delayFrames);
await WaitFramesForManualTestAsync(delayFrames);

int completionFrame = Time.frameCount;
int framesDiff = completionFrame - currentFrame;
Expand Down Expand Up @@ -92,7 +108,7 @@ private static async Task ConcurrentTaskAsync(string taskName, int frames)
int startFrame = Time.frameCount;
Debug.Log($"Task {taskName}: Start (Frame: {startFrame}, Delay: {frames} frames)");

await EditorFrameWaiter.WaitFramesAsync(frames);
await WaitFramesForManualTestAsync(frames);

int endFrame = Time.frameCount;
int elapsed = endFrame - testFrameStart;
Expand Down Expand Up @@ -132,7 +148,7 @@ public static void TestStressLoad()

private static async Task StressTaskAsync(int taskId, Action onComplete)
{
await EditorFrameWaiter.WaitFramesAsync(2); // All tasks execute after 2 frames
await WaitFramesForManualTestAsync(2); // All tasks execute after 2 frames
onComplete?.Invoke();
}

Expand Down Expand Up @@ -160,7 +176,7 @@ private static async Task TestCancellableTaskAsync(CancellationToken cancellatio
try
{
Debug.Log("Cancellable Task: Start (will be cancelled)");
await EditorFrameWaiter.WaitFramesAsync(10, cancellationToken);
await WaitFramesForManualTestAsync(10, cancellationToken);
Debug.Log("Cancellable Task: Complete (should not reach here)");
}
catch (OperationCanceledException)
Expand Down Expand Up @@ -191,13 +207,13 @@ private static async Task SimulateServerRestorationAsync()
Debug.Log("Simulation: Starting server restoration sequence...");

// Test with the same pattern as UnityCliLoopServerController
await EditorFrameWaiter.WaitFramesAsync(1);
await WaitFramesForManualTestAsync(1);
Debug.Log("Simulation: Phase 1 - Port release wait completed");

await EditorFrameWaiter.WaitFramesAsync(1);
await WaitFramesForManualTestAsync(1);
Debug.Log("Simulation: Phase 2 - Server startup completed");

await EditorFrameWaiter.WaitFramesAsync(1);
await WaitFramesForManualTestAsync(1);
Debug.Log("Simulation: Phase 3 - Notification sent");

Debug.Log("Simulation: Server restoration sequence completed!");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ public void DynamicCodeDomainReloadWaitSignal_WhenEditorReportsReloadWork_Should
schema,
editorIsCompiling: true,
reloadSignalObserved: false);
bool reloadSignalShouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait(
schema,
editorIsCompiling: false,
reloadSignalObserved: true);

Assert.That(shouldWait, Is.True);
Assert.That(reloadSignalShouldWait, Is.True);
}

[Test]
Expand Down Expand Up @@ -91,6 +96,36 @@ public void DynamicCodeDomainReloadWaitSignal_WhenRequestCannotWait_ShouldNotReq
Assert.That(noWaitShouldWait, Is.False);
}

[Test]
public void DynamicCodeDomainReloadWaitSignal_WhenDisposedOffMainThread_QueuesEditorEventCleanup()
{
// Tests that timeout continuations do not remove UnityEditor event handlers off the editor thread.
QueuedMainThreadDispatcher dispatcher = new();
MainThreadSwitcher.RegisterService(dispatcher);
DynamicCodeDomainReloadWaitSignal signal = DynamicCodeDomainReloadWaitSignal.Start(
new ExecuteDynamicCodeSchema
{
WaitForDomainReload = true,
CompileOnly = false
});

try
{
signal.Dispose();

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));

dispatcher.RunQueuedContinuationsAsMainThread();

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(0));
}
finally
{
signal.Dispose();
RestoreEditorMainThreadDispatcher();
}
}

[Test]
public async Task ExecuteAsync_WhenInitialCompilationLooksLikeMissingReturn_ShouldRetryOnce()
{
Expand Down Expand Up @@ -926,6 +961,50 @@ private static void AssertPrewarmCodeMatchesLiteralReturnShape(
Assert.That(prewarm.PreparedSource, Is.EqualTo(userReturn.PreparedSource));
}

private static void RestoreEditorMainThreadDispatcher()
{
io.github.hatayama.UnityCliLoop.Infrastructure.EditorMainThreadDispatcher dispatcher = new();
MainThreadSwitcher.RegisterService(dispatcher);
dispatcher.Initialize();
}

private sealed class QueuedMainThreadDispatcher : IMainThreadDispatcher
{
private readonly Queue<Action> _continuations = new();
private bool _isMainThread;

public bool IsMainThread => _isMainThread;

public int PendingContinuationCount => _continuations.Count;

public void Initialize()
{
}

public void AddContinuation(Action continuation)
{
Assert.That(continuation, Is.Not.Null);
_continuations.Enqueue(continuation);
}

public void RunQueuedContinuationsAsMainThread()
{
_isMainThread = true;
try
{
while (_continuations.Count > 0)
{
Action continuation = _continuations.Dequeue();
continuation();
}
}
finally
{
_isMainThread = false;
}
}
}

/// <summary>
/// Test support type used by editor and play mode fixtures.
/// </summary>
Expand Down
147 changes: 147 additions & 0 deletions Assets/Tests/Editor/StaticFacadeStateGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ public sealed class StaticFacadeStateGuardTests
@"\b(public|internal|private|protected)\s+static\s+class\b",
RegexOptions.Compiled);

private static readonly Regex DiscardedMainThreadTimerTaskPattern = new Regex(
@"_\s*=\s*TimerDelay\.WaitThenExecuteOnMainThread",
RegexOptions.Compiled);

private static readonly Regex AllowedStaticIdentifierPattern = new Regex(
@"\b(ServiceValue|RepositoryValue|RegistryValue|RegisteredUseCase)\b",
RegexOptions.Compiled);
Expand Down Expand Up @@ -136,11 +140,154 @@ public void MutableStaticFieldLine_WhenIdentifierOnlyContainsAllowedName_IsRepor
Assert.That(isViolation, Is.True);
}

[Test]
public void RecordingsApplicationFacade_WhenStartingDelayedRecording_DoesNotDiscardTimerTask()
{
// Tests that delayed recording observes timeout faults and keeps its countdown cleanup path reachable.
string source = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/RecordInput/Application/RecordingsApplicationFacade.cs");

Assert.That(DiscardedMainThreadTimerTaskPattern.IsMatch(source), Is.False);
Assert.That(source, Does.Contain("QueueCountdownCleanup"));
Assert.That(source, Does.Contain("StartDelayedRecordingAsync(delayMilliseconds, generation, CancellationToken.None)"));
Assert.That(source, Does.Contain("StartDelayedRecordingAsync(int delayMilliseconds, int generation, CancellationToken ct)"));
}

[Test]
public void RecordInputDelayedStarts_WhenCallbackIsStale_DoNotClearCurrentCountdown()
{
// Tests that stale delayed-recording callbacks cannot clear a newer countdown instance.
string facadeSource = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/RecordInput/Application/RecordingsApplicationFacade.cs");
string useCaseSource = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs");

Assert.That(facadeSource, Does.Contain("if (generation != _countdownGeneration)\n {\n return;\n }"));
Assert.That(useCaseSource, Does.Contain("int generation = Interlocked.Increment(ref _delayedStartGeneration);"));
Assert.That(useCaseSource, Does.Contain("QueueCountdownCleanup(generation);"));
Assert.That(useCaseSource, Does.Contain("if (!IsCurrentDelayedStartGeneration(generation))"));
}

[Test]
public void TimerDelay_WhenMainThreadActionCompletes_CancelsTimeoutWait()
{
// Tests that successful posted actions do not leave the timeout timer running until expiry.
string source = ReadSourceFile("Packages/src/Editor/ToolContracts/TimerDelay.cs");

Assert.That(source, Does.Contain("CancellationTokenSource.CreateLinkedTokenSource(ct)"));
Assert.That(source, Does.Contain("timeoutCancellationSource.Cancel();"));
}

[Test]
public void EditorFrameWaiterManualTests_WhenWaitTimesOut_ReportFailureBeforeCompletionLogs()
{
// Tests that manual frame-wait checks surface timeout failures instead of silently continuing.
string source = ReadSourceFile("Assets/Editor/EditorDelayManualTests.cs");

Assert.That(source, Does.Contain("private static async Task WaitFramesForManualTestAsync"));
Assert.That(source, Does.Contain("Debug.LogWarning($\"[WaitFramesForManualTestAsync] Timed out after"));
Assert.That(source, Does.Contain("throw new TimeoutException("));
}

[Test]
public void ScreenshotUseCase_WhenDestroyingTimedOutAnnotationOverlay_UsesReferenceNullCheck()
{
// Tests that screenshot timeout cleanup does not call UnityEngine.Object null operators off-thread.
string source = ReadSourceFile("Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs");

Assert.That(source, Does.Contain("ReferenceEquals(annotationOverlay, null)"));
Assert.That(source, Does.Not.Contain("annotationOverlay == null"));
}

[Test]
public void ScreenshotUseCase_WhenWindowCaptureTimesOut_PreservesPartialScreenshots()
{
// Tests that window capture timeout results keep screenshots already written to disk.
string source = ReadSourceFile("Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs");

Assert.That(source, Does.Contain("return CreateTimedOutResult(\"EditorWindow capture\", correlationId, screenshots);"));
Assert.That(source, Does.Contain("Screenshots = screenshots,"));
}

[Test]
public void DynamicCodeDomainReloadWaitSignal_WhenCheckingCompileState_SwitchesToMainThreadFirst()
{
// Tests that timeout continuations do not read UnityEditor compile state off the editor thread.
string source = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs");
int methodIndex = source.IndexOf(
"public async Task<bool> ShouldWaitAsync(CancellationToken ct)",
System.StringComparison.Ordinal);
int mainThreadSwitchIndex = source.IndexOf(
"await MainThreadSwitcher.SwitchToMainThread(ct);",
methodIndex,
System.StringComparison.Ordinal);
int editorCompilingIndex = source.IndexOf(
"EditorApplication.isCompiling",
methodIndex,
System.StringComparison.Ordinal);

Assert.That(methodIndex, Is.GreaterThanOrEqualTo(0));
Assert.That(mainThreadSwitchIndex, Is.GreaterThan(methodIndex));
Assert.That(editorCompilingIndex, Is.GreaterThan(mainThreadSwitchIndex));
}

[Test]
public void ExecuteDynamicCodeUseCase_WhenAwaitingWorkflow_DoesNotCaptureEditorSynchronizationContext()
{
// Tests that timeout-sensitive dynamic-code awaits do not capture Unity's synchronization context.
string source = ReadSourceFile(
"Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs");

Assert.That(source, Does.Contain("await WarmForegroundExecutionPathIfNeededAsync(parameters, editorLevel, cancellationToken)\n .ConfigureAwait(false);"));
Assert.That(source, Does.Contain("await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false);"));
Assert.That(source, Does.Contain("await RetryMissingReturnIfNeeded(\n executionResult,"));
Assert.That(source, Does.Contain("cancellationToken).ConfigureAwait(false);"));
Assert.That(source, Does.Contain("await _runtime.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);"));
Assert.That(source, Does.Contain("await _runtime.TryExecuteIfIdleAsync(\n request,\n cancellationToken).ConfigureAwait(false);"));
}

[Test]
public void SimulateMouseUiUseCase_WhenReturningAfterTimeout_UsesCapturedUnityObjectState()
{
// Tests that timeout result branches use plain captured bools instead of UnityEngine.Object null checks.
string source = ReadSourceFile("Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs");

Assert.That(source, Does.Contain("bool hitTarget = target != null;"));
Assert.That(source, Does.Contain("bool shouldReleasePointer = rawTarget != null && target != null;"));
Assert.That(source, Does.Not.Contain("CreateClickResult(parameters, inputPos, targetName, target != null)"));
Assert.That(source, Does.Not.Contain("CreateLongPressResult(parameters, inputPos, targetName, target != null)"));
}

[Test]
public void VibeLogger_WhenCollectingEnvironmentInfo_GatesUnityEditorApiBehindMainThreadCheck()
{
// Tests that debug logging from timeout continuations does not read UnityEditor APIs off-thread.
string source = ReadSourceFile("Packages/src/Editor/ToolContracts/VibeLogger.cs");
int methodIndex = source.IndexOf("private EnvironmentInfo GetEnvironmentInfo()", System.StringComparison.Ordinal);
int mainThreadCheckIndex = source.IndexOf("if (!IsEditorMainThread)", methodIndex, System.StringComparison.Ordinal);
int editorApplicationIndex = source.IndexOf("EditorApplication.isCompiling", methodIndex, System.StringComparison.Ordinal);

Assert.That(methodIndex, Is.GreaterThanOrEqualTo(0));
Assert.That(mainThreadCheckIndex, Is.GreaterThan(methodIndex));
Assert.That(editorApplicationIndex, Is.GreaterThan(mainThreadCheckIndex));
Assert.That(source, Does.Contain("DOMAIN_RELOAD_STATE_UNAVAILABLE_OFF_MAIN_THREAD"));
Assert.That(source, Does.Not.Contain("UnityEngine.Application.dataPath"));
Assert.That(source, Does.Not.Contain("Path.Combine(_logDirectory"));
}

private static List<string> FindMutableStaticFieldViolations()
{
return FindMutableStaticFieldViolations(MigratedFacadePaths);
}

private static string ReadSourceFile(string relativePath)
{
string projectRoot = UnityCliLoopPathResolver.GetProjectRoot();
string absolutePath = Path.Combine(projectRoot, relativePath);
return File.ReadAllText(absolutePath);
}

private static List<string> FindMutableStaticFieldViolations(string[] relativePaths)
{
List<string> violations = new();
Expand Down
Loading
Loading