Skip to content
Merged
129 changes: 129 additions & 0 deletions Assets/Tests/Editor/LogFilteringServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Unit tests for LogFilteringService
/// Related classes: LogFilteringService, GetLogsUseCase, LogEntry
/// </summary>
[TestFixture]
public class LogFilteringServiceTests
{
private LogFilteringService _service;

[SetUp]
public void SetUp()
{
_service = new LogFilteringService();
}

private static UnityCliLoopConsoleLogEntry CreateEntry(string message)
{
return new UnityCliLoopConsoleLogEntry(UnityCliLoopLogType.Log, message, $"stack of {message}");
}

/// <summary>
/// Verifies entries are returned newest-first (input order reversed)
/// </summary>
[Test]
public void FilterAndLimitLogs_ReturnsEntriesNewestFirst()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("oldest"), CreateEntry("middle"), CreateEntry("newest") };

LogEntry[] result = _service.FilterAndLimitLogs(entries, 10, includeStackTrace: false);

Assert.That(result.Length, Is.EqualTo(3));
Assert.That(result[0].Message, Is.EqualTo("newest"));
Assert.That(result[1].Message, Is.EqualTo("middle"));
Assert.That(result[2].Message, Is.EqualTo("oldest"));
}

/// <summary>
/// Verifies maxCount keeps only the newest entries when input exceeds the limit
/// </summary>
[Test]
public void FilterAndLimitLogs_LimitsToNewestEntries()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("first"), CreateEntry("second"), CreateEntry("third"), CreateEntry("fourth") };

LogEntry[] result = _service.FilterAndLimitLogs(entries, 2, includeStackTrace: false);

Assert.That(result.Length, Is.EqualTo(2));
Assert.That(result[0].Message, Is.EqualTo("fourth"));
Assert.That(result[1].Message, Is.EqualTo("third"));
}

/// <summary>
/// Verifies stack traces are included when includeStackTrace is true
/// </summary>
[Test]
public void FilterAndLimitLogs_IncludesStackTraceWhenRequested()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("message") };

LogEntry[] result = _service.FilterAndLimitLogs(entries, 10, includeStackTrace: true);

Assert.That(result[0].StackTrace, Is.EqualTo("stack of message"));
}

/// <summary>
/// Verifies stack traces are null when includeStackTrace is false
/// </summary>
[Test]
public void FilterAndLimitLogs_OmitsStackTraceWhenNotRequested()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("message") };

LogEntry[] result = _service.FilterAndLimitLogs(entries, 10, includeStackTrace: false);

Assert.That(result[0].StackTrace, Is.Null);
}

/// <summary>
/// Verifies maxCount of zero returns an empty array
/// </summary>
[Test]
public void FilterAndLimitLogs_WithZeroMaxCount_ReturnsEmptyArray()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("message") };

LogEntry[] result = _service.FilterAndLimitLogs(entries, 0, includeStackTrace: false);

Assert.That(result, Is.Empty);
}

/// <summary>
/// Verifies empty input returns an empty array
/// </summary>
[Test]
public void FilterAndLimitLogs_WithEmptyInput_ReturnsEmptyArray()
{
LogEntry[] result = _service.FilterAndLimitLogs(Array.Empty<UnityCliLoopConsoleLogEntry>(), 10, includeStackTrace: false);

Assert.That(result, Is.Empty);
}

/// <summary>
/// Verifies null entries are rejected with ArgumentNullException
/// </summary>
[Test]
public void FilterAndLimitLogs_WithNullEntries_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _service.FilterAndLimitLogs(null, 10, includeStackTrace: false));
}

/// <summary>
/// Verifies negative maxCount is rejected with ArgumentOutOfRangeException
/// </summary>
[Test]
public void FilterAndLimitLogs_WithNegativeMaxCount_ThrowsArgumentOutOfRangeException()
{
UnityCliLoopConsoleLogEntry[] entries = { CreateEntry("message") };

Assert.Throws<ArgumentOutOfRangeException>(() => _service.FilterAndLimitLogs(entries, -1, includeStackTrace: false));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/LogFilteringServiceTests.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.30";
public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.31";
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 @@ -13,6 +13,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
public class ConsoleLogRetriever
{
private readonly Type _logEntriesType;
private readonly Type _logEntryType;
private readonly PropertyInfo _consoleFlagsProperty;
private readonly MethodInfo _getCountMethod;
private readonly MethodInfo _getEntryInternalMethod;
private readonly FieldInfo _messageField;
private readonly FieldInfo _modeField;
private readonly FieldInfo _callstackTextStartField;

/// <summary>
/// Initializes the retriever with necessary reflection types
Expand All @@ -27,6 +34,30 @@ public ConsoleLogRetriever()
{
throw new InvalidOperationException("LogEntries type not found. Unity version compatibility issue.");
}

_logEntryType = editorAssembly.GetType("UnityEditor.LogEntry");
if (_logEntryType == null)
{
throw new InvalidOperationException("LogEntry type not found. Unity version compatibility issue.");
}

// GetLogEntryAt runs once per console entry, so member lookups are resolved once here
// instead of per entry; repeated GetMethod/GetField calls dominated get-logs latency.
_consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
_getCountMethod = _logEntriesType.GetMethod("GetCount", BindingFlags.Public | BindingFlags.Static);
_getEntryInternalMethod = _logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Public | BindingFlags.Static);
_messageField = _logEntryType.GetField("message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
_modeField = _logEntryType.GetField("mode", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
_callstackTextStartField = _logEntryType.GetField("callstackTextStartUTF8", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

// Missing members would otherwise surface as silently empty or zeroed log data,
// so an internal Unity API change must fail here instead.
if (_consoleFlagsProperty == null || _getCountMethod == null || _getEntryInternalMethod == null ||
_messageField == null || _modeField == null || _callstackTextStartField == null)
{
throw new InvalidOperationException(
"Required LogEntries/LogEntry members not found. Unity version compatibility issue.");
}
}

/// <summary>
Expand Down Expand Up @@ -73,11 +104,7 @@ public List<LogEntryDto> GetAllLogs()
private void RestoreOriginalMask(int originalUnityMask)
{
// Use consoleFlags property to restore the exact Unity mask
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
consoleFlagsProperty.SetValue(null, originalUnityMask);
}
_consoleFlagsProperty.SetValue(null, originalUnityMask);
}

/// <summary>
Expand Down Expand Up @@ -146,15 +173,8 @@ public List<LogEntryDto> GetLogsByType(LogType logType)
public int GetCurrentMask()
{
// Use the consoleFlags property discovered in the investigation
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
object result = consoleFlagsProperty.GetValue(null);
return result != null ? (int)result : 0;
}

// Fallback: try to get from ConsoleWindow if available
return GetMaskFromConsoleWindow();
object result = _consoleFlagsProperty.GetValue(null);
return result != null ? (int)result : 0;
}

/// <summary>
Expand All @@ -167,14 +187,7 @@ public void SetMask(int mask)
int unityMask = ConvertToUnityMask(mask);

// Use the consoleFlags property
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
consoleFlagsProperty.SetValue(null, unityMask);
return;
}

Debug.LogWarning("Could not find consoleFlags property");
_consoleFlagsProperty.SetValue(null, unityMask);
}

/// <summary>
Expand Down Expand Up @@ -210,14 +223,8 @@ private int ConvertToUnityMask(int simpleMask)
/// </summary>
public int GetLogCount()
{
MethodInfo getCount = _logEntriesType.GetMethod("GetCount", BindingFlags.Public | BindingFlags.Static);
if (getCount != null)
{
object result = getCount.Invoke(null, null);
return result != null ? (int)result : 0;
}

return 0;
object result = _getCountMethod.Invoke(null, null);
return result != null ? (int)result : 0;
}


Expand All @@ -226,40 +233,23 @@ public int GetLogCount()
/// </summary>
private LogEntryDto GetLogEntryAt(int index)
{
// Get LogEntry type from Unity's internal assembly
Assembly editorAssembly = Assembly.GetAssembly(typeof(EditorWindow));
Type logEntryType = editorAssembly.GetType("UnityEditor.LogEntry");
if (logEntryType == null)
{
Debug.LogError("LogEntry type not found");
return null;
}

// Create LogEntry instance
object logEntryInstance = Activator.CreateInstance(logEntryType);

// Use GetEntryInternal method discovered in investigation
MethodInfo getEntryInternal = _logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Public | BindingFlags.Static);
if (getEntryInternal == null)
{
Debug.LogError("GetEntryInternal method not found");
return null;
}
object logEntryInstance = Activator.CreateInstance(_logEntryType);

// Call GetEntryInternal(int row, LogEntry outputEntry)
object[] parameters = new object[] { index, logEntryInstance };
bool success = (bool)getEntryInternal.Invoke(null, parameters);
bool success = (bool)_getEntryInternalMethod.Invoke(null, parameters);

if (!success)
{
Debug.LogWarning($"GetEntryInternal failed for index {index}");
return null;
}

// Extract data from LogEntry instance using reflection
string fullMessage = GetFieldValue(logEntryInstance, "message")?.ToString() ?? "";
int mode = (int)(GetFieldValue(logEntryInstance, "mode") ?? 0);
int callstackTextStart = (int)(GetFieldValue(logEntryInstance, "callstackTextStartUTF8") ?? 0);
// Extract data from LogEntry instance using cached field handles
string fullMessage = _messageField.GetValue(logEntryInstance)?.ToString() ?? "";
int mode = (int)_modeField.GetValue(logEntryInstance);
int callstackTextStart = (int)_callstackTextStartField.GetValue(logEntryInstance);

LogType logType = GetLogTypeFromMode(mode);

Expand All @@ -270,16 +260,6 @@ private LogEntryDto GetLogEntryAt(int index)
return new LogEntryDto(unityCliLoopLogType, message, stackTrace);
}

/// <summary>
/// Helper method to get field value from object using reflection
/// </summary>
private object GetFieldValue(object obj, string fieldName)
{
Type type = obj.GetType();
FieldInfo field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
return field?.GetValue(obj);
}

/// <summary>
/// Converts a UTF-8 byte position to a character position in a string.
/// Unity's LogEntry.callstackTextStartUTF8 provides byte offset, but
Expand Down Expand Up @@ -384,14 +364,5 @@ private int GetMaskForLogType(LogType logType)
};
}

/// <summary>
/// Fallback method to get mask from ConsoleWindow
/// </summary>
private int GetMaskFromConsoleWindow()
{
// Implementation would access ConsoleWindow's filter state
return 7; // Default to show all
}

}
}
26 changes: 24 additions & 2 deletions Packages/src/Editor/FirstPartyTools/Common/Console/LogGetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public static class LogGetter
private static readonly Regex CompilerErrorPattern = new Regex(@":\s*error CS\d+\b", RegexOptions.Compiled);
private static readonly Regex CompilerWarningPattern = new Regex(@":\s*warning CS\d+\b", RegexOptions.Compiled);

// AI clients typically poll get-logs with the same search pattern repeatedly, so the
// last user pattern is kept compiled instead of being re-built on every request.
private static Regex _cachedSearchRegex;

// User-supplied patterns can backtrack pathologically; the match timeout keeps a bad
// pattern from stalling the Editor main thread while scanning every log entry.
private static readonly TimeSpan SearchRegexMatchTimeout = TimeSpan.FromSeconds(2);

static LogGetter()
{
LogRetriever = new ConsoleLogRetriever();
Expand Down Expand Up @@ -254,8 +262,8 @@ public static LogDisplayDto SearchConsoleLogs(string logType, string searchText,
{
if (useRegex)
{
Regex regex = new(searchText);
allEntries = allEntries.FindAll(entry =>
Regex regex = GetOrCreateSearchRegex(searchText);
allEntries = allEntries.FindAll(entry =>
{
bool messageMatch = regex.IsMatch(entry.Message);
bool stackTraceMatch = searchInStackTrace && !string.IsNullOrEmpty(entry.StackTrace) && regex.IsMatch(entry.StackTrace);
Expand All @@ -277,6 +285,20 @@ public static LogDisplayDto SearchConsoleLogs(string logType, string searchText,
return new LogDisplayDto(allEntries.ToArray(), allEntries.Count);
}

/// <summary>
/// Returns a compiled regex for the user search pattern, reusing the previous
/// instance when the pattern is unchanged
/// </summary>
private static Regex GetOrCreateSearchRegex(string searchText)
{
if (_cachedSearchRegex == null || _cachedSearchRegex.ToString() != searchText)
{
_cachedSearchRegex = new Regex(searchText, RegexOptions.Compiled, SearchRegexMatchTimeout);
}

return _cachedSearchRegex;
}

/// <summary>
/// Gets the total number of console logs.
/// </summary>
Expand Down
Loading
Loading