@mflrevan/ucp 0.6.2 → 0.6.3

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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/bridge/com.ucp.bridge/CHANGELOG.md +48 -0
  3. package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +55 -6
  4. package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs +165 -0
  5. package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs.meta +11 -0
  6. package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +22 -5
  7. package/bridge/com.ucp.bridge/Editor/Controllers/EditorController.cs +1 -0
  8. package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +10 -10
  9. package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +71 -0
  10. package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
  11. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +4 -4
  12. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +3 -3
  13. package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +6 -6
  14. package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +7 -7
  15. package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +8 -0
  16. package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +3 -3
  17. package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +3 -3
  18. package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +5 -5
  19. package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +1 -1
  20. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +1 -1
  21. package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs +70 -0
  22. package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs.meta +11 -0
  23. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +1 -1
  24. package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +1 -1
  25. package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs +468 -0
  26. package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs.meta +11 -0
  27. package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs +554 -0
  28. package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta +11 -0
  29. package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs +870 -0
  30. package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta +11 -0
  31. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs +496 -0
  32. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta +11 -0
  33. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs +854 -0
  34. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta +11 -0
  35. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs +374 -0
  36. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta +11 -0
  37. package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs +690 -0
  38. package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta +11 -0
  39. package/bridge/com.ucp.bridge/{Runtime.meta → Editor/Ui.meta} +1 -1
  40. package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +79 -4
  41. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +2 -2
  42. package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs +547 -0
  43. package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta +11 -0
  44. package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs +253 -0
  45. package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta +11 -0
  46. package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs +587 -0
  47. package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta +11 -0
  48. package/bridge/com.ucp.bridge/package.json +1 -1
  49. package/package.json +1 -1
  50. package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef +0 -14
  51. package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef.meta +0 -7
@@ -0,0 +1,870 @@
1
+ #if UNITY_6000_0_OR_NEWER
2
+ using System;
3
+ using System.Collections;
4
+ using System.Collections.Generic;
5
+ using System.Globalization;
6
+ using System.IO;
7
+ using System.Linq;
8
+ using UnityEditor;
9
+ using UnityEngine;
10
+
11
+ namespace UCP.Bridge
12
+ {
13
+ /// <summary>
14
+ /// Runs UI Toolkit work over editor update frames so layout and repaint are never
15
+ /// blocked by the initiating JSON-RPC handler. Operations are serialized because
16
+ /// the capture backend requires a focused editor window.
17
+ /// </summary>
18
+ internal static class UiOperationManager
19
+ {
20
+ private const int MaxRetainedOperations = 16;
21
+ private const double CompletedTtlSeconds = 300.0;
22
+ // Hard ceiling from enqueue through every requested state, independent of
23
+ // each state's settle timeout.
24
+ internal const double MaxOperationDurationSeconds = 300.0;
25
+ private static readonly Dictionary<string, UiOperationRecord> Operations =
26
+ new Dictionary<string, UiOperationRecord>(StringComparer.Ordinal);
27
+ private static readonly Queue<string> Queue = new Queue<string>();
28
+ private static UiRenderOperation _active;
29
+
30
+ static UiOperationManager()
31
+ {
32
+ EditorApplication.update += Tick;
33
+ }
34
+
35
+ internal static Dictionary<string, object> Start(
36
+ string operation,
37
+ Dictionary<string, object> parameters)
38
+ {
39
+ if (Operations.Count >= MaxRetainedOperations)
40
+ {
41
+ PruneCompleted(true);
42
+ if (Operations.Count >= MaxRetainedOperations)
43
+ throw new ArgumentException("Too many retained UI operations; wait for an active operation to finish");
44
+ }
45
+
46
+ var operationId = "ui-" + Guid.NewGuid().ToString("N").Substring(0, 12);
47
+ var record = new UiOperationRecord(operationId, operation, parameters);
48
+ Operations.Add(operationId, record);
49
+ Queue.Enqueue(operationId);
50
+ return new Dictionary<string, object>
51
+ {
52
+ ["status"] = "started",
53
+ ["operationId"] = operationId,
54
+ ["operation"] = operation
55
+ };
56
+ }
57
+
58
+ internal static Dictionary<string, object> Status(string operationId)
59
+ {
60
+ if (string.IsNullOrWhiteSpace(operationId))
61
+ throw new ArgumentException("Missing 'operationId' parameter");
62
+
63
+ if (!Operations.TryGetValue(operationId, out var record))
64
+ {
65
+ return new Dictionary<string, object>
66
+ {
67
+ ["found"] = false,
68
+ ["operationId"] = operationId
69
+ };
70
+ }
71
+
72
+ var envelope = record.Envelope();
73
+ envelope["found"] = true;
74
+ return envelope;
75
+ }
76
+
77
+ internal static void ResetForTests()
78
+ {
79
+ Shutdown();
80
+ Operations.Clear();
81
+ }
82
+
83
+ internal static void TickForTests()
84
+ {
85
+ Tick();
86
+ }
87
+
88
+ /// <summary>Promotes the next queued record to active without running its first tick.</summary>
89
+ internal static void ActivateNextForTests()
90
+ {
91
+ ActivateNext();
92
+ }
93
+
94
+ private static void Tick()
95
+ {
96
+ // Idle editors pay nothing: skip the pruning scan until an operation exists.
97
+ if (_active == null && Queue.Count == 0 && Operations.Count == 0)
98
+ return;
99
+
100
+ PruneCompleted(false);
101
+ ExpireQueuedOperations();
102
+ if (_active == null)
103
+ ActivateNext();
104
+ if (_active == null)
105
+ return;
106
+
107
+ try
108
+ {
109
+ _active.Tick();
110
+ }
111
+ catch (Exception exception)
112
+ {
113
+ _active.Fail(exception);
114
+ }
115
+
116
+ if (!_active.IsTerminal)
117
+ return;
118
+
119
+ var completed = _active;
120
+ _active = null;
121
+ var completionError = completed.Error;
122
+ try
123
+ {
124
+ completed.Dispose();
125
+ }
126
+ catch (Exception exception)
127
+ {
128
+ var cleanupError = UiRenderOperation.BuildError(exception);
129
+ if (completionError == null)
130
+ {
131
+ completionError = cleanupError;
132
+ }
133
+ else
134
+ {
135
+ completionError = new Dictionary<string, object>(completionError, StringComparer.Ordinal)
136
+ {
137
+ ["cleanupError"] = cleanupError
138
+ };
139
+ }
140
+ }
141
+ var record = Operations[completed.OperationId];
142
+ CompleteAndBroadcast(record, completed.Result, completionError);
143
+ }
144
+
145
+ private static void ActivateNext()
146
+ {
147
+ while (Queue.Count > 0)
148
+ {
149
+ var id = Queue.Dequeue();
150
+ if (!Operations.TryGetValue(id, out var record) || record.IsTerminal)
151
+ continue;
152
+
153
+ record.MarkRunning();
154
+ try
155
+ {
156
+ _active = new UiRenderOperation(record);
157
+ return;
158
+ }
159
+ catch (Exception exception)
160
+ {
161
+ _active = null;
162
+ CompleteAndBroadcast(record, null, UiRenderOperation.BuildError(exception));
163
+ }
164
+ }
165
+ }
166
+
167
+ private static void ExpireQueuedOperations()
168
+ {
169
+ var count = Queue.Count;
170
+ var now = EditorApplication.timeSinceStartup;
171
+ for (var index = 0; index < count; index++)
172
+ {
173
+ var id = Queue.Dequeue();
174
+ if (!Operations.TryGetValue(id, out var record) || record.IsTerminal)
175
+ continue;
176
+
177
+ var elapsed = now - record.StartedAt;
178
+ if (HasExceededOperationDuration(record.StartedAt, now))
179
+ {
180
+ CompleteAndBroadcast(
181
+ record,
182
+ null,
183
+ UiRenderOperation.BuildError(CreateOperationTimeout(
184
+ record.Operation,
185
+ 0,
186
+ elapsed)));
187
+ continue;
188
+ }
189
+
190
+ Queue.Enqueue(id);
191
+ }
192
+ }
193
+
194
+ internal static bool HasExceededOperationDuration(double startedAt, double now)
195
+ {
196
+ return now - startedAt > MaxOperationDurationSeconds;
197
+ }
198
+
199
+ internal static UiOperationException CreateOperationTimeout(
200
+ string operation,
201
+ int completedStateCount,
202
+ double elapsed)
203
+ {
204
+ return new UiOperationException(
205
+ "timeout",
206
+ $"UI operation '{operation}' exceeded the {MaxOperationDurationSeconds:F0}s overall duration limit",
207
+ new Dictionary<string, object>
208
+ {
209
+ ["scope"] = "operation",
210
+ ["timeoutSeconds"] = MaxOperationDurationSeconds,
211
+ ["durationSeconds"] = UiValue.FiniteOrNull(elapsed),
212
+ ["completedStateCount"] = completedStateCount
213
+ });
214
+ }
215
+
216
+ private static void CompleteAndBroadcast(
217
+ UiOperationRecord record,
218
+ object result,
219
+ Dictionary<string, object> error)
220
+ {
221
+ record.Complete(result, error);
222
+ BridgeServer.BroadcastNotification("ui/result", record.Envelope());
223
+
224
+ if (record.Error != null &&
225
+ record.Error.TryGetValue("code", out var code) &&
226
+ (string.Equals(code?.ToString(), "internal_error", StringComparison.Ordinal) ||
227
+ string.Equals(code?.ToString(), "cleanup_failed", StringComparison.Ordinal)))
228
+ {
229
+ Debug.LogError($"[UCP] ui/{record.Operation} operation {record.OperationId} failed: " +
230
+ record.Error["message"]);
231
+ }
232
+ }
233
+
234
+ private static void PruneCompleted(bool force)
235
+ {
236
+ var now = EditorApplication.timeSinceStartup;
237
+ var expired = Operations
238
+ .Where(pair => pair.Value.IsTerminal &&
239
+ (force || now - pair.Value.CompletedAt >= CompletedTtlSeconds))
240
+ .Select(pair => pair.Key)
241
+ .ToList();
242
+ foreach (var id in expired)
243
+ Operations.Remove(id);
244
+ }
245
+
246
+ internal static void Shutdown()
247
+ {
248
+ // The bridge calls this before closing client sockets on reload/quit.
249
+ // Retain terminal records for status recovery until the domain unloads.
250
+ foreach (var record in Operations.Values.Where(record => !record.IsTerminal).ToList())
251
+ {
252
+ CompleteAndBroadcast(record, null, new Dictionary<string, object>
253
+ {
254
+ ["code"] = "editor_shutdown",
255
+ ["message"] = "UI operation interrupted by an Editor domain reload or shutdown"
256
+ });
257
+ }
258
+ var active = _active;
259
+ _active = null;
260
+ try
261
+ {
262
+ active?.Dispose();
263
+ }
264
+ catch
265
+ {
266
+ // Reload and quit must continue after best-effort cleanup.
267
+ }
268
+ finally
269
+ {
270
+ Queue.Clear();
271
+ }
272
+ }
273
+ }
274
+
275
+ internal sealed class UiOperationRecord
276
+ {
277
+ internal UiOperationRecord(
278
+ string operationId,
279
+ string operation,
280
+ Dictionary<string, object> parameters)
281
+ {
282
+ OperationId = operationId;
283
+ Operation = operation;
284
+ Parameters = parameters != null
285
+ ? new Dictionary<string, object>(parameters, StringComparer.Ordinal)
286
+ : new Dictionary<string, object>(StringComparer.Ordinal);
287
+ Status = "queued";
288
+ StartedAtUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
289
+ StartedAt = EditorApplication.timeSinceStartup;
290
+ }
291
+
292
+ internal string OperationId { get; }
293
+ internal string Operation { get; }
294
+ internal Dictionary<string, object> Parameters { get; }
295
+ internal string Status { get; private set; }
296
+ internal object Result { get; private set; }
297
+ internal Dictionary<string, object> Error { get; private set; }
298
+ internal string StartedAtUtc { get; }
299
+ internal double StartedAt { get; }
300
+ internal double CompletedAt { get; private set; }
301
+ internal bool IsTerminal => Status == "completed" || Status == "failed";
302
+
303
+ internal void MarkRunning()
304
+ {
305
+ Status = "running";
306
+ }
307
+
308
+ internal void Complete(object result, Dictionary<string, object> error)
309
+ {
310
+ Result = UiJsonSanitizer.Sanitize(result);
311
+ Error = error == null
312
+ ? null
313
+ : (Dictionary<string, object>)UiJsonSanitizer.Sanitize(error);
314
+ Status = Error == null ? "completed" : "failed";
315
+ CompletedAt = EditorApplication.timeSinceStartup;
316
+ }
317
+
318
+ internal Dictionary<string, object> Envelope()
319
+ {
320
+ var envelope = new Dictionary<string, object>
321
+ {
322
+ ["operationId"] = OperationId,
323
+ ["operation"] = Operation,
324
+ ["status"] = Status,
325
+ ["startedAtUtc"] = StartedAtUtc
326
+ };
327
+ if (Result != null)
328
+ envelope["result"] = Result;
329
+ if (Error != null)
330
+ envelope["error"] = Error;
331
+ return envelope;
332
+ }
333
+ }
334
+
335
+ internal sealed class UiRenderOperation : IDisposable
336
+ {
337
+ private enum Phase
338
+ {
339
+ Initialize,
340
+ OpenScenario,
341
+ Settle,
342
+ Terminal
343
+ }
344
+
345
+ private readonly UiOperationRecord _record;
346
+ private readonly bool _capture;
347
+ private readonly bool _inspect;
348
+ private readonly bool _check;
349
+ private readonly bool _failOnWarnings;
350
+ private UiInspectOptions _inspectOptions;
351
+ private readonly List<object> _stateResults = new List<object>();
352
+ private Phase _phase;
353
+ private List<UiResolvedScenario> _scenarios;
354
+ private int _scenarioIndex;
355
+ private UiResolvedScenario _scenario;
356
+ private UiApplyReport _applyReport;
357
+ private UiHostWindow _host;
358
+ private UiCaptureSurface _captureSurface;
359
+ private EditorWindow _previousFocusedWindow;
360
+ private UiGeometrySample _geometry;
361
+ private string _lastGeometryHash;
362
+ private int _geometryStableFrames;
363
+ private string _lastPixelHash;
364
+ private int _pixelStableFrames;
365
+ private UiCaptureSample _latestCapture;
366
+ private double _scenarioStartedAt;
367
+ private int _warmupFrames;
368
+ private Dictionary<string, object> _lintResult;
369
+
370
+ internal UiRenderOperation(UiOperationRecord record)
371
+ {
372
+ _record = record;
373
+ _capture = record.Operation == "screenshot" || record.Operation == "check";
374
+ _inspect = record.Operation == "inspect" || record.Operation == "check";
375
+ _check = record.Operation == "check";
376
+ _failOnWarnings = ReadBool(record.Parameters, "failOnWarnings", false);
377
+ }
378
+
379
+ internal string OperationId => _record.OperationId;
380
+ internal object Result { get; private set; }
381
+ internal Dictionary<string, object> Error { get; private set; }
382
+ internal bool IsTerminal => _phase == Phase.Terminal;
383
+
384
+ internal void Tick()
385
+ {
386
+ var now = EditorApplication.timeSinceStartup;
387
+ if (UiOperationManager.HasExceededOperationDuration(_record.StartedAt, now))
388
+ {
389
+ throw UiOperationManager.CreateOperationTimeout(
390
+ _record.Operation,
391
+ _stateResults.Count,
392
+ now - _record.StartedAt);
393
+ }
394
+
395
+ switch (_phase)
396
+ {
397
+ case Phase.Initialize:
398
+ Initialize();
399
+ break;
400
+ case Phase.OpenScenario:
401
+ OpenScenario();
402
+ break;
403
+ case Phase.Settle:
404
+ Settle();
405
+ break;
406
+ }
407
+ }
408
+
409
+ internal void Fail(Exception exception)
410
+ {
411
+ if (IsTerminal)
412
+ return;
413
+
414
+ Error = BuildError(exception);
415
+ _phase = Phase.Terminal;
416
+ }
417
+
418
+ private void Initialize()
419
+ {
420
+ UiHostCapabilities.EnsureAvailable(_record.Operation);
421
+ _inspectOptions = _inspect ? UiInspectOptions.Parse(_record.Parameters) : null;
422
+
423
+ if (_check)
424
+ {
425
+ var lintParameters = new Dictionary<string, object>
426
+ {
427
+ ["paths"] = new List<object> { ReadRequiredString(_record.Parameters, "target") },
428
+ ["failOnWarnings"] = _failOnWarnings
429
+ };
430
+ if (_record.Parameters.TryGetValue("maxDiagnostics", out var maxDiagnostics))
431
+ lintParameters["maxDiagnostics"] = maxDiagnostics;
432
+ _lintResult = UiLintService.Run(lintParameters);
433
+ }
434
+
435
+ var allStates = ReadBool(_record.Parameters, "allStates", false);
436
+ _scenarios = UiScenarioLoader.Resolve(_record.Parameters, allStates);
437
+ if (_scenarios == null || _scenarios.Count == 0)
438
+ throw new ArgumentException("No UI scenarios matched the request");
439
+
440
+ _previousFocusedWindow = EditorWindow.focusedWindow;
441
+ _scenarioIndex = 0;
442
+ _phase = Phase.OpenScenario;
443
+ }
444
+
445
+ private void OpenScenario()
446
+ {
447
+ DisposeScenario();
448
+ _scenario = _scenarios[_scenarioIndex];
449
+ _host = UiHostWindow.Open(_scenario.Viewport.Width, _scenario.Viewport.Height);
450
+ _scenario.Document.CloneTree(_host.ContentRoot);
451
+ _applyReport = UiScenarioApplier.Apply(_host.ContentRoot, _scenario);
452
+ if (_capture)
453
+ {
454
+ _captureSurface = new UiCaptureSurface(
455
+ _scenario.Viewport.Width,
456
+ _scenario.Viewport.Height);
457
+ }
458
+
459
+ _scenarioStartedAt = EditorApplication.timeSinceStartup;
460
+ _lastGeometryHash = null;
461
+ _geometryStableFrames = 0;
462
+ _lastPixelHash = null;
463
+ _pixelStableFrames = 0;
464
+ _latestCapture = null;
465
+ _warmupFrames = 0;
466
+ _phase = Phase.Settle;
467
+ }
468
+
469
+ private void Settle()
470
+ {
471
+ if (_host == null)
472
+ throw new InvalidOperationException("The transient UI host window was closed before the operation completed");
473
+
474
+ var elapsed = EditorApplication.timeSinceStartup - _scenarioStartedAt;
475
+ if (elapsed > _scenario.Settle.TimeoutSeconds)
476
+ {
477
+ throw new UiOperationException(
478
+ "timeout",
479
+ $"UI state '{_scenario.StateName}' did not settle within " +
480
+ $"{_scenario.Settle.TimeoutSeconds:F1}s",
481
+ new Dictionary<string, object>
482
+ {
483
+ ["scenario"] = _scenario.ToDictionary(),
484
+ ["geometry"] = _geometry.ToDictionary(_geometryStableFrames),
485
+ ["pixelStableFrames"] = _pixelStableFrames,
486
+ ["completedStateCount"] = _stateResults.Count
487
+ });
488
+ }
489
+
490
+ _host.Pump();
491
+ _warmupFrames++;
492
+ _geometry = UiGeometrySampler.Measure(_host.ContentRoot);
493
+ var geometryValid = _warmupFrames >= 2 && _host.hasFocus && _geometry.IsValid;
494
+ if (geometryValid && string.Equals(_lastGeometryHash, _geometry.Hash, StringComparison.Ordinal))
495
+ _geometryStableFrames++;
496
+ else
497
+ _geometryStableFrames = geometryValid ? 1 : 0;
498
+ _lastGeometryHash = _geometry.Hash;
499
+
500
+ if (_geometryStableFrames < _scenario.Settle.StableFrames)
501
+ {
502
+ ResetPixelStability();
503
+ return;
504
+ }
505
+
506
+ if (_capture)
507
+ {
508
+ _latestCapture = _captureSurface.Capture(_host);
509
+ if (string.Equals(_lastPixelHash, _latestCapture.PixelHash, StringComparison.Ordinal))
510
+ _pixelStableFrames++;
511
+ else
512
+ _pixelStableFrames = 1;
513
+ _lastPixelHash = _latestCapture.PixelHash;
514
+
515
+ if (_pixelStableFrames < _scenario.Settle.PixelStableFrames)
516
+ return;
517
+ }
518
+
519
+ CompleteScenario(elapsed);
520
+ }
521
+
522
+ private void CompleteScenario(double elapsed)
523
+ {
524
+ var metadata = UiScenarioApplier.GetCollectionMetadata(_host.ContentRoot);
525
+ var stateResult = new Dictionary<string, object>
526
+ {
527
+ ["scenario"] = _scenario.ToDictionary(),
528
+ ["apply"] = _applyReport.ToDictionary(),
529
+ ["settle"] = BuildSettleResult(elapsed)
530
+ };
531
+
532
+ Dictionary<string, object> audit = null;
533
+ if (_inspect)
534
+ {
535
+ stateResult["snapshot"] = UiVisualTreeInspector.Inspect(
536
+ _host.ContentRoot,
537
+ _inspectOptions,
538
+ metadata);
539
+ }
540
+
541
+ if (_check)
542
+ {
543
+ audit = UiAudit.Run(_host.ContentRoot, _applyReport, metadata);
544
+ stateResult["audit"] = audit;
545
+ }
546
+
547
+ if (_capture)
548
+ {
549
+ var artifactPath = WriteCaptureArtifact(_latestCapture.Png);
550
+ var capture = new Dictionary<string, object>
551
+ {
552
+ ["artifactPath"] = artifactPath,
553
+ ["width"] = _latestCapture.Width,
554
+ ["height"] = _latestCapture.Height,
555
+ ["pixelHash"] = _latestCapture.PixelHash,
556
+ ["pixelStableFrames"] = _pixelStableFrames,
557
+ ["sampledDistinctColors"] = _latestCapture.SampledDistinctColors,
558
+ ["nonTransparentPixels"] = _latestCapture.NonTransparentPixels,
559
+ ["pixelsPerPoint"] = UiValue.FiniteOrNull(EditorGUIUtility.pixelsPerPoint),
560
+ ["compositorScale"] = UiValue.FiniteOrNull(
561
+ 1f / Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint)),
562
+ ["graphicsDeviceType"] = SystemInfo.graphicsDeviceType.ToString()
563
+ };
564
+ stateResult["capture"] = capture;
565
+ stateResult["artifactPath"] = artifactPath;
566
+ stateResult["width"] = _latestCapture.Width;
567
+ stateResult["height"] = _latestCapture.Height;
568
+ stateResult["pixelHash"] = _latestCapture.PixelHash;
569
+ }
570
+
571
+ if (_check)
572
+ {
573
+ var auditPassed = ReadBool(audit, "passed", false);
574
+ var warnings = audit != null && audit.TryGetValue("warningCount", out var warningValue)
575
+ ? Convert.ToInt32(warningValue)
576
+ : 0;
577
+ stateResult["passed"] = auditPassed && (!_failOnWarnings || warnings == 0);
578
+ }
579
+
580
+ _stateResults.Add(stateResult);
581
+ _scenarioIndex++;
582
+ Result = BuildResult();
583
+ DisposeScenario();
584
+ if (_scenarioIndex < _scenarios.Count)
585
+ {
586
+ _phase = Phase.OpenScenario;
587
+ return;
588
+ }
589
+
590
+ _phase = Phase.Terminal;
591
+ }
592
+
593
+ private Dictionary<string, object> BuildSettleResult(double elapsed)
594
+ {
595
+ var settle = _geometry.ToDictionary(_geometryStableFrames);
596
+ settle["pixelStableFrames"] = _pixelStableFrames;
597
+ settle["durationSeconds"] = UiValue.FiniteOrNull(elapsed);
598
+ settle["focused"] = _host != null && _host.hasFocus;
599
+ return settle;
600
+ }
601
+
602
+ private Dictionary<string, object> BuildResult()
603
+ {
604
+ var result = new Dictionary<string, object>
605
+ {
606
+ ["operation"] = _record.Operation,
607
+ ["target"] = ReadRequiredString(_record.Parameters, "target"),
608
+ ["stateCount"] = _stateResults.Count,
609
+ ["states"] = _stateResults,
610
+ ["capabilities"] = UiHostCapabilities.Describe()
611
+ };
612
+
613
+ if (_lintResult != null)
614
+ result["lint"] = _lintResult;
615
+
616
+ if (_stateResults.Count == 1 && _stateResults[0] is Dictionary<string, object> onlyState)
617
+ {
618
+ foreach (var pair in onlyState)
619
+ result[pair.Key] = pair.Value;
620
+ }
621
+
622
+ if (_check)
623
+ {
624
+ var lintPassed = _lintResult != null && ReadBool(_lintResult, "passed", false);
625
+ var statesPassed = _stateResults.All(state =>
626
+ state is Dictionary<string, object> dictionary && ReadBool(dictionary, "passed", false));
627
+ result["passed"] = lintPassed && statesPassed;
628
+ var errorCount = ReadCount(_lintResult, "errorCount");
629
+ var warningCount = ReadCount(_lintResult, "warningCount");
630
+ foreach (var state in _stateResults.OfType<Dictionary<string, object>>())
631
+ {
632
+ if (state.TryGetValue("audit", out var auditObject) &&
633
+ auditObject is Dictionary<string, object> audit)
634
+ {
635
+ errorCount += ReadCount(audit, "errorCount");
636
+ warningCount += ReadCount(audit, "warningCount");
637
+ }
638
+ }
639
+ result["errorCount"] = errorCount;
640
+ result["warningCount"] = warningCount;
641
+ }
642
+
643
+ var elementCount = 0;
644
+ foreach (var state in _stateResults.OfType<Dictionary<string, object>>())
645
+ {
646
+ if (state.TryGetValue("snapshot", out var snapshotObject) &&
647
+ snapshotObject is Dictionary<string, object> snapshot)
648
+ elementCount += ReadCount(snapshot, "returnedElementCount");
649
+ }
650
+ if (_inspect)
651
+ result["elementCount"] = elementCount;
652
+
653
+ return result;
654
+ }
655
+
656
+ private string WriteCaptureArtifact(byte[] png)
657
+ {
658
+ if (png == null || png.Length == 0)
659
+ throw new InvalidOperationException("No PNG data was available after capture settled");
660
+
661
+ var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
662
+ var outputDirectory = Path.Combine(projectRoot, "Library", "UCP", "UiCaptures");
663
+ Directory.CreateDirectory(outputDirectory);
664
+ var path = Path.Combine(outputDirectory, CaptureArtifactFileName(
665
+ _scenario.TargetPath,
666
+ _scenario.StateName,
667
+ _scenarioIndex,
668
+ OperationId));
669
+ File.WriteAllBytes(path, png);
670
+ return UiValue.NormalizePath(Path.GetFullPath(path));
671
+ }
672
+
673
+ internal static string CaptureArtifactFileName(
674
+ string targetPath,
675
+ string stateName,
676
+ int scenarioIndex,
677
+ string operationId)
678
+ {
679
+ // "Inventory.ucp-ui.json" should yield "Inventory-...", not "Inventory.ucp-ui-...".
680
+ var targetName = Path.GetFileNameWithoutExtension(targetPath) ?? string.Empty;
681
+ const string scenarioSuffix = ".ucp-ui";
682
+ if (targetName.EndsWith(scenarioSuffix, StringComparison.OrdinalIgnoreCase))
683
+ targetName = targetName.Substring(0, targetName.Length - scenarioSuffix.Length);
684
+ var targetToken = UiValue.SafeFileName(targetName);
685
+ var stateToken = UiValue.SafeFileName(stateName);
686
+ return $"{targetToken}-{stateToken}-s{scenarioIndex:D4}-{operationId}.png";
687
+ }
688
+
689
+ private void ResetPixelStability()
690
+ {
691
+ _lastPixelHash = null;
692
+ _pixelStableFrames = 0;
693
+ _latestCapture = null;
694
+ }
695
+
696
+ private void DisposeScenario()
697
+ {
698
+ var captureSurface = _captureSurface;
699
+ _captureSurface = null;
700
+ var host = _host;
701
+ _host = null;
702
+
703
+ var error = UiCleanup.RunAll(
704
+ () => captureSurface?.Dispose(),
705
+ () => UiHostWindow.CloseAndDestroy(host));
706
+ if (error != null)
707
+ {
708
+ throw new UiOperationException(
709
+ "cleanup_failed",
710
+ "The UI harness could not release all transient resources",
711
+ new Dictionary<string, object>
712
+ {
713
+ ["exceptionType"] = error.GetType().FullName,
714
+ ["message"] = error.Message
715
+ });
716
+ }
717
+ }
718
+
719
+ public void Dispose()
720
+ {
721
+ var previousFocusedWindow = _previousFocusedWindow;
722
+ _previousFocusedWindow = null;
723
+ var error = UiCleanup.RunAll(
724
+ DisposeScenario,
725
+ () =>
726
+ {
727
+ if (previousFocusedWindow == null)
728
+ return;
729
+ try
730
+ {
731
+ previousFocusedWindow.Focus();
732
+ }
733
+ catch
734
+ {
735
+ // The previously focused window may have been closed during the operation.
736
+ }
737
+ });
738
+ if (error != null)
739
+ throw error;
740
+ }
741
+
742
+ internal static Dictionary<string, object> BuildError(Exception exception)
743
+ {
744
+ string code;
745
+ object details = null;
746
+ if (exception is UiOperationException operationException)
747
+ {
748
+ code = operationException.Code;
749
+ details = operationException.Details;
750
+ }
751
+ else if (exception is UiScenarioException scenarioException)
752
+ {
753
+ code = scenarioException.Code;
754
+ details = new Dictionary<string, object>
755
+ {
756
+ ["location"] = scenarioException.Location
757
+ };
758
+ }
759
+ else if (exception is ArgumentException)
760
+ {
761
+ code = "invalid_params";
762
+ }
763
+ else
764
+ {
765
+ code = "internal_error";
766
+ details = new Dictionary<string, object>
767
+ {
768
+ ["exceptionType"] = exception.GetType().FullName
769
+ };
770
+ }
771
+
772
+ var error = new Dictionary<string, object>
773
+ {
774
+ ["code"] = code,
775
+ ["message"] = exception.Message
776
+ };
777
+ if (details != null)
778
+ error["details"] = details;
779
+ return (Dictionary<string, object>)UiJsonSanitizer.Sanitize(error);
780
+ }
781
+
782
+ private static bool ReadBool(
783
+ Dictionary<string, object> parameters,
784
+ string key,
785
+ bool defaultValue)
786
+ {
787
+ if (parameters == null || !parameters.TryGetValue(key, out var value) || value == null)
788
+ return defaultValue;
789
+ if (value is bool boolean)
790
+ return boolean;
791
+ if (bool.TryParse(value.ToString(), out var parsed))
792
+ return parsed;
793
+ throw new ArgumentException($"'{key}' must be a boolean");
794
+ }
795
+
796
+ private static string ReadRequiredString(Dictionary<string, object> parameters, string key)
797
+ {
798
+ if (parameters == null || !parameters.TryGetValue(key, out var value) ||
799
+ string.IsNullOrWhiteSpace(value?.ToString()))
800
+ throw new ArgumentException($"Missing '{key}' parameter");
801
+ return value.ToString();
802
+ }
803
+
804
+ private static int ReadCount(Dictionary<string, object> values, string key)
805
+ {
806
+ if (values == null || !values.TryGetValue(key, out var value) || value == null)
807
+ return 0;
808
+ try
809
+ {
810
+ return Convert.ToInt32(value);
811
+ }
812
+ catch
813
+ {
814
+ return 0;
815
+ }
816
+ }
817
+ }
818
+
819
+ internal static class UiJsonSanitizer
820
+ {
821
+ internal static object Sanitize(object value)
822
+ {
823
+ if (value == null || value is string || value is bool || value is int || value is long)
824
+ return value;
825
+ if (value is char character)
826
+ return character.ToString();
827
+ if (value is byte || value is sbyte || value is short || value is ushort)
828
+ return Convert.ToInt32(value, CultureInfo.InvariantCulture);
829
+ if (value is uint unsignedInteger)
830
+ return (long)unsignedInteger;
831
+ if (value is ulong unsignedLong)
832
+ return unsignedLong <= long.MaxValue
833
+ ? (object)(long)unsignedLong
834
+ : unsignedLong.ToString(CultureInfo.InvariantCulture);
835
+ if (value is decimal decimalValue)
836
+ return decimalValue.ToString(CultureInfo.InvariantCulture);
837
+ if (value is IntPtr pointer)
838
+ return pointer.ToInt64();
839
+ if (value is UIntPtr unsignedPointer)
840
+ {
841
+ var numericValue = unsignedPointer.ToUInt64();
842
+ return numericValue <= long.MaxValue
843
+ ? (object)(long)numericValue
844
+ : numericValue.ToString(CultureInfo.InvariantCulture);
845
+ }
846
+ if (value is float floatValue)
847
+ return UiValue.FiniteOrNull(floatValue);
848
+ if (value is double doubleValue)
849
+ return UiValue.FiniteOrNull(doubleValue);
850
+ if (value is Enum enumValue)
851
+ return enumValue.ToString();
852
+ if (value is IDictionary dictionary)
853
+ {
854
+ var result = new Dictionary<string, object>(StringComparer.Ordinal);
855
+ foreach (DictionaryEntry pair in dictionary)
856
+ result[pair.Key?.ToString() ?? "null"] = Sanitize(pair.Value);
857
+ return result;
858
+ }
859
+ if (value is IEnumerable enumerable)
860
+ {
861
+ var result = new List<object>();
862
+ foreach (var item in enumerable)
863
+ result.Add(Sanitize(item));
864
+ return result;
865
+ }
866
+ return value.ToString();
867
+ }
868
+ }
869
+ }
870
+ #endif