├── .github └── workflows │ └── build.yaml ├── LICENSE └── addons └── GodotXUnit ├── .gitignore ├── Directory.Build.props ├── GodotTestRunner.cs ├── GodotXUnitApi ├── GDI.cs ├── GDU.cs ├── GodotFactAttribute.cs ├── GodotXUnitApi.csproj ├── GodotXUnitEvents.cs └── Internal │ ├── Consts.cs │ ├── Extensions.cs │ ├── GodotFactDiscoverer.cs │ ├── GodotTestCase.cs │ ├── GodotTestOutputHelper.cs │ ├── GodotXUnitRunnerBase.cs │ ├── MessagePassing.cs │ ├── ProjectListing.cs │ └── WorkFiles.cs ├── LICENSE ├── Plugin.cs ├── README ├── XUnitDock.cs ├── XUnitDock.tscn ├── _work └── .gdignore ├── assets ├── check.png ├── check.png.import ├── error.png ├── error.png.import ├── running.png ├── running.png.import ├── warn.png └── warn.png.import ├── plugin.cfg └── runner ├── EmptyScene.tscn ├── GodotTestEntry.gd ├── GodotTestRunnerScene.tscn └── RiderTestRunner ├── Runner.cs └── Runner.tscn /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: GodotXUnit Tests 2 | 3 | on: 4 | push: {} 5 | 6 | jobs: 7 | run-godot-xunit: 8 | runs-on: ubuntu-latest 9 | container: 10 | image: mcr.microsoft.com/dotnet/sdk:6.0 11 | # image: barichello/godot-ci:mono-4.1 12 | env: 13 | GODOT_VERSION: Godot_v4.1-stable_mono_linux_x86_64 14 | GODOT_EXEC: Godot_v4.1-stable_mono_linux.x86_64 15 | steps: 16 | # checkout the repo 17 | - uses: actions/checkout@v2 18 | 19 | 20 | # ====================================================== 21 | # pull deps and build 22 | # ====================================================== 23 | # download the nuget dependencies 24 | # - run: nuget restore 25 | - run: dotnet restore 26 | # copy the godot DLLs where they are expected 27 | # - run: | 28 | # mkdir -p .mono/assemblies/Debug 29 | # cp /usr/local/bin/GodotSharp/Api/Release/* .mono/assemblies/Debug 30 | # build the solution 31 | - run: dotnet msbuild 32 | 33 | 34 | - run: | 35 | apt-get update 36 | apt-get install -y unzip 37 | wget https://downloads.tuxfamily.org/godotengine/4.1/mono/${GODOT_VERSION}.zip 38 | unzip ${GODOT_VERSION}.zip 39 | mv ${GODOT_VERSION}/${GODOT_EXEC} /usr/local/bin/godot 40 | mv ${GODOT_VERSION}/GodotSharp /usr/local/bin/GodotSharp 41 | 42 | # ====================================================== 43 | # execute tests in root project 44 | # ====================================================== 45 | # you can override any godot project setting: 46 | # https://docs.godotengine.org/en/3.2/classes/class_projectsettings.html 47 | # 48 | # all GodotXUnit config values can be overridden like this. a list of all of them 49 | # and what they do can be found at: 50 | # https://github.com/fledware/GodotXUnit/blob/master/addons/GodotXUnit/GodotXUnitApi/Internal/Consts.cs 51 | - name: Configure Tests For Root Project 52 | run: | 53 | cat > override.cfg < override.cfg < 2 | 3 | 4 | ../../../.mono/build/obj/$(Configuration) 5 | 6 | -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotTestRunner.cs: -------------------------------------------------------------------------------- 1 | using GodotXUnitApi.Internal; 2 | 3 | namespace GodotXUnit 4 | { 5 | public partial class GodotTestRunner : GodotXUnitRunnerBase 6 | { 7 | 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/GDI.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Godot; 4 | 5 | namespace GodotXUnitApi 6 | { 7 | /// 8 | /// helper methods for simulating mouse events. 9 | /// 10 | public static class GDI 11 | { 12 | /// 13 | /// gets the x pixel position based on the percent of the screen 14 | /// 15 | public static float PositionXByScreenPercent(float percent) 16 | { 17 | return GDU.ViewportSize.X * percent; 18 | } 19 | 20 | /// 21 | /// gets the y pixel position based on the percent of the screen 22 | /// 23 | public static float PositionYByScreenPercent(float percent) 24 | { 25 | return GDU.ViewportSize.Y * percent; 26 | } 27 | 28 | /// 29 | /// gets a vector2 representing the pixel positions based on the screen percents. 30 | /// 31 | public static Vector2 PositionByScreenPercent(float screenPercentX, float screenPercentY) 32 | { 33 | return new Vector2(PositionXByScreenPercent(screenPercentX), PositionYByScreenPercent(screenPercentY)); 34 | } 35 | 36 | /// 37 | /// gets a vector2 representing the pixel positions based on the world position handed in. 38 | /// 39 | /// NOTE: i haven't tested this for 3d because i've only written 2d games with godot, 40 | /// so i've limited this method to 2d scenes only. 41 | /// 42 | public static Vector2 PositionBy2DWorldPos(Vector2 worldPos) 43 | { 44 | var scene = GDU.CurrentScene as Node2D; 45 | if (scene == null) 46 | throw new Exception("scene root must be a Node2D to use this method"); 47 | return (scene.GetViewportTransform() * scene.GetGlobalTransform()) * worldPos; 48 | } 49 | 50 | // There exists a mouse_button_to_mask in the C++ code, but it's not exposed. 51 | private static MouseButtonMask MouseButtonToMask(MouseButton index) => 52 | (MouseButtonMask)(1 << ((int)index) - 1); 53 | 54 | /// 55 | /// sends an mouse down event into godot 56 | /// 57 | public static void InputMouseDown(Vector2 screenPosition, MouseButton index = MouseButton.Left) 58 | { 59 | var inputEvent = new InputEventMouseButton(); 60 | inputEvent.GlobalPosition = screenPosition; 61 | inputEvent.Position = screenPosition; 62 | inputEvent.Pressed = true; 63 | inputEvent.ButtonIndex = index; 64 | inputEvent.ButtonMask = MouseButtonToMask(index); 65 | Input.ParseInputEvent(inputEvent); 66 | } 67 | 68 | /// 69 | /// sends an mouse up event into godot 70 | /// 71 | public static void InputMouseUp(Vector2 screenPosition, MouseButton index = MouseButton.Left) 72 | { 73 | var inputEvent = new InputEventMouseButton(); 74 | inputEvent.GlobalPosition = screenPosition; 75 | inputEvent.Position = screenPosition; 76 | inputEvent.Pressed = false; 77 | inputEvent.ButtonIndex = index; 78 | inputEvent.ButtonMask = MouseButtonToMask(index); 79 | Input.ParseInputEvent(inputEvent); 80 | } 81 | 82 | /// 83 | /// sends an mouse move event into godot 84 | /// 85 | public static void InputMouseMove(Vector2 screenPosition) 86 | { 87 | var inputEvent = new InputEventMouseMotion(); 88 | inputEvent.GlobalPosition = screenPosition; 89 | inputEvent.Position = screenPosition; 90 | Input.ParseInputEvent(inputEvent); 91 | } 92 | 93 | /// 94 | /// simulates a click with these steps: 95 | /// - send a mouse moved event to the requested position 96 | /// - send a mouse down event 97 | /// - send a mouse up event 98 | /// 99 | /// at least a single frame is skipped between each event to make sure 100 | /// godot and the scene has time to react. 101 | /// 102 | /// same as PositionByScreenPercent 103 | /// same as PositionByScreenPercent 104 | /// the button index 105 | /// the task that will resolve when the simulation is finished 106 | public static async Task SimulateMouseClick(float screenPercentX, float screenPercentY, MouseButton index = MouseButton.Left) 107 | { 108 | var position = PositionByScreenPercent(screenPercentX, screenPercentY); 109 | await SimulateMouseClick(position, index); 110 | } 111 | 112 | /// 113 | /// simulates a click with these steps: 114 | /// - send a mouse moved event to the requested position 115 | /// - send a mouse down event 116 | /// - send a mouse up event 117 | /// 118 | /// at least a single frame is skipped between each event to make sure 119 | /// godot and the scene has time to react. 120 | /// 121 | /// same as PositionByScreenPercent 122 | /// same as PositionByScreenPercent 123 | /// the button index 124 | public static void SimulateMouseClickNoWait(float screenPercentX, float screenPercentY, MouseButton index = MouseButton.Left) 125 | { 126 | #pragma warning disable 4014 127 | SimulateMouseClick(screenPercentX, screenPercentY, index); 128 | #pragma warning restore 4014 129 | } 130 | 131 | /// 132 | /// simulates a click with these steps: 133 | /// - send a mouse moved event to the requested position 134 | /// - send a mouse down event 135 | /// - send a mouse up event 136 | /// 137 | /// at least a single frame is skipped between each event to make sure 138 | /// godot and the scene has time to react. 139 | /// 140 | /// the task will resolve after the simulation is completed. 141 | /// 142 | /// the position of where to click 143 | /// the button index 144 | /// the task that will resolve when the simulation is finished 145 | public static async Task SimulateMouseClick(Vector2 position, MouseButton index = MouseButton.Left) 146 | { 147 | await GDU.OnProcessAwaiter; 148 | InputMouseMove(position); 149 | await GDU.OnProcessAwaiter; 150 | InputMouseDown(position, index); 151 | await GDU.OnProcessAwaiter; 152 | await GDU.OnProcessAwaiter; 153 | InputMouseUp(position, index); 154 | await GDU.OnProcessAwaiter; 155 | await GDU.OnProcessAwaiter; 156 | } 157 | 158 | /// 159 | /// simulates a click with these steps: 160 | /// - send a mouse moved event to the requested position 161 | /// - send a mouse down event 162 | /// - send a mouse up event 163 | /// 164 | /// at least a single frame is skipped between each event to make sure 165 | /// godot and the scene has time to react. 166 | /// 167 | /// the position of where to click 168 | /// the button index 169 | public static void SimulateMouseClickNoWait(Vector2 position, MouseButton index = MouseButton.Left) 170 | { 171 | #pragma warning disable 4014 172 | SimulateMouseClick(position, index); 173 | #pragma warning restore 4014 174 | } 175 | 176 | /// 177 | /// simulates a mouse drag with these steps: 178 | /// - move mouse to start position 179 | /// - send mouse down into godot 180 | /// - move mouse to end position 181 | /// - send mouse up into godot 182 | /// 183 | /// at least a single frame is skipped between each event to make sure 184 | /// godot and the scene has time to react. 185 | /// 186 | /// same as PositionByScreenPercent 187 | /// same as PositionByScreenPercent 188 | /// same as PositionByScreenPercent 189 | /// same as PositionByScreenPercent 190 | /// the button index 191 | /// the task that will resolve when the simulation is finished 192 | public static async Task SimulateMouseDrag(float startScreenPercentX, float startScreenPercentY, 193 | float endScreenPercentX, float endScreenPercentY, 194 | MouseButton index = MouseButton.Left) 195 | { 196 | var start = PositionByScreenPercent(startScreenPercentX, startScreenPercentY); 197 | var end = PositionByScreenPercent(endScreenPercentX, endScreenPercentY); 198 | await SimulateMouseDrag(start, end, index); 199 | } 200 | 201 | /// 202 | /// simulates a mouse drag with these steps: 203 | /// - move mouse to start position 204 | /// - send mouse down into godot 205 | /// - move mouse to end position 206 | /// - send mouse up into godot 207 | /// 208 | /// at least a single frame is skipped between each event to make sure 209 | /// godot and the scene has time to react. 210 | /// 211 | /// same as PositionByScreenPercent 212 | /// same as PositionByScreenPercent 213 | /// same as PositionByScreenPercent 214 | /// same as PositionByScreenPercent 215 | /// the button index 216 | public static void SimulateMouseDragNoWait(float startScreenPercentX, float startScreenPercentY, 217 | float endScreenPercentX, float endScreenPercentY, 218 | MouseButton index = MouseButton.Left) 219 | { 220 | #pragma warning disable 4014 221 | SimulateMouseDrag(startScreenPercentX, startScreenPercentY, endScreenPercentX, endScreenPercentY, index); 222 | #pragma warning restore 4014 223 | } 224 | 225 | /// 226 | /// simulates a mouse drag with these steps: 227 | /// - move mouse to start position 228 | /// - send mouse down into godot 229 | /// - move mouse to end position 230 | /// - send mouse up into godot 231 | /// 232 | /// at least a single frame is skipped between each event to make sure 233 | /// godot and the scene has time to react. 234 | /// 235 | /// the position of where the drag starts 236 | /// the position of where the drag ends 237 | /// the button index 238 | /// the task that will resolve when the simulation is finished 239 | public static async Task SimulateMouseDrag(Vector2 start, Vector2 end, MouseButton index = MouseButton.Left) 240 | { 241 | await GDU.OnProcessAwaiter; 242 | InputMouseMove(start); 243 | await GDU.OnProcessAwaiter; 244 | InputMouseDown(start, index); 245 | await GDU.OnProcessAwaiter; 246 | await GDU.OnProcessAwaiter; 247 | InputMouseMove(end); 248 | await GDU.OnProcessAwaiter; 249 | InputMouseUp(end, index); 250 | await GDU.OnProcessAwaiter; 251 | await GDU.OnProcessAwaiter; 252 | } 253 | 254 | /// 255 | /// simulates a mouse drag with these steps: 256 | /// - move mouse to start position 257 | /// - send mouse down into godot 258 | /// - move mouse to end position 259 | /// - send mouse up into godot 260 | /// 261 | /// at least a single frame is skipped between each event to make sure 262 | /// godot and the scene has time to react. 263 | /// 264 | /// the position of where the drag starts 265 | /// the position of where the drag ends 266 | /// the button index 267 | public static void SimulateMouseDragNoWait(Vector2 start, Vector2 end, MouseButton index = MouseButton.Left) 268 | { 269 | #pragma warning disable 4014 270 | SimulateMouseDrag(start, end, index); 271 | #pragma warning restore 4014 272 | } 273 | } 274 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/GDU.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Godot; 5 | using GodotXUnitApi.Internal; 6 | 7 | namespace GodotXUnitApi 8 | { 9 | /// 10 | /// a global helper for interacting with the executing tree 11 | /// during a test. 12 | /// 13 | // here are a few usage examples: 14 | // 15 | // - get the current scene: 16 | // var scene = GDU.CurrentScene; 17 | // 18 | // - wait for 60 process frames 19 | // await GDU.WaitForFrames(60); 20 | // 21 | // - move to the physics frame 22 | // await GDU.OnPhysicsProcessAwaiter; 23 | public static class GDU 24 | { 25 | private static Node2D _instance; 26 | 27 | public static Node2D Instance 28 | { 29 | get => _instance ?? throw new Exception("GDU not set"); 30 | set => _instance = value; 31 | } 32 | 33 | public static SignalAwaiter OnProcessAwaiter => 34 | Instance.ToSignal(Instance, "OnProcess"); 35 | 36 | public static SignalAwaiter OnPhysicsProcessAwaiter => 37 | Instance.ToSignal(Instance, "OnPhysicsProcess"); 38 | 39 | public static SignalAwaiter OnProcessFrameAwaiter => 40 | Instance.ToSignal(Instance.GetTree(), SceneTree.SignalName.ProcessFrame); 41 | 42 | public static SceneTree Tree => Instance.GetTree(); 43 | 44 | public static Vector2I ViewportSize => Instance.GetViewport() switch 45 | { 46 | Window window => window.ContentScaleSize, 47 | SubViewport subViewport => subViewport.Size, 48 | var vp => throw new Exception($"Unexpected viewport type {vp.GetType().Name}") 49 | }; 50 | 51 | 52 | public static Node CurrentScene => Instance.GetTree().CurrentScene; 53 | 54 | /// 55 | /// this can be used within tests instead of grabbing ITestOutputHelper 56 | /// 57 | /// 58 | public static void Print(string message) 59 | { 60 | // when [GodotFact] is used, the console output stream is 61 | // automatically overridden for each test. but this will 62 | // avoid the annoying warnings. 63 | Console.WriteLine(message); 64 | } 65 | 66 | /// 67 | /// creates a task the awaits for the given amount of _Process frames to happen 68 | /// 69 | /// the amount of frames to wait 70 | /// the task that resolves after the amount of frames 71 | public static async Task WaitForFrames(int count) 72 | { 73 | for (int i = 0; i < count; i++) 74 | await OnProcessAwaiter; 75 | } 76 | 77 | /// 78 | /// helper to wrap a SignalAwaiter to return the first element from a signal 79 | /// result into the desired type. 80 | /// 81 | /// the target signal to wrap 82 | /// the type to cast to 83 | /// the task that awaits and casts when resolved 84 | public static async Task AwaitType<[MustBeVariant] T>(this SignalAwaiter awaiter) 85 | { 86 | return (await awaiter)[0].As(); 87 | } 88 | 89 | /// 90 | /// creates a task for a godot signal with a timeout. 91 | /// 92 | /// the object that emits the signal 93 | /// the signal to wait for 94 | /// the amount of millis before a timeout happens 95 | /// makes this task throw an exception on timeout. otherwise, just resolves 96 | /// the new task with the given timeout 97 | /// only throws if throwOnTimeout is true 98 | public static async Task ToSignalWithTimeout( 99 | this GodotObject source, 100 | string signal, 101 | int timeoutMillis, 102 | bool throwOnTimeout = true) 103 | { 104 | return await source.ToSignal(source, signal).AwaitWithTimeout(timeoutMillis, throwOnTimeout); 105 | } 106 | 107 | /// 108 | /// wraps the given SignalAwaiter in a task with a timeout. 109 | /// 110 | /// the signal to add a timeout to 111 | /// the amount of millis before a timeout happens 112 | /// makes this task throw an exception on timeout. otherwise, just resolves 113 | /// the new task with the given timeout 114 | /// only throws if throwOnTimeout is true 115 | public static Task AwaitWithTimeout( 116 | this SignalAwaiter awaiter, 117 | int timeoutMillis, 118 | bool throwOnTimeout = true) 119 | { 120 | return Task.Run(async () => await awaiter).AwaitWithTimeout(timeoutMillis, throwOnTimeout); 121 | } 122 | 123 | /// 124 | /// wraps a task with a task that will resolve after the wrapped task 125 | /// or after the specified amount of time (either by exiting or by throwing 126 | /// an exception) 127 | /// 128 | /// the task to wrap 129 | /// the amount of millis before a timeout happens 130 | /// makes this task throw an exception on timeout. otherwise, just resolves 131 | /// the new task with the given timeout 132 | /// only throws if throwOnTimeout is true 133 | public static async Task AwaitWithTimeout( 134 | this Task wrapping, 135 | int timeoutMillis, 136 | bool throwOnTimeout = true) 137 | { 138 | var task = Task.Run(async () => await wrapping); 139 | using var token = new CancellationTokenSource(); 140 | var completedTask = await Task.WhenAny(task, Task.Delay(timeoutMillis, token.Token)); 141 | if (completedTask == task) 142 | { 143 | token.Cancel(); 144 | await task; 145 | } 146 | if (throwOnTimeout) 147 | throw new TimeoutException($"signal {wrapping} timed out after {timeoutMillis}ms."); 148 | } 149 | 150 | /// 151 | /// wraps a task with a task that will resolve after the wrapped task 152 | /// or after the specified amount of time (either by exiting or by throwing 153 | /// an exception) 154 | /// 155 | /// the task to wrap 156 | /// the amount of millis before a timeout happens 157 | /// makes this task throw an exception on timeout. otherwise, just resolves 158 | /// the wrapping type that will be the result if wrapping resolves 159 | /// the new task with the given timeout 160 | /// only throws if throwOnTimeout is true 161 | public static async Task AwaitWithTimeout( 162 | this Task wrapping, 163 | int timeoutMillis, 164 | bool throwOnTimeout = true) 165 | { 166 | var task = Task.Run(async () => await wrapping); 167 | using var token = new CancellationTokenSource(); 168 | var completedTask = await Task.WhenAny(task, Task.Delay(timeoutMillis, token.Token)); 169 | if (completedTask == task) 170 | { 171 | token.Cancel(); 172 | return await task; 173 | } 174 | if (throwOnTimeout) 175 | throw new TimeoutException($"signal {wrapping} timed out after {timeoutMillis}ms."); 176 | return default; 177 | } 178 | 179 | /// 180 | /// allows the caller to draw for the specific amount of frames. 181 | /// 182 | /// very helpful when trying to debug why the freaking buttons are not 183 | /// being clicked when you very clearly asked GDI to click something. 184 | /// 185 | /// 186 | /// 187 | /// 188 | public static async Task RequestDrawing(int frames, Action drawer) 189 | { 190 | for (int i = 0; i < frames; i++) 191 | { 192 | ((GodotXUnitRunnerBase)Instance).RequestDraw(drawer); 193 | await Instance.ToSignal(Instance, "OnDrawRequestDone"); 194 | } 195 | } 196 | } 197 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/GodotFactAttribute.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | using Xunit.Sdk; 3 | 4 | namespace GodotXUnitApi 5 | { 6 | /// 7 | /// use this like the standard Fact attribute. GodotFact can be used 8 | /// in plain unit tests (outside of the godot runtime) and with 9 | /// integration tests. 10 | /// 11 | /// Though, if you run tests outside of the godot runtime, godot methods 12 | /// will not be available. GDU.Instance will not be setup and the test 13 | /// setup will fail if using Scene or Frame. 14 | /// 15 | /// The main thing this will give you if using this outside of the godot 16 | /// runtime is grabbing console output as test output. this is useful 17 | /// when trying to debug non-godot class unit tests. 18 | /// 19 | /// do not use GodotFact outside of the godot runtime if you are 20 | /// running the tests asynchronously. 21 | /// 22 | /* 23 | [GodotFact] 24 | public void SomeNormalUnitTest() 25 | { 26 | Assert.True(false); 27 | } 28 | 29 | [GodotFact(Scene = "res://test_scenes/SomeTestScene.tscn")] 30 | public void IsOnCorrectScene() 31 | { 32 | var scene = GDU.CurrentScene; 33 | Assert.Equal(typeof(SomeTestSceneRoot), scene?.GetType()); 34 | } 35 | 36 | [GodotFact(Frame = GodotFactFrame.Process)] 37 | public void ILikeToRunOnProcess() 38 | { 39 | GD.Print("i'm in the process event!!"); 40 | } 41 | 42 | [GodotFact(Frame = GodotFactFrame.PhysicsProcess)] 43 | public void IsInPhysicsProcess() 44 | { 45 | Assert.True(Engine.IsInPhysicsFrame()); 46 | } 47 | */ 48 | [XunitTestCaseDiscoverer("GodotXUnitApi.Internal.GodotFactDiscoverer", "GodotXUnitApi")] 49 | // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global 50 | public partial class GodotFactAttribute : FactAttribute 51 | { 52 | /// 53 | /// loads the given scene before the test starts and loads an empty scene after 54 | /// the test is finished. the string must start with res://.. 55 | /// 56 | /// NOTE: this cannot be used outside of the godot runtime 57 | /// 58 | public virtual string Scene { get; set; } 59 | 60 | /// 61 | /// changes the frame that the test is run in. 62 | /// 63 | /// NOTE: this cannot be used outside of the godot runtime 64 | /// 65 | public virtual GodotFactFrame Frame { get; set; } = GodotFactFrame.Default; 66 | } 67 | 68 | /// 69 | /// the frame to run the test in 70 | /// 71 | public enum GodotFactFrame 72 | { 73 | /// 74 | /// does not change the thread context before starting the test. 75 | /// note that the test thread can be in any of these frames. 76 | /// 77 | Default = 0, 78 | /// 79 | /// waits for a process frame before running the test 80 | /// 81 | Process = 1, 82 | /// 83 | /// waits for a physics process frame before running the test 84 | /// 85 | PhysicsProcess = 2 86 | } 87 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/GodotXUnitApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.0.0 4 | rexfleischer 5 | net6.0 6 | GodotXUnitApi 7 | GodotXUnitApi 8 | 10 9 | GodotXUnitApi 10 | true 11 | 12 | 13 | ../../../.mono/build/bin/$(Configuration) 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/GodotXUnitEvents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Xunit.Runners; 4 | 5 | namespace GodotXUnitApi 6 | { 7 | [Serializable] 8 | public partial class GodotXUnitSummary 9 | { 10 | public int testsDiscovered; 11 | public int testsExpectedToRun; 12 | public List skipped = new List(); 13 | public List passed = new List(); 14 | public List failed = new List(); 15 | public List diagnostics = new List(); 16 | 17 | public int completed => passed.Count + failed.Count; 18 | 19 | public GodotXUnitTestResult AddSkipped(TestSkippedInfo message) 20 | { 21 | var result = new GodotXUnitTestResult 22 | { 23 | testCaseClass = message.TypeName, 24 | testCaseName = message.MethodName, 25 | result = "skipped" 26 | }; 27 | skipped.Add(result); 28 | return result; 29 | } 30 | 31 | public GodotXUnitTestResult AddPassed(TestPassedInfo message) 32 | { 33 | var result = new GodotXUnitTestResult 34 | { 35 | testCaseClass = message.TypeName, 36 | testCaseName = message.MethodName, 37 | output = message.Output, 38 | time = (float) message.ExecutionTime, 39 | result = "passed" 40 | }; 41 | passed.Add(result); 42 | return result; 43 | } 44 | 45 | public GodotXUnitTestResult AddFailed(TestFailedInfo message) 46 | { 47 | var result = new GodotXUnitTestResult 48 | { 49 | testCaseClass = message.TypeName, 50 | testCaseName = message.MethodName, 51 | output = message.Output, 52 | time = (float) message.ExecutionTime, 53 | result = "failed", 54 | exceptionType = message.ExceptionType, 55 | exceptionMessage = message.ExceptionMessage, 56 | exceptionStackTrace = message.ExceptionStackTrace, 57 | }; 58 | failed.Add(result); 59 | return result; 60 | } 61 | 62 | public GodotXUnitOtherDiagnostic AddDiagnostic(Exception ex) 63 | { 64 | var result = new GodotXUnitOtherDiagnostic 65 | { 66 | message = ex.Message, 67 | exceptionType = ex.GetType().ToString(), 68 | exceptionStackTrace = ex.StackTrace 69 | }; 70 | diagnostics.Add(result); 71 | return result; 72 | } 73 | 74 | public GodotXUnitOtherDiagnostic AddDiagnostic(string message) 75 | { 76 | var result = new GodotXUnitOtherDiagnostic 77 | { 78 | message = message 79 | }; 80 | diagnostics.Add(result); 81 | return result; 82 | } 83 | } 84 | 85 | [Serializable] 86 | public partial class GodotXUnitTestResult 87 | { 88 | public string testCaseClass; 89 | public string testCaseName; 90 | public string output; 91 | public float time; 92 | public string result; 93 | public string exceptionType; 94 | public string exceptionMessage; 95 | public string exceptionStackTrace; 96 | 97 | public string FullName => $"{testCaseClass}.{testCaseName}"; 98 | } 99 | 100 | [Serializable] 101 | public partial class GodotXUnitTestStart 102 | { 103 | public string testCaseClass; 104 | public string testCaseName; 105 | } 106 | 107 | [Serializable] 108 | public partial class GodotXUnitOtherDiagnostic 109 | { 110 | public string message; 111 | public string exceptionType; 112 | public string exceptionStackTrace; 113 | } 114 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/Consts.cs: -------------------------------------------------------------------------------- 1 | using Godot; 2 | using Godot.Collections; 3 | 4 | namespace GodotXUnitApi.Internal 5 | { 6 | public static class Consts 7 | { 8 | public const string SETTING_RESULTS_SUMMARY = "GodotXUnit/results_summary"; 9 | public static readonly string SETTING_RESULTS_SUMMARY_DEF = "res://TestSummary.json"; 10 | public static readonly Dictionary SETTING_RESULT_SUMMARY_PROP = new Dictionary 11 | { 12 | ["name"] = SETTING_RESULTS_SUMMARY, 13 | ["type"] = (long)Variant.Type.String, 14 | ["hint"] = (long)PropertyHint.Dir, 15 | ["hint_string"] = "set where the summary json is written to.", 16 | ["default"] = SETTING_RESULTS_SUMMARY_DEF 17 | }; 18 | 19 | public static readonly string SETTING_TARGET_ASSEMBLY = "GodotXUnit/target_assembly"; 20 | public static readonly Dictionary SETTING_TARGET_ASSEMBLY_PROP = new Dictionary 21 | { 22 | ["name"] = SETTING_TARGET_ASSEMBLY, 23 | ["type"] = (long)Variant.Type.String, 24 | ["hint"] = (long)PropertyHint.None, 25 | ["hint_string"] = "set the name of the csproj to test, or empty string for main assembly (can be set through UI)", 26 | ["default"] = "" 27 | }; 28 | 29 | public static readonly string SETTING_TARGET_ASSEMBLY_CUSTOM_FLAG = "__custom__"; 30 | public static readonly string SETTING_TARGET_ASSEMBLY_CUSTOM = "GodotXUnit/target_assembly_custom"; 31 | public static readonly Dictionary SETTING_TARGET_ASSEMBLY_CUSTOM_PROP = new Dictionary 32 | { 33 | ["name"] = SETTING_TARGET_ASSEMBLY_CUSTOM, 34 | ["type"] = (long)Variant.Type.String, 35 | ["hint"] = (long)PropertyHint.None, 36 | ["hint_string"] = "set the name of the csproj to test, or empty string for main assembly (can be set through UI)", 37 | ["default"] = "" 38 | }; 39 | 40 | public static readonly string SETTING_TARGET_CLASS = "GodotXUnit/target_class"; 41 | public static readonly Dictionary SETTING_TARGET_CLASS_PROP = new Dictionary 42 | { 43 | ["name"] = SETTING_TARGET_CLASS, 44 | ["type"] = (long)Variant.Type.String, 45 | ["hint"] = (long)PropertyHint.None, 46 | ["hint_string"] = "set the name of the class to test, or empty string for all (can be set through UI)", 47 | ["default"] = "" 48 | }; 49 | 50 | public static readonly string SETTING_TARGET_METHOD = "GodotXUnit/target_method"; 51 | public static readonly Dictionary SETTING_TARGET_METHOD_PROP = new Dictionary 52 | { 53 | ["name"] = SETTING_TARGET_METHOD, 54 | ["type"] = (long)Variant.Type.String, 55 | ["hint"] = (long)PropertyHint.None, 56 | ["hint_string"] = "set the name of the method to test, or empty string for all in class (can be set through UI)", 57 | ["default"] = "" 58 | }; 59 | 60 | public const string RUNNER_SCENE_PATH = "res://addons/GodotXUnit/runner/GodotTestRunnerScene.tscn"; 61 | public const string EMPTY_SCENE_PATH = "res://addons/GodotXUnit/runner/EmptyScene.tscn"; 62 | public const string DOCK_SCENE_PATH = "res://addons/GodotXUnit/XUnitDock.tscn"; 63 | 64 | public const string ICON_RUNNING = "res://addons/GodotXUnit/assets/running.png"; 65 | public const string ICON_WARN = "res://addons/GodotXUnit/assets/warn.png"; 66 | public const string ICON_CHECK = "res://addons/GodotXUnit/assets/check.png"; 67 | public const string ICON_ERROR = "res://addons/GodotXUnit/assets/error.png"; 68 | 69 | public static Texture2D IconRunning => GD.Load(ICON_RUNNING); 70 | public static Texture2D IconWarn => GD.Load(ICON_WARN); 71 | public static Texture2D IconCheck => GD.Load(ICON_CHECK); 72 | public static Texture2D IconError => GD.Load(ICON_ERROR); 73 | } 74 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Godot; 3 | 4 | namespace GodotXUnitApi.Internal 5 | { 6 | public static class Extensions 7 | { 8 | public static FileAccess ThrowIfNotOk(this FileAccess file) 9 | { 10 | if (file is null) 11 | { 12 | ThrowIfNotOk(FileAccess.GetOpenError()); 13 | } 14 | return file; 15 | } 16 | 17 | public static DirAccess ThrowIfNotOk(this DirAccess dir) 18 | { 19 | if (dir is null) 20 | { 21 | ThrowIfNotOk(DirAccess.GetOpenError()); 22 | } 23 | return dir; 24 | } 25 | 26 | public static void ThrowIfNotOk(this Error check) 27 | { 28 | if (check == Error.Ok) return; 29 | throw new Exception($"godot error returned: {check}"); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/GodotFactDiscoverer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Xunit.Abstractions; 3 | using Xunit.Sdk; 4 | 5 | namespace GodotXUnitApi.Internal 6 | { 7 | public partial class GodotFactDiscoverer : IXunitTestCaseDiscoverer 8 | { 9 | private readonly IMessageSink diagnosticMessageSink; 10 | 11 | public GodotFactDiscoverer(IMessageSink diagnosticMessageSink) 12 | { 13 | this.diagnosticMessageSink = diagnosticMessageSink; 14 | } 15 | 16 | public IEnumerable Discover(ITestFrameworkDiscoveryOptions options, 17 | ITestMethod method, 18 | IAttributeInfo attribute) 19 | { 20 | yield return new GodotTestCase(attribute, diagnosticMessageSink, options, method); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/GodotTestCase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Godot; 8 | using Xunit.Abstractions; 9 | using Xunit.Sdk; 10 | 11 | namespace GodotXUnitApi.Internal 12 | { 13 | public partial class GodotTestCase : XunitTestCase 14 | { 15 | private IAttributeInfo attribute; 16 | 17 | [EditorBrowsable(EditorBrowsableState.Never)] 18 | [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] 19 | public GodotTestCase() { } 20 | 21 | public GodotTestCase(IAttributeInfo attribute, 22 | IMessageSink diagnosticMessageSink, 23 | ITestFrameworkDiscoveryOptions discoveryOptions, 24 | ITestMethod testMethod, 25 | object[] testMethodArguments = null) 26 | : base(diagnosticMessageSink, 27 | discoveryOptions.MethodDisplayOrDefault(), 28 | discoveryOptions.MethodDisplayOptionsOrDefault(), 29 | testMethod, 30 | testMethodArguments) 31 | { 32 | this.attribute = attribute; 33 | } 34 | 35 | public override async Task RunAsync(IMessageSink diagnosticMessageSink, 36 | IMessageBus messageBus, 37 | object[] constructorArguments, 38 | ExceptionAggregator aggregator, 39 | CancellationTokenSource cancellationTokenSource) 40 | { 41 | return await new GodotTestCaseRunner(attribute, 42 | this, 43 | DisplayName, 44 | SkipReason, 45 | constructorArguments, 46 | TestMethodArguments, 47 | messageBus, 48 | aggregator, 49 | cancellationTokenSource) 50 | .RunAsync(); 51 | } 52 | } 53 | 54 | public partial class GodotTestCaseRunner : XunitTestCaseRunner 55 | { 56 | private IAttributeInfo attribute; 57 | 58 | public GodotTestCaseRunner(IAttributeInfo attribute, 59 | IXunitTestCase testCase, 60 | string displayName, 61 | string skipReason, 62 | object[] constructorArguments, 63 | object[] testMethodArguments, 64 | IMessageBus messageBus, 65 | ExceptionAggregator aggregator, 66 | CancellationTokenSource cancellationTokenSource) 67 | : base(testCase, 68 | displayName, 69 | skipReason, 70 | constructorArguments, 71 | testMethodArguments, 72 | messageBus, 73 | aggregator, 74 | cancellationTokenSource) 75 | { 76 | this.attribute = attribute; 77 | } 78 | 79 | protected override XunitTestRunner CreateTestRunner( 80 | ITest test, 81 | IMessageBus messageBus, 82 | Type testClass, 83 | object[] constructorArguments, 84 | MethodInfo testMethod, 85 | object[] testMethodArguments, 86 | string skipReason, 87 | IReadOnlyList beforeAfterAttributes, 88 | ExceptionAggregator aggregator, 89 | CancellationTokenSource cancellationTokenSource) 90 | { 91 | return new GodotTestRunner(attribute, 92 | test, 93 | messageBus, 94 | testClass, 95 | constructorArguments, 96 | testMethod, 97 | testMethodArguments, 98 | skipReason, 99 | beforeAfterAttributes, 100 | new ExceptionAggregator(aggregator), 101 | cancellationTokenSource); 102 | } 103 | } 104 | 105 | public partial class GodotTestRunner : XunitTestRunner 106 | { 107 | private IAttributeInfo attribute; 108 | 109 | public GodotTestRunner(IAttributeInfo attribute, 110 | ITest test, 111 | IMessageBus messageBus, 112 | Type testClass, 113 | object[] constructorArguments, 114 | MethodInfo testMethod, 115 | object[] testMethodArguments, 116 | string skipReason, 117 | IReadOnlyList beforeAfterAttributes, 118 | ExceptionAggregator aggregator, 119 | CancellationTokenSource cancellationTokenSource) 120 | : base(test, 121 | messageBus, 122 | testClass, 123 | constructorArguments, 124 | testMethod, 125 | testMethodArguments, 126 | skipReason, 127 | beforeAfterAttributes, 128 | aggregator, 129 | cancellationTokenSource) 130 | { 131 | this.attribute = attribute; 132 | } 133 | 134 | protected override async Task> InvokeTestAsync(ExceptionAggregator aggregator) 135 | { 136 | 137 | // override the ITestOutputHelper from XunitTestClassRunner 138 | TestOutputHelper helper = null; 139 | for (int i = 0; i < ConstructorArguments.Length; i++) 140 | { 141 | if (ConstructorArguments[i] is ITestOutputHelper) 142 | { 143 | helper = (TestOutputHelper)ConstructorArguments[i]; 144 | break; 145 | } 146 | } 147 | var output = new GodotTestOutputHelper(helper); 148 | output.Initialize(MessageBus, Test); 149 | var runTime = await InvokeTestMethodAsync(aggregator); 150 | return Tuple.Create(runTime, output.UnInitAndGetOutput()); 151 | } 152 | 153 | protected override Task InvokeTestMethodAsync(ExceptionAggregator aggregator) 154 | { 155 | return new GodotTestInvoker(attribute, 156 | this.Test, 157 | this.MessageBus, 158 | this.TestClass, 159 | this.ConstructorArguments, 160 | this.TestMethod, 161 | this.TestMethodArguments, 162 | this.BeforeAfterAttributes, 163 | aggregator, 164 | this.CancellationTokenSource) 165 | .RunAsync(); 166 | } 167 | } 168 | 169 | public partial class GodotTestInvoker : XunitTestInvoker 170 | { 171 | private IAttributeInfo attribute; 172 | 173 | private Node addingToTree; 174 | 175 | private bool loadEmptyScene; 176 | 177 | public GodotTestInvoker(IAttributeInfo attribute, 178 | ITest test, 179 | IMessageBus messageBus, 180 | Type testClass, 181 | object[] constructorArguments, 182 | MethodInfo testMethod, 183 | object[] testMethodArguments, 184 | IReadOnlyList beforeAfterAttributes, 185 | ExceptionAggregator aggregator, 186 | CancellationTokenSource cancellationTokenSource) 187 | : base(test, 188 | messageBus, 189 | testClass, 190 | constructorArguments, 191 | testMethod, 192 | testMethodArguments, 193 | beforeAfterAttributes, 194 | aggregator, 195 | cancellationTokenSource) 196 | { 197 | this.attribute = attribute; 198 | } 199 | 200 | protected override object CreateTestClass() 201 | { 202 | var check = base.CreateTestClass(); 203 | if (check is Node node) 204 | addingToTree = node; 205 | 206 | return check; 207 | } 208 | 209 | /// 210 | /// Runs the given function in Godot's main thread. 211 | /// 212 | /// 213 | private void CallInGodotMain(Func fn) 214 | { 215 | Exception caught = null; 216 | var semaphore = new Godot.Semaphore(); 217 | // Create a callable and use CallDeferred to add it to Godot's 218 | // execution queue, which will run the function in the main thread. 219 | // Callables do not (as of Godot 4.1) accept Task functions. 220 | // Wrapping it in an action makes it fire-and-forget, which is 221 | // fine; we're using a semaphore to signal completion anyway. 222 | Callable.From(new Action(async () => 223 | { 224 | try 225 | { 226 | await fn(); 227 | } 228 | catch (AggregateException aggregate) 229 | { 230 | caught = aggregate.InnerException; 231 | } 232 | catch (Exception e) 233 | { 234 | caught = e; 235 | } 236 | finally 237 | { 238 | semaphore.Post(); 239 | } 240 | })).CallDeferred(); 241 | // Note: We're blocking the thread here. Is that a bad thing? 242 | // It's probably a XUnit worker thread, so maybe its fine, but 243 | // if any deadlocks are discovered we might want to spawn a new 244 | // thread for this whole operation. It might be nicer if this whole 245 | // method was async anyway. 246 | semaphore.Wait(); 247 | if (caught is not null) 248 | { 249 | throw caught; 250 | } 251 | } 252 | 253 | protected override async Task BeforeTestMethodInvokedAsync() 254 | { 255 | var sceneCheck = attribute.GetNamedArgument(nameof(GodotFactAttribute.Scene)); 256 | try 257 | { 258 | CallInGodotMain(async () => 259 | { 260 | if (!string.IsNullOrEmpty(sceneCheck)) 261 | { 262 | // you must be in the process frame to 263 | await GDU.OnProcessAwaiter; 264 | 265 | if (GDU.Instance.GetTree().ChangeSceneToFile(sceneCheck) != Error.Ok) 266 | { 267 | Aggregator.Add(new Exception($"could not load scene: {sceneCheck}")); 268 | return; 269 | } 270 | loadEmptyScene = true; 271 | 272 | // the scene should be loaded within two frames 273 | await GDU.OnProcessFrameAwaiter; 274 | await GDU.OnProcessFrameAwaiter; 275 | await GDU.OnProcessAwaiter; 276 | } 277 | 278 | if (addingToTree != null) 279 | { 280 | await GDU.OnProcessAwaiter; 281 | GDU.Instance.AddChild(addingToTree); 282 | await GDU.OnProcessAwaiter; 283 | } 284 | }); 285 | 286 | } 287 | catch (Exception e) 288 | { 289 | Aggregator.Add(e); 290 | } 291 | await base.BeforeTestMethodInvokedAsync(); 292 | } 293 | 294 | protected override async Task InvokeTestMethodAsync(object testClassInstance) 295 | { 296 | decimal result = default; 297 | CallInGodotMain(async () => 298 | { 299 | var sceneCheck = attribute.GetNamedArgument(nameof(GodotFactAttribute.Frame)); 300 | switch (sceneCheck) 301 | { 302 | case GodotFactFrame.Default: 303 | break; 304 | case GodotFactFrame.Process: 305 | await GDU.OnProcessAwaiter; 306 | break; 307 | case GodotFactFrame.PhysicsProcess: 308 | await GDU.OnPhysicsProcessAwaiter; 309 | break; 310 | default: 311 | Aggregator.Add(new Exception($"unknown GodotFactFrame: {sceneCheck.ToString()}")); 312 | throw new ArgumentOutOfRangeException(); 313 | } 314 | result = await base.InvokeTestMethodAsync(testClassInstance); 315 | }); 316 | return result; 317 | } 318 | 319 | protected override async Task AfterTestMethodInvokedAsync() 320 | { 321 | await base.AfterTestMethodInvokedAsync(); 322 | CallInGodotMain(async () => 323 | { 324 | if (addingToTree != null) 325 | { 326 | await GDU.OnProcessAwaiter; 327 | GDU.Instance.RemoveChild(addingToTree); 328 | await GDU.OnProcessAwaiter; 329 | } 330 | 331 | if (loadEmptyScene) 332 | { 333 | // change scenes again and wait for godot to catch up 334 | GDU.Instance.GetTree().ChangeSceneToFile(Consts.EMPTY_SCENE_PATH); 335 | await GDU.OnProcessFrameAwaiter; 336 | await GDU.OnProcessFrameAwaiter; 337 | await GDU.OnProcessAwaiter; 338 | } 339 | }); 340 | } 341 | } 342 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/GodotTestOutputHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text; 4 | using Xunit.Abstractions; 5 | using Xunit.Sdk; 6 | 7 | namespace GodotXUnitApi.Internal 8 | { 9 | /// 10 | /// this class will handle outputs in tests. 11 | /// 12 | /// i would also like to figure out how to captures GD.Print 13 | /// to here, but i havent figured out that part yet. 14 | /// 15 | /// the main issue here is that passing around ITestOutputHelper 16 | /// for internal code is bad practice as well, and GD.Print 17 | /// is not available if you're running unit tests outside of godot. 18 | /// 19 | /// also note. we cannot just replace this ITestOutputHelper instance 20 | /// handed in with our own because it breaks compatibility with 21 | /// IDE runners. 22 | /// 23 | public partial class GodotTestOutputHelper : TextWriter 24 | { 25 | private TestOutputHelper wrapping; 26 | private TextWriter oldOutput; 27 | private StringBuilder builder = new StringBuilder(); 28 | 29 | public override Encoding Encoding { get; } = Console.OutputEncoding; 30 | 31 | public GodotTestOutputHelper(TestOutputHelper wrapping = null) 32 | { 33 | this.wrapping = wrapping ?? new TestOutputHelper(); 34 | } 35 | 36 | public void Initialize(IMessageBus messageBus, ITest test) 37 | { 38 | wrapping.Initialize(messageBus, test); 39 | oldOutput = Console.Out; 40 | Console.SetOut(this); 41 | } 42 | 43 | public string UnInitAndGetOutput() 44 | { 45 | if (oldOutput != null) 46 | Console.SetOut(oldOutput); 47 | oldOutput = null; 48 | if (builder.Length != 0) 49 | WriteLine(); 50 | return wrapping.Output; 51 | } 52 | 53 | public override void Write(char value) 54 | { 55 | builder.Append(value); 56 | } 57 | 58 | public override void Write(String value) 59 | { 60 | builder.Append(value); 61 | } 62 | 63 | public override void WriteLine() 64 | { 65 | wrapping.WriteLine(builder.ToString()); 66 | builder.Clear(); 67 | } 68 | 69 | public override void WriteLine(String value) 70 | { 71 | builder.Append(value); 72 | WriteLine(); 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/GodotXUnitRunnerBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Godot; 6 | using Newtonsoft.Json; 7 | using Xunit.Runners; 8 | using Path = System.IO.Path; 9 | 10 | namespace GodotXUnitApi.Internal 11 | { 12 | public abstract partial class GodotXUnitRunnerBase : Node2D 13 | { 14 | /// 15 | /// Get the assembly name from the project settings. 16 | /// 17 | /// The name of the target assembly or null if the default is requested 18 | private String GetTargetAssemblyNameFromSettings() 19 | { 20 | if (!ProjectSettings.HasSetting(Consts.SETTING_TARGET_ASSEMBLY)) 21 | { 22 | return null; 23 | } 24 | var targetProject = ProjectSettings.GetSetting(Consts.SETTING_TARGET_ASSEMBLY).ToString(); 25 | if (string.IsNullOrEmpty(targetProject)) 26 | { 27 | return null; 28 | } 29 | return targetProject; 30 | } 31 | 32 | private String GetAssemblyPath(String assemblyName) 33 | { 34 | var currentDir = System.IO.Directory.GetCurrentDirectory(); 35 | return Path.Combine(currentDir, $".mono/build/bin/Debug/{assemblyName}.dll"); 36 | } 37 | 38 | private String GetDefaultTargetAssemblyPath() 39 | { 40 | return GetAssemblyPath(Assembly.GetExecutingAssembly().GetName().Name); 41 | } 42 | 43 | private String GetTargetAssemblyPath(GodotXUnitSummary summary) 44 | { 45 | var assemblyName = GetTargetAssemblyNameFromSettings(); 46 | if (assemblyName is null) 47 | { 48 | summary.AddDiagnostic("target assembly name is null"); 49 | return GetDefaultTargetAssemblyPath(); 50 | } 51 | if (assemblyName.Equals(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM_FLAG)) 52 | { 53 | var customDll = ProjectSettings.HasSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM) 54 | ? ProjectSettings.GetSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM).ToString() 55 | : ""; 56 | if (string.IsNullOrEmpty(customDll)) 57 | { 58 | summary.AddDiagnostic("no custom dll assembly configured."); 59 | GD.PrintErr("no custom dll assembly configured."); 60 | return GetDefaultTargetAssemblyPath(); 61 | } 62 | 63 | summary.AddDiagnostic($"attempting to load custom dll at: {customDll}"); 64 | return customDll; 65 | } 66 | 67 | // find the project in the project list. if its not there, print error and leave 68 | var projectList = ProjectListing.GetProjectInfo(); 69 | if (!projectList.ContainsKey(assemblyName)) 70 | { 71 | var message = $"unable to find project {assemblyName}. expected values: {string.Join(", ", projectList.Keys)}"; 72 | GD.PrintErr(message); 73 | summary.AddDiagnostic(message); 74 | return GetDefaultTargetAssemblyPath(); 75 | } 76 | 77 | // finally, attempt to load project.. 78 | var targetAssembly = GetAssemblyPath(assemblyName); 79 | return targetAssembly; 80 | } 81 | 82 | protected virtual string GetTargetClass(GodotXUnitSummary summary) 83 | { 84 | return ProjectSettings.HasSetting(Consts.SETTING_TARGET_CLASS) 85 | ? ProjectSettings.GetSetting(Consts.SETTING_TARGET_CLASS).AsString() 86 | : null; 87 | } 88 | 89 | protected virtual string GetTargetMethod(GodotXUnitSummary summary) 90 | { 91 | return ProjectSettings.HasSetting(Consts.SETTING_TARGET_METHOD) 92 | ? ProjectSettings.GetSetting(Consts.SETTING_TARGET_METHOD).AsString() 93 | : null; 94 | } 95 | 96 | private ConcurrentQueue> drawRequests = new ConcurrentQueue>(); 97 | 98 | [Signal] 99 | public delegate void OnProcessEventHandler(); 100 | 101 | [Signal] 102 | public delegate void OnPhysicsProcessEventHandler(); 103 | 104 | [Signal] 105 | public delegate void OnDrawRequestDoneEventHandler(); 106 | 107 | public void RequestDraw(Action request) 108 | { 109 | drawRequests.Enqueue(request); 110 | } 111 | 112 | private AssemblyRunner runner; 113 | 114 | private GodotXUnitSummary summary; 115 | 116 | private MessageSender messages; 117 | 118 | public Assembly AssemblyResolve(object sender, ResolveEventArgs args) 119 | { 120 | GD.Print($"Resolving {args.Name}."); 121 | Assembly assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.FullName == args.Name); 122 | if (assembly is not null) 123 | { 124 | GD.Print($"Assembly resolution success, already loaded: {assembly.Location}"); 125 | return assembly; 126 | } 127 | try 128 | { 129 | var shortName = args.Name.Split(",")[0]; 130 | assembly = Assembly.LoadFile(GetAssemblyPath(shortName)); 131 | GD.Print($"Assembly resolution success {args.Name} -> {assembly.Location}"); 132 | return assembly; 133 | } 134 | catch (System.IO.FileNotFoundException e) 135 | { 136 | var msg = $"Assembly resolution failed for {args.Name}, requested by {args.RequestingAssembly?.FullName ?? "unknown assembly"}"; 137 | GD.PrintErr(msg); 138 | GD.PushError(msg); 139 | return null; 140 | } 141 | } 142 | 143 | public override void _Ready() 144 | { 145 | GDU.Instance = this; 146 | GD.Print($"running tests in tree at: {GetPath()}"); 147 | WorkFiles.CleanWorkDir(); 148 | summary = new GodotXUnitSummary(); 149 | messages = new MessageSender(); 150 | AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolve; 151 | CreateRunner(); 152 | if (runner == null) 153 | { 154 | messages.SendMessage(summary, "summary"); 155 | WriteSummary(summary); 156 | GetTree().Quit(1); 157 | return; 158 | } 159 | runner.OnDiagnosticMessage = message => 160 | { 161 | GD.PrintErr($"OnDiagnosticMessage: {message.Message}"); 162 | summary.AddDiagnostic(message.Message); 163 | }; 164 | runner.OnDiscoveryComplete = message => 165 | { 166 | summary.testsDiscovered = message.TestCasesDiscovered; 167 | summary.testsExpectedToRun = message.TestCasesToRun; 168 | GD.Print($"discovery finished: found {message.TestCasesDiscovered}," + 169 | $" running {message.TestCasesToRun}"); 170 | }; 171 | runner.OnErrorMessage = message => 172 | { 173 | GD.PrintErr($"OnErrorMessage ({message.MesssageType}) {message.ExceptionType}: " + 174 | $"{message.ExceptionMessage}\n{message.ExceptionStackTrace}"); 175 | summary.diagnostics.Add(new GodotXUnitOtherDiagnostic 176 | { 177 | message = message.ExceptionMessage, 178 | exceptionType = message.ExceptionType, 179 | exceptionStackTrace = message.ExceptionStackTrace 180 | }); 181 | }; 182 | runner.OnTestStarting = message => 183 | { 184 | messages.SendMessage(new GodotXUnitTestStart() 185 | { 186 | testCaseClass = message.TypeName, 187 | testCaseName = message.MethodName 188 | }, "start"); 189 | }; 190 | runner.OnTestFailed = message => 191 | { 192 | messages.SendMessage(summary.AddFailed(message), "failed"); 193 | GD.Print($" > OnTestFailed: {message.TestDisplayName} in {message.ExecutionTime}"); 194 | }; 195 | runner.OnTestPassed = message => 196 | { 197 | messages.SendMessage(summary.AddPassed(message), "passed"); 198 | GD.Print($" > OnTestPassed: {message.TestDisplayName} in {message.ExecutionTime}"); 199 | }; 200 | runner.OnTestSkipped = message => 201 | { 202 | messages.SendMessage(summary.AddSkipped(message), "skipped"); 203 | GD.Print($" > OnTestSkipped: {message.TestDisplayName}"); 204 | }; 205 | runner.OnExecutionComplete = message => 206 | { 207 | messages.SendMessage(summary, "summary"); 208 | WriteSummary(summary); 209 | GD.Print($"tests completed ({message.ExecutionTime}): {summary.completed}"); 210 | GetTree().Quit(Mathf.Clamp(summary.failed.Count, 0, 20)); 211 | }; 212 | 213 | var targetMethod = GetTargetMethod(summary); 214 | if (!string.IsNullOrEmpty(targetMethod)) 215 | { 216 | GD.Print($"targeting method for discovery: {targetMethod}"); 217 | runner.TestCaseFilter = test => targetMethod.Equals(test.TestMethod.Method.Name); 218 | } 219 | 220 | // if its an empty string, then we need to set it to null because the runner only checks for null 221 | var targetClass = GetTargetClass(summary); 222 | if (string.IsNullOrEmpty(targetClass)) 223 | targetClass = null; 224 | else 225 | { 226 | GD.Print($"targeting class for discovery: {targetClass}"); 227 | } 228 | runner.Start(targetClass, null, null, null, null, false, null, null); 229 | } 230 | 231 | private void CreateRunner() 232 | { 233 | try 234 | { 235 | var assemblyPath = GetTargetAssemblyPath(summary); 236 | if (String.IsNullOrEmpty(assemblyPath)) 237 | { 238 | summary.AddDiagnostic(new Exception("no assembly returned for tests")); 239 | return; 240 | } 241 | summary.AddDiagnostic($"Loading assembly at {assemblyPath}"); 242 | runner = AssemblyRunner.WithoutAppDomain(assemblyPath); 243 | } 244 | catch (Exception ex) 245 | { 246 | GD.PrintErr($"error while attempting to get test assembly: {ex}"); 247 | summary.AddDiagnostic("error while attempting to get test assembly"); 248 | summary.AddDiagnostic(ex); 249 | } 250 | } 251 | 252 | public override void _ExitTree() 253 | { 254 | GDU.Instance = null; 255 | runner?.Dispose(); 256 | runner = null; 257 | } 258 | 259 | private void WriteSummary(GodotXUnitSummary testSummary) 260 | { 261 | var location = ProjectSettings.HasSetting(Consts.SETTING_RESULTS_SUMMARY) 262 | ? ProjectSettings.GetSetting(Consts.SETTING_RESULTS_SUMMARY).ToString() 263 | : Consts.SETTING_RESULTS_SUMMARY_DEF; 264 | 265 | try 266 | { 267 | var file = FileAccess.Open(location, FileAccess.ModeFlags.Write).ThrowIfNotOk(); 268 | file.StoreString(JsonConvert.SerializeObject(testSummary, Formatting.Indented, WorkFiles.jsonSettings)); 269 | file.Close(); 270 | } 271 | catch (System.IO.IOException e) 272 | { 273 | GD.Print($"error returned for writing message at {location}: {e}"); 274 | } 275 | } 276 | 277 | public override void _Process(double delta) 278 | { 279 | EmitSignal(nameof(OnProcess)); 280 | QueueRedraw(); 281 | } 282 | 283 | public override void _PhysicsProcess(double delta) 284 | { 285 | EmitSignal(nameof(OnPhysicsProcess)); 286 | } 287 | 288 | public override void _Draw() 289 | { 290 | while (drawRequests.TryDequeue(out var request)) 291 | request(this); 292 | EmitSignal(nameof(OnDrawRequestDone)); 293 | } 294 | } 295 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/MessagePassing.cs: -------------------------------------------------------------------------------- 1 | using Godot; 2 | 3 | namespace GodotXUnitApi.Internal 4 | { 5 | public partial class MessageSender 6 | { 7 | public int idAt { get; private set; } 8 | 9 | public int NextId() 10 | { 11 | return ++idAt; 12 | } 13 | 14 | public void SendMessage(object message, string type) 15 | { 16 | WorkFiles.WriteFile($"{NextId().ToString()}-{type}", message); 17 | } 18 | } 19 | 20 | public partial class MessageWatcher 21 | { 22 | public object Poll() 23 | { 24 | var directory = DirAccess.Open(WorkFiles.WorkDir).ThrowIfNotOk(); 25 | directory.IncludeHidden = false; 26 | directory.IncludeNavigational = false; 27 | directory.ListDirBegin().ThrowIfNotOk(); 28 | try 29 | { 30 | while (true) 31 | { 32 | var next = directory.GetNext(); 33 | if (string.IsNullOrEmpty(next)) break; 34 | if (directory.FileExists(next)) 35 | { 36 | var result = WorkFiles.ReadFile(next); 37 | directory.Remove(next).ThrowIfNotOk(); 38 | return result; 39 | } 40 | } 41 | } 42 | finally 43 | { 44 | directory.ListDirEnd(); 45 | } 46 | return null; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/ProjectListing.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using GodotGD = Godot.GD; 5 | 6 | namespace GodotXUnitApi.Internal 7 | { 8 | public static class ProjectListing 9 | { 10 | public static readonly string sep = Path.DirectorySeparatorChar.ToString(); 11 | 12 | private static string _projectDir; 13 | 14 | public static string ProjectDir 15 | { 16 | get 17 | { 18 | if (!string.IsNullOrEmpty(_projectDir)) 19 | return _projectDir; 20 | var current = Directory.GetCurrentDirectory(); 21 | while (!string.IsNullOrEmpty(current)) 22 | { 23 | if (File.Exists($"{current}{sep}project.godot")) 24 | { 25 | _projectDir = current; 26 | return _projectDir; 27 | } 28 | current = Directory.GetParent(current).FullName; 29 | } 30 | GodotGD.PrintErr("unable to find root of godot project"); 31 | throw new Exception("unable to find root dir"); 32 | 33 | // TODO: if this becomes a problem, we can do OS.Execute('pwd'....), but i don't 34 | // want to do that if we don't need to. 35 | } 36 | } 37 | 38 | public static List GetProjectList() 39 | { 40 | var result = new List(); 41 | foreach (var filename in Directory.GetFiles(ProjectDir, "*.csproj", SearchOption.AllDirectories)) 42 | { 43 | if (filename.Contains("GodotXUnitApi")) 44 | continue; 45 | result.Add(Path.GetFileNameWithoutExtension(filename)); 46 | } 47 | return result; 48 | } 49 | 50 | public static Dictionary GetProjectInfo() 51 | { 52 | var result = new Dictionary(); 53 | foreach (var filename in Directory.GetFiles(ProjectDir, "*.csproj", SearchOption.AllDirectories)) 54 | { 55 | if (filename.Contains("GodotXUnitApi")) 56 | continue; 57 | result[Path.GetFileNameWithoutExtension(filename)] = filename; 58 | } 59 | return result; 60 | } 61 | 62 | public static string GetDefaultProject() 63 | { 64 | var project = Directory.GetFiles(ProjectDir, "*.csproj", SearchOption.TopDirectoryOnly); 65 | if (project.Length == 0) 66 | { 67 | GodotGD.PrintErr($"no csproj found on project root at {ProjectDir}. is this a mono project?"); 68 | return ""; 69 | } 70 | if (project.Length > 1) 71 | { 72 | GodotGD.PrintErr($"multiple csproj found on project root at {ProjectDir}."); 73 | return ""; 74 | } 75 | return Path.GetFileNameWithoutExtension(project[0]); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/GodotXUnitApi/Internal/WorkFiles.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Godot; 3 | using Newtonsoft.Json; 4 | 5 | namespace GodotXUnitApi.Internal 6 | { 7 | public static class WorkFiles 8 | { 9 | public static readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings 10 | { 11 | TypeNameHandling = TypeNameHandling.All 12 | }; 13 | 14 | public const string WorkDir = "res://addons/GodotXUnit/_work"; 15 | 16 | public static string PathForResFile(string filename) 17 | { 18 | var appending = filename.EndsWith(".json") ? filename : $"{filename}.json"; 19 | return $"{WorkDir}/{appending}"; 20 | } 21 | 22 | public static void CleanWorkDir() 23 | { 24 | DirAccess.MakeDirRecursiveAbsolute(WorkDir).ThrowIfNotOk(); 25 | var directory = DirAccess.Open(WorkDir).ThrowIfNotOk(); 26 | directory.IncludeHidden = false; 27 | directory.IncludeNavigational = false; 28 | try 29 | { 30 | directory.ListDirBegin().ThrowIfNotOk(); 31 | while (true) 32 | { 33 | var next = directory.GetNext(); 34 | if (string.IsNullOrEmpty(next)) 35 | break; 36 | directory.Remove(next).ThrowIfNotOk(); 37 | } 38 | } 39 | finally 40 | { 41 | directory.ListDirEnd(); 42 | } 43 | } 44 | 45 | public static void WriteFile(string filename, object contents) 46 | { 47 | var writing = JsonConvert.SerializeObject(contents, Formatting.Indented, jsonSettings); 48 | var file = FileAccess.Open(PathForResFile(filename), FileAccess.ModeFlags.WriteRead).ThrowIfNotOk(); 49 | try 50 | { 51 | file.StoreString(writing); 52 | } 53 | finally 54 | { 55 | file.Close(); 56 | } 57 | } 58 | 59 | public static object ReadFile(string filename) 60 | { 61 | var file = FileAccess.Open(PathForResFile(filename), FileAccess.ModeFlags.Read).ThrowIfNotOk(); 62 | try 63 | { 64 | return JsonConvert.DeserializeObject(file.GetAsText(), jsonSettings); 65 | } 66 | finally 67 | { 68 | file.Close(); 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /addons/GodotXUnit/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Godot; 3 | using Godot.Collections; 4 | using GodotXUnitApi.Internal; 5 | 6 | namespace GodotXUnit 7 | { 8 | [Tool] 9 | public partial class Plugin : EditorPlugin 10 | { 11 | private static Plugin _instance; 12 | 13 | public static Plugin Instance => _instance ?? throw new Exception("Plugin not set"); 14 | 15 | private XUnitDock dock; 16 | 17 | public override string _GetPluginName() 18 | { 19 | return nameof(GodotXUnit); 20 | } 21 | 22 | public override void _EnterTree() 23 | { 24 | _instance = this; 25 | EnsureProjectSetting(Consts.SETTING_RESULT_SUMMARY_PROP); 26 | EnsureProjectSetting(Consts.SETTING_TARGET_ASSEMBLY_PROP); 27 | EnsureProjectSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM_PROP); 28 | EnsureProjectSetting(Consts.SETTING_TARGET_CLASS_PROP); 29 | EnsureProjectSetting(Consts.SETTING_TARGET_METHOD_PROP); 30 | dock = GD.Load(Consts.DOCK_SCENE_PATH).Instantiate(); 31 | AddControlToBottomPanel(dock, _GetPluginName()); 32 | } 33 | 34 | public override void _ExitTree() 35 | { 36 | _instance = null; 37 | if (dock != null) 38 | { 39 | RemoveControlFromBottomPanel(dock); 40 | dock.Free(); 41 | dock = null; 42 | } 43 | } 44 | 45 | private void EnsureProjectSetting(Dictionary prop) 46 | { 47 | var name = prop["name"].AsString() ?? throw new Exception("no name in prop"); 48 | if (!ProjectSettings.HasSetting(name)) 49 | { 50 | ProjectSettings.SetSetting(name, prop["default"]); 51 | ProjectSettings.SetInitialValue(name, prop["default"]); 52 | ProjectSettings.AddPropertyInfo(prop); 53 | ProjectSettings.Save(); 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/README: -------------------------------------------------------------------------------- 1 | 2 | the complete installation process can be found here: 3 | https://github.com/fledware/GodotXUnit 4 | -------------------------------------------------------------------------------- /addons/GodotXUnit/XUnitDock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Text; 4 | using Godot; 5 | using Godot.Collections; 6 | using GodotXUnitApi; 7 | using GodotXUnitApi.Internal; 8 | using StringList = System.Collections.Generic.List; 9 | using GodotArray = Godot.Collections.Array; 10 | 11 | namespace GodotXUnit 12 | { 13 | [Tool] 14 | public partial class XUnitDock : MarginContainer 15 | { 16 | private RichTextLabel resultDetails; 17 | private RichTextLabel resultDiagnostics; 18 | private Tree resultsTree; 19 | private MessageWatcher watcher; 20 | private Dictionary testDetails = new Dictionary(); 21 | private Dictionary> testTargets = new Dictionary>(); 22 | private Button stopButton; 23 | private Button runAllButton; 24 | private Button reRunButton; 25 | private Button runSelectedButton; 26 | private LineEdit targetAssemblyLabel; 27 | private OptionButton targetAssemblyOption; 28 | private LineEdit targetClassLabel; 29 | private LineEdit targetMethodLabel; 30 | private int runningPid = -1; 31 | private CheckBox verboseCheck; 32 | private TabContainer runTabContainer; 33 | 34 | // there are better ways to do this, but to try to limit the amount of user 35 | // setup required, we'll just do this the hacky way. 36 | private Label totalRanLabel; 37 | private int totalRanValue; 38 | private Label passedLabel; 39 | private int passedValue; 40 | private Label failedLabel; 41 | private int failedValue; 42 | private Label timeLabel; 43 | private float timeValue; 44 | 45 | public override void _Ready() 46 | { 47 | totalRanLabel = (Label)FindChild("TotalRanLabel"); 48 | passedLabel = (Label)FindChild("PassedLabel"); 49 | failedLabel = (Label)FindChild("FailedLabel"); 50 | timeLabel = (Label)FindChild("TimeLabel"); 51 | ResetLabels(); 52 | 53 | stopButton = (Button)FindChild("StopButton"); 54 | stopButton.Connect("pressed", new Callable(this, nameof(StopTests))); 55 | runAllButton = (Button)FindChild("RunAllTestsButton"); 56 | runAllButton.Connect("pressed", new Callable(this, nameof(RunAllTests))); 57 | reRunButton = (Button)FindChild("ReRunButton"); 58 | reRunButton.Connect("pressed", new Callable(this, nameof(ReRunTests))); 59 | targetClassLabel = (LineEdit)FindChild("TargetClassLabel"); 60 | targetMethodLabel = (LineEdit)FindChild("TargetMethodLabel"); 61 | runSelectedButton = (Button)FindChild("RunSelectedButton"); 62 | runSelectedButton.Connect("pressed", new Callable(this, nameof(RunSelected))); 63 | runSelectedButton.Disabled = true; 64 | targetAssemblyOption = (OptionButton)FindChild("TargetAssemblyOption"); 65 | targetAssemblyOption.Connect("pressed", new Callable(this, nameof(TargetAssemblyOptionPressed))); 66 | targetAssemblyOption.Connect("item_selected", new Callable(this, nameof(TargetAssemblyOptionSelected))); 67 | targetAssemblyLabel = (LineEdit)FindChild("TargetAssemblyLabel"); 68 | targetAssemblyLabel.Text = ProjectSettings.HasSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM) 69 | ? ProjectSettings.GetSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM).ToString() 70 | : ""; 71 | targetAssemblyLabel.Connect("text_changed", new Callable(this, nameof(TargetAssemblyLabelChanged))); 72 | TargetAssemblyOptionPressed(); 73 | resultsTree = (Tree)FindChild("ResultsTree"); 74 | resultsTree.HideRoot = true; 75 | resultsTree.SelectMode = Tree.SelectModeEnum.Single; 76 | resultsTree.Connect("cell_selected", new Callable(this, nameof(OnCellSelected))); 77 | resultDetails = (RichTextLabel)FindChild("ResultDetails"); 78 | resultDiagnostics = (RichTextLabel)FindChild("Diagnostics"); 79 | verboseCheck = (CheckBox)FindChild("VerboseCheckBox"); 80 | runTabContainer = (TabContainer)FindChild("RunTabContainer"); 81 | runTabContainer.CurrentTab = 0; 82 | SetProcess(false); 83 | } 84 | 85 | public void StopTests() 86 | { 87 | if (IsRunningTests()) 88 | OS.Kill(runningPid); 89 | GoToPostState(); 90 | } 91 | 92 | public void RunAllTests() 93 | { 94 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_CLASS, ""); 95 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_METHOD, ""); 96 | ProjectSettings.Save(); 97 | StartTests(); 98 | } 99 | 100 | public void ReRunTests() 101 | { 102 | StartTests(); 103 | } 104 | 105 | public void RunSelected() 106 | { 107 | var item = resultsTree.GetSelected(); 108 | // if nothing is selected, just rerun what is there. 109 | if (item != null) 110 | { 111 | if (testTargets.TryGetValue(item, out var value)) 112 | { 113 | targetClassLabel.Text = value[0] ?? ""; 114 | targetMethodLabel.Text = value[1] ?? ""; 115 | } 116 | else 117 | { 118 | targetClassLabel.Text = item.GetText(0) ?? ""; 119 | targetMethodLabel.Text = ""; 120 | } 121 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_CLASS, targetClassLabel.Text); 122 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_METHOD, targetMethodLabel.Text); 123 | ProjectSettings.Save(); 124 | } 125 | StartTests(); 126 | } 127 | 128 | public void StartTests() 129 | { 130 | if (IsRunningTests()) 131 | OS.Kill(runningPid); 132 | 133 | runAllButton.Disabled = true; 134 | reRunButton.Disabled = true; 135 | runSelectedButton.Disabled = true; 136 | ResetLabels(); 137 | resultsTree.Clear(); 138 | testTargets.Clear(); 139 | testDetails.Clear(); 140 | resultDiagnostics.Text = ""; 141 | resultDetails.Text = ""; 142 | watcher = new MessageWatcher(); 143 | 144 | // if things dont clean up correctly, the old messages can still 145 | // be on the file system. this will cause the XUnitDock process to 146 | // see stale messages and potentially stop picking up new messages. 147 | WorkFiles.CleanWorkDir(); 148 | 149 | var runArgs = new StringList(); 150 | runArgs.Add(Consts.RUNNER_SCENE_PATH); 151 | if (verboseCheck.ButtonPressed) 152 | runArgs.Add("--verbose"); 153 | runningPid = OS.Execute(OS.GetExecutablePath(), runArgs.ToArray(), null, false); 154 | 155 | SetProcess(true); 156 | } 157 | 158 | public bool IsRunningTests() 159 | { 160 | if (runningPid < 0) return false; 161 | try 162 | { 163 | Process.GetProcessById(runningPid); 164 | return true; 165 | } 166 | catch (Exception) 167 | { 168 | GoToPostState(); 169 | return false; 170 | } 171 | } 172 | 173 | private void TargetAssemblyOptionPressed() 174 | { 175 | targetAssemblyOption.Clear(); 176 | var projectList = ProjectListing.GetProjectList(); 177 | var projectSelected = ProjectSettings.HasSetting(Consts.SETTING_TARGET_ASSEMBLY) 178 | ? ProjectSettings.GetSetting(Consts.SETTING_TARGET_ASSEMBLY).ToString() 179 | : ""; 180 | var projectSelectedIndex = 0; 181 | for (int i = 0; i < projectList.Count; i++) 182 | { 183 | var projectName = projectList[i]; 184 | targetAssemblyOption.AddItem(projectName, i); 185 | if (projectName.Equals(projectSelected)) 186 | projectSelectedIndex = i; 187 | } 188 | targetAssemblyOption.AddItem("Custom Location ", 1000); 189 | if (projectSelected.Equals(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM_FLAG)) 190 | projectSelectedIndex = projectList.Count; 191 | targetAssemblyOption.Selected = projectSelectedIndex; 192 | } 193 | 194 | private void TargetAssemblyOptionSelected(int index) 195 | { 196 | var projectId = targetAssemblyOption.GetItemId(index); 197 | switch (projectId) 198 | { 199 | case 1000: 200 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_ASSEMBLY, 201 | Consts.SETTING_TARGET_ASSEMBLY_CUSTOM_FLAG); 202 | break; 203 | default: 204 | var projectName = targetAssemblyOption.GetItemText(index); 205 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_ASSEMBLY, projectName); 206 | break; 207 | } 208 | ProjectSettings.Save(); 209 | } 210 | 211 | private void TargetAssemblyLabelChanged(string new_text) 212 | { 213 | ProjectSettings.SetSetting(Consts.SETTING_TARGET_ASSEMBLY_CUSTOM, new_text); 214 | ProjectSettings.Save(); 215 | } 216 | 217 | private void OnCellSelected() 218 | { 219 | runSelectedButton.Disabled = resultsTree.GetSelected() == null; 220 | if (!testDetails.TryGetValue(resultsTree.GetSelected(), out var details)) 221 | details = "Not Found"; 222 | resultDetails.Text = details; 223 | runTabContainer.CurrentTab = 0; 224 | } 225 | 226 | public override void _Process(double delta) 227 | { 228 | while (watcher != null) 229 | { 230 | var message = watcher.Poll(); 231 | if (message == null) break; 232 | 233 | switch (message) 234 | { 235 | case GodotXUnitTestStart testStart: 236 | HandleTestStart(testStart); 237 | break; 238 | case GodotXUnitTestResult testResult: 239 | HandleTestResult(testResult); 240 | break; 241 | case GodotXUnitSummary testSummary: 242 | HandleTestSummary(testSummary); 243 | break; 244 | default: 245 | GD.PrintErr($"unable to handle message type: {message.GetType()}"); 246 | break; 247 | } 248 | } 249 | if (!IsProcessing()) 250 | { 251 | GoToPostState(); 252 | } 253 | } 254 | 255 | private void GoToPostState() 256 | { 257 | if (watcher != null) 258 | { 259 | while (true) 260 | { 261 | var missed = watcher.Poll(); 262 | if (missed == null) break; 263 | GD.PrintErr($"missed message: {missed.GetType()}"); 264 | } 265 | } 266 | watcher = null; 267 | runningPid = -1; 268 | runAllButton.Disabled = false; 269 | reRunButton.Disabled = false; 270 | runSelectedButton.Disabled = resultsTree.GetSelected() != null; 271 | } 272 | 273 | private void HandleTestStart(GodotXUnitTestStart testStart) 274 | { 275 | var testItem = EnsureTreeClassAndMethod(testStart.testCaseClass, testStart.testCaseName); 276 | if (testItem.GetIcon(0) == null) 277 | { 278 | testItem.SetIcon(0, Consts.IconRunning); 279 | } 280 | } 281 | 282 | private void HandleTestResult(GodotXUnitTestResult testResult) 283 | { 284 | var testItem = EnsureTreeClassAndMethod(testResult.testCaseClass, testResult.testCaseName); 285 | switch (testResult.result) 286 | { 287 | case "passed": 288 | testItem.SetIcon(0, Consts.IconCheck); 289 | if (!testDetails.ContainsKey(testItem)) 290 | { 291 | IncPassedLabel(); 292 | IncTotalLabel(); 293 | IncTimeLabel(testResult.time); 294 | } 295 | break; 296 | case "failed": 297 | testItem.SetIcon(0, Consts.IconError); 298 | if (!testDetails.ContainsKey(testItem)) 299 | { 300 | IncFailedLabel(); 301 | IncTotalLabel(); 302 | IncTimeLabel(testResult.time); 303 | } 304 | break; 305 | case "skipped": 306 | testItem.SetIcon(0, Consts.IconWarn); 307 | break; 308 | default: 309 | testItem.SetText(0, $"{testResult.testCaseName}: unknown result: {testResult.result}"); 310 | break; 311 | } 312 | SetTestResultDetails(testResult, testItem); 313 | } 314 | 315 | private void SetTestResultDetails(GodotXUnitTestResult testResult, TreeItem item) 316 | { 317 | // set the header to include the time it took 318 | var millis = (int)(testResult.time * 1000f); 319 | item.SetText(0, $"{testResult.testCaseName} ({millis} ms)"); 320 | 321 | // create the test result details 322 | var details = new StringBuilder(); 323 | details.AppendLine(testResult.FullName); 324 | details.AppendLine(testResult.result); 325 | details.AppendLine(); 326 | if (!string.IsNullOrEmpty(testResult.exceptionType)) 327 | { 328 | details.AppendLine(testResult.exceptionMessage); 329 | details.AppendLine(testResult.exceptionType); 330 | details.AppendLine(testResult.exceptionStackTrace); 331 | details.AppendLine(); 332 | } 333 | details.AppendLine(string.IsNullOrEmpty(testResult.output) ? "No Output" : testResult.output); 334 | testDetails[item] = details.ToString(); 335 | 336 | // add the target so the run selected button can query what to run 337 | var target = new Array(); 338 | target.Add(testResult.testCaseClass); 339 | target.Add(testResult.testCaseName); 340 | testTargets[item] = target; 341 | } 342 | 343 | private void HandleTestSummary(GodotXUnitSummary testSummary) 344 | { 345 | foreach (var failed in testSummary.failed) 346 | HandleTestResult(failed); 347 | foreach (var passed in testSummary.passed) 348 | HandleTestResult(passed); 349 | foreach (var skipped in testSummary.skipped) 350 | HandleTestResult(skipped); 351 | SetProcess(false); 352 | 353 | if (testSummary.diagnostics.Count > 0) 354 | { 355 | var diagnostics = new StringBuilder(); 356 | foreach (var diagnostic in testSummary.diagnostics) 357 | { 358 | if (diagnostic.exceptionType != null) 359 | { 360 | diagnostics.Append(diagnostic.exceptionType).Append(": "); 361 | } 362 | diagnostics.AppendLine(diagnostic.message); 363 | if (diagnostic.exceptionStackTrace != null) 364 | { 365 | diagnostics.AppendLine(diagnostic.exceptionStackTrace); 366 | } 367 | diagnostics.AppendLine(); 368 | diagnostics.AppendLine(); 369 | } 370 | resultDiagnostics.Text = diagnostics.ToString(); 371 | } 372 | } 373 | 374 | private TreeItem EnsureTreeClassAndMethod(string testClass, string testCaseName) 375 | { 376 | var testClassItem = EnsureTreeClass(testClass); 377 | return FindTreeChildOrCreate(testClassItem, testCaseName); 378 | } 379 | 380 | private TreeItem EnsureTreeClass(string testClass) 381 | { 382 | var root = resultsTree.GetRoot() ?? resultsTree.CreateItem(); 383 | return FindTreeChildOrCreate(root, testClass); 384 | } 385 | 386 | private TreeItem FindTreeChildOrCreate(TreeItem parent, string name) 387 | { 388 | foreach (var child in parent.GetChildren()) 389 | { 390 | var text = child.GetMeta("for"); 391 | if (text.AsString().Equals(name)) return child; 392 | } 393 | var newClassItem = resultsTree.CreateItem(parent); 394 | newClassItem.SetMeta("for", name); 395 | newClassItem.SetText(0, name); 396 | return newClassItem; 397 | } 398 | 399 | // label work below... 400 | // dont look at me, i'm hideous 401 | private void ResetLabels() 402 | { 403 | totalRanValue = 0; 404 | totalRanLabel.Text = $"TotalRan: {totalRanValue}"; 405 | passedValue = 0; 406 | passedLabel.Text = $"Passed: {passedValue}"; 407 | failedValue = 0; 408 | failedLabel.Text = $"Failed: {failedValue}"; 409 | timeValue = 0; 410 | timeLabel.Text = $"Time: {timeValue} ms"; 411 | } 412 | 413 | private void IncPassedLabel() 414 | { 415 | // gross 416 | passedValue++; 417 | passedLabel.Text = $"Passed: {passedValue}"; 418 | } 419 | 420 | private void IncFailedLabel() 421 | { 422 | // naughty 423 | failedValue++; 424 | failedLabel.Text = $"Failed: {failedValue}"; 425 | } 426 | 427 | private void IncTotalLabel() 428 | { 429 | // terrible 430 | totalRanValue++; 431 | totalRanLabel.Text = $"TotalRan: {totalRanValue}"; 432 | } 433 | 434 | private void IncTimeLabel(float time) 435 | { 436 | // why? 437 | timeValue += time; 438 | timeLabel.Text = $"Time: {(int)(timeValue * 1000)} ms"; 439 | } 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /addons/GodotXUnit/XUnitDock.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/GodotXUnit/XUnitDock.cs" type="Script" id=1] 4 | 5 | [node name="Control" type="MarginContainer"] 6 | anchor_right = 1.0 7 | anchor_bottom = 1.0 8 | theme_override_constants/margin_right = 0 9 | theme_override_constants/margin_top = 0 10 | theme_override_constants/margin_left = 0 11 | theme_override_constants/margin_bottom = 0 12 | script = ExtResource( 1 ) 13 | __meta__ = { 14 | "_edit_use_anchors_": false 15 | } 16 | 17 | [node name="Rows" type="VBoxContainer" parent="."] 18 | offset_right = 1024.0 19 | offset_bottom = 600.0 20 | 21 | [node name="Buttons" type="MarginContainer" parent="Rows"] 22 | offset_right = 1024.0 23 | offset_bottom = 44.0 24 | theme_override_constants/margin_right = 5 25 | theme_override_constants/margin_top = 5 26 | theme_override_constants/margin_left = 5 27 | theme_override_constants/margin_bottom = 5 28 | 29 | [node name="Panel" type="Panel" parent="Rows/Buttons"] 30 | offset_left = 5.0 31 | offset_top = 5.0 32 | offset_right = 1019.0 33 | offset_bottom = 39.0 34 | __meta__ = { 35 | "_edit_use_anchors_": false 36 | } 37 | 38 | [node name="Margin" type="MarginContainer" parent="Rows/Buttons"] 39 | offset_left = 5.0 40 | offset_top = 5.0 41 | offset_right = 1019.0 42 | offset_bottom = 39.0 43 | theme_override_constants/margin_right = 5 44 | theme_override_constants/margin_top = 5 45 | theme_override_constants/margin_left = 5 46 | theme_override_constants/margin_bottom = 5 47 | 48 | [node name="Buttons" type="HBoxContainer" parent="Rows/Buttons/Margin"] 49 | offset_left = 5.0 50 | offset_top = 5.0 51 | offset_right = 1009.0 52 | offset_bottom = 29.0 53 | __meta__ = { 54 | "_edit_use_anchors_": false 55 | } 56 | 57 | [node name="StopButton" type="Button" parent="Rows/Buttons/Margin/Buttons"] 58 | offset_right = 40.0 59 | offset_bottom = 24.0 60 | text = "Stop" 61 | 62 | [node name="RunAllTestsButton" type="Button" parent="Rows/Buttons/Margin/Buttons"] 63 | offset_left = 44.0 64 | offset_right = 139.0 65 | offset_bottom = 24.0 66 | text = "Run All Tests" 67 | 68 | [node name="ReRunButton" type="Button" parent="Rows/Buttons/Margin/Buttons"] 69 | offset_left = 143.0 70 | offset_right = 195.0 71 | offset_bottom = 24.0 72 | text = "ReRun" 73 | 74 | [node name="TargetClassLabel" type="LineEdit" parent="Rows/Buttons/Margin/Buttons"] 75 | offset_left = 199.0 76 | offset_right = 389.0 77 | offset_bottom = 24.0 78 | size_flags_horizontal = 3 79 | placeholder_text = "Target Class" 80 | 81 | [node name="TargetMethodLabel" type="LineEdit" parent="Rows/Buttons/Margin/Buttons"] 82 | offset_left = 393.0 83 | offset_right = 583.0 84 | offset_bottom = 24.0 85 | size_flags_horizontal = 3 86 | placeholder_text = "Target Method" 87 | 88 | [node name="TargetAssemblyOption" type="OptionButton" parent="Rows/Buttons/Margin/Buttons"] 89 | offset_left = 587.0 90 | offset_right = 808.0 91 | offset_bottom = 24.0 92 | size_flags_horizontal = 3 93 | text = "SubProjectForIntegrationTests" 94 | items = [ "GodotXUnit (main)", null, false, 0, null, "SubProjectForUnitTests", null, false, 1, null, "SubProjectForIntegrationTests", null, false, 2, null, "Custom Location ", null, false, 1000, null ] 95 | selected = 2 96 | 97 | [node name="TargetAssemblyLabel" type="LineEdit" parent="Rows/Buttons/Margin/Buttons"] 98 | offset_left = 812.0 99 | offset_right = 1004.0 100 | offset_bottom = 24.0 101 | size_flags_horizontal = 3 102 | text = "not found" 103 | placeholder_text = "Custom Assembly Path (dll)" 104 | __meta__ = { 105 | "_editor_description_": "" 106 | } 107 | 108 | [node name="Summary" type="MarginContainer" parent="Rows"] 109 | offset_top = 48.0 110 | offset_right = 1024.0 111 | offset_bottom = 92.0 112 | theme_override_constants/margin_right = 5 113 | theme_override_constants/margin_top = 5 114 | theme_override_constants/margin_left = 5 115 | theme_override_constants/margin_bottom = 5 116 | 117 | [node name="Panel" type="Panel" parent="Rows/Summary"] 118 | offset_left = 5.0 119 | offset_top = 5.0 120 | offset_right = 1019.0 121 | offset_bottom = 39.0 122 | __meta__ = { 123 | "_edit_use_anchors_": false 124 | } 125 | 126 | [node name="Margin" type="MarginContainer" parent="Rows/Summary"] 127 | offset_left = 5.0 128 | offset_top = 5.0 129 | offset_right = 1019.0 130 | offset_bottom = 39.0 131 | theme_override_constants/margin_right = 5 132 | theme_override_constants/margin_top = 5 133 | theme_override_constants/margin_left = 5 134 | theme_override_constants/margin_bottom = 5 135 | 136 | [node name="Results" type="HBoxContainer" parent="Rows/Summary/Margin"] 137 | offset_left = 5.0 138 | offset_top = 5.0 139 | offset_right = 1009.0 140 | offset_bottom = 29.0 141 | __meta__ = { 142 | "_edit_use_anchors_": false 143 | } 144 | 145 | [node name="TotalRanLabel" type="Label" parent="Rows/Summary/Margin/Results"] 146 | offset_top = 5.0 147 | offset_right = 227.0 148 | offset_bottom = 19.0 149 | size_flags_horizontal = 3 150 | text = "TotalRan: 0" 151 | 152 | [node name="PassedLabel" type="Label" parent="Rows/Summary/Margin/Results"] 153 | offset_left = 231.0 154 | offset_top = 5.0 155 | offset_right = 458.0 156 | offset_bottom = 19.0 157 | size_flags_horizontal = 3 158 | text = "Passed: 0" 159 | 160 | [node name="FailedLabel" type="Label" parent="Rows/Summary/Margin/Results"] 161 | offset_left = 462.0 162 | offset_right = 689.0 163 | offset_bottom = 24.0 164 | size_flags_horizontal = 3 165 | size_flags_vertical = 1 166 | text = "Failed: 0" 167 | 168 | [node name="TimeLabel" type="Label" parent="Rows/Summary/Margin/Results"] 169 | offset_left = 693.0 170 | offset_top = 5.0 171 | offset_right = 920.0 172 | offset_bottom = 19.0 173 | size_flags_horizontal = 3 174 | text = "Time: 0 ms" 175 | 176 | [node name="VerboseCheckBox" type="CheckBox" parent="Rows/Summary/Margin/Results"] 177 | offset_left = 924.0 178 | offset_right = 1004.0 179 | offset_bottom = 24.0 180 | text = "Verbose" 181 | 182 | [node name="Details" type="MarginContainer" parent="Rows"] 183 | offset_top = 96.0 184 | offset_right = 1024.0 185 | offset_bottom = 600.0 186 | size_flags_horizontal = 3 187 | size_flags_vertical = 3 188 | theme_override_constants/margin_right = 5 189 | theme_override_constants/margin_top = 5 190 | theme_override_constants/margin_left = 5 191 | theme_override_constants/margin_bottom = 5 192 | 193 | [node name="HBoxContainer" type="HBoxContainer" parent="Rows/Details"] 194 | offset_left = 5.0 195 | offset_top = 5.0 196 | offset_right = 1019.0 197 | offset_bottom = 499.0 198 | 199 | [node name="ResultsTree" type="Tree" parent="Rows/Details/HBoxContainer"] 200 | offset_right = 505.0 201 | offset_bottom = 494.0 202 | size_flags_horizontal = 3 203 | size_flags_vertical = 3 204 | hide_root = true 205 | 206 | [node name="VBoxContainer" type="VBoxContainer" parent="Rows/Details/HBoxContainer"] 207 | offset_left = 509.0 208 | offset_right = 1014.0 209 | offset_bottom = 494.0 210 | size_flags_horizontal = 3 211 | size_flags_vertical = 3 212 | 213 | [node name="HBoxContainer" type="HBoxContainer" parent="Rows/Details/HBoxContainer/VBoxContainer"] 214 | offset_right = 505.0 215 | offset_bottom = 20.0 216 | size_flags_horizontal = 3 217 | 218 | [node name="RunSelectedButton" type="Button" parent="Rows/Details/HBoxContainer/VBoxContainer/HBoxContainer"] 219 | offset_right = 95.0 220 | offset_bottom = 20.0 221 | disabled = true 222 | text = "Run Selected" 223 | 224 | [node name="RunTabContainer" type="TabContainer" parent="Rows/Details/HBoxContainer/VBoxContainer"] 225 | offset_top = 24.0 226 | offset_right = 505.0 227 | offset_bottom = 494.0 228 | size_flags_horizontal = 3 229 | size_flags_vertical = 3 230 | 231 | [node name="ResultDetails" type="RichTextLabel" parent="Rows/Details/HBoxContainer/VBoxContainer/RunTabContainer"] 232 | anchor_right = 1.0 233 | anchor_bottom = 1.0 234 | offset_left = 4.0 235 | offset_top = 32.0 236 | offset_right = -4.0 237 | offset_bottom = -4.0 238 | focus_mode = 2 239 | size_flags_horizontal = 3 240 | size_flags_vertical = 3 241 | selection_enabled = true 242 | 243 | [node name="Diagnostics" type="RichTextLabel" parent="Rows/Details/HBoxContainer/VBoxContainer/RunTabContainer"] 244 | visible = false 245 | anchor_right = 1.0 246 | anchor_bottom = 1.0 247 | offset_left = 4.0 248 | offset_top = 32.0 249 | offset_right = -4.0 250 | offset_bottom = -4.0 251 | focus_mode = 2 252 | size_flags_horizontal = 3 253 | size_flags_vertical = 3 254 | selection_enabled = true 255 | -------------------------------------------------------------------------------- /addons/GodotXUnit/_work/.gdignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fledware/GodotXUnit/9dfda7b56052a70dfb8dfd89a2673373b7eb6746/addons/GodotXUnit/_work/.gdignore -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fledware/GodotXUnit/9dfda7b56052a70dfb8dfd89a2673373b7eb6746/addons/GodotXUnit/assets/check.png -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/check.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://cccd5c8wn54vy" 6 | path="res://.godot/imported/check.png-93c4fe9a01601b2e5a6e2306ca570d1e.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/GodotXUnit/assets/check.png" 14 | dest_files=["res://.godot/imported/check.png-93c4fe9a01601b2e5a6e2306ca570d1e.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fledware/GodotXUnit/9dfda7b56052a70dfb8dfd89a2673373b7eb6746/addons/GodotXUnit/assets/error.png -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/error.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://pt7u6b6txpf4" 6 | path="res://.godot/imported/error.png-7d7dee34d347bdc60be578dd61003c69.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/GodotXUnit/assets/error.png" 14 | dest_files=["res://.godot/imported/error.png-7d7dee34d347bdc60be578dd61003c69.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fledware/GodotXUnit/9dfda7b56052a70dfb8dfd89a2673373b7eb6746/addons/GodotXUnit/assets/running.png -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/running.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://dpjbiechcpl37" 6 | path="res://.godot/imported/running.png-f4bbeb95db62dba2dfecfd374db363b5.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/GodotXUnit/assets/running.png" 14 | dest_files=["res://.godot/imported/running.png-f4bbeb95db62dba2dfecfd374db363b5.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/warn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fledware/GodotXUnit/9dfda7b56052a70dfb8dfd89a2673373b7eb6746/addons/GodotXUnit/assets/warn.png -------------------------------------------------------------------------------- /addons/GodotXUnit/assets/warn.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://q0gw22cly0cq" 6 | path="res://.godot/imported/warn.png-d4a9955bdfe89d37d1373fc2d882c282.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://addons/GodotXUnit/assets/warn.png" 14 | dest_files=["res://.godot/imported/warn.png-d4a9955bdfe89d37d1373fc2d882c282.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | -------------------------------------------------------------------------------- /addons/GodotXUnit/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | name="GodotXUnit" 3 | description="A fully integrated xunit testing executor" 4 | author="https://github.com/fledware/GodotXUnit" 5 | version="0.10.0" 6 | script="Plugin.cs" -------------------------------------------------------------------------------- /addons/GodotXUnit/runner/EmptyScene.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene format=2] 2 | 3 | [node name="EmptyScene" type="Node"] 4 | -------------------------------------------------------------------------------- /addons/GodotXUnit/runner/GodotTestEntry.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | 3 | func _process(delta): 4 | if get_node_or_null("/root/GodotTestRunner") == null: 5 | var runner = load("res://addons/GodotXUnit/GodotTestRunner.cs").new() 6 | runner.name = "GodotTestRunner" 7 | get_tree().root.add_child(runner) 8 | set_process(false) 9 | -------------------------------------------------------------------------------- /addons/GodotXUnit/runner/GodotTestRunnerScene.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://dpfjqgmj8q0ex"] 2 | 3 | [ext_resource type="Script" path="res://addons/GodotXUnit/runner/GodotTestEntry.gd" id="1"] 4 | 5 | [node name="GodotTestRunnerScene" type="Node2D"] 6 | script = ExtResource("1") 7 | -------------------------------------------------------------------------------- /addons/GodotXUnit/runner/RiderTestRunner/Runner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Godot; 4 | using GodotXUnitApi; 5 | using GodotXUnitApi.Internal; 6 | using Environment = System.Environment; 7 | using Thread = System.Threading.Thread; 8 | 9 | // ReSharper disable once CheckNamespace 10 | namespace RiderTestRunner 11 | { 12 | // ReSharper disable once UnusedType.Global 13 | public partial class Runner : GodotXUnitRunnerBase // for GodotXUnit use: public partial class Runner : GodotTestRunner. https://github.com/fledware/GodotXUnit/issues/8#issuecomment-929849478 14 | { 15 | public override void _Ready() 16 | { 17 | GDU.Instance = this; // for GodotXUnit https://github.com/fledware/GodotXUnit/issues/8#issuecomment-929849478 18 | var textNode = GetNode("RichTextLabel"); 19 | foreach (var arg in OS.GetCmdlineArgs()) 20 | { 21 | textNode.Text += Environment.NewLine + arg; 22 | } 23 | 24 | if (OS.GetCmdlineArgs().Length < 4) 25 | return; 26 | 27 | var unitTestAssembly = OS.GetCmdlineArgs()[2]; 28 | var unitTestArgs = OS.GetCmdlineArgs()[4].Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries).ToArray(); 29 | // https://docs.microsoft.com/en-us/dotnet/api/system.appdomain.executeassembly?view=netframework-4.7.2 30 | AppDomain currentDomain = AppDomain.CurrentDomain; 31 | var thread = new Thread(() => 32 | { 33 | currentDomain.ExecuteAssembly(unitTestAssembly, unitTestArgs); 34 | GetTree().Quit(); 35 | }); 36 | thread.Start(); 37 | 38 | WaitForThreadExit(thread); 39 | } 40 | 41 | private async void WaitForThreadExit(Thread thread) 42 | { 43 | while (thread.IsAlive) 44 | { 45 | await ToSignal(GetTree().CreateTimer(1), "timeout"); 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /addons/GodotXUnit/runner/RiderTestRunner/Runner.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=2] 2 | 3 | [ext_resource path="Runner.cs" type="Script" id=1] 4 | 5 | [node name="Node2D" type="Node2D"] 6 | script = ExtResource( 1 ) 7 | 8 | [node name="RichTextLabel" type="RichTextLabel" parent="."] 9 | offset_left = 10.0 10 | offset_top = 10.0 11 | offset_right = 500.0 12 | offset_bottom = 200.0 13 | scale = Vector2( 2, 2 ) 14 | text = "Running" 15 | __meta__ = { 16 | "_edit_use_anchors_": false 17 | } --------------------------------------------------------------------------------