using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using System.Threading.Tasks; using NUnit.Framework; using Newtonsoft.Json.Linq; using Unity.Pipeline; using Unity.Pipeline.Editor.Commands.Scripts; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace Unity.Pipeline.Tests.Editor.Scripts { /// /// Tests for the run_script command (AUTHAPI-26): single-file in-memory compile + named entry /// point execution, exercised directly and via PipelineClient. Covers the ticket's acceptance /// criteria: a valid entry runs and returns its result, compile errors return Roslyn diagnostics /// without executing, missing/ambiguous entry names error clearly, missing required params error, /// arg coercion (int/float/enum/string[]/ObjectRef), dry_run, defines, and hotpatch delegation. /// /// The scripts are written to a temp folder OUTSIDE Assets/ on purpose: run_script must compile /// them in memory with no asset import and no domain reload. /// class RunScriptCommandTests { private readonly List m_TempFiles = new List(); private readonly List m_Spawned = new List(); [TearDown] public void TearDown() { foreach (var path in m_TempFiles) { try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } } m_TempFiles.Clear(); foreach (var o in m_Spawned) if (o != null) Object.DestroyImmediate(o); m_Spawned.Clear(); } private string WriteTempScript(string source) { var path = Path.Combine(Path.GetTempPath(), "run_script_test_" + System.Guid.NewGuid().ToString("N") + ".cs"); File.WriteAllText(path, source); m_TempFiles.Add(path); return path; } private GameObject TrackGo(GameObject go) { m_Spawned.Add(go); return go; } #region Valid entry point [Test] public async Task RunScript_SinglePublicStatic_AutoDetectsAndReturnsResult() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 42; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(42, r.Result); // A warm Roslyn compile of a trivial file can complete in under 1ms — don't flake on it. Assert.GreaterOrEqual(r.CompileMs, 0, "compileMs should be recorded"); Assert.IsNotNull(r.AssemblyName); } [Test] public async Task RunScript_ExplicitQualifiedEntry_Runs() { var file = WriteTempScript(@" namespace My.Space { public static class Tools { public static string Greet() { return ""hi""; } public static int Other() { return 1; } } }"); var r = await RunScriptCommand.RunScript(file, entry: "My.Space.Tools.Greet"); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual("hi", r.Result); } [Test] public async Task RunScript_UnityApiInEntry_Works() { var file = WriteTempScript(@" using UnityEngine; public static class Builder { public static string Build() { return Application.unityVersion; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.IsInstanceOf(r.Result); } #endregion #region Compile errors and dry_run [Test] public async Task RunScript_CompileError_ReturnsDiagnostics_NothingExecutes() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 2 +; } // syntax error }"); var r = await RunScriptCommand.RunScript(file); Assert.IsFalse(r.Success); Assert.AreEqual("Compilation Failed", r.Error); Assert.Greater(r.Diagnostics.Count, 0, "should surface Roslyn diagnostics"); Assert.IsNotEmpty(r.Diagnostics[0].Id, "diagnostic should carry a Roslyn id (e.g. CS1525)"); Assert.AreEqual(0, r.ExecuteMs, "nothing should have executed"); } [Test] public async Task RunScript_DryRun_CompilesWithoutExecuting() { var file = WriteTempScript(@" public static class Builder { public static int Build() { throw new System.Exception(""should not run""); } }"); var r = await RunScriptCommand.RunScript(file, dryRun: true); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.IsNull(r.Result, "dry_run must not execute the entry"); Assert.AreEqual(0, r.ExecuteMs); // dry_run is emit-only: the compiled assembly is never loaded into the domain (Mono // can't unload it), so there is no assembly name to report either. Assert.IsNull(r.AssemblyName, "dry_run must not load (or name) an assembly"); } [Test] public async Task RunScript_DryRun_CompileError_ReturnsSameDiagnostics() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 2 +; } }"); var r = await RunScriptCommand.RunScript(file, dryRun: true); Assert.IsFalse(r.Success); Assert.AreEqual("Compilation Failed", r.Error); Assert.Greater(r.Diagnostics.Count, 0); } #endregion #region Entry point resolution errors [Test] public async Task RunScript_UnknownEntry_ErrorsClearly() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 1; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Nope.DoesNotExist"); Assert.IsFalse(r.Success); Assert.AreEqual("Entry Point Not Found", r.Error); } [Test] public async Task RunScript_AmbiguousEntry_ErrorsClearly() { var file = WriteTempScript(@" public static class Builder { public static int A() { return 1; } public static int B() { return 2; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsFalse(r.Success); Assert.AreEqual("Entry Point Not Found", r.Error); StringAssert.Contains("Ambiguous", r.ErrorDetails); } [Test] public async Task RunScript_NoEntry_FallsBackToMain() { var file = WriteTempScript(@" public static class Builder { public static int Helper() { return 1; } public static int Main() { return 99; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(99, r.Result); } [Test] public async Task RunScript_InstanceMethodEntry_Rejected() { var file = WriteTempScript(@" public class Builder { public int Build() { return 1; } // instance, not static public static int Trigger() { return 0; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build"); Assert.IsFalse(r.Success); Assert.AreEqual("Entry Point Not Found", r.Error); StringAssert.Contains("instance method", r.ErrorDetails); } [Test] public async Task RunScript_GenericEntry_RejectedClearly() { // A generic method can't be invoked without type arguments — instead of a raw // reflection exception, the resolver must say so. var file = WriteTempScript(@" public static class Builder { public static T Build() { return default(T); } public static int Other() { return 1; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build"); Assert.IsFalse(r.Success); Assert.AreEqual("Entry Point Not Found", r.Error); StringAssert.Contains("generic entry points are not supported", r.ErrorDetails); } [Test] public async Task RunScript_NestedTypeEntry_Resolves() { // Callers naturally write nested types with dots ("Outer.Inner.Method"); reflection // names them "Outer+Inner". The resolver retries the '+' form. var file = WriteTempScript(@" public static class Outer { public static class Inner { public static int Build() { return 7; } } }"); var r = await RunScriptCommand.RunScript(file, entry: "Outer.Inner.Build"); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(7, r.Result); } #endregion #region Argument coercion [Test] public async Task RunScript_Args_PrimitivesEnumAndStringArray_Coerced() { var file = WriteTempScript(@" public static class Builder { public enum Kind { Red, Green, Blue } public static string Build(int i, float f, Kind k, string[] names) { return i + "":"" + f + "":"" + k + "":"" + names.Length; } }"); var args = new JArray(3, 2.5f, "Green", new JArray("x", "y")); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: args); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual("3:2.5:Green:2", r.Result); } [Test] public async Task RunScript_Args_ObjectRefParameter_ResolvesUnityObject() { var go = TrackGo(new GameObject("RunScript_RefTarget")); var file = WriteTempScript(@" using UnityEngine; public static class Builder { public static string Build(GameObject go) { return go.name; } }"); var handle = JObject.FromObject(new { instanceId = PipelineUtils.GetObjectId(go) }); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(handle)); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual("RunScript_RefTarget", r.Result); } [Test] public async Task RunScript_Args_MissingRequiredArgument_Errors() { var file = WriteTempScript(@" public static class Builder { public static int Build(int required) { return required; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray()); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); StringAssert.Contains("Missing required argument", r.ErrorDetails); } [Test] public async Task RunScript_Args_DefaultValueUsedWhenOmitted() { var file = WriteTempScript(@" public static class Builder { public static int Build(int a, int b = 7) { return a + b; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(3)); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(10, r.Result); } [Test] public async Task RunScript_Args_TooMany_Errors() { var file = WriteTempScript(@" public static class Builder { public static int Build(int a) { return a; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(1, 2, 3)); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); } [Test] public async Task RunScript_Args_NullForValueType_ErrorsInsteadOfSilentDefault() { // JSON null used to silently become default(T) for value-type parameters — a caller // mistake that must surface as a coercion error instead. var file = WriteTempScript(@" public static class Builder { public static int Build(int a) { return a; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray((object)null)); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); StringAssert.Contains("null", r.ErrorDetails); StringAssert.Contains("value type", r.ErrorDetails); } [Test] public async Task RunScript_Args_NullForNullableValueType_StillWorks() { var file = WriteTempScript(@" public static class Builder { public static string Build(int? a) { return a.HasValue ? a.Value.ToString() : ""none""; } }"); var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray((object)null)); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual("none", r.Result); } #endregion #region Runtime exceptions [Test] public async Task RunScript_RuntimeException_StructuredError() { var file = WriteTempScript(@" public static class Builder { public static int Build() { throw new System.InvalidOperationException(""boom""); } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsFalse(r.Success); Assert.AreEqual("Runtime Error", r.Error); StringAssert.Contains("boom", r.ErrorDetails); } [Test] public async Task RunScript_RuntimeException_ErrorDetails_MapToSourceFileAndLine() { // The PDB acceptance criterion: ephemeral runs always emit a source-mapped portable // PDB, so a runtime exception's stack trace must map back to the original .cs file // and line — not to an anonymous in-memory buffer. var file = WriteTempScript( "public static class Builder\n" + "{\n" + " public static int Build()\n" + " {\n" + " throw new System.InvalidOperationException(\"mapped\");\n" + // line 5 " }\n" + "}\n"); var r = await RunScriptCommand.RunScript(file); Assert.IsFalse(r.Success); Assert.AreEqual("Runtime Error", r.Error); var fileName = Path.GetFileName(file); StringAssert.Contains(fileName, r.ErrorDetails, "stack trace should reference the source file"); // .NET renders "file.cs:line 5", Mono "file.cs:5" — accept either, but demand the // throw site's actual line number. Assert.IsTrue( Regex.IsMatch(r.ErrorDetails, Regex.Escape(fileName) + @":(line )?5\b"), $"stack trace should carry the source line of the throw. ErrorDetails:\n{r.ErrorDetails}"); } #endregion #region Async entry points [Test] public async Task RunScript_AsyncTaskEntry_Awaited_ExceptionSurfaced() { // A Task-returning entry point must be awaited: without it the command reports // success the moment the first await yields, and the exception is never observed. var file = WriteTempScript(@" using System.Threading.Tasks; public static class Builder { public static async Task Build() { await Task.Delay(10).ConfigureAwait(false); throw new System.InvalidOperationException(""async boom""); } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsFalse(r.Success, "the faulted task's exception must fail the run"); Assert.AreEqual("Runtime Error", r.Error); StringAssert.Contains("async boom", r.ErrorDetails); } [Test] public async Task RunScript_AsyncTaskEntry_CompletesAndReturnsNull() { var file = WriteTempScript(@" using System.Threading.Tasks; public static class Builder { public static int Ran; public static async Task Build() { await Task.Delay(10).ConfigureAwait(false); Ran = 1; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); // async Task materializes as Task; the placeholder must not leak // into the result. Assert.IsNull(r.Result, "a plain async Task entry has no value to return"); } [Test] public async Task RunScript_AsyncTaskIntEntry_ReturnsValue() { var file = WriteTempScript(@" using System.Threading.Tasks; public static class Builder { public static async Task Build() { await Task.Delay(10).ConfigureAwait(false); return 42; } }"); var r = await RunScriptCommand.RunScript(file); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(42, r.Result, "Task.Result should be unwrapped into the response"); } [Test] public async Task RunScript_AsyncEntry_ResumingOnUnityContext_CompletesWithoutDeadlock() { // THE deadlock regression (found by manual testing): awaits without // ConfigureAwait(false) resume on Unity's main-thread SynchronizationContext — the // default way anyone writes async in Unity. The old blocking GetResult() wait // deadlocked the whole Editor permanently on this input; the await-based wait must // let the editor loop pump those continuations and complete normally. var file = WriteTempScript(@" using System.Threading.Tasks; public static class Builder { public static async Task Build(int a, int b) { await Task.Yield(); return a * b; } }"); var r = await RunScriptCommand.RunScript(file, args: new JArray(6, 7)); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(42, r.Result); } [Test] public async Task RunScript_AsyncEntry_ExceedingBudget_TimesOutStructured() { // An entry task that outlives timeout_ms must produce a structured Timeout error // (the task keeps running detached), never hang the command or the Editor. var file = WriteTempScript(@" using System.Threading.Tasks; public static class Builder { public static async Task Build() { await Task.Delay(30000); return 1; } }"); var sw = System.Diagnostics.Stopwatch.StartNew(); var r = await RunScriptCommand.RunScript(file, timeoutMs: 1500); sw.Stop(); Assert.IsFalse(r.Success, "the entry task cannot have completed within the budget"); Assert.AreEqual("Timeout", r.Error); StringAssert.Contains("keeps running detached", r.ErrorDetails); Assert.Less(sw.ElapsedMilliseconds, 15000, "the command must give up at ~timeout_ms, not wait out the 30s entry task"); } #endregion #region Bad input [Test] public async Task RunScript_NullFile_BadRequest() { var r = await RunScriptCommand.RunScript(null); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); } [Test] public async Task RunScript_NonCsFile_BadRequest() { var r = await RunScriptCommand.RunScript("Something.txt"); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); } [Test] public async Task RunScript_MissingFile_FileNotFound() { var path = Path.Combine(Path.GetTempPath(), "run_script_missing_" + System.Guid.NewGuid().ToString("N") + ".cs"); var r = await RunScriptCommand.RunScript(path); Assert.IsFalse(r.Success); Assert.AreEqual("File Not Found", r.Error); } [Test] public async Task RunScript_UnknownMode_BadRequest() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 1; } }"); var r = await RunScriptCommand.RunScript(file, mode: "banana"); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); } #endregion #region defines [Test] public async Task RunScript_Defines_ExtendActiveEditorDefines() { // Ephemeral compiles are seeded with the project's active editor defines // (EditorUserBuildSettings.activeScriptCompilationDefines), so UNITY_EDITOR is set by // default — sources behave like project code. The user's `defines` are APPENDED to // that set, not a replacement for it. var source = @" public static class Builder { public static string Build() { var parts = new System.Collections.Generic.List(); #if UNITY_EDITOR parts.Add(""editor""); #endif #if MY_FLAG parts.Add(""flag""); #endif return string.Join("","", parts); } }"; var file = WriteTempScript(source); var withoutFlag = await RunScriptCommand.RunScript(file); Assert.IsTrue(withoutFlag.Success, withoutFlag.Error + " / " + withoutFlag.ErrorDetails); Assert.AreEqual("editor", withoutFlag.Result, "UNITY_EDITOR should be defined by default"); var withFlag = await RunScriptCommand.RunScript(file, defines: new[] { "MY_FLAG" }); Assert.IsTrue(withFlag.Success, withFlag.Error + " / " + withFlag.ErrorDetails); Assert.AreEqual("editor,flag", withFlag.Result, "a custom define must extend, not replace, the active defines"); } #endregion #region Path resolution [Test] public async Task RunScript_RelativePath_ResolvesAgainstProjectRoot() { // Relative paths must resolve against the PROJECT ROOT (parent of Assets/), not the // process working directory. Temporarily move the CWD elsewhere to prove it. var projectRoot = Directory.GetParent(Application.dataPath).FullName; var fileName = "run_script_rel_" + System.Guid.NewGuid().ToString("N") + ".cs"; var relative = Path.Combine("Temp", fileName); var absolute = Path.Combine(projectRoot, relative); Directory.CreateDirectory(Path.Combine(projectRoot, "Temp")); File.WriteAllText(absolute, @" public static class Builder { public static int Build() { return 11; } }"); m_TempFiles.Add(absolute); var originalCwd = Directory.GetCurrentDirectory(); try { Directory.SetCurrentDirectory(Path.GetTempPath()); var r = await RunScriptCommand.RunScript(relative); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(11, r.Result); } finally { Directory.SetCurrentDirectory(originalCwd); } } #endregion #region hotpatch delegation [Test] public async Task RunScript_Hotpatch_MissingFile_MapsReloadFileFailure() { // hotpatch delegates to reload_file; a clearly-missing file must surface as a mapped // failure rather than throwing, proving the delegation + error mapping. var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_missing_" + System.Guid.NewGuid().ToString("N") + ".cs"); var r = await RunScriptCommand.RunScript(path, mode: "hotpatch"); Assert.IsFalse(r.Success); Assert.IsNotNull(r.Error); } [Test] public async Task RunScript_Hotpatch_DryRun_Rejected_NothingApplied() { // dry_run has no compile-only meaning in hotpatch mode — reload_file would apply live // method replacements. It must be rejected up front, not silently applied. // Proof nothing ran: the file deliberately does not exist. If the guard didn't fire, // reload_file would answer "File Not Found" — a "Bad Request" naming dry_run proves the // request was rejected before any hotpatch machinery (or compile) was touched. var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_dryrun_" + System.Guid.NewGuid().ToString("N") + ".cs"); var r = await RunScriptCommand.RunScript(path, mode: "hotpatch", dryRun: true); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); StringAssert.Contains("dry_run", r.ErrorDetails); StringAssert.Contains("hotpatch", r.ErrorDetails); Assert.AreEqual(0, r.CompileMs, "nothing should have compiled"); Assert.AreEqual(0, r.ExecuteMs, "nothing should have been applied"); } [Test] public async Task RunScript_Hotpatch_EntrySupplied_Rejected() { // hotpatch never invokes an entry point, so a supplied entry must be rejected by name // instead of silently ignored. var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_entry_" + System.Guid.NewGuid().ToString("N") + ".cs"); var r = await RunScriptCommand.RunScript(path, entry: "Builder.Build", mode: "hotpatch"); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); StringAssert.Contains("'entry'", r.ErrorDetails); } [Test] public async Task RunScript_Hotpatch_ArgsSupplied_Rejected() { var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_args_" + System.Guid.NewGuid().ToString("N") + ".cs"); var r = await RunScriptCommand.RunScript(path, args: new JArray(1), mode: "hotpatch"); Assert.IsFalse(r.Success); Assert.AreEqual("Bad Request", r.Error); StringAssert.Contains("'args'", r.ErrorDetails); } #endregion #region ViaClient [Test] public void RunScript_ViaClient_ReturnsResult() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 6 * 7; } }"); using (var server = new PipelineTestServer()) { var response = server.Execute("run_script", new { file, timeout_ms = 15000 }); Assert.IsTrue(response.IsSuccess, response.Error); var r = response.GetTypedResponse(); Assert.IsNotNull(r, "Should deserialize a RunScriptResponse"); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual(42, r.Result); } } [Test] public void RunScript_ViaClient_ArgsCoercedFromJsonArray() { var file = WriteTempScript(@" public static class Builder { public static string Build(int i, string[] names) { return i + "":"" + names.Length; } }"); using (var server = new PipelineTestServer()) { var response = server.Execute("run_script", new { file, entry = "Builder.Build", args = new object[] { 5, new[] { "a", "b", "c" } } }); Assert.IsTrue(response.IsSuccess, response.Error); var r = response.GetTypedResponse(); Assert.IsNotNull(r); Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails); Assert.AreEqual("5:3", r.Result); } } [Test] public void RunScript_ViaClient_TimeoutMs_GovernsDispatcherWaitBudget() { // Proves timeout_ms actually reaches the dispatcher's wait budget end-to-end // (UUM-148641 semantics extended to run_script): an entry that sleeps 2000ms with // timeout_ms=300 must time out at ~300ms instead of waiting out the sleep (or the // dispatcher's 60s default). var file = WriteTempScript(@" public static class Builder { public static int Build() { System.Threading.Thread.Sleep(2000); return 1; } }"); using (var server = new PipelineTestServer()) { LogAssert.Expect(LogType.Error, new Regex("Failed to handle /api/exec request.*timed out after 300ms")); // Like the matching eval test: PipelineTestServer.Execute's pump loop runs the // dequeued work item synchronously, so it can't observe completion until the // compile + 2000ms sleep finish — give the outer wait headroom even though the // dispatcher timeout itself fires at ~300ms. var response = server.Execute("run_script", new { file, timeout_ms = 300 }, timeoutMs: 30000); Assert.IsFalse(response.IsSuccess, $"Expected the 300ms budget to time out instead of waiting for the 2000ms sleep: {response.RawResponse}"); } } [Test] public void RunScript_ViaClient_CompileError_ReturnsDiagnostics() { var file = WriteTempScript(@" public static class Builder { public static int Build() { return 2 +; } }"); using (var server = new PipelineTestServer()) { var response = server.Execute("run_script", new { file }); var r = response.GetTypedResponse(); Assert.IsNotNull(r); Assert.IsFalse(r.Success); Assert.AreEqual("Compilation Failed", r.Error); Assert.Greater(r.Diagnostics.Count, 0); } } #endregion } }