@mflrevan/ucp 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.5.0` of the Unity Control Protocol CLI.
3
+ Version `0.5.1` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.5.0";
29
+ private const string ProtocolVersion = "0.5.1";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -2,6 +2,7 @@ using System;
2
2
  using System.Collections.Generic;
3
3
  using System.IO;
4
4
  using System.Reflection;
5
+ using System.Text.RegularExpressions;
5
6
  using UnityEditor;
6
7
  using UnityEngine;
7
8
 
@@ -28,6 +29,7 @@ namespace UCP.Bridge
28
29
  string typeFilter = null;
29
30
  string nameFilter = null;
30
31
  string pathFilter = null;
32
+ bool useRegex = false;
31
33
  int maxResults = 100;
32
34
 
33
35
  if (p != null)
@@ -38,13 +40,28 @@ namespace UCP.Bridge
38
40
  nameFilter = nObj.ToString();
39
41
  if (p.TryGetValue("path", out var pObj) && pObj != null)
40
42
  pathFilter = pObj.ToString();
43
+ if (p.TryGetValue("regex", out var regexObj) && regexObj != null)
44
+ useRegex = Convert.ToBoolean(regexObj);
41
45
  if (p.TryGetValue("maxResults", out var mObj))
42
46
  maxResults = Convert.ToInt32(mObj);
43
47
  }
44
48
 
49
+ Regex nameRegex = null;
50
+ if (useRegex && !string.IsNullOrEmpty(nameFilter))
51
+ {
52
+ try
53
+ {
54
+ nameRegex = new Regex(nameFilter, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
55
+ }
56
+ catch (ArgumentException ex)
57
+ {
58
+ throw new ArgumentException($"Invalid regex for name filter: {ex.Message}");
59
+ }
60
+ }
61
+
45
62
  // Build search filter
46
63
  string filter = "";
47
- if (!string.IsNullOrEmpty(nameFilter))
64
+ if (!useRegex && !string.IsNullOrEmpty(nameFilter))
48
65
  filter += nameFilter + " ";
49
66
  if (!string.IsNullOrEmpty(typeFilter))
50
67
  filter += $"t:{GetSearchTypeFilter(typeFilter)}";
@@ -64,7 +81,7 @@ namespace UCP.Bridge
64
81
  for (int i = 0; i < guids.Length; i++)
65
82
  {
66
83
  string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
67
- foreach (var asset in GetMatchingAssets(assetPath, typeFilter, nameFilter))
84
+ foreach (var asset in GetMatchingAssets(assetPath, typeFilter, nameFilter, nameRegex))
68
85
  {
69
86
  totalMatches++;
70
87
  if (results.Count >= maxResults)
@@ -354,6 +371,7 @@ namespace UCP.Bridge
354
371
  throw new ArgumentException("Missing 'moves' parameter");
355
372
 
356
373
  var continueOnError = TryGetOptionalBool(parameters, "continueOnError");
374
+ var dryRun = TryGetOptionalBool(parameters, "dryRun");
357
375
  var requests = ParseMoveRequests(movesObject);
358
376
  var results = new List<object>();
359
377
  var errors = new List<object>();
@@ -361,15 +379,14 @@ namespace UCP.Bridge
361
379
  var stopped = false;
362
380
  var anyChanged = false;
363
381
 
364
- AssetDatabase.StartAssetEditing();
365
- try
382
+ if (dryRun)
366
383
  {
367
384
  for (var index = 0; index < requests.Count; index++)
368
385
  {
369
386
  var request = requests[index];
370
387
  try
371
388
  {
372
- var moveResult = MoveAssetInternal(request.SourcePath, request.Destination, false);
389
+ var moveResult = PreviewMoveInternal(request.SourcePath, request.Destination);
373
390
  results.Add(AddMoveIndex(moveResult, index));
374
391
  if (GetBool(moveResult, "changed"))
375
392
  {
@@ -395,12 +412,75 @@ namespace UCP.Bridge
395
412
  }
396
413
  }
397
414
  }
398
- finally
415
+ else
399
416
  {
400
- AssetDatabase.StopAssetEditing();
417
+ var preparedMoves = new List<PreparedMove>();
418
+ for (var index = 0; index < requests.Count; index++)
419
+ {
420
+ var request = requests[index];
421
+ try
422
+ {
423
+ var preparedMove = PrepareMove(request.SourcePath, request.Destination, true);
424
+ preparedMoves.Add(new PreparedMove(index, request.SourcePath, preparedMove));
425
+ }
426
+ catch (Exception ex)
427
+ {
428
+ errors.Add(new Dictionary<string, object>
429
+ {
430
+ ["index"] = index,
431
+ ["sourcePath"] = request.SourcePath,
432
+ ["destinationPath"] = request.Destination,
433
+ ["message"] = ex.Message
434
+ });
435
+
436
+ if (!continueOnError)
437
+ {
438
+ stopped = true;
439
+ break;
440
+ }
441
+ }
442
+ }
443
+
444
+ AssetDatabase.StartAssetEditing();
445
+ try
446
+ {
447
+ foreach (var preparedMove in preparedMoves)
448
+ {
449
+ try
450
+ {
451
+ var moveResult = MovePreparedAsset(preparedMove.SourcePath, preparedMove.Move, false);
452
+ results.Add(AddMoveIndex(moveResult, preparedMove.Index));
453
+ if (GetBool(moveResult, "changed"))
454
+ {
455
+ movedCount++;
456
+ anyChanged = true;
457
+ }
458
+ }
459
+ catch (Exception ex)
460
+ {
461
+ errors.Add(new Dictionary<string, object>
462
+ {
463
+ ["index"] = preparedMove.Index,
464
+ ["sourcePath"] = preparedMove.SourcePath,
465
+ ["destinationPath"] = preparedMove.Move.ResolvedDestination,
466
+ ["message"] = ex.Message
467
+ });
468
+
469
+ if (!continueOnError)
470
+ {
471
+ stopped = true;
472
+ break;
473
+ }
474
+ }
475
+ }
476
+ }
477
+ finally
478
+ {
479
+ AssetDatabase.StopAssetEditing();
480
+ }
401
481
  }
402
482
 
403
- if (anyChanged)
483
+ if (!dryRun && anyChanged)
404
484
  {
405
485
  AssetDatabase.SaveAssets();
406
486
  AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
@@ -412,6 +492,7 @@ namespace UCP.Bridge
412
492
  ["requested"] = requests.Count,
413
493
  ["moved"] = movedCount,
414
494
  ["failed"] = errors.Count,
495
+ ["dryRun"] = dryRun,
415
496
  ["stopped"] = stopped,
416
497
  ["results"] = results,
417
498
  ["errors"] = errors
@@ -431,10 +512,19 @@ namespace UCP.Bridge
431
512
  }
432
513
  }
433
514
 
434
- private static IEnumerable<UnityEngine.Object> GetMatchingAssets(string assetPath, string typeFilter, string nameFilter)
515
+ private static IEnumerable<UnityEngine.Object> GetMatchingAssets(
516
+ string assetPath,
517
+ string typeFilter,
518
+ string nameFilter,
519
+ Regex nameRegex)
435
520
  {
436
521
  UnityEngine.Object[] candidates;
437
- if (string.IsNullOrEmpty(typeFilter) && string.IsNullOrEmpty(nameFilter))
522
+ if (string.IsNullOrEmpty(typeFilter) && string.IsNullOrEmpty(nameFilter) && nameRegex == null)
523
+ {
524
+ var mainAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
525
+ candidates = mainAsset != null ? new[] { mainAsset } : Array.Empty<UnityEngine.Object>();
526
+ }
527
+ else if (assetPath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase))
438
528
  {
439
529
  var mainAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
440
530
  candidates = mainAsset != null ? new[] { mainAsset } : Array.Empty<UnityEngine.Object>();
@@ -447,17 +537,24 @@ namespace UCP.Bridge
447
537
  foreach (var candidate in candidates)
448
538
  {
449
539
  if (candidate == null) continue;
450
- if (!MatchesName(candidate, assetPath, nameFilter)) continue;
540
+ if (!MatchesName(candidate, assetPath, nameFilter, nameRegex)) continue;
451
541
  if (!MatchesType(candidate, assetPath, typeFilter)) continue;
452
542
  yield return candidate;
453
543
  }
454
544
  }
455
545
 
456
- private static bool MatchesName(UnityEngine.Object asset, string assetPath, string nameFilter)
546
+ private static bool MatchesName(UnityEngine.Object asset, string assetPath, string nameFilter, Regex nameRegex)
457
547
  {
458
- if (string.IsNullOrEmpty(nameFilter))
548
+ if (string.IsNullOrEmpty(nameFilter) && nameRegex == null)
459
549
  return true;
460
550
 
551
+ if (nameRegex != null)
552
+ {
553
+ var fileStem = System.IO.Path.GetFileNameWithoutExtension(assetPath);
554
+ return nameRegex.IsMatch(asset.name)
555
+ || (!string.IsNullOrEmpty(fileStem) && nameRegex.IsMatch(fileStem));
556
+ }
557
+
461
558
  return asset.name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)
462
559
  || System.IO.Path.GetFileNameWithoutExtension(assetPath).Contains(nameFilter, StringComparison.OrdinalIgnoreCase);
463
560
  }
@@ -507,30 +604,29 @@ namespace UCP.Bridge
507
604
 
508
605
  private static Dictionary<string, object> MoveAssetInternal(string sourcePath, string destination, bool finalize)
509
606
  {
510
- var normalizedSource = NormalizeMovePath(sourcePath);
511
- var isFolder = AssetDatabase.IsValidFolder(normalizedSource);
512
- if (!isFolder && !AssetDatabase.AssetPathExists(normalizedSource))
513
- throw new ArgumentException($"Asset not found: {normalizedSource}");
514
-
515
- if (!IsMovableAssetPath(normalizedSource))
516
- throw new ArgumentException($"Asset moves are only supported under Assets/: {normalizedSource}");
517
-
518
- var identity = DescribeExistingAsset(normalizedSource, isFolder);
519
- var resolvedDestination = ResolveDestinationPath(normalizedSource, destination);
520
- if (!IsMovableAssetPath(resolvedDestination))
521
- throw new ArgumentException($"Destination must be under Assets/: {resolvedDestination}");
522
-
523
- if (string.Equals(normalizedSource, resolvedDestination, StringComparison.OrdinalIgnoreCase))
524
- {
525
- return DescribeMovedAsset(normalizedSource, resolvedDestination, false, identity);
526
- }
607
+ var move = PrepareMove(sourcePath, destination, true);
608
+ return MovePreparedAsset(NormalizeMovePath(sourcePath), move, finalize);
609
+ }
527
610
 
528
- if (AssetDatabase.AssetPathExists(resolvedDestination) || AssetDatabase.IsValidFolder(resolvedDestination))
529
- throw new ArgumentException($"Destination already exists: {resolvedDestination}");
611
+ private static Dictionary<string, object> PreviewMoveInternal(string sourcePath, string destination)
612
+ {
613
+ var normalizedSource = NormalizeMovePath(sourcePath);
614
+ var move = PrepareMove(normalizedSource, destination, false);
615
+ return DescribeMovedAsset(normalizedSource, move.ResolvedDestination, move.Changed, move.Identity);
616
+ }
530
617
 
531
- EnsureParentFoldersExist(resolvedDestination);
618
+ private static ValidatedMove PrepareMove(string sourcePath, string destination, bool ensureParentFolders)
619
+ {
620
+ var normalizedSource = NormalizeMovePath(sourcePath);
621
+ var move = ValidateMoveRequest(normalizedSource, destination);
622
+ if (ensureParentFolders)
623
+ EnsureParentFoldersExist(move.ResolvedDestination);
624
+ return move;
625
+ }
532
626
 
533
- var moveError = AssetDatabase.MoveAsset(normalizedSource, resolvedDestination);
627
+ private static Dictionary<string, object> MovePreparedAsset(string normalizedSource, ValidatedMove move, bool finalize)
628
+ {
629
+ var moveError = AssetDatabase.MoveAsset(normalizedSource, move.ResolvedDestination);
534
630
  if (!string.IsNullOrEmpty(moveError))
535
631
  throw new InvalidOperationException(moveError);
536
632
 
@@ -540,7 +636,7 @@ namespace UCP.Bridge
540
636
  AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
541
637
  }
542
638
 
543
- return DescribeMovedAsset(normalizedSource, resolvedDestination, true, identity);
639
+ return DescribeMovedAsset(normalizedSource, move.ResolvedDestination, true, move.Identity);
544
640
  }
545
641
 
546
642
  private static Dictionary<string, object> DescribeMovedAsset(
@@ -648,6 +744,29 @@ namespace UCP.Bridge
648
744
  return path?.Trim().Replace('\\', '/');
649
745
  }
650
746
 
747
+ private static ValidatedMove ValidateMoveRequest(string normalizedSource, string destination)
748
+ {
749
+ var isFolder = AssetDatabase.IsValidFolder(normalizedSource);
750
+ if (!isFolder && !AssetDatabase.AssetPathExists(normalizedSource))
751
+ throw new ArgumentException(BuildAssetNotFoundMessage(normalizedSource));
752
+
753
+ if (!IsMovableAssetPath(normalizedSource))
754
+ throw new ArgumentException($"Asset moves are only supported under Assets/: {normalizedSource}");
755
+
756
+ var identity = DescribeExistingAsset(normalizedSource, isFolder);
757
+ var resolvedDestination = ResolveDestinationPath(normalizedSource, destination);
758
+ if (!IsMovableAssetPath(resolvedDestination))
759
+ throw new ArgumentException($"Destination must be under Assets/: {resolvedDestination}");
760
+
761
+ if (string.Equals(normalizedSource, resolvedDestination, StringComparison.OrdinalIgnoreCase))
762
+ return new ValidatedMove(identity, resolvedDestination, false);
763
+
764
+ if (AssetDatabase.AssetPathExists(resolvedDestination) || AssetDatabase.IsValidFolder(resolvedDestination))
765
+ throw new ArgumentException($"Destination already exists: {resolvedDestination}");
766
+
767
+ return new ValidatedMove(identity, resolvedDestination, true);
768
+ }
769
+
651
770
  private static bool IsMovableAssetPath(string path)
652
771
  {
653
772
  return !string.IsNullOrEmpty(path)
@@ -655,6 +774,60 @@ namespace UCP.Bridge
655
774
  || path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase));
656
775
  }
657
776
 
777
+ private static string BuildAssetNotFoundMessage(string normalizedSource)
778
+ {
779
+ var message = $"Asset not found: {normalizedSource}.";
780
+ var suggestions = FindSimilarAssetPaths(normalizedSource, 3);
781
+ if (suggestions.Count > 0)
782
+ message += " Did you mean: " + string.Join(", ", suggestions) + "?";
783
+ message += " If the asset was created or renamed outside Unity, refresh or reimport so AssetDatabase can pick up the latest path.";
784
+ return message;
785
+ }
786
+
787
+ private static List<string> FindSimilarAssetPaths(string normalizedSource, int maxSuggestions)
788
+ {
789
+ var targetName = Path.GetFileNameWithoutExtension(normalizedSource) ?? normalizedSource;
790
+ var targetFileName = Path.GetFileName(normalizedSource) ?? normalizedSource;
791
+ var targetExtension = Path.GetExtension(normalizedSource) ?? string.Empty;
792
+ var matches = new List<(string path, int score)>();
793
+
794
+ foreach (var candidate in AssetDatabase.GetAllAssetPaths())
795
+ {
796
+ if (!candidate.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
797
+ continue;
798
+
799
+ var score = 0;
800
+ if (candidate.IndexOf(targetName, StringComparison.OrdinalIgnoreCase) >= 0)
801
+ score += 3;
802
+ if (string.Equals(Path.GetFileName(candidate), targetFileName, StringComparison.OrdinalIgnoreCase))
803
+ score += 4;
804
+ if (string.Equals(Path.GetExtension(candidate), targetExtension, StringComparison.OrdinalIgnoreCase))
805
+ score += 1;
806
+ if (score > 0)
807
+ matches.Add((candidate, score));
808
+ }
809
+
810
+ matches.Sort((left, right) =>
811
+ {
812
+ var scoreCompare = right.score.CompareTo(left.score);
813
+ return scoreCompare != 0
814
+ ? scoreCompare
815
+ : string.Compare(left.path, right.path, StringComparison.OrdinalIgnoreCase);
816
+ });
817
+
818
+ var suggestions = new List<string>();
819
+ foreach (var match in matches)
820
+ {
821
+ if (suggestions.Contains(match.path))
822
+ continue;
823
+ suggestions.Add(match.path);
824
+ if (suggestions.Count >= maxSuggestions)
825
+ break;
826
+ }
827
+
828
+ return suggestions;
829
+ }
830
+
658
831
  private static Dictionary<string, object> ParseParameters(string paramsJson)
659
832
  {
660
833
  return MiniJson.Deserialize(paramsJson) as Dictionary<string, object>
@@ -750,6 +923,20 @@ namespace UCP.Bridge
750
923
  public string Destination { get; }
751
924
  }
752
925
 
926
+ private readonly struct PreparedMove
927
+ {
928
+ public PreparedMove(int index, string sourcePath, ValidatedMove move)
929
+ {
930
+ Index = index;
931
+ SourcePath = sourcePath;
932
+ Move = move;
933
+ }
934
+
935
+ public int Index { get; }
936
+ public string SourcePath { get; }
937
+ public ValidatedMove Move { get; }
938
+ }
939
+
753
940
  private readonly struct AssetIdentity
754
941
  {
755
942
  public AssetIdentity(string guid, string name, string type, bool isFolder)
@@ -766,5 +953,19 @@ namespace UCP.Bridge
766
953
  public bool IsFolder { get; }
767
954
  }
768
955
 
956
+ private readonly struct ValidatedMove
957
+ {
958
+ public ValidatedMove(AssetIdentity identity, string resolvedDestination, bool changed)
959
+ {
960
+ Identity = identity;
961
+ ResolvedDestination = resolvedDestination;
962
+ Changed = changed;
963
+ }
964
+
965
+ public AssetIdentity Identity { get; }
966
+ public string ResolvedDestination { get; }
967
+ public bool Changed { get; }
968
+ }
969
+
769
970
  }
770
971
  }
@@ -80,6 +80,47 @@ namespace UCP.Bridge
80
80
  return CreateReimportResult(requestedPath, assetPath, true, false, null);
81
81
  }
82
82
 
83
+ public static Dictionary<string, object> ReimportRecursive(string requestedPath, bool forceSynchronous = true)
84
+ {
85
+ var assetPath = GetPrimaryAssetPath(requestedPath);
86
+ if (string.IsNullOrEmpty(assetPath))
87
+ throw new ArgumentException("Missing 'path' parameter");
88
+ if (!IsProjectAssetPath(assetPath))
89
+ throw new ArgumentException($"Path is not under Assets/ or Packages/: {requestedPath}");
90
+
91
+ if (!AssetDatabase.IsValidFolder(assetPath))
92
+ return Reimport(requestedPath, forceSynchronous);
93
+
94
+ var options = ImportAssetOptions.ForceUpdate;
95
+ if (forceSynchronous)
96
+ options |= ImportAssetOptions.ForceSynchronousImport;
97
+
98
+ var guidResults = AssetDatabase.FindAssets(string.Empty, new[] { assetPath });
99
+ var importedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
100
+
101
+ foreach (var guid in guidResults)
102
+ {
103
+ var childPath = AssetDatabase.GUIDToAssetPath(guid);
104
+ if (string.IsNullOrEmpty(childPath) || AssetDatabase.IsValidFolder(childPath))
105
+ continue;
106
+
107
+ AssetDatabase.ImportAsset(childPath, options);
108
+ importedPaths.Add(childPath);
109
+ RecordReimport(childPath);
110
+ }
111
+
112
+ return new Dictionary<string, object>
113
+ {
114
+ ["requestedPath"] = requestedPath,
115
+ ["assetPath"] = assetPath,
116
+ ["recursive"] = true,
117
+ ["requested"] = importedPaths.Count,
118
+ ["reimported"] = importedPaths.Count,
119
+ ["skipped"] = 0,
120
+ ["paths"] = new List<object>(importedPaths)
121
+ };
122
+ }
123
+
83
124
  public static Dictionary<string, object> SaveImporterSettings(string requestedPath, AssetImporter importer, bool noReimport)
84
125
  {
85
126
  if (importer == null)
@@ -18,7 +18,10 @@ namespace UCP.Bridge
18
18
  {
19
19
  var parameters = ParseParameters(paramsJson);
20
20
  var requestedPath = RequirePath(parameters);
21
- return AssetImportSupport.Reimport(requestedPath);
21
+ var recursive = TryGetOptionalBool(parameters, "recursive");
22
+ return recursive
23
+ ? AssetImportSupport.ReimportRecursive(requestedPath)
24
+ : AssetImportSupport.Reimport(requestedPath);
22
25
  }
23
26
 
24
27
  private static object HandleRead(string paramsJson)
@@ -45,6 +45,27 @@ namespace UCP.Bridge
45
45
  return RecordLog(level, message, stackTrace);
46
46
  }
47
47
 
48
+ public static long GetLatestId()
49
+ {
50
+ lock (s_historyLock)
51
+ {
52
+ return s_history.Count == 0 ? 0 : s_history[s_history.Count - 1].Id;
53
+ }
54
+ }
55
+
56
+ public static Dictionary<string, object> BuildStatusSummary(long afterId = 0)
57
+ {
58
+ lock (s_historyLock)
59
+ {
60
+ var ordered = s_history
61
+ .Where(entry => entry.Id > afterId)
62
+ .OrderBy(entry => entry.Id)
63
+ .ToList();
64
+
65
+ return BuildStatusResult(ordered, afterId);
66
+ }
67
+ }
68
+
48
69
  private static object HandleTail(string paramsJson)
49
70
  {
50
71
  var query = ParseQuery(paramsJson, includePattern: false);
@@ -77,82 +98,12 @@ namespace UCP.Bridge
77
98
 
78
99
  private static object HandleStatus(string paramsJson)
79
100
  {
80
- lock (s_historyLock)
81
- {
82
- var ordered = s_history.OrderBy(entry => entry.Id).ToList();
83
- var byLevel = new Dictionary<string, object>
84
- {
85
- ["info"] = ordered.Count(entry => entry.Level == "info"),
86
- ["warning"] = ordered.Count(entry => entry.Level == "warning"),
87
- ["error"] = ordered.Count(entry => entry.Level == "error"),
88
- ["exception"] = ordered.Count(entry => entry.Level == "exception")
89
- };
90
-
91
- var grouped = ordered
92
- .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
93
- .Select(group =>
94
- {
95
- var first = group.First();
96
- var last = group.Last();
97
- return new Dictionary<string, object>
98
- {
99
- ["level"] = first.Level,
100
- ["fingerprint"] = Fingerprint(first.Message),
101
- ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
102
- ["count"] = group.Count(),
103
- ["firstTimestamp"] = first.Timestamp,
104
- ["lastTimestamp"] = last.Timestamp,
105
- ["latestId"] = last.Id
106
- };
107
- })
108
- .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
109
- .ThenBy(entry => entry["sampleMessage"].ToString())
110
- .ToList();
111
-
112
- var result = new Dictionary<string, object>
113
- {
114
- ["total"] = ordered.Count,
115
- ["byLevel"] = byLevel,
116
- ["uniqueCount"] = grouped.Count,
117
- ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
118
- };
119
-
120
- if (ordered.Count > 0)
121
- {
122
- var first = ordered.First();
123
- var last = ordered.Last();
124
- result["firstTimestamp"] = first.Timestamp;
125
- result["lastTimestamp"] = last.Timestamp;
126
- result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
127
- result["latestId"] = last.Id;
128
- }
129
-
130
- var playSession = PlayModeController.GetSessionSnapshot();
131
- result["play"] = SerializePlaySession(playSession);
132
-
133
- if (playSession.LastEnteredPlayAtUtc.HasValue)
134
- {
135
- var sessionEnd = playSession.Playing
136
- ? DateTime.UtcNow
137
- : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
138
- var sessionLogs = ordered
139
- .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
140
- && entry.TimestampUtc <= sessionEnd)
141
- .ToList();
142
-
143
- result["lastPlayWindow"] = new Dictionary<string, object>
144
- {
145
- ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
146
- ["endedAt"] = sessionEnd.ToString("o"),
147
- ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
148
- ["total"] = sessionLogs.Count,
149
- ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
150
- ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
151
- };
152
- }
101
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
102
+ long afterId = 0;
103
+ if (p != null && p.TryGetValue("afterId", out var afterIdObj) && afterIdObj != null)
104
+ afterId = Math.Max(0, Convert.ToInt64(afterIdObj));
153
105
 
154
- return result;
155
- }
106
+ return BuildStatusSummary(afterId);
156
107
  }
157
108
 
158
109
  private static Dictionary<string, object> RecordLog(string level, string message, string stackTrace)
@@ -257,6 +208,83 @@ namespace UCP.Bridge
257
208
  };
258
209
  }
259
210
 
211
+ private static Dictionary<string, object> BuildStatusResult(List<LogRecord> ordered, long afterId)
212
+ {
213
+ var byLevel = new Dictionary<string, object>
214
+ {
215
+ ["info"] = ordered.Count(entry => entry.Level == "info"),
216
+ ["warning"] = ordered.Count(entry => entry.Level == "warning"),
217
+ ["error"] = ordered.Count(entry => entry.Level == "error"),
218
+ ["exception"] = ordered.Count(entry => entry.Level == "exception")
219
+ };
220
+
221
+ var grouped = ordered
222
+ .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
223
+ .Select(group =>
224
+ {
225
+ var first = group.First();
226
+ var last = group.Last();
227
+ return new Dictionary<string, object>
228
+ {
229
+ ["level"] = first.Level,
230
+ ["fingerprint"] = Fingerprint(first.Message),
231
+ ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
232
+ ["count"] = group.Count(),
233
+ ["firstTimestamp"] = first.Timestamp,
234
+ ["lastTimestamp"] = last.Timestamp,
235
+ ["latestId"] = last.Id
236
+ };
237
+ })
238
+ .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
239
+ .ThenBy(entry => entry["sampleMessage"].ToString())
240
+ .ToList();
241
+
242
+ var result = new Dictionary<string, object>
243
+ {
244
+ ["afterId"] = afterId,
245
+ ["total"] = ordered.Count,
246
+ ["byLevel"] = byLevel,
247
+ ["uniqueCount"] = grouped.Count,
248
+ ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
249
+ };
250
+
251
+ if (ordered.Count > 0)
252
+ {
253
+ var first = ordered.First();
254
+ var last = ordered.Last();
255
+ result["firstTimestamp"] = first.Timestamp;
256
+ result["lastTimestamp"] = last.Timestamp;
257
+ result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
258
+ result["latestId"] = last.Id;
259
+ }
260
+
261
+ var playSession = PlayModeController.GetSessionSnapshot();
262
+ result["play"] = SerializePlaySession(playSession);
263
+
264
+ if (playSession.LastEnteredPlayAtUtc.HasValue)
265
+ {
266
+ var sessionEnd = playSession.Playing
267
+ ? DateTime.UtcNow
268
+ : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
269
+ var sessionLogs = ordered
270
+ .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
271
+ && entry.TimestampUtc <= sessionEnd)
272
+ .ToList();
273
+
274
+ result["lastPlayWindow"] = new Dictionary<string, object>
275
+ {
276
+ ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
277
+ ["endedAt"] = sessionEnd.ToString("o"),
278
+ ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
279
+ ["total"] = sessionLogs.Count,
280
+ ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
281
+ ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
282
+ };
283
+ }
284
+
285
+ return result;
286
+ }
287
+
260
288
  private static Dictionary<string, object> SerializeSummary(LogRecord entry)
261
289
  {
262
290
  return new Dictionary<string, object>
@@ -44,20 +44,26 @@ namespace UCP.Bridge
44
44
  throw new System.ArgumentException("Missing 'path' parameter");
45
45
 
46
46
  var path = pathObj.ToString();
47
+ var additive = GetBoolParam(p, "additive", false);
47
48
  var saveDirtyScenes = GetBoolParam(p, "saveDirtyScenes", true);
48
49
  var discardUntitled = GetBoolParam(p, "discardUntitled", true);
49
50
 
50
51
  if (EditorApplication.isPlaying)
51
52
  {
52
- SceneManager.LoadScene(path);
53
+ SceneManager.LoadScene(path, additive ? LoadSceneMode.Additive : LoadSceneMode.Single);
53
54
  }
54
55
  else
55
56
  {
56
57
  SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
57
- EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
58
+ EditorSceneManager.OpenScene(path, additive ? OpenSceneMode.Additive : OpenSceneMode.Single);
58
59
  }
59
60
 
60
- return new { status = "ok", loaded = path };
61
+ return new Dictionary<string, object>
62
+ {
63
+ ["status"] = "ok",
64
+ ["loaded"] = path,
65
+ ["additive"] = additive
66
+ };
61
67
  }
62
68
 
63
69
  private static bool GetBoolParam(Dictionary<string, object> parameters, string key, bool defaultValue)
@@ -11,6 +11,7 @@ namespace UCP.Bridge
11
11
  {
12
12
  private static TestRunnerApi s_api;
13
13
  private static TestResultCollector s_collector;
14
+ private const string ConsoleGuardName = "UCP.Bridge.Tests.ConsoleLogGuard";
14
15
 
15
16
  public static void Register(CommandRouter router)
16
17
  {
@@ -42,7 +43,8 @@ namespace UCP.Bridge
42
43
  if (s_collector != null)
43
44
  s_api.UnregisterCallbacks(s_collector);
44
45
 
45
- s_collector = new TestResultCollector();
46
+ var logCursor = LogsController.GetLatestId();
47
+ s_collector = new TestResultCollector(logCursor);
46
48
  s_api.RegisterCallbacks(s_collector);
47
49
 
48
50
  var executionSettings = new ExecutionSettings
@@ -130,11 +132,13 @@ namespace UCP.Bridge
130
132
  private class TestResultCollector : ICallbacks
131
133
  {
132
134
  private readonly List<object> _results = new();
135
+ private readonly long _logCursor;
133
136
  private int _passed, _failed, _skipped;
134
137
  private double _startTime;
135
138
 
136
- public TestResultCollector()
139
+ public TestResultCollector(long logCursor)
137
140
  {
141
+ _logCursor = logCursor;
138
142
  _startTime = EditorApplication.timeSinceStartup;
139
143
  }
140
144
 
@@ -147,6 +151,21 @@ namespace UCP.Bridge
147
151
  _failed = 0;
148
152
  _skipped = 0;
149
153
  CollectLeafResults(result);
154
+ var logSummary = LogsController.BuildStatusSummary(_logCursor);
155
+ var consoleWarnings = GetLevelCount(logSummary, "warning");
156
+ var consoleErrors = GetLevelCount(logSummary, "error") + GetLevelCount(logSummary, "exception");
157
+
158
+ if (consoleErrors > 0)
159
+ {
160
+ _failed++;
161
+ _results.Add(new Dictionary<string, object>
162
+ {
163
+ ["name"] = ConsoleGuardName,
164
+ ["status"] = "failed",
165
+ ["duration"] = 0.0,
166
+ ["message"] = BuildConsoleGuardMessage(consoleErrors, consoleWarnings, logSummary)
167
+ });
168
+ }
150
169
 
151
170
  var duration = EditorApplication.timeSinceStartup - _startTime;
152
171
 
@@ -156,13 +175,17 @@ namespace UCP.Bridge
156
175
  ["passed"] = _passed,
157
176
  ["failed"] = _failed,
158
177
  ["skipped"] = _skipped,
159
- ["duration"] = (double)duration
178
+ ["duration"] = (double)duration,
179
+ ["consoleClean"] = consoleErrors == 0,
180
+ ["consoleWarnings"] = consoleWarnings,
181
+ ["consoleErrors"] = consoleErrors
160
182
  };
161
183
 
162
184
  BridgeServer.BroadcastNotification("tests/result", new Dictionary<string, object>
163
185
  {
164
186
  ["summary"] = summary,
165
- ["tests"] = _results
187
+ ["tests"] = _results,
188
+ ["logs"] = logSummary
166
189
  });
167
190
 
168
191
  if (s_api != null)
@@ -235,6 +258,59 @@ namespace UCP.Bridge
235
258
 
236
259
  return result.Name;
237
260
  }
261
+
262
+ private static int GetLevelCount(Dictionary<string, object> logSummary, string level)
263
+ {
264
+ if (!logSummary.TryGetValue("byLevel", out var byLevelObj)
265
+ || byLevelObj is not Dictionary<string, object> byLevel
266
+ || !byLevel.TryGetValue(level, out var value))
267
+ {
268
+ return 0;
269
+ }
270
+
271
+ return Convert.ToInt32(value);
272
+ }
273
+
274
+ private static string BuildConsoleGuardMessage(
275
+ int consoleErrors,
276
+ int consoleWarnings,
277
+ Dictionary<string, object> logSummary)
278
+ {
279
+ var message = $"Unity emitted {consoleErrors} error/exception log(s) during the test run";
280
+ if (consoleWarnings > 0)
281
+ message += $" and {consoleWarnings} warning log(s)";
282
+
283
+ if (!logSummary.TryGetValue("topCategories", out var categoriesObj)
284
+ || categoriesObj is not List<object> categories
285
+ || categories.Count == 0)
286
+ {
287
+ return message + ".";
288
+ }
289
+
290
+ var parts = new List<string>();
291
+ foreach (var categoryObj in categories)
292
+ {
293
+ if (categoryObj is not Dictionary<string, object> category)
294
+ continue;
295
+
296
+ var count = category.TryGetValue("count", out var countObj)
297
+ ? Convert.ToInt32(countObj)
298
+ : 0;
299
+ var sample = category.TryGetValue("sampleMessage", out var sampleObj)
300
+ ? sampleObj?.ToString() ?? string.Empty
301
+ : string.Empty;
302
+ if (count <= 0 || string.IsNullOrEmpty(sample))
303
+ continue;
304
+
305
+ parts.Add($"{count}x {sample}");
306
+ if (parts.Count >= 3)
307
+ break;
308
+ }
309
+
310
+ return parts.Count == 0
311
+ ? message + "."
312
+ : $"{message}. Top categories: {string.Join(" | ", parts)}";
313
+ }
238
314
  }
239
315
  }
240
316
  }
@@ -22,11 +22,14 @@ namespace UCP.Bridge.Tests
22
22
  private const string TempScriptPath = "Assets/UcpControllerSmokeComponent.cs";
23
23
  private const string TempTexturePath = "Assets/UcpImporterSmoke.png";
24
24
  private const string TempScenePath = "Assets/UcpControllerMoveScene.unity";
25
+ private const string TempSceneBPath = "Assets/UcpControllerSceneB.unity";
25
26
  private const string TempMovedFolderPath = "Assets/UcpControllerMoved";
26
27
  private const string TempMovedAssetPath = "Assets/UcpControllerMoved/UcpControllerSmoke.asset";
27
28
  private const string TempMovedReferenceAssetPath = "Assets/UcpControllerMoved/UcpControllerReference.asset";
28
29
  private const string TempMovedMaterialPath = "Assets/UcpControllerMoved/UcpControllerSmoke.mat";
29
30
  private const string TempMovedScenePath = "Assets/UcpControllerMoved/UcpControllerMoveScene.unity";
31
+ private const string TempReimportFolderPath = "Assets/UcpRecursiveReimport";
32
+ private const string TempReimportTexturePath = "Assets/UcpRecursiveReimport/UcpRecursiveA.png";
30
33
  private const string TempProfilerExportPath = "ProfilerCaptures\\smoke-export.json";
31
34
  private const string TempLocalPackageFolder = "TempUcpLocalPackage";
32
35
  private const string TempLocalPackageName = "com.ucp.temp.local";
@@ -40,6 +43,8 @@ namespace UCP.Bridge.Tests
40
43
  SnapshotController.Register(_router);
41
44
  AssetController.Register(_router);
42
45
  ImporterController.Register(_router);
46
+ PlayModeController.Register(_router);
47
+ ReferenceController.Register(_router);
43
48
  LogsController.Register(_router);
44
49
  HierarchyController.Register(_router);
45
50
  ProfilerController.Register(_router);
@@ -60,7 +65,9 @@ namespace UCP.Bridge.Tests
60
65
  DeleteTempScriptFile();
61
66
  DeleteTempTextureAsset();
62
67
  DeleteTempScene();
68
+ DeleteTempSceneB();
63
69
  DeleteTempMovedFolder();
70
+ DeleteTempReimportFolder();
64
71
  DeleteTempProfilerExport();
65
72
  DeleteTempLocalPackage();
66
73
  RemoveTempLocalPackageDependencyIfPresent();
@@ -83,7 +90,9 @@ namespace UCP.Bridge.Tests
83
90
  DeleteTempScriptFile();
84
91
  DeleteTempTextureAsset();
85
92
  DeleteTempScene();
93
+ DeleteTempSceneB();
86
94
  DeleteTempMovedFolder();
95
+ DeleteTempReimportFolder();
87
96
  DeleteTempProfilerExport();
88
97
  DeleteTempLocalPackage();
89
98
  RemoveTempLocalPackageDependencyIfPresent();
@@ -246,6 +255,64 @@ namespace UCP.Bridge.Tests
246
255
  Assert.That(Convert.ToBoolean(match["isSubAsset"]), Is.True);
247
256
  }
248
257
 
258
+ [Test]
259
+ public void AssetSearch_SupportsRegexNameFiltering()
260
+ {
261
+ const string RegexAssetPath = "Assets/SCN_101.asset";
262
+
263
+ try
264
+ {
265
+ var sceneAsset = ScriptableObject.CreateInstance<SearchRootAsset>();
266
+ sceneAsset.name = "SCN_101";
267
+ AssetDatabase.CreateAsset(sceneAsset, RegexAssetPath);
268
+ AssetDatabase.SaveAssets();
269
+
270
+ var response = _router.Dispatch(
271
+ "asset/search",
272
+ 1,
273
+ "{\"name\":\"^SCN_[0-9]+$\",\"regex\":true,\"path\":\"Assets\",\"maxResults\":10}");
274
+
275
+ Assert.That(response.error, Is.Null);
276
+
277
+ var result = (Dictionary<string, object>)response.result;
278
+ var matches = (List<object>)result["results"];
279
+ var match = FindAssetMatchByPath(matches, RegexAssetPath);
280
+ Assert.That(match, Is.Not.Null);
281
+ }
282
+ finally
283
+ {
284
+ if (AssetDatabase.AssetPathExists(RegexAssetPath))
285
+ {
286
+ AssetDatabase.DeleteAsset(RegexAssetPath);
287
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
288
+ }
289
+ }
290
+ }
291
+
292
+ [Test]
293
+ public void AssetSearch_DoesNotEmitSceneReadObjectThreadedErrors()
294
+ {
295
+ EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
296
+ EditorSceneManager.SaveScene(UnityEngine.SceneManagement.SceneManager.GetActiveScene(), TempScenePath);
297
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
298
+ LogsController.ClearHistoryForTests();
299
+
300
+ var response = _router.Dispatch(
301
+ "asset/search",
302
+ 1,
303
+ "{\"name\":\"UcpControllerMoveScene\",\"path\":\"Assets\",\"maxResults\":10}");
304
+
305
+ Assert.That(response.error, Is.Null);
306
+
307
+ var status = _router.Dispatch("logs/status", 1, "{}");
308
+ Assert.That(status.error, Is.Null);
309
+
310
+ var result = (Dictionary<string, object>)status.result;
311
+ var byLevel = (Dictionary<string, object>)result["byLevel"];
312
+ Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(0));
313
+ Assert.That(Convert.ToInt32(byLevel["exception"]), Is.EqualTo(0));
314
+ }
315
+
249
316
  [Test]
250
317
  public void LogsTail_ReturnsRequestedBufferedCount()
251
318
  {
@@ -344,6 +411,30 @@ namespace UCP.Bridge.Tests
344
411
  Assert.That(result["stackTrace"], Is.EqualTo("stack line 1\nstack line 2"));
345
412
  }
346
413
 
414
+ [Test]
415
+ public void LogsStatus_CanScopeToNewEntriesAfterCursor()
416
+ {
417
+ var first = LogsController.RecordTestLog("info", "Before");
418
+ LogsController.RecordTestLog("warning", "After warning");
419
+ LogsController.RecordTestLog("error", "After error");
420
+
421
+ var response = _router.Dispatch(
422
+ "logs/status",
423
+ 1,
424
+ "{\"afterId\":" + Convert.ToInt64(first["id"]) + "}");
425
+
426
+ Assert.That(response.error, Is.Null);
427
+
428
+ var result = (Dictionary<string, object>)response.result;
429
+ Assert.That(Convert.ToInt64(result["afterId"]), Is.EqualTo(Convert.ToInt64(first["id"])));
430
+ Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(2));
431
+
432
+ var byLevel = (Dictionary<string, object>)result["byLevel"];
433
+ Assert.That(Convert.ToInt32(byLevel["info"]), Is.EqualTo(0));
434
+ Assert.That(Convert.ToInt32(byLevel["warning"]), Is.EqualTo(1));
435
+ Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(1));
436
+ }
437
+
347
438
  [Test]
348
439
  public void ObjectLifecycle_CreateMutateAndDelete_WorksEndToEnd()
349
440
  {
@@ -556,6 +647,28 @@ namespace UCP.Bridge.Tests
556
647
  Assert.That(AssetDatabase.GetAssetPath(movedAsset.referenceAsset), Is.EqualTo(TempMovedReferenceAssetPath));
557
648
  }
558
649
 
650
+ [Test]
651
+ public void AssetBulkMove_DryRunDoesNotMutateProject()
652
+ {
653
+ var asset = ScriptableObject.CreateInstance<SearchRootAsset>();
654
+ asset.name = "DryRunAsset";
655
+ AssetDatabase.CreateAsset(asset, TempAssetPath);
656
+ AssetDatabase.SaveAssets();
657
+
658
+ var response = _router.Dispatch(
659
+ "asset/bulk-move",
660
+ 1,
661
+ "{\"dryRun\":true,\"moves\":[{\"from\":\"" + TempAssetPath + "\",\"to\":\"" + TempMovedAssetPath + "\"}]}");
662
+
663
+ Assert.That(response.error, Is.Null);
664
+
665
+ var result = (Dictionary<string, object>)response.result;
666
+ Assert.That(Convert.ToBoolean(result["dryRun"]), Is.True);
667
+ Assert.That(Convert.ToInt32(result["moved"]), Is.EqualTo(1));
668
+ Assert.That(AssetDatabase.LoadAssetAtPath<SearchRootAsset>(TempAssetPath), Is.Not.Null);
669
+ Assert.That(AssetDatabase.LoadAssetAtPath<SearchRootAsset>(TempMovedAssetPath), Is.Null);
670
+ }
671
+
559
672
  [Test]
560
673
  public void AssetMove_PreservesSceneMaterialReference()
561
674
  {
@@ -758,6 +871,53 @@ namespace UCP.Bridge.Tests
758
871
  Assert.That(importer.isReadable, Is.True);
759
872
  }
760
873
 
874
+ [Test]
875
+ public void ImporterController_ReimportRecursive_ReimportsFolderContents()
876
+ {
877
+ CreateTempRecursiveTextureAsset(Color.magenta);
878
+ AssetImportSupport.ClearTestState();
879
+
880
+ var response = _router.Dispatch(
881
+ "asset/reimport",
882
+ 1,
883
+ "{\"path\":\"" + TempReimportFolderPath + "\",\"recursive\":true}");
884
+
885
+ Assert.That(response.error, Is.Null);
886
+
887
+ var result = (Dictionary<string, object>)response.result;
888
+ Assert.That(Convert.ToBoolean(result["recursive"]), Is.True);
889
+ Assert.That(Convert.ToInt32(result["requested"]), Is.GreaterThanOrEqualTo(1));
890
+ Assert.That(Convert.ToInt32(result["reimported"]), Is.GreaterThanOrEqualTo(1));
891
+ Assert.That(AssetImportSupport.LastReimportedPathForTests, Is.EqualTo(TempReimportTexturePath));
892
+ }
893
+
894
+ [Test]
895
+ public void SceneController_Load_AdditiveKeepsExistingSceneLoaded()
896
+ {
897
+ var firstScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
898
+ new GameObject("SceneAObject");
899
+ Assert.That(EditorSceneManager.SaveScene(firstScene, TempScenePath), Is.True);
900
+
901
+ var secondScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
902
+ new GameObject("SceneBObject");
903
+ Assert.That(EditorSceneManager.SaveScene(secondScene, TempSceneBPath), Is.True);
904
+
905
+ EditorSceneManager.OpenScene(TempScenePath, OpenSceneMode.Single);
906
+
907
+ var response = _router.Dispatch(
908
+ "scene/load",
909
+ 1,
910
+ "{\"path\":\"" + TempSceneBPath + "\",\"additive\":true,\"saveDirtyScenes\":true,\"discardUntitled\":true}");
911
+
912
+ Assert.That(response.error, Is.Null);
913
+
914
+ var result = (Dictionary<string, object>)response.result;
915
+ Assert.That(Convert.ToBoolean(result["additive"]), Is.True);
916
+ Assert.That(UnityEngine.SceneManagement.SceneManager.sceneCount, Is.EqualTo(2));
917
+ Assert.That(UnityEngine.SceneManagement.SceneManager.GetSceneByPath(TempScenePath).isLoaded, Is.True);
918
+ Assert.That(UnityEngine.SceneManagement.SceneManager.GetSceneByPath(TempSceneBPath).isLoaded, Is.True);
919
+ }
920
+
761
921
  [Test]
762
922
  public void PackagesController_DependencySetInfoAndRemove_LocalFilePackage()
763
923
  {
@@ -1092,6 +1252,15 @@ namespace UCP.Bridge.Tests
1092
1252
  }
1093
1253
  }
1094
1254
 
1255
+ private static void DeleteTempSceneB()
1256
+ {
1257
+ if (AssetDatabase.LoadMainAssetAtPath(TempSceneBPath) != null)
1258
+ {
1259
+ AssetDatabase.DeleteAsset(TempSceneBPath);
1260
+ AssetDatabase.SaveAssets();
1261
+ }
1262
+ }
1263
+
1095
1264
  private static void DeleteTempMovedFolder()
1096
1265
  {
1097
1266
  if (AssetDatabase.IsValidFolder(TempMovedFolderPath))
@@ -1101,6 +1270,15 @@ namespace UCP.Bridge.Tests
1101
1270
  }
1102
1271
  }
1103
1272
 
1273
+ private static void DeleteTempReimportFolder()
1274
+ {
1275
+ if (AssetDatabase.IsValidFolder(TempReimportFolderPath))
1276
+ {
1277
+ AssetDatabase.DeleteAsset(TempReimportFolderPath);
1278
+ AssetDatabase.SaveAssets();
1279
+ }
1280
+ }
1281
+
1104
1282
  private static void DeleteTempProfilerExport()
1105
1283
  {
1106
1284
  var fullPath = ResolveProjectRelativePath(TempProfilerExportPath);
@@ -1180,6 +1358,24 @@ namespace UCP.Bridge.Tests
1180
1358
  ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
1181
1359
  }
1182
1360
 
1361
+ private static void CreateTempRecursiveTextureAsset(Color color)
1362
+ {
1363
+ DeleteTempReimportFolder();
1364
+ Directory.CreateDirectory(ResolveProjectRelativePath(TempReimportFolderPath));
1365
+
1366
+ var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false);
1367
+ texture.SetPixels(new[] { color, color, color, color });
1368
+ texture.Apply();
1369
+
1370
+ var bytes = texture.EncodeToPNG();
1371
+ UnityEngine.Object.DestroyImmediate(texture);
1372
+
1373
+ File.WriteAllBytes(ResolveProjectRelativePath(TempReimportTexturePath), bytes);
1374
+ AssetDatabase.ImportAsset(
1375
+ TempReimportTexturePath,
1376
+ ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
1377
+ }
1378
+
1183
1379
  private static string ResolveProjectRelativePath(string assetPath)
1184
1380
  {
1185
1381
  var projectRoot = Path.GetDirectoryName(Application.dataPath);
@@ -1228,6 +1424,19 @@ namespace UCP.Bridge.Tests
1228
1424
  return null;
1229
1425
  }
1230
1426
 
1427
+ private static Dictionary<string, object> FindAssetMatchByPath(List<object> matches, string expectedPath)
1428
+ {
1429
+ foreach (var entry in matches)
1430
+ {
1431
+ var match = (Dictionary<string, object>)entry;
1432
+ var path = match.ContainsKey("path") ? match["path"].ToString() : string.Empty;
1433
+ if (path == expectedPath)
1434
+ return match;
1435
+ }
1436
+
1437
+ return null;
1438
+ }
1439
+
1231
1440
  private sealed class SearchRootAsset : ScriptableObject
1232
1441
  {
1233
1442
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "displayName": "Unity Control Protocol Bridge",
5
5
  "description": "WebSocket bridge for programmatic Unity Editor control via CLI and AI agents.",
6
6
  "unity": "2021.3",
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Unity Control Protocol - CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/mflRevan/unity-control-protocol.git"
8
+ "url": "git+https://github.com/mflRevan/unity-control-protocol.git"
9
9
  },
10
10
  "bin": {
11
11
  "ucp": "bin/ucp.js"