Skip to content
12 changes: 6 additions & 6 deletions Editor/TemporaryCopyAssetsForPlayer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2025 Koji Hasegawa.
// Copyright (c) 2023-2026 Koji Hasegawa.
// This software is released under the MIT License.

using System;
Expand All @@ -22,7 +22,7 @@ namespace TestHelper.Editor
/// </remarks>
internal static class TemporaryCopyAssetsForPlayer
{
internal const string ResourcesRoot = "Assets/com.nowsprinting.test-helper";
internal const string ResourcesParent = "Assets/com.nowsprinting.test-helper";

private static IEnumerable<T> FindAttributesOnFields<T>() where T : Attribute
{
Expand Down Expand Up @@ -50,16 +50,16 @@ internal static void CopyAssetFiles()
{
foreach (var attribute in FindAttributesOnFields<LoadAssetAttribute>())
{
var destFileName = Path.Combine(ResourcesRoot, "Resources", attribute.AssetPath);
var destDir = Path.GetDirectoryName(destFileName);
var destFilePath = Path.Combine(ResourcesParent, "Resources", attribute.ResourcePath);
var destDir = Path.GetDirectoryName(destFilePath);
if (destDir != null && !Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}

if (!AssetDatabase.CopyAsset(attribute.AssetPath, destFileName))
if (!AssetDatabase.CopyAsset(attribute.AssetPath, destFilePath))
{
Debug.LogError($"Failed to copy asset file from '{attribute.AssetPath}' to '{destFileName}'");
Debug.LogError($"Failed to copy asset file from '{attribute.AssetPath}' to '{destFilePath}'");
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Editor/TestRunnerCallbacks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ public void RunFinished(ITestResultAdaptor result)
}

// Delete temporary copied asset files for running play mode tests on player.
if (Directory.Exists(TemporaryCopyAssetsForPlayer.ResourcesRoot))
if (Directory.Exists(TemporaryCopyAssetsForPlayer.ResourcesParent))
{
AssetDatabase.DeleteAsset(TemporaryCopyAssetsForPlayer.ResourcesRoot);
AssetDatabase.DeleteAsset(TemporaryCopyAssetsForPlayer.ResourcesParent);
// Note: delete with .meta file.
}
}
Expand Down
87 changes: 50 additions & 37 deletions Runtime/Attributes/LoadAssetAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright (c) 2023-2025 Koji Hasegawa.
// Copyright (c) 2023-2026 Koji Hasegawa.
// This software is released under the MIT License.

using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using TestHelper.RuntimeInternals;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
Expand All @@ -16,7 +16,10 @@ namespace TestHelper.Attributes
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class LoadAssetAttribute : Attribute
{
#if UNITY_EDITOR
internal string AssetPath { get; private set; }
#endif
internal string ResourcePath { get; private set; }

/// <summary>
/// Loads an asset file at the specified path into the field.
Expand All @@ -38,46 +41,63 @@ public class LoadAssetAttribute : Attribute
/// </param>
/// <param name="callerFilePath">Test file path set by <see cref="CallerFilePathAttribute"/></param>
/// <remarks>
/// When running tests on the player, it temporarily copies asset files to the <c>Resources</c> folder by <see cref="TestHelper.Editor.TemporaryCopyAssetsForPlayer"/>.
/// When running tests on the player, it temporarily copies asset files to the <c>Resources</c> folder by <see cref="Editor.TemporaryCopyAssetsForPlayer"/>.
/// </remarks>
public LoadAssetAttribute(string path, [CallerFilePath] string callerFilePath = null)
{
if (path.StartsWith("."))
#if UNITY_EDITOR
if (path != null && path.StartsWith("."))
{
AssetPath = PathHelper.ResolveUnityPath(path, callerFilePath);
}
else
{
AssetPath = PathHelper.ResolveUnityPath(path);
}
#endif
if (path != null && path.StartsWith("."))
{
AssetPath = GetAbsolutePath(path, callerFilePath);
ResourcePath = BuildResourcePath(path, callerFilePath);
}
else
{
AssetPath = path;
ResourcePath = BuildResourcePath(path);
}
}

internal static string GetAbsolutePath(string relativePath, string callerFilePath)
private static string BuildResourcePath(string relativePath, string callerFilePath)
{
var callerDirectory = Path.GetDirectoryName(callerFilePath);
// ReSharper disable once AssignNullToNotNullAttribute
var absolutePath = Path.GetFullPath(Path.Combine(callerDirectory, relativePath));
var absolutePath = Path.GetFullPath(Path.Combine(callerDirectory!, relativePath));

var assetsIndexOf = absolutePath.IndexOf("Assets", StringComparison.Ordinal);
if (assetsIndexOf > 0)
var assetsIndexOf = absolutePath.IndexOf(
$"{Path.DirectorySeparatorChar}Assets{Path.DirectorySeparatorChar}",
StringComparison.Ordinal);
if (assetsIndexOf >= 0)
{
return ConvertToUnixPathSeparator(absolutePath.Substring(assetsIndexOf));
return BuildResourcePath(absolutePath.Substring(assetsIndexOf + 1));
}

var packageIndexOf = absolutePath.IndexOf("Packages", StringComparison.Ordinal);
if (packageIndexOf > 0)
// Next, look for Packages/ (ensure it's a directory, not part of another name like "LocalPackages")
var packageIndexOf =
absolutePath.IndexOf($"{Path.DirectorySeparatorChar}Packages{Path.DirectorySeparatorChar}",
StringComparison.Ordinal);
if (packageIndexOf >= 0)
{
return ConvertToUnixPathSeparator(absolutePath.Substring(packageIndexOf));
return BuildResourcePath(absolutePath.Substring(packageIndexOf + 1));
}

Debug.LogError($"Can not resolve absolute path. relative: {relativePath}, caller: {callerFilePath}");
return null;
// Note: Do not use Exception (and Assert). Because freezes async tests on UTF v1.3.4, See UUM-25085.
return BuildResourcePath(absolutePath);
}

string ConvertToUnixPathSeparator(string path)
{
return path.Replace('\\', '/'); // Normalize path separator
}
private static string BuildResourcePath(string absolutePath)
{
// Convert to resource path (remove extension)
var directoryName = Path.Join("com.nowsprinting.test-helper", Path.GetDirectoryName(absolutePath)); // TODO: Path.Join
var filenameWithoutExtension = Path.GetFileNameWithoutExtension(absolutePath);
return string.IsNullOrEmpty(directoryName)
? filenameWithoutExtension
: $"{directoryName.Replace('\\', '/')}/{filenameWithoutExtension}";
}

/// <summary>
Expand Down Expand Up @@ -107,28 +127,21 @@ public static void LoadAssets(object testClassInstance)
}
#if UNITY_EDITOR
var asset = AssetDatabase.LoadAssetAtPath(attribute.AssetPath, field.FieldType);
#else
var resourcePath = GetResourcePath(attribute.AssetPath);
var asset = Resources.Load(resourcePath, field.FieldType);
#endif
if (asset == null)
{
Debug.LogError($"Failed to load asset at path: {attribute.AssetPath} type: {field.FieldType}");
continue;
}

#else
var asset = Resources.Load(attribute.ResourcePath, field.FieldType);
if (asset == null)
{
Debug.LogError($"Failed to load asset at path: {attribute.ResourcePath} type: {field.FieldType}");
continue;
}
#endif
field.SetValue(testClassInstance, asset);
}
}

[SuppressMessage("ReSharper", "UnusedMember.Local")]
private static string GetResourcePath(string assetPath)
{
var directoryName = Path.GetDirectoryName(assetPath);
var filenameWithoutExtension = Path.GetFileNameWithoutExtension(assetPath);
return string.IsNullOrEmpty(directoryName)
? filenameWithoutExtension
: $"{directoryName.Replace('\\', '/')}/{filenameWithoutExtension}";
}
}
}
167 changes: 166 additions & 1 deletion RuntimeInternals/PathHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2025 Koji Hasegawa.
// Copyright (c) 2023-2026 Koji Hasegawa.
// This software is released under the MIT License.

using System;
Expand Down Expand Up @@ -143,5 +143,170 @@ private static string GetFilename(string callerMemberName)
return name;
}
#endif

/// <summary>
/// Resolves a relative path from the caller file to Unity path format (Assets/ or Packages/).
/// </summary>
/// <param name="relativePath">Relative path from caller file</param>
/// <param name="callerFilePath">Caller file path</param>
/// <returns>Unity path format (e.g., "Assets/...", "Packages/...")</returns>
internal static string ResolveUnityPath(string relativePath, string callerFilePath)
{
var callerDirectory = Path.GetDirectoryName(callerFilePath);
var absolutePath = Path.GetFullPath(Path.Combine(callerDirectory!, relativePath));
return ResolveUnityPath(absolutePath);
}

/// <summary>
/// Resolves an absolute path to Unity path format (Assets/ or Packages/).
/// </summary>
/// <param name="absolutePath">Absolute file path</param>
/// <returns>Unity path format (e.g., "Assets/...", "Packages/...")</returns>
internal static string ResolveUnityPath(string absolutePath)
{
if (!Application.isEditor)
{
throw new InvalidOperationException("ResolveUnityPath can be used only in the Editor.");
}

if (absolutePath.StartsWith($"Assets{Path.DirectorySeparatorChar}") ||
absolutePath.StartsWith($"Packages{Path.DirectorySeparatorChar}"))
{
// Already in Unity path format
return absolutePath;
}

// First, look for Assets/ (ensure it's a directory, not part of another name)
var assetsIndexOf =
absolutePath.IndexOf($"{Path.DirectorySeparatorChar}Assets{Path.DirectorySeparatorChar}",
StringComparison.Ordinal);
if (assetsIndexOf >= 0)
{
return ConvertToUnixPathSeparator(absolutePath.Substring(assetsIndexOf + 1));
}

// Next, look for Packages/ (ensure it's a directory, not part of another name like "LocalPackages")
var packageIndexOf =
absolutePath.IndexOf($"{Path.DirectorySeparatorChar}Packages{Path.DirectorySeparatorChar}",
StringComparison.Ordinal);
if (packageIndexOf >= 0)
{
return ConvertToUnixPathSeparator(absolutePath.Substring(packageIndexOf + 1));
}

// If neither found, it might be an Embedded package
// Find project root and convert to Packages/{packageName}/ format
var projectRoot = FindProjectRoot(absolutePath);
if (projectRoot != null)
{
var relativeFromRoot = absolutePath.Substring(projectRoot.Length)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var unityPath = ConvertToUnityPath(relativeFromRoot, projectRoot);
if (unityPath != null)
{
return unityPath;
}
}
Comment on lines +197 to +209
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new embedded package functionality introduced in ResolveUnityPath (lines 173-184) lacks test coverage. The existing test cases only cover standard Assets/ and Packages/ paths, but don't test the embedded package scenario where neither "Assets" nor "Packages" is found in the absolute path. Consider adding test cases for embedded package paths to verify this functionality works correctly.

Copilot uses AI. Check for mistakes.

Debug.LogError($"Can not resolve from absolute path: {absolutePath}");
return null;
// Note: Do not use Exception (and Assert). Because freezes async tests on UTF v1.3.4, See UUM-25085.

// Local function
string ConvertToUnixPathSeparator(string path)
{
return path.Replace('\\', '/');
}
}

private static string FindProjectRoot(string absolutePath)
{
var directory = Path.GetDirectoryName(absolutePath);
while (!string.IsNullOrEmpty(directory))
{
// Project root has Assets/ directory or Packages/manifest.json
if (Directory.Exists(Path.Combine(directory, "Assets")) ||
File.Exists(Path.Combine(directory, "Packages", "manifest.json")))
{
return directory;
}

directory = Path.GetDirectoryName(directory);
}

return null;
}

private static string ConvertToUnityPath(string relativePathFromRoot, string projectRoot)
{
relativePathFromRoot = relativePathFromRoot.Replace('\\', '/');
var segments = relativePathFromRoot.Split('/');
if (segments.Length < 2)
{
return null;
}

// Search for package.json at any depth (from deep to shallow)
for (var depth = segments.Length - 1; depth >= 1; depth--)
{
var packageDirSegments = new string[depth];
Array.Copy(segments, 0, packageDirSegments, 0, depth);
var packageDirRelative = string.Join("/", packageDirSegments);

// Use absolute path for File.Exists check
var packageJsonPath = Path.Combine(projectRoot, packageDirRelative, "package.json");

if (File.Exists(packageJsonPath))
{
var packageName = GetPackageNameFromJson(packageJsonPath);
if (!string.IsNullOrEmpty(packageName))
{
var remainingSegments = new string[segments.Length - depth];
Array.Copy(segments, depth, remainingSegments, 0, remainingSegments.Length);
return "Packages/" + packageName + "/" + string.Join("/", remainingSegments);
}
}
}

return null;
}

private static string GetPackageNameFromJson(string packageJsonPath)
{
try
{
var json = File.ReadAllText(packageJsonPath);
// Simple JSON parsing to extract "name": "..."
var nameIndex = json.IndexOf("\"name\"", StringComparison.Ordinal);
if (nameIndex < 0)
{
return null;
}

var colonIndex = json.IndexOf(":", nameIndex, StringComparison.Ordinal);
if (colonIndex < 0)
{
return null;
}

var openQuoteIndex = json.IndexOf("\"", colonIndex, StringComparison.Ordinal);
if (openQuoteIndex < 0)
{
return null;
}

var closeQuoteIndex = json.IndexOf("\"", openQuoteIndex + 1, StringComparison.Ordinal);
if (closeQuoteIndex < 0)
{
return null;
}

return json.Substring(openQuoteIndex + 1, closeQuoteIndex - openQuoteIndex - 1);
}
catch
{
Comment on lines +306 to +307
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block on line 274 silently swallows all exceptions without logging. While the method returns null to indicate failure, this makes debugging difficult when issues occur during file reading or JSON parsing. Consider logging the exception using Debug.LogWarning or Debug.LogError to help diagnose problems in production.

Suggested change
catch
{
catch (Exception ex)
{
Debug.LogWarning($"Failed to read or parse package.json at '{packageJsonPath}': {ex}");

Copilot uses AI. Check for mistakes.
return null;
}
}
Comment on lines +279 to +310
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The simple string-based JSON parsing in GetPackageNameFromJson is fragile and may fail with valid JSON that has whitespace variations, comments, or different formatting. For example, it won't handle cases where there are spaces after the colon (: "name"), or where the "name" field is not the first field with quotes. Consider using a proper JSON parser (like Unity's JsonUtility or a lightweight JSON library) for more robust parsing, or at minimum, add logic to skip whitespace after the colon.

Suggested change
// Simple JSON parsing to extract "name": "..."
var nameIndex = json.IndexOf("\"name\"", StringComparison.Ordinal);
if (nameIndex < 0)
{
return null;
}
var colonIndex = json.IndexOf(":", nameIndex, StringComparison.Ordinal);
if (colonIndex < 0)
{
return null;
}
var openQuoteIndex = json.IndexOf("\"", colonIndex, StringComparison.Ordinal);
if (openQuoteIndex < 0)
{
return null;
}
var closeQuoteIndex = json.IndexOf("\"", openQuoteIndex + 1, StringComparison.Ordinal);
if (closeQuoteIndex < 0)
{
return null;
}
return json.Substring(openQuoteIndex + 1, closeQuoteIndex - openQuoteIndex - 1);
}
catch
{
return null;
}
}
var packageInfo = JsonUtility.FromJson<PackageJson>(json);
if (packageInfo == null || string.IsNullOrEmpty(packageInfo.name))
{
return null;
}
return packageInfo.name;
}
catch
{
return null;
}
}
[Serializable]
private class PackageJson
{
public string name;
}

Copilot uses AI. Check for mistakes.
}
}
Loading
Loading