From 4947898216d3f48a2bd9dcd551cb00e06d1c5b3b Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Wed, 6 May 2026 17:35:11 +0900 Subject: [PATCH 1/9] tests: extract ScreenshotComparator into shared sources/tests/ project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the LPIPS / Claude vision comparator out of samples/Tests/Comparator/ into sources/tests/Stride.ScreenshotComparator/ so other test pipelines (notably the GameStudio AutoTesting fixtures in tests/editor/) can reuse it via ProjectReference. Namespace renamed Stride.SampleScreenshotComparator to Stride.Tests.ScreenshotComparator. The lpips_alex.onnx model propagates to consumers' bins via . samples/Tests/Stride.Samples.Tests.csproj drops its inline OnnxRuntime + ImageSharp PackageRefs and the explicit model item — all come transitively from the shared lib now. SampleScreenshotTests.cs uses the new namespace and lets the comparator default to AppContext.BaseDirectory for model lookup. Stride.sln update is intentionally deferred — bundle it with the AutoTesting sln entry when those land together. --- build/Stride.sln | 15 +++++++++ samples/Tests/SampleScreenshotTests.cs | 11 ++++--- samples/Tests/Stride.Samples.Tests.csproj | 8 +---- .../ClaudeVisionFallback.cs | 33 ++++++++++++++----- .../ScreenshotComparator.cs | 4 +-- .../Stride.ScreenshotComparator.csproj | 20 +++++++++++ .../models/README.md | 0 .../models/export.py | 0 .../models/lpips_alex.onnx | 0 9 files changed, 68 insertions(+), 23 deletions(-) rename {samples/Tests/Comparator => sources/tests/Stride.ScreenshotComparator}/ClaudeVisionFallback.cs (67%) rename {samples/Tests/Comparator => sources/tests/Stride.ScreenshotComparator}/ScreenshotComparator.cs (98%) create mode 100644 sources/tests/Stride.ScreenshotComparator/Stride.ScreenshotComparator.csproj rename {samples/Tests/Comparator => sources/tests/Stride.ScreenshotComparator}/models/README.md (100%) rename {samples/Tests/Comparator => sources/tests/Stride.ScreenshotComparator}/models/export.py (100%) rename {samples/Tests/Comparator => sources/tests/Stride.ScreenshotComparator}/models/lpips_alex.onnx (100%) diff --git a/build/Stride.sln b/build/Stride.sln index ef0a3f6140..77a689bef5 100644 --- a/build/Stride.sln +++ b/build/Stride.sln @@ -349,6 +349,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Stride.Build.Sdk.Tests", "S ..\sources\sdk\Stride.Build.Sdk.Tests\Sdk\Sdk.targets = ..\sources\sdk\Stride.Build.Sdk.Tests\Sdk\Sdk.targets EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.ScreenshotComparator", "..\sources\tests\Stride.ScreenshotComparator\Stride.ScreenshotComparator.csproj", "{74D99A2C-7F6E-473E-8839-8229F475AA5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1502,6 +1504,18 @@ Global {35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Mixed Platforms.Build.0 = Release|Any CPU {35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Win32.ActiveCfg = Release|Any CPU {35EC42D8-0A09-41AE-A918-B8C2796061B3}.Release|Win32.Build.0 = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Win32.ActiveCfg = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Debug|Win32.Build.0 = Debug|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Any CPU.Build.0 = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Win32.ActiveCfg = Release|Any CPU + {74D99A2C-7F6E-473E-8839-8229F475AA5A}.Release|Win32.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1629,6 +1643,7 @@ Global {D26186F8-7158-4A01-9524-EF4F53E0802C} = {0B81090E-4066-4723-A658-8AEDBEADE619} {8D873BE7-8EF2-4478-B86A-249021D046EB} = {0B81090E-4066-4723-A658-8AEDBEADE619} {E6B11A34-A1DB-41C2-B509-94DACA9D9BDE} = {0B81090E-4066-4723-A658-8AEDBEADE619} + {74D99A2C-7F6E-473E-8839-8229F475AA5A} = {1AE1AC60-5D2F-4CA7-AE20-888F44551185} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FF877973-604D-4EA7-B5F5-A129961F9EF2} diff --git a/samples/Tests/SampleScreenshotTests.cs b/samples/Tests/SampleScreenshotTests.cs index 2432dc7340..53574c5c90 100644 --- a/samples/Tests/SampleScreenshotTests.cs +++ b/samples/Tests/SampleScreenshotTests.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Stride.SampleScreenshotComparator; using Stride.SampleScreenshotRunner; +using Stride.Tests.ScreenshotComparator; using Xunit; using Xunit.Abstractions; @@ -67,17 +67,18 @@ public void Sample(string name) // Compare: in-proc LPIPS against committed baselines. --sample isolates this test from // earlier theory entries that may have left captures in the same captureRoot. - var modelPath = Path.Combine(worktree, "samples", "Tests", "Comparator", "models", "lpips_alex.onnx"); + // ScreenshotComparator defaults to /models/lpips_alex.onnx, which the shared + // Stride.ScreenshotComparator project copies into our output via ProjectReference. var baselineDir = Path.Combine(worktree, "tests", "Stride.Samples.Tests"); - var results = ScreenshotComparator.Compare(captureRoot, baselineDir, sampleFilter: name, modelPath: modelPath); + var results = ScreenshotComparator.Compare(captureRoot, baselineDir, sampleFilter: name); foreach (var r in results) { var d = r.Lpips.HasValue ? $"lpips={r.Lpips.Value:F4} thr={r.Threshold:F2}" : ""; output.WriteLine($"[compare] {r.Status,-7} {r.Frame,-20} {d}{(r.Detail is null ? "" : " " + r.Detail)}"); } - var drift = results.Where(r => r.Status is "drift" or "error").ToList(); - Assert.Empty(drift); + var failures = results.Where(r => r.Status is "drift" or "error" or "new").ToList(); + Assert.Empty(failures); // Test passed — wipe the regenerated sample dir to keep working trees small. Skip on // failure so the post-mortem still has the regenerated project for local debugging. diff --git a/samples/Tests/Stride.Samples.Tests.csproj b/samples/Tests/Stride.Samples.Tests.csproj index 01b2e39c30..8a4e39c69b 100644 --- a/samples/Tests/Stride.Samples.Tests.csproj +++ b/samples/Tests/Stride.Samples.Tests.csproj @@ -33,16 +33,10 @@ + - - - - - - - diff --git a/samples/Tests/Comparator/ClaudeVisionFallback.cs b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs similarity index 67% rename from samples/Tests/Comparator/ClaudeVisionFallback.cs rename to sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs index 52130b7775..4e913dea74 100644 --- a/samples/Tests/Comparator/ClaudeVisionFallback.cs +++ b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System; @@ -7,7 +7,7 @@ using System.Text; using System.Text.Json; -namespace Stride.SampleScreenshotComparator; +namespace Stride.Tests.ScreenshotComparator; /// /// Calls Claude Haiku 4.5 vision with the baseline + capture and asks "is this the same scene?". @@ -34,13 +34,28 @@ public static Verdict Compare(string baselinePath, string capturePath, string? e var baselineB64 = Convert.ToBase64String(File.ReadAllBytes(baselinePath)); var captureB64 = Convert.ToBase64String(File.ReadAllBytes(capturePath)); - var prompt = "Compare these two game screenshots — first is the BASELINE (expected), " + - "second is the CAPTURE (this run). Reply YES if they show the same UI state " + - "and same visible content (same text, buttons, characters, scene layout). " + - "Treat noise (particle positions, animation cycle phase, lighting flicker) " + - "as acceptable. Reply NO if there is a meaningful regression (different UI " + - "page, missing element, wrong text, different scene). " + - "Format: \"YES: \" or \"NO: \"."; + var prompt = + "Compare these two Stride engine screenshots — BASELINE (expected) vs CAPTURE " + + "(this run). Both were produced by the same engine code; visible differences are " + + "almost always caused by test-harness timing nondeterminism (variable frame pacing " + + "across graphics APIs), NOT by a rendering regression. A rendering regression looks " + + "broken, not just 'different'. Be tolerant.\n\n" + + "YES (not a regression):\n" + + "- HUD numeric values differ (ammo, score, timer, FPS, health). This is gameplay state.\n" + + "- Character / weapon / camera pose, aim angle, or hand-bob phase differs. Animation phase.\n" + + "- Particle / smoke / fire / cloth / water / lighting / post-process noise differs.\n" + + "- Same overall scene with one element in a slightly different position or animation state.\n" + + "\n" + + "NO (real regression):\n" + + "- Whole-frame color / gamma / brightness shift (capture noticeably darker, desaturated, " + + "washed-out, or with wrong sRGB encoding).\n" + + "- Missing or corrupt geometry (broken meshes, distorted models, holes).\n" + + "- Missing or wrong textures (pink/purple checkerboard, all-black surfaces, wrong materials).\n" + + "- Missing post-process pass (no bloom / shadow / SSAO / tonemapping where baseline has them).\n" + + "- Different UI page, missing UI elements, wrong UI text labels (label text, not numeric values).\n" + + "- Wrong scene entirely (different level, different camera angle by 90°+, missing major objects).\n" + + "\n" + + "Format: \"YES: \" or \"NO: \"."; if (!string.IsNullOrEmpty(extraHint)) prompt += " Additional context for this specific frame: " + extraHint; diff --git a/samples/Tests/Comparator/ScreenshotComparator.cs b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs similarity index 98% rename from samples/Tests/Comparator/ScreenshotComparator.cs rename to sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs index 4b52771bb8..f4fd56d44f 100644 --- a/samples/Tests/Comparator/ScreenshotComparator.cs +++ b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System.Text.Json; @@ -8,7 +8,7 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -namespace Stride.SampleScreenshotComparator; +namespace Stride.Tests.ScreenshotComparator; /// /// Pixel-perceptual screenshot comparator. Reads new captures from and diff --git a/sources/tests/Stride.ScreenshotComparator/Stride.ScreenshotComparator.csproj b/sources/tests/Stride.ScreenshotComparator/Stride.ScreenshotComparator.csproj new file mode 100644 index 0000000000..50d7cfa975 --- /dev/null +++ b/sources/tests/Stride.ScreenshotComparator/Stride.ScreenshotComparator.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + latest + enable + false + false + true + + + + + + + + + + diff --git a/samples/Tests/Comparator/models/README.md b/sources/tests/Stride.ScreenshotComparator/models/README.md similarity index 100% rename from samples/Tests/Comparator/models/README.md rename to sources/tests/Stride.ScreenshotComparator/models/README.md diff --git a/samples/Tests/Comparator/models/export.py b/sources/tests/Stride.ScreenshotComparator/models/export.py similarity index 100% rename from samples/Tests/Comparator/models/export.py rename to sources/tests/Stride.ScreenshotComparator/models/export.py diff --git a/samples/Tests/Comparator/models/lpips_alex.onnx b/sources/tests/Stride.ScreenshotComparator/models/lpips_alex.onnx similarity index 100% rename from samples/Tests/Comparator/models/lpips_alex.onnx rename to sources/tests/Stride.ScreenshotComparator/models/lpips_alex.onnx From 9e735a149f9d0a6aecc44472cf12adf31198a7c2 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 13:41:37 +0900 Subject: [PATCH 2/9] sdk: collapse versioned TFM suffix into StrideTargetFramework Lets projects opt into versioned OS monikers (e.g. net10.0-windows10.0.22621.0 for WinRT) without breaking platform/dependency conditions. Also surfaces NuGet restore diagnostics in NuGetAssemblyResolver and lets consumers override NuGetResolverTargetFramework. --- sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets | 2 +- .../Sdk/Stride.Dependencies.targets | 8 +++---- .../Sdk/Stride.Frameworks.props | 12 ++++++++++ .../Sdk/Stride.Frameworks.targets | 12 ++++++++++ .../Sdk/Stride.Graphics.targets | 18 +++++++------- .../Sdk/Stride.GraphicsApi.InnerBuild.targets | 2 +- .../Sdk/Stride.Platform.props | 18 +++++++------- .../Sdk/Stride.Platform.targets | 24 +++++++++---------- .../Stride.NuGetResolver.Targets.projitems | 6 ++--- .../NuGetAssemblyResolver.cs | 7 +++++- 10 files changed, 69 insertions(+), 40 deletions(-) diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets index 53f66f69ae..29847c147a 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Sdk.targets @@ -136,7 +136,7 @@ - false + false false diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets index 2c9c609ec9..756176e531 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Dependencies.targets @@ -115,19 +115,19 @@ - + PreserveNewest - + - + <_StrideDependencyNativeLib> $([System.Text.RegularExpressions.Regex]::Match('%(Filename)', `(lib)*(.+)`).get_Groups().get_Item(2).ToString()) @@ -138,7 +138,7 @@ - + $(StrideMTouchExtras) -L"%24{ProjectDir}" @(_StrideDependencyNativeLib->'-l%(LibraryName) "%24{ProjectDir}/%(Filename)%(Extension)"',' ') $(MtouchExtraArgs) --compiler=clang -cxx -gcc_flags '-lstdc++ $(MtouchExtraArgsLibs)' diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props index 40559ef618..3b3ba2e5d8 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.props @@ -11,4 +11,16 @@ true + + + $(TargetFramework) + $(StrideFrameworkWindows) + $(StrideFrameworkAndroid) + $(StrideFrameworkiOS) + $(StrideFrameworkmacOS) + + \ No newline at end of file diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets index 73b579772c..77dcf8900a 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Frameworks.targets @@ -6,6 +6,18 @@ but BEFORE Sdk.targets. --> + + + $(TargetFramework) + $(StrideFrameworkWindows) + $(StrideFrameworkAndroid) + $(StrideFrameworkiOS) + $(StrideFrameworkmacOS) + + diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets index 933cea7e5f..62ccbbf8c0 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Graphics.targets @@ -21,20 +21,20 @@ - Direct3D11;Direct3D12;Vulkan + Direct3D11;Direct3D12;Vulkan $(StrideGraphicsApis.Split(';', StringSplitOptions.RemoveEmptyEntries)[0]) - Direct3D11 - Vulkan - Vulkan + Direct3D11 + Vulkan + Vulkan - false - false - false + false + false + false $(StrideDefaultGraphicsApi) @@ -84,8 +84,8 @@ - SDL - $(StrideUI);WINFORMS;WPF + SDL + $(StrideUI);WINFORMS;WPF $(DefineConstants);STRIDE_UI_SDL $(DefineConstants);STRIDE_UI_WINFORMS diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets index c68f1167d6..ea5c83509d 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.GraphicsApi.InnerBuild.targets @@ -115,7 +115,7 @@ - <_StrideGraphicsApiDependentDisabledAtCurrentTF Condition="'$(TargetFramework)' == '$(StrideFrameworkiOS)' Or '$(TargetFramework)' == '$(StrideFrameworkAndroid)' Or '$(TargetFramework)' == '$(StrideFrameworkUWP)'">true + <_StrideGraphicsApiDependentDisabledAtCurrentTF Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkiOS)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkAndroid)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkUWP)'">true - dotnet - UWP - Android - iOS + dotnet + UWP + Android + iOS diff --git a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets index 8f5d8a7687..a0e1272762 100644 --- a/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets +++ b/sources/sdk/Stride.Build.Sdk/Sdk/Stride.Platform.targets @@ -15,21 +15,21 @@ - + STRIDE_PLATFORM_DESKTOP - + STRIDE_PLATFORM_MONO_MOBILE;STRIDE_PLATFORM_ANDROID - + STRIDE_PLATFORM_MONO_MOBILE;STRIDE_PLATFORM_IOS @@ -53,7 +53,7 @@ - + Library 21 @@ -61,14 +61,14 @@ $(AssemblyName) - + true - + True None - + False SdkOnly @@ -76,15 +76,15 @@ - + iPhone Resources - - - - + + + + diff --git a/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems b/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems index 6a55e67415..e9df633792 100644 --- a/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems +++ b/sources/shared/Stride.NuGetResolver.Targets/Stride.NuGetResolver.Targets.projitems @@ -20,9 +20,9 @@ $(IntermediateOutputPath)$(MSBuildProjectName).NuGetResolverEntryPoint$(DefaultLanguageSourceExtension) - $(TargetFramework) - $(TargetFramework)$(TargetPlatformVersion) - STRIDE_NUGET_RESOLVER_UI;$(DefineConstants) + $(TargetFramework) + $(TargetFramework)$(TargetPlatformVersion) + STRIDE_NUGET_RESOLVER_UI;$(DefineConstants) diff --git a/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs b/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs index 09bde9dc30..4a3c534dbb 100644 --- a/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs +++ b/sources/shared/Stride.NuGetResolver/NuGetAssemblyResolver.cs @@ -119,7 +119,12 @@ public static void SetupNuGet(List<(string targetFramework, string packageName, var (request, result) = RestoreHelper.Restore(logger, nugetFramework, RuntimeInformation.RuntimeIdentifier, packageName, versionRange); if (!result.Success) { - throw new InvalidOperationException("Could not restore NuGet packages"); + var diagnostics = string.Join(Environment.NewLine, + logger.Logs + .Where(l => l.Level >= LogLevel.Warning) + .Select(l => $" [{l.Level}] {l.Message}")); + throw new InvalidOperationException( + $"Could not restore NuGet packages for {packageName} {packageVersion} ({targetFramework}).{Environment.NewLine}{diagnostics}"); } // Build list of assemblies From 584367998201fec5810b71fdd9ccad3fcad59798 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 13:41:55 +0900 Subject: [PATCH 3/9] editor/autotest: GameStudio screenshot regression harness AutoTesting hosts GS in-process and drives [UITest] fixtures through IUITestContext (queue waits, WGC capture, AvalonDock panel capture, modal drive-by). Stride.Editor.Tests adds EmptyEditor / NewGameEditor / TopDownCreate / TopDownLoad fixtures. GS gets an appHosted callback in Run() and qualified XAML pack URIs so resources resolve from a non-entry assembly. --- build/Stride.sln | 28 +- sources/Directory.Packages.props | 2 + .../Services/AssetBuilderService.cs | 9 + .../Build/GameStudioBuilderService.cs | 6 + .../Stride.GameStudio.AutoTesting/DpiUtil.cs | 45 ++ .../GraphicsCaptureClient.cs | 358 +++++++++ .../Stride.GameStudio.AutoTesting/IUITest.cs | 13 + .../IUITestContext.cs | 78 ++ .../Stride.GameStudio.AutoTesting/Program.cs | 101 +++ .../Stride.GameStudio.AutoTesting.csproj | 49 ++ .../UITestAttribute.cs | 14 + .../UITestHost.cs | 712 ++++++++++++++++++ .../app.manifest | 26 + sources/editor/Stride.GameStudio/App.xaml | 2 +- sources/editor/Stride.GameStudio/Program.cs | 31 +- .../Properties/AssemblyInfo.cs | 3 + .../View/GameStudioWindow.xaml | 2 +- tests/editor/EmptyEditor.cs | 20 + tests/editor/NewGameEditor.cs | 50 ++ tests/editor/Stride.Editor.Tests.csproj | 18 + tests/editor/TopDownCreate.cs | 57 ++ tests/editor/TopDownLoad.cs | 28 + 22 files changed, 1646 insertions(+), 6 deletions(-) create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/Program.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs create mode 100644 sources/editor/Stride.GameStudio.AutoTesting/app.manifest create mode 100644 tests/editor/EmptyEditor.cs create mode 100644 tests/editor/NewGameEditor.cs create mode 100644 tests/editor/Stride.Editor.Tests.csproj create mode 100644 tests/editor/TopDownCreate.cs create mode 100644 tests/editor/TopDownLoad.cs diff --git a/build/Stride.sln b/build/Stride.sln index 77a689bef5..5bb844ee81 100644 --- a/build/Stride.sln +++ b/build/Stride.sln @@ -146,6 +146,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Editor", "..\sources EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.GameStudio.Tests", "..\sources\editor\Stride.GameStudio.Tests\Stride.GameStudio.Tests.csproj", "{0EA748AF-E1DC-4788-BA50-8BABD56F220C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.GameStudio.AutoTesting", "..\sources\editor\Stride.GameStudio.AutoTesting\Stride.GameStudio.AutoTesting.csproj", "{9B07728D-FF67-493D-939C-87CE6B788A89}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Editor.Tests", "..\tests\editor\Stride.Editor.Tests.csproj", "{34A6666F-EFF5-4979-973E-91C4185EE27B}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Design", "..\sources\core\Stride.Core.Design\Stride.Core.Design.csproj", "{66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Graphics.Regression", "..\sources\engine\Stride.Graphics.Regression\Stride.Graphics.Regression.csproj", "{D002FEB1-00A6-4AB1-A83F-1F253465E64D}" @@ -776,6 +780,26 @@ Global {0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Mixed Platforms.Build.0 = Release|Any CPU {0EA748AF-E1DC-4788-BA50-8BABD56F220C}.Release|Win32.ActiveCfg = Release|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Debug|Win32.ActiveCfg = Debug|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Any CPU.Build.0 = Release|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {9B07728D-FF67-493D-939C-87CE6B788A89}.Release|Win32.ActiveCfg = Release|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Debug|Win32.ActiveCfg = Debug|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Any CPU.Build.0 = Release|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {34A6666F-EFF5-4979-973E-91C4185EE27B}.Release|Win32.ActiveCfg = Release|Any CPU {66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Any CPU.Build.0 = Debug|Any CPU {66581DAD-70AD-4475-AE47-C6C0DF1EC5E2}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -1567,6 +1591,8 @@ Global {E7B1B17F-D04B-4978-B504-A6BB3EE846C9} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} {16E02D45-5530-4617-97DC-BC3BDF77DE2C} = {5D2D3BE8-9910-45CA-8E45-95660DA4C563} {0EA748AF-E1DC-4788-BA50-8BABD56F220C} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579} + {9B07728D-FF67-493D-939C-87CE6B788A89} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579} + {34A6666F-EFF5-4979-973E-91C4185EE27B} = {F5F744B5-803E-4180-B82A-8B1F0BCD6579} {66581DAD-70AD-4475-AE47-C6C0DF1EC5E2} = {25F10A0B-7259-404C-86BE-FD2363C92F72} {D002FEB1-00A6-4AB1-A83F-1F253465E64D} = {A7ED9F01-7D78-4381-90A6-D50E51C17250} {942A5B1D-2B3D-4B30-98DE-336CE93F4F12} = {860946E4-CC77-4FDA-A4FD-3DB2A502A696} @@ -1659,8 +1685,6 @@ Global ..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{3a3cb33c-64d9-4948-86c1-0d86320d23c3}*SharedItemsImports = 13 ..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{50d1a3bb-4b41-4ef5-8d2f-3618a3b6c698}*SharedItemsImports = 5 ..\sources\editor\Stride.Core.MostRecentlyUsedFiles\Stride.Core.MostRecentlyUsedFiles.projitems*{5863574d-7a55-49bc-8e65-babb74d8e66e}*SharedItemsImports = 5 - ..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{75d71310-ecf7-4592-9e35-3fe540040982}*SharedItemsImports = 5 - ..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{75d71310-ecf7-4592-9e35-3fe540040982}*SharedItemsImports = 5 ..\sources\shared\Stride.Core.ShellHelper\Stride.Core.ShellHelper.projitems*{77e2fcc0-4ca6-436c-be6f-9418cb807d45}*SharedItemsImports = 5 ..\sources\shared\Stride.NuGetResolver.Targets\Stride.NuGetResolver.Targets.projitems*{77e2fcc0-4ca6-436c-be6f-9418cb807d45}*SharedItemsImports = 5 ..\sources\engine\Stride.Shared\Refactor\Stride.Refactor.projitems*{7af4b563-aad3-42ff-b91e-84b9d34d904a}*SharedItemsImports = 5 diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index 595a80c42d..4f24ff4b1d 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -11,6 +11,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs b/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs index 20469997f5..ddca9c2926 100644 --- a/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs +++ b/sources/editor/Stride.Core.Assets.Editor/Services/AssetBuilderService.cs @@ -44,6 +44,15 @@ public AssetBuilderService([NotNull] string buildDirectory) public event EventHandler AssetBuilt; + /// + /// The number of build units waiting in the queue to be picked up. Test harnesses use this + /// to detect a quiescent state; production code should treat it as informational only. + /// + public int QueuedBuildUnitCount + { + get { lock (queueLock) { return queue.Count; } } + } + public virtual void Dispose() { builder.Dispose(); diff --git a/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs b/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs index 2f910cb206..7f5d906196 100644 --- a/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs +++ b/sources/editor/Stride.Editor/Build/GameStudioBuilderService.cs @@ -82,6 +82,12 @@ public GameStudioBuilderService(SessionViewModel sessionViewModel, GameSettingsP /// public bool IsDisposed { get; private set; } + /// + /// The number of shader-compile tasks waiting in the queue. Test harnesses use this to + /// detect a quiescent state; production code should treat it as informational only. + /// + public int PendingShaderCompilationCount => taskScheduler.QueuedTaskCount; + public override void Dispose() { base.Dispose(); diff --git a/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs b/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs new file mode 100644 index 0000000000..d88f299b19 --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/DpiUtil.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Stride.GameStudio.AutoTesting; + +/// Helpers shared between the AutoTesting runner and the xunit orchestrator. +public static class DpiUtil +{ + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); + [DllImport("Shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); + [DllImport("user32.dll")] + private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X; public int Y; } + + private static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = (IntPtr)(-4); + + /// + /// Returns the primary monitor's effective DPI scale as an integer percentage (96 → 100, + /// 144 → 150, 192 → 200, …). Switches the calling thread to PerMonitorAwareV2 first because + /// GetDpiForMonitor(MDT_EFFECTIVE_DPI) returns 96 in DPI-unaware processes regardless + /// of actual scaling. + /// + public static int DetectDpiPercent() + { + var prevContext = IntPtr.Zero; + try + { + prevContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + var hMon = MonitorFromPoint(default, 1 /* MONITOR_DEFAULTTOPRIMARY */); + if (GetDpiForMonitor(hMon, 0 /* MDT_EFFECTIVE_DPI */, out var dpi, out _) == 0) + return (int)Math.Round(dpi / 96.0 * 100); + } + catch { /* fall back to 100 */ } + finally + { + if (prevContext != IntPtr.Zero) SetThreadDpiAwarenessContext(prevContext); + } + return 100; + } +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs new file mode 100644 index 0000000000..74d29b210f --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs @@ -0,0 +1,358 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Silk.NET.Core.Native; +using Silk.NET.Direct3D11; +using Silk.NET.DXGI; +using Windows.Foundation.Metadata; +using Windows.Graphics.Capture; +using WinRT; + +// Disambiguate from Silk.NET.* counterparts. +using IDirect3DDevice = Windows.Graphics.DirectX.Direct3D11.IDirect3DDevice; +using DirectXPixelFormat = Windows.Graphics.DirectX.DirectXPixelFormat; + +namespace Stride.GameStudio.AutoTesting; + +/// +/// Captures a top-level window's pixel content via Windows.Graphics.Capture (WGC). WGC reads from +/// the DWM compositor's output, so the source can be drawn by any graphics API — D3D11/12, Vulkan, +/// GDI — and DComp content (WPF chrome, AvalonDock panels) is captured correctly. The yellow capture +/// border and cursor are disabled where the OS supports it (Win11 22H2 / Win10 1903+). +/// +internal static class GraphicsCaptureClient +{ + private const string GraphicsCaptureSessionType = "Windows.Graphics.Capture.GraphicsCaptureSession"; + + [ComImport, System.Runtime.InteropServices.Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IGraphicsCaptureItemInterop + { + [PreserveSig] int CreateForWindow(IntPtr window, ref System.Guid iid, out IntPtr value); + [PreserveSig] int CreateForMonitor(IntPtr monitor, ref System.Guid iid, out IntPtr value); + } + + [ComImport, System.Runtime.InteropServices.Guid("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IDirect3DDxgiInterfaceAccess + { + [PreserveSig] int GetInterface(ref System.Guid iid, out IntPtr ptr); + } + + // IID of IGraphicsCaptureItem; pinned because typeof(GraphicsCaptureItem).GUID is projection- + // version dependent. + private static readonly System.Guid IID_IGraphicsCaptureItem = new("79C3F95B-31F7-4EC2-A464-632EF5D30760"); + + [DllImport("d3d11.dll", ExactSpelling = true)] + private static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice); + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int Left, Top, Right, Bottom; } + + [DllImport("user32.dll")] + [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool GetClientRect(IntPtr hwnd, out RECT lpRect); + + [DllImport("user32.dll")] + [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(IntPtr hwnd); + + [DllImport("user32.dll")] + [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool RedrawWindow(IntPtr hwnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, uint flags); + + [DllImport("user32.dll")] + [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow); + + private const int SW_RESTORE = 9; + private const uint RDW_INVALIDATE = 0x0001; + private const uint RDW_ALLCHILDREN = 0x0080; + private const uint RDW_UPDATENOW = 0x0100; + private const int GWL_STYLE = -16; + private const int GWL_EXSTYLE = -20; + private const long WS_EX_NOREDIRECTIONBITMAP = 0x00200000L; + private const int WDA_EXCLUDEFROMCAPTURE = 0x00000011; + + [DllImport("user32.dll")] + private static extern long GetWindowLongPtrW(IntPtr hwnd, int nIndex); + + [DllImport("user32.dll")] + private static extern int GetWindowDisplayAffinity(IntPtr hwnd, out uint dwAffinity); + + private const int GWLP_HWNDPARENT = -8; + private const int E_INVALIDARG = unchecked((int)0x80070057); + private const long WS_EX_APPWINDOW = 0x00040000L; + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern long SetWindowLongPtrW(IntPtr hwnd, int nIndex, long dwNewLong); + + [DllImport("dwmapi.dll", PreserveSig = false)] + private static extern void DwmFlush(); + + private static readonly string DiagLogPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gs-diag.log"); + private static void DiagLog(string message) + { + try { System.IO.File.AppendAllText(DiagLogPath, $"{DateTime.UtcNow:HH:mm:ss.fff} [tid={System.Threading.Thread.CurrentThread.ManagedThreadId}] WGC: {message}\n"); } + catch { } + } + + [DllImport("combase.dll", PreserveSig = false)] + private static extern void RoGetActivationFactory(IntPtr classId, in System.Guid iid, out IntPtr factory); + + [DllImport("combase.dll", PreserveSig = false)] + private static extern void WindowsCreateString([MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string sourceString, uint length, out IntPtr hstring); + + [DllImport("combase.dll")] + private static extern int WindowsDeleteString(IntPtr hstring); + + private static T GetActivationFactory(string runtimeClassName) where T : class + { + WindowsCreateString(runtimeClassName, (uint)runtimeClassName.Length, out var hstring); + try + { + var iid = typeof(T).GUID; + RoGetActivationFactory(hstring, in iid, out var factoryPtr); + try { return (T)Marshal.GetObjectForIUnknown(factoryPtr); } + finally { Marshal.Release(factoryPtr); } + } + finally { WindowsDeleteString(hstring); } + } + + public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) + { + if (hwnd == IntPtr.Zero) throw new ArgumentException("HWND is zero.", nameof(hwnd)); + + // 1. HWND → GraphicsCaptureItem via the activation factory's IGraphicsCaptureItemInterop. + var itemFactory = GetActivationFactory("Windows.Graphics.Capture.GraphicsCaptureItem"); + var itemIid = IID_IGraphicsCaptureItem; + var createHr = itemFactory.CreateForWindow(hwnd, ref itemIid, out var itemAbi); + + // WGC rejects owned/transient/no-AppWindow windows (e.g. AvalonDock floating panels) with + // E_INVALIDARG — see Microsoft's IsCapturableWindow sample. Promote temporarily: clear + // owner and add WS_EX_APPWINDOW. The window must stay promoted through capture, so we + // hand the original style/owner to the async helper to restore in its finally block. + long origExStyle = 0, origOwner = 0; + var promoted = false; + if (createHr == E_INVALIDARG) + { + origExStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + origOwner = GetWindowLongPtrW(hwnd, GWLP_HWNDPARENT); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle | WS_EX_APPWINDOW); + SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, 0); + promoted = true; + DiagLog($"WGC E_INVALIDARG; promoted hwnd 0x{hwnd.ToInt64():X} (cleared owner 0x{origOwner:X}, added WS_EX_APPWINDOW)"); + createHr = itemFactory.CreateForWindow(hwnd, ref itemIid, out itemAbi); + if (createHr < 0) DiagLog($"CreateForWindow (after promotion) hr=0x{createHr:X8}"); + } + if (createHr < 0) + { + if (promoted) + { + SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, origOwner); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle); + } + Marshal.ThrowExceptionForHR(createHr); + } + GraphicsCaptureItem item; + try { item = MarshalInterface.FromAbi(itemAbi); } + finally { Marshal.Release(itemAbi); } + + // 2. Create a D3D11 device. BGRA support is required for the WGC framepool. + var d3d11 = D3D11.GetApi(null); + ID3D11Device* devicePtr = null; + ID3D11DeviceContext* contextPtr = null; + D3DFeatureLevel level = 0; + HResult hr = d3d11.CreateDevice( + pAdapter: null, DriverType: D3DDriverType.Hardware, Software: IntPtr.Zero, + Flags: (uint)CreateDeviceFlag.BgraSupport, + pFeatureLevels: null, FeatureLevels: 0, + SDKVersion: D3D11.SdkVersion, + ppDevice: ref devicePtr, pFeatureLevel: &level, ppImmediateContext: ref contextPtr); + if (hr.IsFailure) throw Marshal.GetExceptionForHR(hr.Value)!; + + // 3. ID3D11Device → IDXGIDevice → IDirect3DDevice (WinRT). + IDirect3DDevice graphicsDevice; + IDXGIDevice* dxgiDevice = null; + var dxgiIid = IDXGIDevice.Guid; + SilkMarshal.ThrowHResult(devicePtr->QueryInterface(ref dxgiIid, (void**)&dxgiDevice)); + try + { + Marshal.ThrowExceptionForHR(CreateDirect3D11DeviceFromDXGIDevice((IntPtr)dxgiDevice, out var graphicsDeviceUnk)); + try { graphicsDevice = MarshalInspectable.FromAbi(graphicsDeviceUnk); } + finally { Marshal.Release(graphicsDeviceUnk); } + } + finally { dxgiDevice->Release(); } + + // 4. Framepool + session. CreateFreeThreaded dispatches FrameArrived on a threadpool + // thread; the regular Create requires a DispatcherQueue on the calling thread. + var size = item.Size; + DiagLog($"item.Size={size.Width}x{size.Height} hwnd=0x{hwnd.ToInt64():X}"); + if (size.Width <= 0 || size.Height <= 0) + { + // Fall back to GetClientRect-derived size — happens if WPF hasn't fully laid out yet. + if (GetClientRect(hwnd, out var rect)) + { + size.Width = rect.Right - rect.Left; + size.Height = rect.Bottom - rect.Top; + DiagLog($"WGC: fell back to GetClientRect → {size.Width}x{size.Height}"); + } + if (size.Width <= 0 || size.Height <= 0) + throw new InvalidOperationException($"GraphicsCaptureItem reports zero size and GetClientRect failed; window not yet realised."); + } + var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded( + graphicsDevice, + DirectXPixelFormat.B8G8R8A8UIntNormalized, + numberOfBuffers: 1, + size: size); + var session = framePool.CreateCaptureSession(item); + + // Suppress yellow capture border (Win11 22H2+) and cursor overlay (Win10 1903+). + if (ApiInformation.IsPropertyPresent(GraphicsCaptureSessionType, "IsBorderRequired")) + session.IsBorderRequired = false; + if (ApiInformation.IsPropertyPresent(GraphicsCaptureSessionType, "IsCursorCaptureEnabled")) + session.IsCursorCaptureEnabled = false; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int frameCallbackCount = 0; + Windows.Foundation.TypedEventHandler handler = null!; + handler = (sender, _) => + { + var n = System.Threading.Interlocked.Increment(ref frameCallbackCount); + var frame = sender.TryGetNextFrame(); + DiagLog($"FrameArrived #{n} frame={(frame is null ? "null" : $"{frame.ContentSize.Width}x{frame.ContentSize.Height}")}"); + if (frame is null) return; + framePool.FrameArrived -= handler; + tcs.TrySetResult(frame); + }; + framePool.FrameArrived += handler; + + // Diagnostics: WS_EX_NOREDIRECTIONBITMAP excludes from DWM redirection (and so from WGC); + // WDA_EXCLUDEFROMCAPTURE opts out of capture entirely. + var exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + GetWindowDisplayAffinity(hwnd, out var affinity); + DiagLog($"Styles: exStyle=0x{exStyle:X} NoRedirBitmap={(exStyle & WS_EX_NOREDIRECTIONBITMAP) != 0} affinity=0x{affinity:X} ExcludeFromCapture={affinity == WDA_EXCLUDEFROMCAPTURE}"); + + // WGC only delivers FrameArrived when DWM presents new composition; nudge the window so + // the first frame arrives. + var fg = SetForegroundWindow(hwnd); + var sw = ShowWindow(hwnd, SW_RESTORE); + var rd = RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW); + DiagLog($"Nudge: SetForegroundWindow={fg} ShowWindow={sw} RedrawWindow={rd}"); + + session.StartCapture(); + DiagLog("StartCapture returned"); + + // DwmFlush blocks until DWM advances its composition — guarantees at least one frame + // pass that WGC can pick up. + try { DwmFlush(); DiagLog("DwmFlush returned"); } + catch (Exception ex) { DiagLog($"DwmFlush threw: {ex.Message}"); } + + // Hand off to the async helper as IntPtrs (managed pointers can't cross an await). + return WaitAndEncodeAsync(tcs.Task, (IntPtr)devicePtr, (IntPtr)contextPtr, framePool, session, path, + hwnd, promoted, origExStyle, origOwner); + } + + private static async Task WaitAndEncodeAsync( + Task frameTask, + IntPtr deviceAbi, + IntPtr contextAbi, + Direct3D11CaptureFramePool framePool, + GraphicsCaptureSession session, + string path, + IntPtr hwnd, + bool promoted, + long origExStyle, + long origOwner) + { + try + { + var winner = await Task.WhenAny(frameTask, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false); + if (winner != frameTask) + throw new TimeoutException("WGC FrameArrived didn't fire within 10s; window may be occluded or DWM isn't compositing it."); + using var frame = await frameTask.ConfigureAwait(false); + EncodeFrameToPng(frame, deviceAbi, contextAbi, path); + } + finally + { + session.Dispose(); + framePool.Dispose(); + ReleasePointer(contextAbi); + ReleasePointer(deviceAbi); + if (promoted) + { + SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, origOwner); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle); + } + } + } + + private static unsafe void ReleasePointer(IntPtr abi) + { + if (abi != IntPtr.Zero) ((IUnknown*)abi)->Release(); + } + + + private static unsafe void EncodeFrameToPng( + Direct3D11CaptureFrame frame, + IntPtr deviceAbi, + IntPtr contextAbi, + string path) + { + var device = (ID3D11Device*)deviceAbi; + var context = (ID3D11DeviceContext*)contextAbi; + + // Reach the underlying ID3D11Texture2D via IDirect3DDxgiInterfaceAccess (CsWinRT's .As + // performs the QI through the projection's RCW). + var dxgiAccess = frame.Surface.As(); + var texIid = ID3D11Texture2D.Guid; + Marshal.ThrowExceptionForHR(dxgiAccess.GetInterface(ref texIid, out var srcTexUnk)); + ID3D11Texture2D* srcTex = (ID3D11Texture2D*)srcTexUnk; + try + { + Texture2DDesc desc; + srcTex->GetDesc(&desc); + + // CPU-readable staging copy so we can map and read pixels. + var stagingDesc = desc; + stagingDesc.Usage = Usage.Staging; + stagingDesc.CPUAccessFlags = (uint)CpuAccessFlag.Read; + stagingDesc.BindFlags = 0; + stagingDesc.MiscFlags = 0; + ID3D11Texture2D* staging = null; + SilkMarshal.ThrowHResult(device->CreateTexture2D(in stagingDesc, null, ref staging)); + try + { + context->CopyResource((ID3D11Resource*)staging, (ID3D11Resource*)srcTex); + + MappedSubresource mapped = default; + SilkMarshal.ThrowHResult(context->Map((ID3D11Resource*)staging, 0, Map.Read, 0, ref mapped)); + try + { + int w = (int)desc.Width; + int h = (int)desc.Height; + using var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb); + var rect = new Rectangle(0, 0, w, h); + var bd = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); + try + { + int srcPitch = (int)mapped.RowPitch; + int dstPitch = bd.Stride; + int rowBytes = w * 4; + var src = (byte*)mapped.PData; + var dst = (byte*)bd.Scan0; + for (int y = 0; y < h; y++) + Buffer.MemoryCopy(src + y * srcPitch, dst + y * dstPitch, dstPitch, rowBytes); + } + finally { bmp.UnlockBits(bd); } + bmp.Save(path, ImageFormat.Png); + } + finally { context->Unmap((ID3D11Resource*)staging, 0); } + } + finally { staging->Release(); } + } + finally { srcTex->Release(); } + } +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs b/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs new file mode 100644 index 0000000000..93dfd8ef3a --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/IUITest.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.GameStudio.AutoTesting; + +/// +/// Author-side test fixture. The class is instantiated by the GameStudio loader after the main +/// window is up; is then driven on a background task with a context handle. +/// +public interface IUITest +{ + Task Run(IUITestContext ctx); +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs new file mode 100644 index 0000000000..3b93f4ef39 --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.GameStudio.AutoTesting; + +/// +/// Wait/screenshot/exit primitives handed to the test fixture by the AutoTesting runner. +/// +public interface IUITestContext +{ + /// Opens the .sln pre-positioned on disk by the runner. No-op when no project test is configured. + Task OpenProject(); + + /// Returns when the editor's asset build queue is empty for two consecutive frames. + Task WaitForAssetBuild(); + + /// Returns when the shader compile queue is empty for two consecutive frames. + Task WaitForShaders(); + + /// Returns when the WPF dispatcher has drained to ApplicationIdle priority. + Task WaitDispatcherIdle(); + + /// Awaits N successive render frames. + Task WaitFrames(int n = 1); + + /// Convenience: awaits asset build, shaders, dispatcher idle, one trailing frame, and rendering. + Task WaitIdle(); + + /// + /// Returns when every active EditorServiceGame instance — the embedded scene/prefab/UI/sprite + /// document games and the shared asset-preview game — has advanced its DrawTime.FrameCount + /// by at least since the call started, ensuring swap-chains have + /// presented real content. No-op if no games are active. Times out after + /// with a log message — never throws. + /// + Task WaitForRendering(int frames = 60, double timeoutSeconds = 30); + + /// Captures the active main window to a PNG named . + Task Screenshot(string name); + + /// + /// Resizes a top-level window (looked up by class name) to a fixed × + /// and centers it on the primary screen. Used by fixtures to pin the + /// main editor window to a deterministic capture size before . + /// + Task SetWindowSize(string windowTypeName, int width, int height); + + /// + /// Floats a single docked panel or document into its own window sized to + /// × , captures it via WGC, then restores its original docked / auto-hide + /// state. Lookup by ContentId for anchorable panels (e.g. "AssetView", "PropertyGrid", + /// "SolutionExplorer", "BuildLog", "References") or by Title for asset-editor documents + /// (e.g. "MainScene") which are added with empty ContentId and Title=asset.Url. + /// + Task CapturePanel(string idOrTitle, string name, int width = 1200, int height = 900); + + /// + /// Polls the WPF Application.Windows set until a Window of class name + /// is visible and loaded, or + /// elapses. Returns true on success. + /// + Task WaitForWindow(string windowTypeName, double timeoutSeconds = 120); + + /// + /// Selects a template in the ProjectSelectionWindow by template GUID and returns true if found. + /// The dialog stays open; close it via . + /// + Task SelectTemplate(string templateGuid); + + /// + /// Closes a modal dialog with DialogResult.Ok (equivalent to clicking OK / Create). + /// Returns true if the window was found and closed. + /// + Task CloseModalWithOk(string windowTypeName); + + /// Sets the process exit code and shuts the editor down. + void Exit(int exitCode = 0); +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/Program.cs b/sources/editor/Stride.GameStudio.AutoTesting/Program.cs new file mode 100644 index 0000000000..bfb3dc11ae --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/Program.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows; +using System.Windows.Threading; + +namespace Stride.GameStudio.AutoTesting; + +/// +/// Stride.GameStudio.AutoTesting runner entry point. Hosts Stride.GameStudio in-process and +/// wires into the WPF Application via the appHosted callback. +/// +/// CLI: +/// AutoTesting.exe --test-dll <path> --test-name <class> [project.sln] [GS args...] +/// +internal static class Program +{ + [STAThread] + public static int Main(string[] osArgs) + { + // Bypasses the adapter-needs-an-output filter in GraphicsDeviceManager.FindBestDevices, + // so headless runners (no DXGI outputs) can still pick a hardware adapter or fall back + // to WARP. Must be set before any Stride code runs. + Environment.SetEnvironmentVariable("STRIDE_GRAPHICS_SOFTWARE_RENDERING", "1"); + + // Pre-accept the Stride 4.0 privacy policy: PrivacyPolicyHelper would otherwise pop a + // modal at startup with no one to click Accept on CI. + try + { + using var subkey = Microsoft.Win32.Registry.CurrentUser + .OpenSubKey(@"SOFTWARE\Stride\Agreements\", writable: true) + ?? Microsoft.Win32.Registry.CurrentUser.CreateSubKey(@"SOFTWARE\Stride\Agreements\"); + subkey?.SetValue("Stride-4.0", "True"); + } + catch { /* best-effort — failure shows up as the privacy-policy hang */ } + + // Parse our own args. Anything we don't recognise is forwarded to Stride.GameStudio.Run. + string? testDll = null; + string? testName = null; + var gsArgs = new List(); + for (var i = 0; i < osArgs.Length; i++) + { + if (osArgs[i] == "--test-dll" && i + 1 < osArgs.Length) + { + testDll = osArgs[++i]; + } + else if (osArgs[i] == "--test-name" && i + 1 < osArgs.Length) + { + testName = osArgs[++i]; + } + else + { + gsArgs.Add(osArgs[i]); + } + } + if (testDll is null) + { + Console.Error.WriteLine("usage: Stride.GameStudio.AutoTesting --test-dll [--test-name ] [project.sln] [GS args...]"); + return 2; + } + if (!File.Exists(testDll)) + { + Console.Error.WriteLine($"Test DLL not found: {testDll}"); + return 2; + } + + // GS's CrashReport ends with Environment.Exit(0) which masks the underlying error; + // capture every exception (including the swallowed ones) to a diag log. + var diagPath = Path.Combine(Path.GetTempPath(), "autotest-diag.log"); + try { File.Delete(diagPath); } catch { } + void Diag(string msg) { try { File.AppendAllText(diagPath, $"{DateTime.UtcNow:HH:mm:ss.fff} {msg}\n"); } catch { } } + Diag($"AutoTesting.Main entered. testDll={testDll} testName={testName} gsArgs=[{string.Join(", ", gsArgs)}]"); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Diag($"UnhandledException terminating={e.IsTerminating}: {e.ExceptionObject}"); + AppDomain.CurrentDomain.FirstChanceException += (_, e) => + Diag($"FirstChance: {e.Exception.GetType().Name}: {e.Exception.Message}"); + AppDomain.CurrentDomain.ProcessExit += (_, _) => Diag("ProcessExit"); + + UITestHost? host = null; + try + { + Stride.GameStudio.Program.Run(gsArgs, (app, dispatcher) => + { + Diag("appHosted callback fired; constructing UITestHost"); + host = new UITestHost(dispatcher, testDll, testName); + host.Start(); + Diag("UITestHost.Start returned"); + }); + Diag("Stride.GameStudio.Program.Run returned"); + } + catch (Exception ex) + { + Diag($"EXCEPTION escaped Program.Run: {ex}"); + throw; + } + + return host?.ExitCode ?? 0; + } +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj new file mode 100644 index 0000000000..0703976600 --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj @@ -0,0 +1,49 @@ + + + + WinExe + + $(StrideEditorTargetFramework)10.0.22621.0 + + $(StrideEditorTargetFramework)10.0.22621 + win-x64 + false + enable + enable + true + true + true + + app.manifest + + true + false + + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + + + + + + + diff --git a/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs b/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs new file mode 100644 index 0000000000..67d1d6aa3e --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/UITestAttribute.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.GameStudio.AutoTesting; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class UITestAttribute : Attribute +{ + /// + /// Optional template GUID. The test runner uses it to regenerate the matching sample on disk + /// before launching GameStudio; the harness itself does not consult this field. + /// + public string? SampleTemplateId { get; set; } +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs b/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs new file mode 100644 index 0000000000..77028a59f7 --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/UITestHost.cs @@ -0,0 +1,712 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Stride.Core.Assets.Editor.Components.TemplateDescriptions.ViewModels; +using Stride.Core.Assets.Editor.Components.TemplateDescriptions.Views; +using Stride.Core.Assets.Editor.Services; +using Stride.Core.Assets.Editor.ViewModel; +using Stride.Core.Presentation.Controls; +using Stride.Core.Presentation.Services; +using Stride.Editor.Build; +using Stride.Editor.EditorGame.Game; +using Stride.Editor.Preview; +using Stride.GameStudio.ViewModels; + +namespace Stride.GameStudio.AutoTesting; + +/// +/// Loads the test DLL, polls for a settled WPF window, runs the chosen +/// fixture, and provides the impl (waits + WGC capture). +/// +internal sealed class UITestHost +{ + // Output dir suffix is the runtime monitor DPI percentage so per-DPI captures stay separate + // (baselines are likewise stored under tests/editor/baselines/dpi/). The runner detects + // DPI at startup via GetDpiForMonitor(MDT_EFFECTIVE_DPI), which returns the user-set scale + // factor regardless of process DPI-awareness. + private const string OutDirNamePrefix = "ui-test-out-dpi"; + private const string ScreenshotsDir = "screenshots"; + private const string DoneFileName = "done.json"; + private const string LogFileName = "log.txt"; + + // Window types that indicate the editor has finished startup; transients like + // WorkProgressWindow are intentionally excluded. + private static readonly HashSet ReadyWindowTypeNames = new(StringComparer.Ordinal) + { + "GameStudioWindow", + "ProjectSelectionWindow", + }; + + private readonly Dispatcher dispatcher; + private readonly string testDllPath; + private readonly string? testClassName; + private readonly string outputDir; + private StreamWriter? logWriter; + private readonly List capturedNames = new(); + private string lastSeenWindowsSummary = ""; + + public int ExitCode { get; private set; } + + public UITestHost(Dispatcher dispatcher, string testDllPath, string? testClassName) + { + this.dispatcher = dispatcher; + this.testDllPath = testDllPath; + this.testClassName = testClassName; + outputDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(testDllPath))!, OutDirNamePrefix + DpiUtil.DetectDpiPercent()); + Directory.CreateDirectory(Path.Combine(outputDir, ScreenshotsDir)); + try { logWriter = new StreamWriter(new FileStream(Path.Combine(outputDir, LogFileName), FileMode.Create, FileAccess.Write, FileShare.Read)) { AutoFlush = true }; } + catch (Exception ex) { Console.Error.WriteLine($"UITestHost: failed to open log: {ex.Message}"); } + } + + public void Start() + { + Log($"Start: testDllPath={testDllPath} testClassName={testClassName ?? "(auto)"}"); + var test = LoadTest(); + Log($"Test loaded: {test.GetType().FullName}"); + var ctx = new Context(this); + + // Background polling loop that marshals each window check onto the dispatcher. + var fired = false; + Task.Run(async () => + { + await Task.Delay(2000).ConfigureAwait(false); + for (var i = 0; i < 1500; i++) + { + if (fired) return; + bool ready; + try { ready = await dispatcher.InvokeAsync(HasReadyWindow).Task.ConfigureAwait(false); } + catch (Exception ex) { Log($"Poll: InvokeAsync failed: {ex.Message}"); return; } + if (ready) + { + fired = true; + await dispatcher.InvokeAsync(() => RunTest(test, ctx)).Task.ConfigureAwait(false); + return; + } + await Task.Delay(200).ConfigureAwait(false); + } + Log("Poll: gave up after 1500 iterations (~5min)."); + }); + } + + private void Log(string message) + { + var line = $"{DateTime.UtcNow:HH:mm:ss.fff} {message}"; + Console.Error.WriteLine(line); + try { logWriter?.WriteLine(line); } + catch { /* best-effort */ } + } + + private bool HasReadyWindow() + { + var app = Application.Current; + if (app is null) return false; + var summary = string.Join(", ", app.Windows.OfType().Select(w => + $"{w.GetType().Name}[Title='{w.Title}'](visible={w.IsVisible},loaded={w.IsLoaded},{w.ActualWidth}x{w.ActualHeight})")); + if (summary != lastSeenWindowsSummary) + { + Log($"windows: {summary}"); + lastSeenWindowsSummary = summary; + } + foreach (var win in app.Windows.OfType()) + { + if (!win.IsVisible || !win.IsLoaded) continue; + if (win.ActualWidth < 100 || win.ActualHeight < 100) continue; + if (!ReadyWindowTypeNames.Contains(win.GetType().Name)) continue; + Log($"ready: '{win.GetType().Name}' Title='{win.Title}' Size={win.ActualWidth}x{win.ActualHeight}"); + return true; + } + return false; + } + + private IUITest LoadTest() + { + var asm = Assembly.LoadFrom(testDllPath); + var candidates = asm.GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .ToList(); + if (candidates.Count == 0) + throw new InvalidOperationException($"No [UITest] class found in '{testDllPath}'."); + + Type chosen; + if (testClassName is not null) + { + chosen = candidates.FirstOrDefault(t => t.Name == testClassName || t.FullName == testClassName) + ?? throw new InvalidOperationException($"No [UITest] class named '{testClassName}' in '{testDllPath}'. Available: {string.Join(", ", candidates.Select(t => t.FullName))}"); + } + else if (candidates.Count == 1) + { + chosen = candidates[0]; + } + else + { + throw new InvalidOperationException($"Multiple [UITest] classes in '{testDllPath}'; pass --test-name to select. Available: {string.Join(", ", candidates.Select(t => t.FullName))}"); + } + + if (!typeof(IUITest).IsAssignableFrom(chosen)) + throw new InvalidOperationException($"[UITest] class {chosen.FullName} must implement IUITest."); + + return (IUITest)Activator.CreateInstance(chosen)!; + } + + private void RunTest(IUITest test, Context ctx) + { + Task.Run(async () => + { + var status = "ok"; + object? exceptionInfo = null; + try + { + await test.Run(ctx); + } + catch (Exception ex) + { + status = "error"; + exceptionInfo = SerializeException(ex); + Console.Error.WriteLine(ex); + ExitCode = 1; + } + finally + { + WriteDoneJson(status, exceptionInfo); + ctx.ShutdownInternal(); + } + }); + } + + private void WriteDoneJson(string status, object? exceptionInfo) + { + try + { + var donePath = Path.Combine(outputDir, DoneFileName); + // claudeFallback=true on every editor frame: the editor UI naturally drifts + // (asset-thumbnail render order, scroll positions, scene-camera framing) and LPIPS + // alone produces too many false positives. Claude vision only fires on frames that + // already exceed the LPIPS threshold, so cost is bounded. + var payload = new + { + status, + screenshots = capturedNames.Select(n => new { name = n, claudeFallback = true }), + exception = exceptionInfo, + }; + File.WriteAllText(donePath, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) + { + Console.Error.WriteLine($"UITestHost: failed to write done.json: {ex}"); + } + } + + private static object SerializeException(Exception ex) => new + { + type = ex.GetType().FullName, + message = ex.Message, + stack = ex.ToString(), + }; + + private sealed class Context : IUITestContext + { + private readonly UITestHost host; + public Context(UITestHost host) { this.host = host; } + + public Task OpenProject() => Task.CompletedTask; // project is loaded by GameStudio's positional-arg path before the test runs + + public Task WaitForAssetBuild() => WaitForQueueDrained("asset-build", () => + { + var session = TryGetSession(); + return session?.ServiceProvider.TryGet()?.QueuedBuildUnitCount ?? 0; + }); + + public Task WaitForShaders() => WaitForQueueDrained("shader-compile", () => + { + var session = TryGetSession(); + return session?.ServiceProvider.TryGet()?.PendingShaderCompilationCount ?? 0; + }); + + public Task WaitDispatcherIdle() + { + var tcs = new TaskCompletionSource(); + host.dispatcher.BeginInvoke(() => tcs.SetResult(), DispatcherPriority.ApplicationIdle); + return tcs.Task; + } + + public async Task WaitFrames(int n = 1) + { + for (var i = 0; i < n; i++) + await WaitDispatcherIdle(); + } + + public async Task WaitIdle() + { + await WaitForAssetBuild(); + await WaitForShaders(); + await WaitDispatcherIdle(); + await WaitFrames(1); + await WaitForRendering(); + } + + public async Task WaitForRendering(int frames = 60, double timeoutSeconds = 30) + { + // Snapshot the (game, startFrameCount) pairs on the dispatcher; reading EditorServiceGame + // state from the WPF UI thread is the safe path. PreviewGame lives on its own thread but + // its DrawTime.FrameCount property is just an int read. + var watched = await host.dispatcher.InvokeAsync(EnumerateActiveGames).Task.ConfigureAwait(false); + if (watched.Count == 0) + { + host.Log("WaitForRendering: no active EditorServiceGame instances — skipping"); + return; + } + host.Log($"WaitForRendering: watching {watched.Count} game(s) for ≥{frames} frames each (timeout {timeoutSeconds}s)"); + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(100).ConfigureAwait(false); + var allReady = true; + foreach (var w in watched) + { + int current; + try { current = w.Game.DrawTime?.FrameCount ?? w.StartFrame; } + catch { continue; } // game disposed mid-wait — treat as ready (drop from watch list semantically) + if (current - w.StartFrame < frames) { allReady = false; break; } + } + if (allReady) + { + host.Log($"WaitForRendering: all watched games advanced ≥{frames} frames"); + return; + } + } + var snapshot = string.Join(", ", watched.Select(w => + { + int? cur; try { cur = w.Game.DrawTime?.FrameCount; } catch { cur = null; } + return $"{w.Game.GetType().Name}={(cur is null ? "?" : (cur - w.StartFrame).ToString())}"; + })); + host.Log($"WaitForRendering: timed out after {timeoutSeconds}s — advances {snapshot}"); + } + + private readonly record struct WatchedGame(EditorServiceGame Game, int StartFrame); + + /// + /// Walks the session's preview service + open asset-editor list and returns one entry per + /// running with its current . + /// Reflection-based: AssetEditorsManager.assetEditors is private, and + /// EditorGameController<T>.Game is a protected field — neither is reachable from + /// the AutoTesting assembly without [InternalsVisibleTo], which we don't need to add for + /// this read-only diagnostic walk. + /// + private List EnumerateActiveGames() + { + var list = new List(); + var session = TryGetSession(); + if (session is null) return list; + + // 1) Shared asset-preview game (runs on its own STA thread, drives thumbnail rendering). + var previewSvc = session.ServiceProvider.TryGet(); + if (previewSvc?.PreviewGame is { IsRunning: true } previewGame) + list.Add(new WatchedGame(previewGame, previewGame.DrawTime?.FrameCount ?? 0)); + + // 2) Each open asset editor's embedded game. Multiple scene/prefab/UI/sprite documents + // can be open simultaneously — collect each one's running game. + var aem = session.ServiceProvider.TryGet(); + if (aem is null) return list; + var assetEditorsField = aem.GetType().GetField("assetEditors", BindingFlags.NonPublic | BindingFlags.Instance); + if (assetEditorsField?.GetValue(aem) is not IDictionary assetEditors) return list; + foreach (var editorVm in assetEditors.Keys) + { + if (editorVm is null) continue; + try + { + // Walk declared-only because SceneEditorViewModel etc. shadow GameEditorViewModel.Controller + // with a more-derived return type — a flat GetProperty("Controller") triggers AmbiguousMatchException. + var controllerProp = FindMemberDeclaredOnly(editorVm.GetType(), t => t.GetProperty("Controller", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + if (controllerProp?.GetValue(editorVm) is not { } controller) continue; + var gameField = FindMemberDeclaredOnly(controller.GetType(), t => t.GetField("Game", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)); + if (gameField?.GetValue(controller) is EditorServiceGame game && game.IsRunning) + list.Add(new WatchedGame(game, game.DrawTime?.FrameCount ?? 0)); + } + catch (Exception ex) + { + host.Log($"WaitForRendering: skipping editor '{editorVm.GetType().Name}': {ex.GetType().Name}: {ex.Message}"); + } + } + return list; + } + + /// + /// Walks + base types, returning the first member found by . + /// Caller passes a DeclaredOnly lookup so each level is searched independently — sidesteps + /// AmbiguousMatchException from new-slot/shadowed members across the hierarchy. + /// + private static T? FindMemberDeclaredOnly(Type? t, Func lookup) where T : class + { + while (t is not null) + { + var hit = lookup(t); + if (hit is not null) return hit; + t = t.BaseType; + } + return null; + } + + /// + /// Returns when reads zero on two consecutive idle ticks. + /// The two-tick rule absorbs the race where one drain seeds the queue from a follow-up. + /// + private async Task WaitForQueueDrained(string label, Func getCount) + { + const int RequiredStableTicks = 2; + var deadline = DateTime.UtcNow.AddSeconds(120); + var stable = 0; + var lastLogged = -1; + var nextLogAt = DateTime.UtcNow.AddSeconds(2); + while (DateTime.UtcNow < deadline) + { + await WaitDispatcherIdle(); + var count = await host.dispatcher.InvokeAsync(getCount, DispatcherPriority.ApplicationIdle); + if (DateTime.UtcNow >= nextLogAt && count != lastLogged) + { + host.Log($"WaitForQueueDrained('{label}') count={count}"); + lastLogged = count; + nextLogAt = DateTime.UtcNow.AddSeconds(2); + } + if (count == 0) + { + if (++stable >= RequiredStableTicks) return; + } + else + { + stable = 0; + } + } + host.Log($"WaitForQueueDrained('{label}') timed out after 120s."); + } + + private static SessionViewModel? TryGetSession() + { + var app = Application.Current; + if (app is null) return null; + foreach (var w in app.Windows.OfType()) + { + if (w.DataContext is GameStudioViewModel gs) return gs.Session; + } + return null; + } + + public async Task Screenshot(string name) + { + var window = await host.dispatcher.InvokeAsync(ResolveCaptureWindow).Task.ConfigureAwait(false); + if (window is null) + { + host.Log("Screenshot: no window to capture."); + return; + } + var (winInfo, hwnd) = await host.dispatcher.InvokeAsync(() => + ($"'{window.GetType().Name}' Title='{window.Title}' Size={window.ActualWidth}x{window.ActualHeight}", + new WindowInteropHelper(window).Handle)).Task.ConfigureAwait(false); + host.Log($"Screenshot: capturing {winInfo}"); + + // Force a fresh WPF render so DWM has a frame for WGC to capture. + await host.dispatcher.InvokeAsync(() => + { + window.Activate(); + window.InvalidateVisual(); + window.UpdateLayout(); + }, DispatcherPriority.Normal).Task.ConfigureAwait(false); + await host.dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render).Task.ConfigureAwait(false); + await Task.Delay(150).ConfigureAwait(false); + + try + { + var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png"); + if (hwnd == IntPtr.Zero) throw new InvalidOperationException("Window has no HWND yet."); + await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false); + host.capturedNames.Add(name); + } + catch (Exception ex) + { + host.Log($"Screenshot('{name}') failed: {ex}"); + } + } + + public async Task WaitForWindow(string windowTypeName, double timeoutSeconds = 120) + { + host.Log($"WaitForWindow: '{windowTypeName}' (timeout={timeoutSeconds}s)"); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var found = await host.dispatcher.InvokeAsync(() => + { + var app = Application.Current; + return app?.Windows.OfType().Any(w => + w.GetType().Name == windowTypeName && w.IsVisible && w.IsLoaded + && w.ActualWidth >= 100 && w.ActualHeight >= 100) ?? false; + }).Task.ConfigureAwait(false); + if (found) + { + host.Log($"WaitForWindow: '{windowTypeName}' ready"); + return true; + } + await Task.Delay(200).ConfigureAwait(false); + } + host.Log($"WaitForWindow: '{windowTypeName}' timed out after {timeoutSeconds}s"); + return false; + } + + public Task SelectTemplate(string templateGuid) => + host.dispatcher.InvokeAsync(() => + { + host.Log($"SelectTemplate: '{templateGuid}'"); + var app = Application.Current; + if (app is null) return false; + var win = app.Windows.OfType().FirstOrDefault(); + if (win is null) { host.Log("SelectTemplate: ProjectSelectionWindow not found"); return false; } + var collection = win.Templates; + if (collection is null) { host.Log("SelectTemplate: ProjectSelectionWindow.Templates is null"); return false; } + if (!Guid.TryParse(templateGuid, out var targetId)) + { host.Log($"SelectTemplate: '{templateGuid}' is not a valid GUID"); return false; } + // Templates is the per-group filtered view; full set lives behind RootGroups. + var candidates = collection.Templates + .Concat(collection.RootGroups.SelectMany(g => g.GetTemplatesRecursively())) + .ToList(); + var match = candidates.FirstOrDefault(t => t.Id == targetId); + if (match is null) + { host.Log($"SelectTemplate: no template with Id={templateGuid} in {candidates.Count} candidates"); return false; } + collection.SelectedTemplate = match; + host.Log($"SelectTemplate: selected '{match.GetType().Name}' (Id={match.Id})"); + return true; + }).Task; + + public Task CloseModalWithOk(string windowTypeName) => + host.dispatcher.InvokeAsync(() => + { + host.Log($"CloseModalWithOk: '{windowTypeName}'"); + var app = Application.Current; + if (app is null) return false; + var win = app.Windows.OfType().FirstOrDefault(w => w.GetType().Name == windowTypeName); + if (win is null) { host.Log($"CloseModalWithOk: '{windowTypeName}' not found"); return false; } + if (win is ModalWindow modal) + { + modal.RequestClose(DialogResult.Ok); + host.Log($"CloseModalWithOk: RequestClose(Ok) on '{windowTypeName}'"); + return true; + } + win.Close(); + host.Log($"CloseModalWithOk: Close() on '{windowTypeName}' (not a ModalWindow)"); + return true; + }).Task; + + public async Task SetWindowSize(string windowTypeName, int width, int height) => + await host.dispatcher.InvokeAsync(() => + { + var work = SystemParameters.WorkArea; + // Clamp to work area so the window stays fully on-screen — partially off-screen + // windows confuse DWM redirection and break WGC capture downstream. + var w = Math.Min(width, (int)work.Width); + var h = Math.Min(height, (int)work.Height); + host.Log($"SetWindowSize: '{windowTypeName}' → req {width}x{height} clamped {w}x{h} (work={work.Width}x{work.Height})"); + var win = Application.Current?.Windows.OfType() + .FirstOrDefault(w0 => w0.GetType().Name == windowTypeName); + if (win is null) { host.Log($"SetWindowSize: '{windowTypeName}' not found"); return; } + win.SetCurrentValue(Window.WindowStateProperty, WindowState.Normal); + win.SetCurrentValue(Window.SizeToContentProperty, SizeToContent.Manual); + win.SetCurrentValue(Window.WidthProperty, (double)w); + win.SetCurrentValue(Window.HeightProperty, (double)h); + win.SetCurrentValue(Window.LeftProperty, work.Left + Math.Max(0.0, (work.Width - w) / 2.0)); + win.SetCurrentValue(Window.TopProperty, work.Top + Math.Max(0.0, (work.Height - h) / 2.0)); + win.UpdateLayout(); + host.Log($"SetWindowSize: after — Width={win.Width} Height={win.Height} Actual={win.ActualWidth}x{win.ActualHeight} State={win.WindowState}"); + }, DispatcherPriority.Render).Task.ConfigureAwait(false); + + public async Task CapturePanel(string idOrTitle, string name, int width = 1200, int height = 900) + { + host.Log($"CapturePanel: id='{idOrTitle}' name='{name}' size={width}x{height}"); + var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png"); + object? anchorable = null; + AnchorableState? originalState = null; + try + { + anchorable = await host.dispatcher.InvokeAsync(() => FindAnchorable(idOrTitle)).Task.ConfigureAwait(false); + if (anchorable is null) { host.Log($"CapturePanel: '{idOrTitle}' not found."); return; } + + originalState = await host.dispatcher.InvokeAsync(() => FloatAnchorable(anchorable, width, height)).Task.ConfigureAwait(false); + + // Let the floating window realize and lay out. + await WaitDispatcherIdle(); + await Task.Delay(250).ConfigureAwait(false); + await WaitDispatcherIdle(); + + await host.dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render).Task.ConfigureAwait(false); + await Task.Delay(150).ConfigureAwait(false); + + var (winInfo, hwnd) = await host.dispatcher.InvokeAsync(() => + { + var floatWin = FindFloatingWindow(idOrTitle) + ?? throw new InvalidOperationException($"Floating window for '{idOrTitle}' not found after Float()."); + floatWin.UpdateLayout(); + return ($"'{floatWin.GetType().Name}' Size={floatWin.ActualWidth}x{floatWin.ActualHeight}", + new WindowInteropHelper(floatWin).Handle); + }).Task.ConfigureAwait(false); + host.Log($"CapturePanel: capturing {winInfo}"); + if (hwnd == IntPtr.Zero) throw new InvalidOperationException("Floating window has no HWND yet."); + + // WGC captures DWM composition output, including D3DImage interop content like the + // embedded scene preview's swap-chain. + await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false); + host.Log($"CapturePanel: wrote → {path}"); + host.capturedNames.Add(name); + } + catch (Exception ex) + { + host.Log($"CapturePanel('{idOrTitle}','{name}') failed: {ex}"); + } + finally + { + if (anchorable is not null && originalState is not null) + { + try + { + await host.dispatcher.InvokeAsync(() => RestoreAnchorable(anchorable, originalState.Value)).Task.ConfigureAwait(false); + } + catch (Exception ex) { host.Log($"CapturePanel: restore failed: {ex}"); } + } + } + } + + /// + /// Walks and returns the first top-level Window whose visual + /// tree contains the LayoutAnchorable for — i.e. the floating + /// window AvalonDock spawned by Float(). Skips the main GameStudioWindow. + /// + private static Window? FindFloatingWindow(string contentId) + { + var app = Application.Current; + if (app is null) return null; + foreach (var w in app.Windows.OfType()) + { + if (w.GetType().Name == "GameStudioWindow") continue; + if (SearchTree(w, contentId, returnElement: false) is not null) + return w; + } + return null; + } + + /// Finds the FrameworkElement hosting an AvalonDock panel by ContentId. + private static FrameworkElement? FindPanelContent(string contentId) + { + var app = Application.Current; + if (app is null) return null; + foreach (var w in app.Windows.OfType()) + { + var hit = SearchTree(w, contentId, returnElement: true) as FrameworkElement; + if (hit is not null) return hit; + } + return null; + } + + /// Finds the AvalonDock LayoutAnchorable (DataContext object with the matching ContentId). + private static object? FindAnchorable(string contentId) + { + var app = Application.Current; + if (app is null) return null; + foreach (var w in app.Windows.OfType()) + { + var hit = SearchTree(w, contentId, returnElement: false); + if (hit is not null) return hit; + } + return null; + } + + private static object? SearchTree(DependencyObject node, string idOrTitle, bool returnElement) + { + if (node is FrameworkElement fe && fe.DataContext is { } dc) + { + var t = dc.GetType(); + // Anchorables (panels) match by ContentId; documents (asset editors) typically have + // an empty ContentId and identify via Title (the asset URL). + if (t.GetProperty("ContentId")?.GetValue(dc) is string cid && string.Equals(cid, idOrTitle, StringComparison.Ordinal)) + return returnElement ? fe : dc; + if (t.GetProperty("Title")?.GetValue(dc) is string title && string.Equals(title, idOrTitle, StringComparison.Ordinal)) + return returnElement ? fe : dc; + } + var count = VisualTreeHelper.GetChildrenCount(node); + for (var i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(node, i); + var hit = SearchTree(child, idOrTitle, returnElement); + if (hit is not null) return hit; + } + return null; + } + + private readonly record struct AnchorableState(bool WasAutoHidden, bool WasFloating, double OldFloatingWidth, double OldFloatingHeight); + + private static AnchorableState FloatAnchorable(object anchorable, int width, int height) + { + var t = anchorable.GetType(); + var wasAutoHidden = (bool?)t.GetProperty("IsAutoHidden")?.GetValue(anchorable) ?? false; + var wasFloating = (bool?)t.GetProperty("IsFloating")?.GetValue(anchorable) ?? false; + var oldFW = (double?)t.GetProperty("FloatingWidth")?.GetValue(anchorable) ?? 0; + var oldFH = (double?)t.GetProperty("FloatingHeight")?.GetValue(anchorable) ?? 0; + // Auto-hidden panels need to be expanded before Float() can move them, otherwise the + // anchorable is still parented to the auto-hide pane and the call no-ops. + if (wasAutoHidden) t.GetMethod("ToggleAutoHide")?.Invoke(anchorable, null); + t.GetProperty("FloatingWidth")?.SetValue(anchorable, (double)width); + t.GetProperty("FloatingHeight")?.SetValue(anchorable, (double)height); + if (!wasFloating) t.GetMethod("Float")?.Invoke(anchorable, null); + return new AnchorableState(wasAutoHidden, wasFloating, oldFW, oldFH); + } + + private static void RestoreAnchorable(object anchorable, AnchorableState state) + { + var t = anchorable.GetType(); + if (!state.WasFloating) t.GetMethod("Dock")?.Invoke(anchorable, null); + t.GetProperty("FloatingWidth")?.SetValue(anchorable, state.OldFloatingWidth); + t.GetProperty("FloatingHeight")?.SetValue(anchorable, state.OldFloatingHeight); + var nowAutoHidden = (bool?)t.GetProperty("IsAutoHidden")?.GetValue(anchorable) ?? false; + if (state.WasAutoHidden && !nowAutoHidden) t.GetMethod("ToggleAutoHide")?.Invoke(anchorable, null); + } + + public void Exit(int newExitCode = 0) + { + host.ExitCode = newExitCode; + ShutdownInternal(); + } + + public void ShutdownInternal() + { + host.dispatcher.BeginInvoke(() => + { + Environment.ExitCode = host.ExitCode; + var app = Application.Current; + if (app is null) return; + foreach (var win in app.Windows.Cast().ToList()) + { + try { win.Close(); } catch { /* best-effort */ } + } + app.Shutdown(host.ExitCode); + }); + } + + private static Window? ResolveCaptureWindow() + { + var app = Application.Current; + if (app is null) return null; + return app.Windows.OfType().FirstOrDefault(w => w.IsActive) + ?? app.Windows.OfType().LastOrDefault(w => w.IsLoaded) + ?? app.MainWindow; + } + } +} diff --git a/sources/editor/Stride.GameStudio.AutoTesting/app.manifest b/sources/editor/Stride.GameStudio.AutoTesting/app.manifest new file mode 100644 index 0000000000..28132d0a9f --- /dev/null +++ b/sources/editor/Stride.GameStudio.AutoTesting/app.manifest @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + false + unaware + + + diff --git a/sources/editor/Stride.GameStudio/App.xaml b/sources/editor/Stride.GameStudio/App.xaml index adc22a989f..020eb7ab3a 100644 --- a/sources/editor/Stride.GameStudio/App.xaml +++ b/sources/editor/Stride.GameStudio/App.xaml @@ -4,7 +4,7 @@ - + diff --git a/sources/editor/Stride.GameStudio/Program.cs b/sources/editor/Stride.GameStudio/Program.cs index 7bccfc7dbd..28fef770e3 100644 --- a/sources/editor/Stride.GameStudio/Program.cs +++ b/sources/editor/Stride.GameStudio/Program.cs @@ -60,9 +60,28 @@ public static class Program private static readonly ConcurrentQueue LogRingbuffer = new(); private static bool enableThumbnailServices = true; + // Startup checkpoints; shared file with the AutoTesting runner. + private static readonly string DiagLogPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gs-diag.log"); + private static void DiagLog(string message) + { + try { System.IO.File.AppendAllText(DiagLogPath, $"{DateTime.UtcNow:HH:mm:ss.fff} [tid={Thread.CurrentThread.ManagedThreadId}] GS: {message}\n"); } + catch { /* best-effort */ } + } + [STAThread] public static void Main() { + Run(Environment.GetCommandLineArgs().Skip(1).ToList()); + } + + /// + /// Editor entry point body. fires after + /// InitializeComponent and before app.Run, giving the AutoTesting runner + /// access to the WPF Application + dispatcher. + /// + public static void Run(IList args, Action? appHosted = null) + { + DiagLog($"Run entered. args=[{string.Join(", ", args)}]"); AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; EditorPath.EditorTitle = StrideGameStudio.EditorName; @@ -73,7 +92,9 @@ public static void Main() } PrivacyPolicyHelper.RestartApplication = RestartApplication; + DiagLog("Calling EnsurePrivacyPolicyStride40"); PrivacyPolicyHelper.EnsurePrivacyPolicyStride40(); + DiagLog("EnsurePrivacyPolicyStride40 returned"); // We use MRU of the current version only when we're trying to reload last session. var mru = new MostRecentlyUsedFileCollection(InternalSettings.LoadProfileCopy, InternalSettings.MostRecentlyUsedSessions, InternalSettings.WriteFile); @@ -81,14 +102,13 @@ public static void Main() EditorSettings.Initialize(); Thread.CurrentThread.Name = "Main thread"; + DiagLog("EditorSettings initialized"); // Install Metrics for the editor using (StrideGameStudio.MetricsClient = EditorSettings.EnableMetrics.GetValue() ? new MetricsClient(CommonApps.StrideEditorAppId) : null) { try { - // Environment.GetCommandLineArgs correctly process arguments regarding the presence of '\' and '"' - var args = Environment.GetCommandLineArgs().Skip(1).ToList(); var startupSessionPath = StrideEditorSettings.StartupSession.GetValue(); var lastSessionPath = EditorSettings.ReloadLastSession.GetValue() ? mru.MostRecentlyUsedFiles.FirstOrDefault() : null; var initialSessionPath = !UPath.IsNullOrEmpty(startupSessionPath) ? startupSessionPath : lastSessionPath?.FilePath; @@ -139,6 +159,7 @@ public static void Main() } } RuntimeHelpers.RunModuleConstructor(typeof(Asset).Module.ModuleHandle); + DiagLog("Asset module constructor ran"); //listen to logger for crash report GlobalLogger.GlobalMessageLogged += GlobalLoggerOnGlobalMessageLogged; @@ -148,6 +169,7 @@ public static void Main() using (new WindowManager(mainDispatcher)) { + DiagLog("WindowManager created, constructing App"); app = new App { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.Activated += (sender, eventArgs) => { @@ -158,8 +180,13 @@ public static void Main() StrideGameStudio.MetricsClient?.SetActiveState(false); }; + DiagLog("Calling app.InitializeComponent"); app.InitializeComponent(); + DiagLog("InitializeComponent returned, invoking appHosted"); + appHosted?.Invoke(app, mainDispatcher); + DiagLog("appHosted returned, calling app.Run"); app.Run(); + DiagLog("app.Run returned"); } renderDocManager?.RemoveHooks(); diff --git a/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs b/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs index fd7a71ada8..808521c3ef 100644 --- a/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs +++ b/sources/editor/Stride.GameStudio/Properties/AssemblyInfo.cs @@ -1,8 +1,11 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System.Reflection; +using System.Runtime.CompilerServices; using System.Windows; +[assembly: InternalsVisibleTo("Stride.GameStudio.AutoTesting")] + // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. diff --git a/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml b/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml index ac2d088168..b4b55a439b 100644 --- a/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml +++ b/sources/editor/Stride.GameStudio/View/GameStudioWindow.xaml @@ -21,7 +21,7 @@ - + diff --git a/tests/editor/EmptyEditor.cs b/tests/editor/EmptyEditor.cs new file mode 100644 index 0000000000..55099ca451 --- /dev/null +++ b/tests/editor/EmptyEditor.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +// Smoke test: launch the editor with no project, capture the startup ProjectSelectionWindow, exit. +using System.Threading.Tasks; +using Stride.GameStudio.AutoTesting; + +namespace Stride.Editor.Tests; + +[UITest] +public class EmptyEditor : IUITest +{ + public async Task Run(IUITestContext ctx) + { + await ctx.WaitDispatcherIdle(); + await ctx.WaitFrames(2); + await ctx.Screenshot("startup"); + ctx.Exit(); + } +} diff --git a/tests/editor/NewGameEditor.cs b/tests/editor/NewGameEditor.cs new file mode 100644 index 0000000000..eccf2ba2ee --- /dev/null +++ b/tests/editor/NewGameEditor.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +// GameStudio capture for the "create a new game" flow: pick the New Game template in +// ProjectSelectionWindow, accept GameTemplateWindow defaults, wait for GameStudioWindow. +using System; +using System.Threading.Tasks; +using Stride.GameStudio.AutoTesting; + +namespace Stride.Editor.Tests; + +[UITest(SampleTemplateId = "81d2adea-37b1-4711-834c-0d73a05c206c")] +public class NewGameEditor : IUITest +{ + public async Task Run(IUITestContext ctx) + { + await ctx.WaitDispatcherIdle(); + + // The /NewProject arg shows ProjectSelectionWindow on launch — wait for it, pick the + // New Game template, then proceed. + if (!await ctx.WaitForWindow("ProjectSelectionWindow", timeoutSeconds: 30)) + { + ctx.Exit(1); + return; + } + await Task.Delay(TimeSpan.FromSeconds(1)); // let templates panel populate + + if (!await ctx.SelectTemplate("81d2adea-37b1-4711-834c-0d73a05c206c")) + { + ctx.Exit(1); + return; + } + await ctx.WaitFrames(2); + + // Click OK on ProjectSelectionWindow → triggers PrepareForRun on NewGameTemplateGenerator + // which shows GameTemplateWindow (parameter dialog). Defaults are usable; close with Ok. + if (!await ctx.CloseModalWithOk("ProjectSelectionWindow")) { ctx.Exit(1); return; } + if (!await ctx.WaitForWindow("GameTemplateWindow", timeoutSeconds: 30)) { ctx.Exit(1); return; } + if (!await ctx.CloseModalWithOk("GameTemplateWindow")) { ctx.Exit(1); return; } + + // Project generation runs (creates .sln, .csproj, asset folders, restores NuGet). + // Then the editor opens it and GameStudioWindow appears. + if (!await ctx.WaitForWindow("GameStudioWindow", timeoutSeconds: 180)) { ctx.Exit(1); return; } + await ctx.SetWindowSize("GameStudioWindow", 2560, 1440); + await ctx.WaitIdle(); + + await ctx.Screenshot("new-game-editor"); + ctx.Exit(); + } +} diff --git a/tests/editor/Stride.Editor.Tests.csproj b/tests/editor/Stride.Editor.Tests.csproj new file mode 100644 index 0000000000..d4bbf09359 --- /dev/null +++ b/tests/editor/Stride.Editor.Tests.csproj @@ -0,0 +1,18 @@ + + + + net10.0-windows10.0.22621.0 + enable + enable + Stride.Editor.Tests + Stride.Editor.Tests + + true + false + + + + + diff --git a/tests/editor/TopDownCreate.cs b/tests/editor/TopDownCreate.cs new file mode 100644 index 0000000000..b52028845f --- /dev/null +++ b/tests/editor/TopDownCreate.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +// GameStudio capture for the TopDownRPG template "create + open" flow: pick TopDownRPG in +// ProjectSelectionWindow, accept the platform dialog, wait for GameStudioWindow. +using System; +using System.Threading.Tasks; +using Stride.GameStudio.AutoTesting; + +namespace Stride.Editor.Tests; + +[UITest(SampleTemplateId = "A363FBC5-89EF-4E7A-B870-6D070813D034")] +public class TopDownCreate : IUITest +{ + public async Task Run(IUITestContext ctx) + { + await ctx.WaitDispatcherIdle(); + + if (!await ctx.WaitForWindow("ProjectSelectionWindow", timeoutSeconds: 30)) + { + ctx.Exit(1); + return; + } + await Task.Delay(TimeSpan.FromSeconds(1)); // templates panel populate + + if (!await ctx.SelectTemplate("A363FBC5-89EF-4E7A-B870-6D070813D034")) + { + ctx.Exit(1); + return; + } + await ctx.WaitFrames(2); + + // Sample templates flow: ProjectSelectionWindow.OK → TemplateSampleGenerator opens + // UpdatePlatformsWindow (Windows preselected) → project gen → GameStudioWindow. + // (NewGameTemplateGenerator's GameTemplateWindow is for blank-game templates, not samples.) + if (!await ctx.CloseModalWithOk("ProjectSelectionWindow")) { ctx.Exit(1); return; } + if (!await ctx.WaitForWindow("UpdatePlatformsWindow", timeoutSeconds: 30)) { ctx.Exit(1); return; } + if (!await ctx.CloseModalWithOk("UpdatePlatformsWindow")) { ctx.Exit(1); return; } + + // TopDownRPG generation pulls in more assets than NewGame (NavMesh, prefabs, scripts) — + // bigger window than the empty-game flow. Cap at 5 min to absorb cold NuGet restore on CI. + if (!await ctx.WaitForWindow("GameStudioWindow", timeoutSeconds: 300)) { ctx.Exit(1); return; } + await ctx.SetWindowSize("GameStudioWindow", 2560, 1440); + await ctx.WaitIdle(); + + await ctx.Screenshot("main"); + await ctx.CapturePanel("AssetView", "panel-assets", 1200, 900); + await ctx.CapturePanel("PropertyGrid", "panel-properties", 700, 900); + await ctx.CapturePanel("SolutionExplorer", "panel-solution", 700, 900); + await ctx.CapturePanel("References", "panel-references", 700, 900); + await ctx.CapturePanel("BuildLog", "panel-buildlog", 1200, 900); + // Scene editor document — Title is the asset URL ("MainScene" for TopDownRPG). + await ctx.CapturePanel("MainScene", "scene-main", 1400, 900); + + ctx.Exit(); + } +} diff --git a/tests/editor/TopDownLoad.cs b/tests/editor/TopDownLoad.cs new file mode 100644 index 0000000000..efa8ad6190 --- /dev/null +++ b/tests/editor/TopDownLoad.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +// GameStudio capture for an existing TopDownRPG project. The runner passes the .sln path on +// the AutoTesting CLI, so GS opens straight to GameStudioWindow. +using System.Threading.Tasks; +using Stride.GameStudio.AutoTesting; + +namespace Stride.Editor.Tests; + +[UITest(SampleTemplateId = "A363FBC5-89EF-4E7A-B870-6D070813D034")] +public class TopDownLoad : IUITest +{ + public async Task Run(IUITestContext ctx) + { + if (!await ctx.WaitForWindow("GameStudioWindow", timeoutSeconds: 180)) + { + ctx.Exit(1); + return; + } + await ctx.SetWindowSize("GameStudioWindow", 2560, 1440); + await ctx.WaitIdle(); + + await ctx.Screenshot("main"); + + ctx.Exit(); + } +} From ad59bedd451eb2538aec35ed0db7aa22539f76d2 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 13:42:03 +0900 Subject: [PATCH 4/9] ci/editor: editor screenshot workflow + virtual display actions test-windows-editor.yml runs the four EditorScreenshotTests fixtures (EmptyEditor, NewGameEditor, TopDownCreate, TopDownLoad) and uploads the captured PNGs + diag logs. Trigger is daily cron + workflow_dispatch (matches test-samples-screenshots), offset to avoid runner contention. set-display-resolution-vdd installs the IDD-based MttVDD driver via NefCon and disables Hyper-V Video so the virtual display is the only attached output (reaches 2560x1600). set-display-resolution is the simpler Set-DisplayResolution variant for workflows that only need <=1920x1080. --- .../set-display-resolution-vdd/action.yml | 135 ++++++++++++++++++ .../actions/set-display-resolution/action.yml | 27 ++++ .github/actions/set-monitor-dpi/action.yml | 97 +++++++++++++ .github/workflows/test-windows-editor.yml | 123 ++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 .github/actions/set-display-resolution-vdd/action.yml create mode 100644 .github/actions/set-display-resolution/action.yml create mode 100644 .github/actions/set-monitor-dpi/action.yml create mode 100644 .github/workflows/test-windows-editor.yml diff --git a/.github/actions/set-display-resolution-vdd/action.yml b/.github/actions/set-display-resolution-vdd/action.yml new file mode 100644 index 0000000000..f76d295076 --- /dev/null +++ b/.github/actions/set-display-resolution-vdd/action.yml @@ -0,0 +1,135 @@ +name: Set up display resolution (via MttVDD virtual display driver) +description: >- + Install VirtualDrivers/Virtual-Display-Driver (IDD-based) silently via NefCon, + pre-write vdd_settings.xml so the driver spawns a single monitor at the + requested resolution, disable the Hyper-V Video adapter so MttVDD becomes the + primary display, and apply the requested per-monitor DPI scale via the shared + set-monitor-dpi sub-action. Use set-monitor-dpi directly later in the job to + switch DPI without re-installing VDD. + +inputs: + width: + description: Display width in pixels + required: false + default: '3840' + height: + description: Display height in pixels + required: false + default: '2160' + target-dpi: + description: >- + Initial per-monitor DPI scale percentage (100, 125, 150, 175, 200, 225, + 250, 300, …). Default 100. Reusable by calling ./.github/actions/set-monitor-dpi + to switch later in the same job. + required: false + default: '100' + +runs: + using: composite + steps: + # Settings must exist before the driver loads so the first device init reads our config. + - name: Pre-write vdd_settings.xml + shell: pwsh + env: + VDD_WIDTH: ${{ inputs.width }} + VDD_HEIGHT: ${{ inputs.height }} + run: | + $vddDir = "C:\VirtualDisplayDriver" + New-Item -ItemType Directory -Path $vddDir -Force | Out-Null + + $xml = @" + + + 1 + default + + 60 + + + + $($env:VDD_WIDTH) + $($env:VDD_HEIGHT) + 60 + + + + "@ + Set-Content -Path "$vddDir\vdd_settings.xml" -Value $xml -Encoding utf8 + Get-Content "$vddDir\vdd_settings.xml" + + # Adapted from VirtualDrivers/Virtual-Display-Driver Community Scripts/silent-install.ps1. + - name: Install Virtual Display Driver via NefCon + shell: pwsh + run: | + $tempDir = Join-Path $env:TEMP "VDDInstall" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + # NefCon (driver install tool — pnputil alternative that handles INF + Root devices) + $NefConURL = "https://github.com/nefarius/nefcon/releases/download/v1.14.0/nefcon_v1.14.0.zip" + $NefConZip = Join-Path $tempDir "nefcon.zip" + Write-Host "Downloading NefCon..." + Invoke-WebRequest -Uri $NefConURL -OutFile $NefConZip -UseBasicParsing + Expand-Archive -Path $NefConZip -DestinationPath $tempDir -Force + $NefConExe = Join-Path $tempDir "x64\nefconw.exe" + + # VDD driver (signed via SignPath) + $DriverURL = "https://github.com/VirtualDrivers/Virtual-Display-Driver/releases/download/25.7.23/VirtualDisplayDriver-x86.Driver.Only.zip" + $driverZip = Join-Path $tempDir "driver.zip" + Write-Host "Downloading VDD driver..." + Invoke-WebRequest -Uri $DriverURL -OutFile $driverZip -UseBasicParsing + Expand-Archive -Path $driverZip -DestinationPath $tempDir -Force + + # Trust the driver's signing certificate so Windows accepts the install without prompting. + $catFile = Join-Path $tempDir "VirtualDisplayDriver\mttvdd.cat" + $catBytes = [System.IO.File]::ReadAllBytes($catFile) + $certificates = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection + $certificates.Import($catBytes) + $certsFolder = Join-Path $tempDir "ExportedCerts" + New-Item -ItemType Directory -Path $certsFolder -Force | Out-Null + foreach ($cert in $certificates) { + $certPath = Join-Path $certsFolder "$($cert.Thumbprint).cer" + [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + Import-Certificate -FilePath $certPath -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" | Out-Null + } + + Write-Host "Installing VDD driver..." + Push-Location $tempDir + & $NefConExe install ".\VirtualDisplayDriver\MttVDD.inf" "Root\MttVDD" + Pop-Location + Start-Sleep -Seconds 15 + Write-Host "After install:" + Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution + Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue | Format-Table FriendlyName, Status + + # DPI before Hyper-V disable so VDD inherits the scale and we skip a re-scale event. + # No PnP cycle: the upcoming Hyper-V disable is itself a display-config-change event + # which causes Windows to read PerMonitorSettings. + - name: Apply initial per-monitor DPI scale + uses: ./.github/actions/set-monitor-dpi + with: + target-dpi: ${{ inputs.target-dpi }} + monitor-height: ${{ inputs.height }} + pnp-cycle: 'false' + + # MttVDD spawned a monitor at the requested resolution from vdd_settings.xml. Disable the + # Hyper-V Video adapter via PnP so MttVDD is the only display and inherits primary. + - name: Disable Hyper-V Video adapter (leave MttVDD as the only display) + shell: pwsh + run: | + Write-Host "Display adapters before disable:" + Get-PnpDevice -Class Display | Format-Table FriendlyName, Status, InstanceId + $hyperv = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -like "*Hyper-V*" } + if ($hyperv) { + foreach ($dev in $hyperv) { + Write-Host "Disabling: $($dev.FriendlyName) ($($dev.InstanceId))" + Disable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Continue + } + Start-Sleep -Seconds 5 + } else { + Write-Host "No Hyper-V Video adapter found — nothing to disable." + } + Write-Host "Display adapters after disable:" + Get-PnpDevice -Class Display | Format-Table FriendlyName, Status + Write-Host "Final display state:" + Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution + Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue | Format-Table FriendlyName, Status diff --git a/.github/actions/set-display-resolution/action.yml b/.github/actions/set-display-resolution/action.yml new file mode 100644 index 0000000000..c70b7ebdb6 --- /dev/null +++ b/.github/actions/set-display-resolution/action.yml @@ -0,0 +1,27 @@ +name: Set up display resolution +description: >- + Resize the runner's primary display via the built-in Set-DisplayResolution + cmdlet. The Hyper-V Video adapter caps at 1920x1080; for higher resolutions + use set-display-resolution-vdd (IDD virtual display). + +inputs: + width: + description: Display width in pixels (max 1920 on stock GH runners) + required: false + default: '1920' + height: + description: Display height in pixels (max 1080 on stock GH runners) + required: false + default: '1080' + +runs: + using: composite + steps: + - name: Set display resolution to ${{ inputs.width }}x${{ inputs.height }} + shell: pwsh + run: | + Write-Host "Before:" + Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution + Set-DisplayResolution -Width ${{ inputs.width }} -Height ${{ inputs.height }} -Force + Write-Host "After:" + Get-CimInstance Win32_VideoController | Format-Table Name, CurrentHorizontalResolution, CurrentVerticalResolution diff --git a/.github/actions/set-monitor-dpi/action.yml b/.github/actions/set-monitor-dpi/action.yml new file mode 100644 index 0000000000..539b758271 --- /dev/null +++ b/.github/actions/set-monitor-dpi/action.yml @@ -0,0 +1,97 @@ +name: Set per-monitor DPI scale +description: >- + Override the per-monitor DPI scaling for every display Windows knows about. Re-runnable — + call as many times as needed within a job to switch DPI between test sessions. + +inputs: + target-dpi: + description: >- + Target DPI scale percentage (100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500). + required: true + monitor-height: + description: >- + Physical height of the active monitor in pixels. Used to derive the scale Windows + considers "recommended" (Windows Server 2025 picks `recommended_pct = height/720*100` + snapped to the standard table for GH-hosted runners regardless of EDID). + required: true + pnp-cycle: + description: >- + PnP-cycle active displays after writing the registry. Required for Windows to re-read + PerMonitorSettings mid-session (registry-only change is otherwise cached). Set to + 'false' when this is the initial DPI setup right after a VDD install, since the + Hyper-V→VDD switch is already a display-config-change event that triggers the read. + required: false + default: 'true' + +runs: + using: composite + steps: + # Writes HKCU\Control Panel\Desktop\PerMonitorSettings\\DpiValue for each + # monitor enumerated under HKLM ScaleFactors. DpiValue is a signed-DWORD step offset from + # recommended; the standard scale table is { 100, 125, 150, 175, 200, 225, 250, 300, 350, + # 400, 450, 500 } percent. Applies live without session logoff via WM_SETTINGCHANGE. + - name: Apply per-monitor DPI scale (target ${{ inputs.target-dpi }}%) + shell: pwsh + env: + TARGET_DPI: ${{ inputs.target-dpi }} + MONITOR_HEIGHT: ${{ inputs.monitor-height }} + PNP_CYCLE: ${{ inputs.pnp-cycle }} + run: | + $scaleSteps = @(100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500) + function ClosestStepIdx($pct) { + $best = 0; $bestDist = [int]::MaxValue + for ($i = 0; $i -lt $scaleSteps.Count; $i++) { + $d = [Math]::Abs($scaleSteps[$i] - $pct) + if ($d -lt $bestDist) { $bestDist = $d; $best = $i } + } + return $best + } + + $target = [int]$env:TARGET_DPI + $height = [int]$env:MONITOR_HEIGHT + $recommendedPct = $height / 720.0 * 100 + $recommendedIdx = ClosestStepIdx $recommendedPct + $targetIdx = ClosestStepIdx $target + $offset = $targetIdx - $recommendedIdx + $offsetDword = if ($offset -lt 0) { 0x100000000 + $offset } else { [uint32]$offset } + Write-Host "monitor ${height}p → recommended ~$([int]$recommendedPct)% (idx $recommendedIdx); target $target% (idx $targetIdx); offset=$offset (DWORD 0x$('{0:X8}' -f $offsetDword))" + + $pms = "HKCU:\Control Panel\Desktop\PerMonitorSettings" + if (-not (Test-Path $pms)) { New-Item -Path $pms -Force | Out-Null } + $sf = "HKLM:\System\CurrentControlSet\Control\GraphicsDrivers\ScaleFactors" + if (-not (Test-Path $sf)) { + Write-Host "HKLM ScaleFactors path not present (no monitors registered) — DPI override skipped." + return + } + foreach ($m in Get-ChildItem -Path $sf) { + $sub = Join-Path $pms $m.PSChildName + if (-not (Test-Path $sub)) { New-Item -Path $sub -Force | Out-Null } + Set-ItemProperty -Path $sub -Name "DpiValue" -Value $offsetDword -Type DWord -Force + Write-Host " $($m.PSChildName) → DpiValue=$offset" + } + + # PowerShell here-string `'@` at column 0 conflicts with YAML block-scalar indentation — + # build the C# source as a string array joined with newlines instead. + $cs = @( + 'using System;', + 'using System.Runtime.InteropServices;', + 'public static class P {', + ' [DllImport("user32.dll")]', + ' public static extern int SendMessageTimeout(IntPtr h, uint m, IntPtr w, IntPtr l, uint f, uint t, out IntPtr r);', + '}' + ) -join "`n" + Add-Type -TypeDefinition $cs + $r = [IntPtr]::Zero + [P]::SendMessageTimeout([IntPtr]0xFFFF, 0x001A, [IntPtr]::Zero, [IntPtr]::Zero, 2, 5000, [ref]$r) | Out-Null + Start-Sleep -Seconds 2 + + # PnP-cycle active displays so Windows re-reads PerMonitorSettings (registry-only + # change is otherwise cached after the first display config change of the session). + if ($env:PNP_CYCLE -eq 'true') { + $active = Get-PnpDevice -Class Display | Where-Object { $_.Status -eq 'OK' } + foreach ($d in $active) { Disable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction Continue } + Start-Sleep -Seconds 2 + foreach ($d in $active) { Enable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction Continue } + Start-Sleep -Seconds 5 + Write-Host "PnP-cycled $($active.Count) display device(s)" + } diff --git a/.github/workflows/test-windows-editor.yml b/.github/workflows/test-windows-editor.yml new file mode 100644 index 0000000000..4d8df6873f --- /dev/null +++ b/.github/workflows/test-windows-editor.yml @@ -0,0 +1,123 @@ +name: Test GameStudio (Editor Screenshots) + +on: + workflow_dispatch: + inputs: + build-type: + description: Build + default: Debug + type: choice + options: + - Debug + - Release + schedule: + # Daily at 05:17 UTC — offset off top-of-hour to avoid GitHub Actions cron load spikes; + # gap from test-samples-screenshots (04:37) so they don't compete for runners. + - cron: '17 5 * * *' + +concurrency: + group: test-windows-editor-${{ github.ref }} + cancel-in-progress: true + +jobs: + Run: + name: Editor screenshots (${{ github.event.inputs.build-type || 'Debug' }}) + runs-on: windows-2025-vs2026 + env: + DOTNET_DbgEnableMiniDump: "1" + DOTNET_DbgMiniDumpType: "1" + DOTNET_DbgMiniDumpName: "${{ github.workspace }}\\crash-dumps\\dotnet_%p.dmp" + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Configure crash dumps + shell: pwsh + run: | + reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting" /v DontShowUI /t REG_DWORD /d 1 /f + $dumpDir = "${{ github.workspace }}\crash-dumps" + New-Item -Path $dumpDir -ItemType Directory -Force | Out-Null + reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpFolder /t REG_EXPAND_SZ /d $dumpDir /f + reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpType /t REG_DWORD /d 1 /f + reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpCount /t REG_DWORD /d 10 /f + + # nuget.config's `stride-local` source points at bin/packages, which doesn't exist on a + # fresh checkout (auto-pack-deploy populates it on first build); pre-create it empty. + - name: Pre-create bin/packages so restore doesn't fail on missing local source + shell: pwsh + run: New-Item -ItemType Directory -Path bin/packages -Force | Out-Null + + # PackageStore.LoadDefaultSettings(null) only reads the user + machine NuGet configs, + # not the workspace nuget.config — register bin/packages in the user config. + - name: Register bin/packages with user NuGet config + shell: pwsh + run: | + New-Item -Path "$env:APPDATA\NuGet" -ItemType Directory -Force | Out-Null + dotnet nuget add source "${{ github.workspace }}\bin\packages" --name stride-local --configfile "$env:APPDATA\NuGet\NuGet.Config" + + # GH runners default to a 0x0 fallback display; WPF clamps the editor to that. The + # composite action installs an IDD virtual display so we get a real desktop surface. + - name: Set display resolution + uses: ./.github/actions/set-display-resolution-vdd + with: + width: '3840' + height: '2160' + + - name: Build Stride.GameStudio.AutoTesting + run: | + dotnet build sources\editor\Stride.GameStudio.AutoTesting\Stride.GameStudio.AutoTesting.csproj ` + -nr:false -v:m -p:WarningLevel=0 ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} + + - name: Build Stride.Editor.Tests + run: | + dotnet build tests\editor\Stride.Editor.Tests.csproj ` + -nr:false -v:m -p:WarningLevel=0 ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} + + # EditorScreenshotTests.Capture is an xunit [Theory] over Fixtures(); each entry spawns + # the AutoTesting CLI as a subprocess (per-fixture WPF singleton-state isolation), + # snapshots the runner output into /ui-test-out-dpi//, then runs + # ScreenshotComparator.Compare against tests/editor/baselines/dpi/. ANTHROPIC_API_KEY + # opts the comparator into Claude vision second-opinions on frames that exceed LPIPS — + # cost is bounded because Claude only fires on already-failing frames. + - name: Run editor screenshot tests + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + dotnet test tests\editor\Stride.Editor.Tests.csproj ` + --no-build ` + --filter "FullyQualifiedName~Stride.Editor.Tests.EditorScreenshotTests" ` + --logger "trx;LogFileName=editor-screenshots.trx" ` + --results-directory TestResults ` + -p:Configuration=${{ github.event.inputs.build-type || 'Debug' }} + + - name: Publish test report + if: always() + uses: phoenix-actions/test-reporting@v15 + with: + name: 'Editor screenshot regression' + path: TestResults/*.trx + reporter: dotnet-trx + output-to: step-summary + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: editor-screenshots + path: ui-test-out-dpi*/ + if-no-files-found: warn + + - name: Upload crash dumps + if: always() + uses: actions/upload-artifact@v4 + with: + name: editor-crash-dumps + path: crash-dumps/ + if-no-files-found: ignore From 43bf0c4872e53ce9922af4e1d2953a34ad0c89ae Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 18:28:27 +0900 Subject: [PATCH 5/9] comparator: ComparisonPrompt domain classes + Claude vision wiring Adds an overrideable record-based prompt builder (ComparisonPrompt with sealed Gameplay/Editor variants, each carrying its own tolerance and regression-trigger toggles plus a curated static Default). Threads the prompt through ClaudeVisionFallback.Compare and ScreenshotComparator's new defaultPrompt parameter. Existing samples flow keeps the gameplay preset as the default; editor flow can opt into EditorComparisonPrompt. --- .../ClaudeVisionFallback.cs | 29 +------ .../ComparisonPrompt.cs | 47 +++++++++++ .../EditorComparisonPrompt.cs | 77 +++++++++++++++++++ .../GameplayComparisonPrompt.cs | 62 +++++++++++++++ .../ScreenshotComparator.cs | 6 +- 5 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 sources/tests/Stride.ScreenshotComparator/ComparisonPrompt.cs create mode 100644 sources/tests/Stride.ScreenshotComparator/EditorComparisonPrompt.cs create mode 100644 sources/tests/Stride.ScreenshotComparator/GameplayComparisonPrompt.cs diff --git a/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs index 4e913dea74..f671597069 100644 --- a/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs +++ b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs @@ -25,7 +25,7 @@ public static class ClaudeVisionFallback public readonly record struct Verdict(bool Pass, string Reason); - public static Verdict Compare(string baselinePath, string capturePath, string? extraHint) + public static Verdict Compare(string baselinePath, string capturePath, ComparisonPrompt prompt) { var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); if (string.IsNullOrEmpty(apiKey)) @@ -34,30 +34,7 @@ public static Verdict Compare(string baselinePath, string capturePath, string? e var baselineB64 = Convert.ToBase64String(File.ReadAllBytes(baselinePath)); var captureB64 = Convert.ToBase64String(File.ReadAllBytes(capturePath)); - var prompt = - "Compare these two Stride engine screenshots — BASELINE (expected) vs CAPTURE " + - "(this run). Both were produced by the same engine code; visible differences are " + - "almost always caused by test-harness timing nondeterminism (variable frame pacing " + - "across graphics APIs), NOT by a rendering regression. A rendering regression looks " + - "broken, not just 'different'. Be tolerant.\n\n" + - "YES (not a regression):\n" + - "- HUD numeric values differ (ammo, score, timer, FPS, health). This is gameplay state.\n" + - "- Character / weapon / camera pose, aim angle, or hand-bob phase differs. Animation phase.\n" + - "- Particle / smoke / fire / cloth / water / lighting / post-process noise differs.\n" + - "- Same overall scene with one element in a slightly different position or animation state.\n" + - "\n" + - "NO (real regression):\n" + - "- Whole-frame color / gamma / brightness shift (capture noticeably darker, desaturated, " + - "washed-out, or with wrong sRGB encoding).\n" + - "- Missing or corrupt geometry (broken meshes, distorted models, holes).\n" + - "- Missing or wrong textures (pink/purple checkerboard, all-black surfaces, wrong materials).\n" + - "- Missing post-process pass (no bloom / shadow / SSAO / tonemapping where baseline has them).\n" + - "- Different UI page, missing UI elements, wrong UI text labels (label text, not numeric values).\n" + - "- Wrong scene entirely (different level, different camera angle by 90°+, missing major objects).\n" + - "\n" + - "Format: \"YES: \" or \"NO: \"."; - if (!string.IsNullOrEmpty(extraHint)) - prompt += " Additional context for this specific frame: " + extraHint; + var promptText = prompt.Build(); var body = JsonSerializer.Serialize(new { @@ -75,7 +52,7 @@ public static Verdict Compare(string baselinePath, string capturePath, string? e new { type = "image", source = new { type = "base64", media_type = "image/png", data = baselineB64 } }, new { type = "text", text = "CAPTURE:" }, new { type = "image", source = new { type = "base64", media_type = "image/png", data = captureB64 } }, - new { type = "text", text = prompt }, + new { type = "text", text = promptText }, }, }, }, diff --git a/sources/tests/Stride.ScreenshotComparator/ComparisonPrompt.cs b/sources/tests/Stride.ScreenshotComparator/ComparisonPrompt.cs new file mode 100644 index 0000000000..97b7e25e16 --- /dev/null +++ b/sources/tests/Stride.ScreenshotComparator/ComparisonPrompt.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Text; + +namespace Stride.Tests.ScreenshotComparator; + +/// +/// Base for the Claude-vision comparison prompts. Subclasses (one per capture domain — gameplay, +/// editor UI) declare their own tolerance / regression-trigger bool properties and override +/// to compose the actual prompt string. The base supplies the shared +/// framing (BASELINE/CAPTURE intro, YES/NO format suffix, frame-level extra hint). +/// +public abstract record ComparisonPrompt +{ + /// Optional per-frame guidance appended to the prompt (e.g. "this frame includes a transient WorkProgress dialog"). + public string? ExtraHint { get; init; } + + /// + /// Composes the full prompt sent to Claude vision. > 1 reframes + /// the comparison as "is the capture consistent with the variance shown across N baselines?". + /// + public abstract string Build(int baselineCount = 1); + + /// One-line opener describing the comparison and its tolerance bias. + protected static string Intro(string domain, int baselineCount = 1) => + baselineCount <= 1 + ? $"Compare these two {domain} screenshots — BASELINE (expected) vs CAPTURE (this run). " + + "Both came from the same code; visible differences are typically harness-timing nondeterminism, " + + "NOT a regression. Be tolerant.\n\n" + : $"Compare CAPTURE against {baselineCount} {domain} BASELINES that show the acceptable variance " + + "for this frame. The capture is acceptable if its content falls within the range of variation " + + "demonstrated by the baselines (it does not have to match any single baseline exactly). Flag a " + + "regression only when the capture exhibits a quality / structural problem that NONE of the " + + "baselines show.\n\n"; + + /// YES/NO format directive plus optional per-frame hint. + protected string OutroWithHint() => + "\nFormat: \"YES: \" or \"NO: \"." + + (string.IsNullOrEmpty(ExtraHint) ? "" : " Frame context: " + ExtraHint); + + /// Appends "- {line}\n" to when is true. + protected static void AppendIf(StringBuilder sb, bool flag, string line) + { + if (flag) sb.Append("- ").Append(line).Append('\n'); + } +} diff --git a/sources/tests/Stride.ScreenshotComparator/EditorComparisonPrompt.cs b/sources/tests/Stride.ScreenshotComparator/EditorComparisonPrompt.cs new file mode 100644 index 0000000000..7e997d37de --- /dev/null +++ b/sources/tests/Stride.ScreenshotComparator/EditorComparisonPrompt.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Text; + +namespace Stride.Tests.ScreenshotComparator; + +/// +/// Prompt tuned for GameStudio editor UI captures (docked panels, dialogs, scene-document floats). +/// carries the curated preset used by editor screenshot tests; bare +/// new() is a blank slate. Tweak via EditorComparisonPrompt.Default with { ... }. +/// +public sealed record EditorComparisonPrompt : ComparisonPrompt +{ + // Tolerances — sources of false-positive drift in editor captures. + public bool TolerateBuildLogTimestamps { get; init; } + public bool TolerateAssetCounts { get; init; } + public bool TolerateScenePreviewDrift { get; init; } + public bool TolerateFontRasterization { get; init; } + public bool TolerateThumbnailRenderTiming { get; init; } + public bool TolerateScrollPosition { get; init; } + public bool TolerateSelectionHighlight { get; init; } + + // Regression triggers — visible differences that ARE a real regression. + public bool RegressionOnBlankPanel { get; init; } + public bool RegressionOnExceptionDialog { get; init; } + public bool RegressionOnBrokenLayout { get; init; } + public bool RegressionOnMissingControls { get; init; } + public bool RegressionOnWrongLabels { get; init; } + public bool RegressionOnBrokenScenePreview { get; init; } + public bool RegressionOnThemeColorShift { get; init; } + public bool RegressionOnBuildLogErrors { get; init; } + + /// Curated preset for editor screenshot tests. + public static readonly EditorComparisonPrompt Default = new() + { + TolerateBuildLogTimestamps = true, + TolerateAssetCounts = true, + TolerateScenePreviewDrift = true, + TolerateFontRasterization = true, + TolerateThumbnailRenderTiming = true, + TolerateScrollPosition = true, + TolerateSelectionHighlight = true, + RegressionOnBlankPanel = true, + RegressionOnExceptionDialog = true, + RegressionOnBrokenLayout = true, + RegressionOnMissingControls = true, + RegressionOnWrongLabels = true, + RegressionOnBrokenScenePreview = true, + RegressionOnThemeColorShift = true, + RegressionOnBuildLogErrors = true, + }; + + public override string Build(int baselineCount = 1) + { + var sb = new StringBuilder(Intro("Stride GameStudio editor UI", baselineCount)); + sb.Append("YES (not a regression):\n"); + AppendIf(sb, TolerateBuildLogTimestamps, "Timestamps / elapsed-time strings / dates in build or output logs."); + AppendIf(sb, TolerateAssetCounts, "Asset, file, or item counts differ slightly (template content drift between runs)."); + AppendIf(sb, TolerateScenePreviewDrift, "Embedded 3D scene viewport content differs (camera angle, lighting, frame timing — nondeterministic)."); + AppendIf(sb, TolerateFontRasterization, "Sub-pixel font rasterization differences (ClearType, theme variations)."); + AppendIf(sb, TolerateThumbnailRenderTiming, "Asset-thumbnail loading state (rendered icon vs placeholder vs spinner)."); + AppendIf(sb, TolerateScrollPosition, "Scroll position / first-visible-item differs in lists or trees."); + AppendIf(sb, TolerateSelectionHighlight, "Selection / hover / focus highlight on a different item."); + sb.Append("\nNO (real regression):\n"); + AppendIf(sb, RegressionOnBlankPanel, "Panel is blank / black / shows only chrome with no content."); + AppendIf(sb, RegressionOnExceptionDialog, "An error / exception / crash dialog is visible."); + AppendIf(sb, RegressionOnBrokenLayout, "Broken layout (panels overlapping, controls clipped, docking glitches, content overflowing chrome)."); + AppendIf(sb, RegressionOnMissingControls, "Missing UI controls (toolbar buttons, menu items, tab headers, treeview nodes)."); + AppendIf(sb, RegressionOnWrongLabels, "Wrong UI text labels (button captions, panel titles, menu item names — not numeric values)."); + AppendIf(sb, RegressionOnBrokenScenePreview, "Embedded scene preview is BROKEN (pink-checker, all-black, distorted, debug-error overlay) — distinct from normal viewport drift."); + AppendIf(sb, RegressionOnThemeColorShift, "Whole-window color / theme shift (light vs dark theme, wrong accent color throughout)."); + AppendIf(sb, RegressionOnBuildLogErrors, "Build/output log shows error or warning lines (color-coded red/yellow vs the normal info-level color) — but timestamps and elapsed-time numbers in those lines are still tolerated."); + sb.Append(OutroWithHint()); + return sb.ToString(); + } +} diff --git a/sources/tests/Stride.ScreenshotComparator/GameplayComparisonPrompt.cs b/sources/tests/Stride.ScreenshotComparator/GameplayComparisonPrompt.cs new file mode 100644 index 0000000000..b380f5e43c --- /dev/null +++ b/sources/tests/Stride.ScreenshotComparator/GameplayComparisonPrompt.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Text; + +namespace Stride.Tests.ScreenshotComparator; + +/// +/// Prompt tuned for in-game (rendered scene) captures. carries the curated +/// preset used by sample screenshot tests; bare new() is a blank slate. Tweak via +/// GameplayComparisonPrompt.Default with { ... }. +/// +public sealed record GameplayComparisonPrompt : ComparisonPrompt +{ + // Tolerances — sources of false-positive drift between runs. + public bool TolerateHudValues { get; init; } + public bool ToleratePoseAndCameraAngle { get; init; } + public bool TolerateAnimationPhase { get; init; } + public bool TolerateParticleAndPostFxNoise { get; init; } + + // Regression triggers — visible differences that ARE a real regression. + public bool RegressionOnColorShift { get; init; } + public bool RegressionOnMissingGeometry { get; init; } + public bool RegressionOnMissingTextures { get; init; } + public bool RegressionOnMissingPostProcess { get; init; } + public bool RegressionOnUiPageChange { get; init; } + public bool RegressionOnWrongScene { get; init; } + + /// Curated preset for sample screenshot tests. + public static readonly GameplayComparisonPrompt Default = new() + { + TolerateHudValues = true, + ToleratePoseAndCameraAngle = true, + TolerateAnimationPhase = true, + TolerateParticleAndPostFxNoise = true, + RegressionOnColorShift = true, + RegressionOnMissingGeometry = true, + RegressionOnMissingTextures = true, + RegressionOnMissingPostProcess = true, + RegressionOnUiPageChange = true, + RegressionOnWrongScene = true, + }; + + public override string Build(int baselineCount = 1) + { + var sb = new StringBuilder(Intro("Stride engine", baselineCount)); + sb.Append("YES (not a regression):\n"); + AppendIf(sb, TolerateHudValues, "HUD numeric values differ (ammo, score, timer, FPS, health). Gameplay state."); + AppendIf(sb, ToleratePoseAndCameraAngle, "Character / weapon / camera pose, aim angle, hand-bob phase differs. Animation phase."); + AppendIf(sb, TolerateAnimationPhase, "Animation phase / IK / skinning state differs."); + AppendIf(sb, TolerateParticleAndPostFxNoise, "Particle / smoke / fire / cloth / water / lighting / post-process noise differs."); + sb.Append("\nNO (real regression):\n"); + AppendIf(sb, RegressionOnColorShift, "Whole-frame color / gamma / brightness shift (capture noticeably darker, washed-out, wrong sRGB encoding)."); + AppendIf(sb, RegressionOnMissingGeometry, "Missing or corrupt geometry (broken meshes, distorted models, holes)."); + AppendIf(sb, RegressionOnMissingTextures, "Missing or wrong textures (pink/purple checkerboard, all-black surfaces, wrong materials)."); + AppendIf(sb, RegressionOnMissingPostProcess, "Missing post-process pass (no bloom / shadow / SSAO / tonemapping where baseline has them)."); + AppendIf(sb, RegressionOnUiPageChange, "Different UI page, missing UI elements, wrong UI text labels (label text, not numeric values)."); + AppendIf(sb, RegressionOnWrongScene, "Wrong scene entirely (different level, camera angle differs by 90°+, missing major objects)."); + sb.Append(OutroWithHint()); + return sb.ToString(); + } +} diff --git a/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs index f4fd56d44f..701fa20405 100644 --- a/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs +++ b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs @@ -39,8 +39,9 @@ public static class ScreenshotComparator /// the executing assembly's models/ sibling — works when the file is CopyToOutputDirectory'd /// into the consumer's bin). /// - public static List Compare(string newDir, string baselineDir, string? sampleFilter = null, float defaultThreshold = DefaultThreshold, string? modelPath = null) + public static List Compare(string newDir, string baselineDir, string? sampleFilter = null, float defaultThreshold = DefaultThreshold, string? modelPath = null, ComparisonPrompt? defaultPrompt = null) { + defaultPrompt ??= GameplayComparisonPrompt.Default; modelPath ??= Path.Combine(AppContext.BaseDirectory, "models", "lpips_alex.onnx"); if (!File.Exists(modelPath)) throw new FileNotFoundException($"LPIPS model not found at {modelPath}", modelPath); @@ -96,7 +97,8 @@ public static List Compare(string newDir, string baselineDir, if (meta.ClaudeFallbackEnabled) { - var verdict = ClaudeVisionFallback.Compare(baselinePng, newPng, meta.ClaudeFallbackHint); + var prompt = defaultPrompt with { ExtraHint = meta.ClaudeFallbackHint }; + var verdict = ClaudeVisionFallback.Compare(baselinePng, newPng, prompt); var detail = $"lpips drift; claude: {verdict.Reason}"; results.Add(new ComparisonResult(sample, frame, distance, frameThreshold, verdict.Pass ? "ok-via-claude" : "drift", detail)); From cd1cb6a90d642890ef86642eca9b6dcaac9d888d Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 18:31:34 +0900 Subject: [PATCH 6/9] comparator: multi-baseline-per-frame variants Adds support for committing multiple acceptable variants of a frame as '..png' siblings of the canonical '.png' baseline. LPIPS picks the closest match across the variant set (best-of); the Claude vision fallback receives all variants in one request and is asked whether the capture is consistent with the demonstrated variance range. Single-baseline path is unchanged. --- .../ClaudeVisionFallback.cs | 42 +++++++++++-------- .../ScreenshotComparator.cs | 37 ++++++++++++---- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs index f671597069..99cc38e92b 100644 --- a/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs +++ b/sources/tests/Stride.ScreenshotComparator/ClaudeVisionFallback.cs @@ -2,7 +2,9 @@ // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -10,10 +12,11 @@ namespace Stride.Tests.ScreenshotComparator; /// -/// Calls Claude Haiku 4.5 vision with the baseline + capture and asks "is this the same scene?". +/// Calls Claude Haiku 4.5 vision with the baseline(s) + capture and asks "is this the same scene?". /// Used as a second-opinion fallback when LPIPS is over threshold but the test opted into -/// claudeFallback. ANTHROPIC_API_KEY env var is required; if missing, the fallback fails -/// closed (returns Pass=false) so the regression sticks. +/// claudeFallback. When more than one baseline is provided they're framed as the +/// acceptable variance range for the frame. ANTHROPIC_API_KEY env var is required; if missing, +/// the fallback fails closed (returns Pass=false) so the regression sticks. /// public static class ClaudeVisionFallback { @@ -25,16 +28,32 @@ public static class ClaudeVisionFallback public readonly record struct Verdict(bool Pass, string Reason); + /// Single-baseline overload — back-compat shim around the multi-baseline form. public static Verdict Compare(string baselinePath, string capturePath, ComparisonPrompt prompt) + => Compare(new[] { baselinePath }, capturePath, prompt); + + public static Verdict Compare(IReadOnlyList baselinePaths, string capturePath, ComparisonPrompt prompt) { var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); if (string.IsNullOrEmpty(apiKey)) return new Verdict(false, "ANTHROPIC_API_KEY not set"); + if (baselinePaths.Count == 0) + return new Verdict(false, "no baselines provided"); - var baselineB64 = Convert.ToBase64String(File.ReadAllBytes(baselinePath)); var captureB64 = Convert.ToBase64String(File.ReadAllBytes(capturePath)); + var promptText = prompt.Build(baselinePaths.Count); - var promptText = prompt.Build(); + var content = new List(); + for (var i = 0; i < baselinePaths.Count; i++) + { + var label = baselinePaths.Count == 1 ? "BASELINE:" : $"BASELINE {i + 1} of {baselinePaths.Count}:"; + var b64 = Convert.ToBase64String(File.ReadAllBytes(baselinePaths[i])); + content.Add(new { type = "text", text = label }); + content.Add(new { type = "image", source = new { type = "base64", media_type = "image/png", data = b64 } }); + } + content.Add(new { type = "text", text = "CAPTURE:" }); + content.Add(new { type = "image", source = new { type = "base64", media_type = "image/png", data = captureB64 } }); + content.Add(new { type = "text", text = promptText }); var body = JsonSerializer.Serialize(new { @@ -43,18 +62,7 @@ public static Verdict Compare(string baselinePath, string capturePath, Compariso temperature = 0.0, messages = new[] { - new - { - role = "user", - content = new object[] - { - new { type = "text", text = "BASELINE:" }, - new { type = "image", source = new { type = "base64", media_type = "image/png", data = baselineB64 } }, - new { type = "text", text = "CAPTURE:" }, - new { type = "image", source = new { type = "base64", media_type = "image/png", data = captureB64 } }, - new { type = "text", text = promptText }, - }, - }, + new { role = "user", content = content.ToArray() }, }, }); diff --git a/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs index 701fa20405..8a7eee0d19 100644 --- a/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs +++ b/sources/tests/Stride.ScreenshotComparator/ScreenshotComparator.cs @@ -68,11 +68,11 @@ public static List Compare(string newDir, string baselineDir, foreach (var newPng in Directory.EnumerateFiles(screenshotsDir, "*.png")) { var frame = Path.GetFileNameWithoutExtension(newPng); - var baselinePng = Path.Combine(baselineDir, sample, frame + ".png"); + var baselines = FindBaselineVariants(Path.Combine(baselineDir, sample), frame); perFrameMetadata.TryGetValue(frame, out var meta); var frameThreshold = meta.Threshold ?? defaultThreshold; - if (!File.Exists(baselinePng)) + if (baselines.Count == 0) { results.Add(new ComparisonResult(sample, frame, null, frameThreshold, "new", "no baseline yet")); continue; @@ -81,7 +81,9 @@ public static List Compare(string newDir, string baselineDir, float distance; try { - distance = ComputeLpips(session, baselinePng, newPng); + // Multi-baseline: take the closest match — captures only need to align with ONE + // of the curated acceptable variants for LPIPS to pass. + distance = baselines.Min(b => ComputeLpips(session, b, newPng)); } catch (Exception ex) { @@ -98,8 +100,8 @@ public static List Compare(string newDir, string baselineDir, if (meta.ClaudeFallbackEnabled) { var prompt = defaultPrompt with { ExtraHint = meta.ClaudeFallbackHint }; - var verdict = ClaudeVisionFallback.Compare(baselinePng, newPng, prompt); - var detail = $"lpips drift; claude: {verdict.Reason}"; + var verdict = ClaudeVisionFallback.Compare(baselines, newPng, prompt); + var detail = $"lpips drift (vs {baselines.Count} baseline(s)); claude: {verdict.Reason}"; results.Add(new ComparisonResult(sample, frame, distance, frameThreshold, verdict.Pass ? "ok-via-claude" : "drift", detail)); continue; @@ -110,6 +112,8 @@ public static List Compare(string newDir, string baselineDir, } // Walk baselines that have no matching new capture (missing — capture probably failed). + // Variant files like "main.dark-scene.png" share the same frame name "main", so collapse + // them to a set of unique frame names before checking the capture dir. if (Directory.Exists(baselineDir)) { foreach (var sampleDir in Directory.EnumerateDirectories(baselineDir)) @@ -117,9 +121,11 @@ public static List Compare(string newDir, string baselineDir, var sample = Path.GetFileName(sampleDir); if (sampleFilter is not null && !string.Equals(sample, sampleFilter, StringComparison.OrdinalIgnoreCase)) continue; - foreach (var baselinePng in Directory.EnumerateFiles(sampleDir, "*.png")) + var frames = Directory.EnumerateFiles(sampleDir, "*.png") + .Select(p => Path.GetFileNameWithoutExtension(p).Split('.')[0]) + .Distinct(StringComparer.Ordinal); + foreach (var frame in frames) { - var frame = Path.GetFileNameWithoutExtension(baselinePng); var newPng = Path.Combine(newDir, sample, "screenshots", frame + ".png"); if (File.Exists(newPng)) continue; @@ -136,6 +142,23 @@ public static List Compare(string newDir, string baselineDir, return results; } + /// + /// Returns the set of acceptable baselines for in : + /// the canonical frame.png plus any variants named frame.<tag>.png. Empty list if + /// the sample directory or no matching files exist. + /// + private static List FindBaselineVariants(string sampleDir, string frame) + { + var list = new List(); + if (!Directory.Exists(sampleDir)) return list; + var canonical = Path.Combine(sampleDir, frame + ".png"); + if (File.Exists(canonical)) list.Add(canonical); + // ..png — Win32 glob treats the last "." segment as the extension. + foreach (var f in Directory.EnumerateFiles(sampleDir, frame + ".*.png")) + list.Add(f); + return list; + } + private static float ComputeLpips(InferenceSession session, string pathA, string pathB) { using var imgA = Image.Load(pathA); From 7886f76884d5f7b55bc45ad6ede6ebc6f8f14ce0 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Thu, 7 May 2026 18:31:47 +0900 Subject: [PATCH 7/9] editor/autotest: EditorScreenshotTests xunit orchestrator [Theory] driven from the in-DLL fixture list (EmptyEditor, NewGameEditor, TopDownCreate, TopDownLoad); each entry spawns Stride.GameStudio.AutoTesting.exe in a subprocess for WPF singleton-state isolation, snapshots the per-fixture output dir, then runs ScreenshotComparator.Compare against tests/editor/baselines/dpi100/ with EditorComparisonPrompt.Default. Adds xunit + Microsoft.NET.Test.Sdk package refs and the Stride.ScreenshotComparator project ref. --- tests/Directory.Build.props | 9 ++ tests/Directory.Packages.props | 3 + tests/editor/EditorScreenshotTests.cs | 157 ++++++++++++++++++++++++ tests/editor/Stride.Editor.Tests.csproj | 16 ++- tests/editor/TopDownCreate.cs | 7 +- 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 tests/Directory.Build.props create mode 100644 tests/Directory.Packages.props create mode 100644 tests/editor/EditorScreenshotTests.cs diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000000..3681f35343 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,9 @@ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../')) + + diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props new file mode 100644 index 0000000000..f0c2f63cc8 --- /dev/null +++ b/tests/Directory.Packages.props @@ -0,0 +1,3 @@ + + + diff --git a/tests/editor/EditorScreenshotTests.cs b/tests/editor/EditorScreenshotTests.cs new file mode 100644 index 0000000000..c31ee358af --- /dev/null +++ b/tests/editor/EditorScreenshotTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Stride.GameStudio.AutoTesting; +using Stride.Tests.ScreenshotComparator; +using Xunit; +using Xunit.Abstractions; + +namespace Stride.Editor.Tests; + +/// +/// xunit orchestrator for the GameStudio editor screenshot regression pipeline. Each [Theory] entry +/// spawns Stride.GameStudio.AutoTesting.exe in a subprocess (one fixture per process for WPF +/// singleton-state isolation), waits for it to exit, then runs +/// against committed baselines under tests/editor/baselines/dpi100/<fixture>/<frame>.png. +/// +[CollectionDefinition("EditorScreenshots", DisableParallelization = true)] +public class EditorScreenshotsCollection { } + +[Collection("EditorScreenshots")] +public class EditorScreenshotTests +{ + // Detect the runtime DPI so capture and baseline directories are labeled honestly. Both this + // test process and the AutoTesting runner subprocess call the same helper and converge on + // the same string, so the snapshot/copy paths line up. + private static readonly string Dpi = "dpi" + DpiUtil.DetectDpiPercent(); + + private readonly ITestOutputHelper output; + public EditorScreenshotTests(ITestOutputHelper output) => this.output = output; + + public static IEnumerable Fixtures() + { + // (fixtureName, optional .sln path relative to worktree, timeout-minutes) + yield return new object?[] { "EmptyEditor", null, 3 }; + yield return new object?[] { "TopDownCreate", null, 8 }; + yield return new object?[] { "TopDownLoad", "samples/Templates/TopDownRPG/TopDownRPG.sln", 5 }; + yield return new object?[] { "NewGameEditor", null, 5 }; + } + + [Theory] + [MemberData(nameof(Fixtures))] + public void Capture(string fixtureName, string? slnPathRel, int timeoutMin) + { + var worktree = WorktreeRoot(); + var captureRoot = Path.Combine(worktree, "ui-test-out-" + Dpi); + var fixtureCapture = Path.Combine(captureRoot, fixtureName); + if (Directory.Exists(fixtureCapture)) + Directory.Delete(fixtureCapture, recursive: true); + + var dll = typeof(EditorScreenshotTests).Assembly.Location; + var exe = ResolveAutoTestingExe(dll, worktree); + var args = new List { "--test-dll", dll, "--test-name", fixtureName }; + if (slnPathRel is not null) args.Add(Path.Combine(worktree, slnPathRel)); + + // Clean the runner-side output dir so stale files from a previous fixture invocation + // don't leak into this fixture's capture set. + var runnerOut = Path.Combine(Path.GetDirectoryName(dll)!, "ui-test-out-" + Dpi); + if (Directory.Exists(runnerOut)) Directory.Delete(runnerOut, recursive: true); + + var psi = new ProcessStartInfo(exe) + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WorkingDirectory = worktree, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi)!; + proc.OutputDataReceived += (_, e) => { if (e.Data != null) output.WriteLine($"[stdout] {e.Data}"); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) output.WriteLine($"[stderr] {e.Data}"); }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + if (!proc.WaitForExit(timeoutMin * 60_000)) + { + try { proc.Kill(); } catch { } + throw new TimeoutException($"{fixtureName} timed out after {timeoutMin}min"); + } + Assert.True(proc.ExitCode == 0, $"{fixtureName} exit={proc.ExitCode}"); + + // Snapshot per-fixture: the runner writes to /ui-test-out-dpi100/{screenshots, + // log.txt, done.json}; relocate that into /ui-test-out-dpi100// so the + // comparator can find //screenshots/.png. + if (!Directory.Exists(runnerOut)) + throw new DirectoryNotFoundException($"Runner output dir not found: {runnerOut}"); + Directory.CreateDirectory(fixtureCapture); + CopyAll(runnerOut, fixtureCapture); + + // Diag logs live in $TEMP and are overwritten by each fixture's runner — copy them into + // the per-fixture capture dir before the next [Theory] entry runs. + var temp = Path.GetTempPath(); + foreach (var diag in new[] { "autotest-diag.log", "gs-diag.log" }) + { + var src = Path.Combine(temp, diag); + if (File.Exists(src)) + { + try { File.Copy(src, Path.Combine(fixtureCapture, diag), overwrite: true); } catch { } + } + } + + // Compare against baselines. Filter to this fixture so the same captureRoot can host + // multiple fixtures' captures across test invocations. + var baselineDir = Path.Combine(worktree, "tests", "editor", "baselines", Dpi); + var results = ScreenshotComparator.Compare(captureRoot, baselineDir, + sampleFilter: fixtureName, defaultPrompt: EditorComparisonPrompt.Default); + + foreach (var r in results) + { + var lpips = r.Lpips.HasValue ? $"lpips={r.Lpips.Value:F4}" : ""; + output.WriteLine($"[compare] {r.Status,-12} {r.Frame,-25} thr={r.Threshold:F2} {lpips}{(r.Detail is null ? "" : " " + r.Detail)}"); + } + var failures = results.Where(r => r.Status is "drift" or "error" or "new").ToList(); + Assert.Empty(failures); + } + + private static string ResolveAutoTestingExe(string testDllPath, string worktree) + { + // tests\editor\bin\\\Stride.Editor.Tests.dll → mirror the cfg+tfm into the runner's + // sources\editor\Stride.GameStudio.AutoTesting\bin tree. + var dllDir = new DirectoryInfo(Path.GetDirectoryName(testDllPath)!); + var tfm = dllDir.Name; + var cfg = dllDir.Parent!.Name; + var path = Path.Combine(worktree, "sources", "editor", "Stride.GameStudio.AutoTesting", + "bin", cfg, tfm, "Stride.GameStudio.AutoTesting.exe"); + if (!File.Exists(path)) + throw new FileNotFoundException($"AutoTesting runner exe not found at: {path}", path); + return path; + } + + private static void CopyAll(string src, string dst) + { + Directory.CreateDirectory(dst); + foreach (var f in Directory.EnumerateFiles(src)) + File.Copy(f, Path.Combine(dst, Path.GetFileName(f)), overwrite: true); + foreach (var d in Directory.EnumerateDirectories(src)) + CopyAll(d, Path.Combine(dst, Path.GetFileName(d))); + } + + /// Walks up from cwd until it finds a NuGet.config — that's the worktree root. + private static string WorktreeRoot() + { + var dir = new DirectoryInfo(Environment.CurrentDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "NuGet.config")) || File.Exists(Path.Combine(dir.FullName, "nuget.config"))) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not locate worktree root from " + Environment.CurrentDirectory); + } +} diff --git a/tests/editor/Stride.Editor.Tests.csproj b/tests/editor/Stride.Editor.Tests.csproj index d4bbf09359..47498a982c 100644 --- a/tests/editor/Stride.Editor.Tests.csproj +++ b/tests/editor/Stride.Editor.Tests.csproj @@ -1,4 +1,5 @@ - + + + false true false + Direct3D11 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/editor/TopDownCreate.cs b/tests/editor/TopDownCreate.cs index b52028845f..9dfb743655 100644 --- a/tests/editor/TopDownCreate.cs +++ b/tests/editor/TopDownCreate.cs @@ -30,15 +30,12 @@ public async Task Run(IUITestContext ctx) } await ctx.WaitFrames(2); - // Sample templates flow: ProjectSelectionWindow.OK → TemplateSampleGenerator opens - // UpdatePlatformsWindow (Windows preselected) → project gen → GameStudioWindow. - // (NewGameTemplateGenerator's GameTemplateWindow is for blank-game templates, not samples.) + // Sample flow: ProjectSelectionWindow → UpdatePlatformsWindow → project gen → GameStudioWindow. if (!await ctx.CloseModalWithOk("ProjectSelectionWindow")) { ctx.Exit(1); return; } if (!await ctx.WaitForWindow("UpdatePlatformsWindow", timeoutSeconds: 30)) { ctx.Exit(1); return; } if (!await ctx.CloseModalWithOk("UpdatePlatformsWindow")) { ctx.Exit(1); return; } - // TopDownRPG generation pulls in more assets than NewGame (NavMesh, prefabs, scripts) — - // bigger window than the empty-game flow. Cap at 5 min to absorb cold NuGet restore on CI. + // TopDownRPG pulls in more assets than NewGame; cap at 5 min for cold NuGet restore on CI. if (!await ctx.WaitForWindow("GameStudioWindow", timeoutSeconds: 300)) { ctx.Exit(1); return; } await ctx.SetWindowSize("GameStudioWindow", 2560, 1440); await ctx.WaitIdle(); From 6ff99fa94b68c8c8cb53e73ffaeb8d01fcdac584 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Sat, 9 May 2026 13:50:28 +0900 Subject: [PATCH 8/9] editor/autotest: F5 + scene-mutation primitives in NewGameEditor New IUITestContext primitives: - RunProject / WaitForGameWindow / WaitForGameFrames / ScreenshotHwnd / CloseGameWindow: drive F5 from GameStudio and capture the launched game once WGC has delivered enough frames for post-effects to settle - AddAssetFromTemplate / QueueAssetPickerResponse / AddEntityToScene: invoke the same VM commands the user does (RunAssetTemplate, CreateEntityInRootCommand) plus drive intermediate dialogs NewGameEditor fixture adds a procedural capsule (with Sphere Material picked via the auto-resolved picker), drops it into the scene, runs the game, and captures four artefacts: new-game-editor, scene-with-capsule, game-running, build-log-after-run. GraphicsCaptureClient: factor shared OpenSession / StartCaptureAndNudge / CloseSession so CaptureToPngAsync and the new WaitForFramesAsync share the WGC plumbing. Caller attaches FrameArrived before StartCapture so frames produced during nudge don't slip past the unattached handler. DebuggingViewModel: tuple-return BuildProject(Core) so RunProjectAsync can surface the launched Process to the autotest path. UITestHost: typed access for AvalonDock panels (Dirkster.AvalonDock pkg ref), EditorGameController.IEditorGameAccess interface and AssetEditorsManager .EditorViewModels accessor (both via InternalsVisibleTo) replace the reflection-based EnumerateActiveGames + SearchTree / FloatAnchorable walks. SelectTemplate / AddAssetFromTemplate now take Guid; built-in templates referenced via {Type}TemplateGenerator.TemplateId. --- .gitignore | 1 + .../Services/EditorGameController.cs | 11 +- .../Properties/AssemblyInfo.cs | 3 + .../GraphicsCaptureClient.cs | 184 ++++++--- .../IUITestContext.cs | 54 ++- .../Stride.GameStudio.AutoTesting.csproj | 2 + .../UITestHost.cs | 355 +++++++++++++----- .../AssetsEditors/AssetEditorsManager.cs | 2 + sources/editor/Stride.GameStudio/Program.cs | 9 +- .../ViewModels/DebuggingViewModel.cs | 42 ++- tests/editor/NewGameEditor.cs | 28 +- tests/editor/TopDownCreate.cs | 7 +- 12 files changed, 510 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index 0e2b546ccf..74957e514d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ screenshots samplesGenerated screenshot-regression-out screenshot-out +ui-test-out-dpi* # Auto-generated by samples/Directory.Build.targets when -p:StrideAutoTesting=true is passed # (force-loads Stride.Games.AutoTesting at startup so its [ModuleInitializer] runs). _AutoTestingBootstrap.g.cs diff --git a/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs b/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs index 6a0e43af43..4e99658ace 100644 --- a/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs +++ b/sources/editor/Stride.Assets.Presentation/AssetEditors/GameEditor/Services/EditorGameController.cs @@ -40,7 +40,14 @@ namespace Stride.Assets.Presentation.AssetEditors.GameEditor.Services public delegate TEditorGame EditorGameFactory(TaskCompletionSource gameContentLoadedTaskSource, IEffectCompiler effectCompiler, string effectLogPath) where TEditorGame : EditorServiceGame; - public abstract partial class EditorGameController : IEditorGameController + /// Non-generic internal hook so external assemblies (test harness) can reach the + /// underlying without depending on the closed generic type. + internal interface IEditorGameAccess + { + EditorServiceGame EditorGame { get; } + } + + public abstract partial class EditorGameController : IEditorGameController, IEditorGameAccess where TEditorGame : EditorServiceGame { /// @@ -51,6 +58,8 @@ public abstract partial class EditorGameController : IEditorGameCon /// Gets the game associated with the scene editor. /// protected readonly TEditorGame Game; + + EditorServiceGame IEditorGameAccess.EditorGame => Game; /// /// The scene game thread. /// diff --git a/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs b/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs index e69801cead..0c1fdd0837 100644 --- a/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs +++ b/sources/editor/Stride.Assets.Presentation/Properties/AssemblyInfo.cs @@ -1,8 +1,11 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System.Reflection; +using System.Runtime.CompilerServices; using System.Windows; +[assembly: InternalsVisibleTo("Stride.GameStudio.AutoTesting")] + // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. diff --git a/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs index 74d29b210f..bb30fd3602 100644 --- a/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs +++ b/sources/editor/Stride.GameStudio.AutoTesting/GraphicsCaptureClient.cs @@ -121,19 +121,35 @@ private static T GetActivationFactory(string runtimeClassName) where T : clas finally { WindowsDeleteString(hstring); } } - public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) + /// Resources held open while a WGC capture session is active. Disposed via . + private sealed unsafe class CaptureSessionHandle + { + public GraphicsCaptureSession Session; + public Direct3D11CaptureFramePool FramePool; + public IntPtr DeviceAbi; + public IntPtr ContextAbi; + public IntPtr Hwnd; + public bool Promoted; + public long OrigExStyle; + public long OrigOwner; + } + + /// + /// Creates a GraphicsCaptureItem from the HWND (with WS_EX_APPWINDOW promotion fallback for + /// owned/transient windows), spins up a D3D11 device + framepool + session, and returns a + /// handle. The caller must attach its FrameArrived handler before calling + /// , then dispose via . + /// + private static unsafe CaptureSessionHandle OpenSession(IntPtr hwnd) { if (hwnd == IntPtr.Zero) throw new ArgumentException("HWND is zero.", nameof(hwnd)); - // 1. HWND → GraphicsCaptureItem via the activation factory's IGraphicsCaptureItemInterop. + // 1. HWND → GraphicsCaptureItem. WGC rejects owned/transient/no-AppWindow windows (e.g. + // AvalonDock floating panels) with E_INVALIDARG — promote temporarily by clearing owner + // and adding WS_EX_APPWINDOW; restore in CloseSession. var itemFactory = GetActivationFactory("Windows.Graphics.Capture.GraphicsCaptureItem"); var itemIid = IID_IGraphicsCaptureItem; var createHr = itemFactory.CreateForWindow(hwnd, ref itemIid, out var itemAbi); - - // WGC rejects owned/transient/no-AppWindow windows (e.g. AvalonDock floating panels) with - // E_INVALIDARG — see Microsoft's IsCapturableWindow sample. Promote temporarily: clear - // owner and add WS_EX_APPWINDOW. The window must stay promoted through capture, so we - // hand the original style/owner to the async helper to restore in its finally block. long origExStyle = 0, origOwner = 0; var promoted = false; if (createHr == E_INVALIDARG) @@ -160,7 +176,7 @@ public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) try { item = MarshalInterface.FromAbi(itemAbi); } finally { Marshal.Release(itemAbi); } - // 2. Create a D3D11 device. BGRA support is required for the WGC framepool. + // 2. D3D11 device (BGRA support required for WGC framepool). var d3d11 = D3D11.GetApi(null); ID3D11Device* devicePtr = null; ID3D11DeviceContext* contextPtr = null; @@ -186,13 +202,11 @@ public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) } finally { dxgiDevice->Release(); } - // 4. Framepool + session. CreateFreeThreaded dispatches FrameArrived on a threadpool - // thread; the regular Create requires a DispatcherQueue on the calling thread. + // 4. Framepool + session. CreateFreeThreaded dispatches FrameArrived on a threadpool thread. var size = item.Size; DiagLog($"item.Size={size.Width}x{size.Height} hwnd=0x{hwnd.ToInt64():X}"); if (size.Width <= 0 || size.Height <= 0) { - // Fall back to GetClientRect-derived size — happens if WPF hasn't fully laid out yet. if (GetClientRect(hwnd, out var rect)) { size.Width = rect.Right - rect.Left; @@ -200,7 +214,7 @@ public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) DiagLog($"WGC: fell back to GetClientRect → {size.Width}x{size.Height}"); } if (size.Width <= 0 || size.Height <= 0) - throw new InvalidOperationException($"GraphicsCaptureItem reports zero size and GetClientRect failed; window not yet realised."); + throw new InvalidOperationException("GraphicsCaptureItem reports zero size and GetClientRect failed; window not yet realised."); } var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded( graphicsDevice, @@ -215,7 +229,66 @@ public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) if (ApiInformation.IsPropertyPresent(GraphicsCaptureSessionType, "IsCursorCaptureEnabled")) session.IsCursorCaptureEnabled = false; + // Diagnostics: WS_EX_NOREDIRECTIONBITMAP excludes from DWM redirection (and so from WGC); + // WDA_EXCLUDEFROMCAPTURE opts out of capture entirely. + var exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + GetWindowDisplayAffinity(hwnd, out var affinity); + DiagLog($"Styles: exStyle=0x{exStyle:X} NoRedirBitmap={(exStyle & WS_EX_NOREDIRECTIONBITMAP) != 0} affinity=0x{affinity:X} ExcludeFromCapture={affinity == WDA_EXCLUDEFROMCAPTURE}"); + + // With numberOfBuffers=1, any frame produced before the FrameArrived handler is attached + // cycles through the pool without firing the event. Caller attaches its handler then + // calls StartCaptureAndNudge. + return new CaptureSessionHandle + { + Session = session, + FramePool = framePool, + DeviceAbi = (IntPtr)devicePtr, + ContextAbi = (IntPtr)contextPtr, + Hwnd = hwnd, + Promoted = promoted, + OrigExStyle = origExStyle, + OrigOwner = origOwner, + }; + } + + /// + /// Starts a capture session opened via after the caller has attached + /// its FrameArrived handler. Nudges the window so DWM produces composition. + /// + private static void StartCaptureAndNudge(CaptureSessionHandle handle) + { + // WGC only delivers FrameArrived when DWM presents new composition; nudge the window. + var fg = SetForegroundWindow(handle.Hwnd); + var sw = ShowWindow(handle.Hwnd, SW_RESTORE); + var rd = RedrawWindow(handle.Hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW); + DiagLog($"Nudge: SetForegroundWindow={fg} ShowWindow={sw} RedrawWindow={rd}"); + + handle.Session.StartCapture(); + DiagLog("StartCapture returned"); + + try { DwmFlush(); } + catch (Exception ex) { DiagLog($"DwmFlush threw: {ex.Message}"); } + } + + private static void CloseSession(CaptureSessionHandle h) + { + h.Session?.Dispose(); + h.FramePool?.Dispose(); + ReleasePointer(h.ContextAbi); + ReleasePointer(h.DeviceAbi); + if (h.Promoted) + { + SetWindowLongPtrW(h.Hwnd, GWLP_HWNDPARENT, h.OrigOwner); + SetWindowLongPtrW(h.Hwnd, GWL_EXSTYLE, h.OrigExStyle); + } + } + + public static Task CaptureToPngAsync(IntPtr hwnd, string path) + { + var handle = OpenSession(hwnd); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var framePool = handle.FramePool; int frameCallbackCount = 0; Windows.Foundation.TypedEventHandler handler = null!; handler = (sender, _) => @@ -228,44 +301,53 @@ public static unsafe Task CaptureToPngAsync(IntPtr hwnd, string path) tcs.TrySetResult(frame); }; framePool.FrameArrived += handler; + StartCaptureAndNudge(handle); + return WaitAndEncodeAsync(tcs.Task, handle, path); + } - // Diagnostics: WS_EX_NOREDIRECTIONBITMAP excludes from DWM redirection (and so from WGC); - // WDA_EXCLUDEFROMCAPTURE opts out of capture entirely. - var exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); - GetWindowDisplayAffinity(hwnd, out var affinity); - DiagLog($"Styles: exStyle=0x{exStyle:X} NoRedirBitmap={(exStyle & WS_EX_NOREDIRECTIONBITMAP) != 0} affinity=0x{affinity:X} ExcludeFromCapture={affinity == WDA_EXCLUDEFROMCAPTURE}"); - - // WGC only delivers FrameArrived when DWM presents new composition; nudge the window so - // the first frame arrives. - var fg = SetForegroundWindow(hwnd); - var sw = ShowWindow(hwnd, SW_RESTORE); - var rd = RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW); - DiagLog($"Nudge: SetForegroundWindow={fg} ShowWindow={sw} RedrawWindow={rd}"); - - session.StartCapture(); - DiagLog("StartCapture returned"); + /// + /// Returns when at least FrameArrived events have fired AND + /// have elapsed since the first. + /// + public static async Task WaitForFramesAsync(IntPtr hwnd, int minFrames, double postFirstFrameDelaySeconds, double timeoutSeconds) + { + var handle = OpenSession(hwnd); + var framePool = handle.FramePool; + var firstFrameAt = DateTime.MinValue; + var count = 0; + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // DwmFlush blocks until DWM advances its composition — guarantees at least one frame - // pass that WGC can pick up. - try { DwmFlush(); DiagLog("DwmFlush returned"); } - catch (Exception ex) { DiagLog($"DwmFlush threw: {ex.Message}"); } + Windows.Foundation.TypedEventHandler handler = null!; + handler = (sender, _) => + { + using var f = sender.TryGetNextFrame(); + if (f is null) return; + var n = System.Threading.Interlocked.Increment(ref count); + if (n == 1) firstFrameAt = DateTime.UtcNow; + if (n >= minFrames && (DateTime.UtcNow - firstFrameAt).TotalSeconds >= postFirstFrameDelaySeconds) + { + framePool.FrameArrived -= handler; + done.TrySetResult(true); + } + }; + framePool.FrameArrived += handler; + StartCaptureAndNudge(handle); - // Hand off to the async helper as IntPtrs (managed pointers can't cross an await). - return WaitAndEncodeAsync(tcs.Task, (IntPtr)devicePtr, (IntPtr)contextPtr, framePool, session, path, - hwnd, promoted, origExStyle, origOwner); + try + { + var winner = await Task.WhenAny(done.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds))).ConfigureAwait(false); + if (winner != done.Task) + DiagLog($"WaitForFramesAsync: timed out after {timeoutSeconds}s with count={count} firstFrameAt={(firstFrameAt == DateTime.MinValue ? "(none)" : firstFrameAt.ToString("HH:mm:ss.fff"))}"); + else + DiagLog($"WaitForFramesAsync: completed with count={count} elapsedSinceFirstFrame={(DateTime.UtcNow - firstFrameAt).TotalSeconds:F2}s"); + } + finally + { + CloseSession(handle); + } } - private static async Task WaitAndEncodeAsync( - Task frameTask, - IntPtr deviceAbi, - IntPtr contextAbi, - Direct3D11CaptureFramePool framePool, - GraphicsCaptureSession session, - string path, - IntPtr hwnd, - bool promoted, - long origExStyle, - long origOwner) + private static async Task WaitAndEncodeAsync(Task frameTask, CaptureSessionHandle handle, string path) { try { @@ -273,19 +355,11 @@ private static async Task WaitAndEncodeAsync( if (winner != frameTask) throw new TimeoutException("WGC FrameArrived didn't fire within 10s; window may be occluded or DWM isn't compositing it."); using var frame = await frameTask.ConfigureAwait(false); - EncodeFrameToPng(frame, deviceAbi, contextAbi, path); + EncodeFrameToPng(frame, handle.DeviceAbi, handle.ContextAbi, path); } finally { - session.Dispose(); - framePool.Dispose(); - ReleasePointer(contextAbi); - ReleasePointer(deviceAbi); - if (promoted) - { - SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, origOwner); - SetWindowLongPtrW(hwnd, GWL_EXSTYLE, origExStyle); - } + CloseSession(handle); } } diff --git a/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs index 3b93f4ef39..37403ce60a 100644 --- a/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs +++ b/sources/editor/Stride.GameStudio.AutoTesting/IUITestContext.cs @@ -62,10 +62,10 @@ public interface IUITestContext Task WaitForWindow(string windowTypeName, double timeoutSeconds = 120); /// - /// Selects a template in the ProjectSelectionWindow by template GUID and returns true if found. + /// Selects a template in the ProjectSelectionWindow by template id and returns true if found. /// The dialog stays open; close it via . /// - Task SelectTemplate(string templateGuid); + Task SelectTemplate(Guid templateId); /// /// Closes a modal dialog with DialogResult.Ok (equivalent to clicking OK / Create). @@ -73,6 +73,56 @@ public interface IUITestContext /// Task CloseModalWithOk(string windowTypeName); + /// + /// Equivalent of pressing F5 in GameStudio: builds the current project and launches the resulting + /// .exe. Returns the launched process id (or -1 on failure). + /// + Task RunProject(); + + /// + /// Polls the process by id until its is + /// non-zero, then calls Process.WaitForInputIdle. Returns the HWND or IntPtr.Zero on + /// timeout / process exit. + /// + Task WaitForGameWindow(int pid, double timeoutSeconds = 60); + + /// + /// Returns when WGC has delivered frames AND + /// have elapsed since the first one (lets TAA-style + /// post-effects converge). Default timeout absorbs cold shader-cache builds. + /// + Task WaitForGameFrames(IntPtr hwnd, int minFrames = 100, double postFirstFrameDelaySeconds = 2.0, double timeoutSeconds = 90); + + /// Captures a specific HWND (e.g. a game window from a child process) to a PNG. + Task ScreenshotHwnd(IntPtr hwnd, string name); + + /// + /// Sends WM_CLOSE to the game window and waits for the process to exit. Force-kills if + /// the process doesn't exit within the timeout. + /// + Task CloseGameWindow(int pid, double timeoutSeconds = 30); + + /// + /// Invokes the same RunAssetTemplate path the asset-templates dialog uses on OK. Pass + /// to disambiguate when several templates share an Id (e.g. + /// procedural-model variants). Returns the created asset's id, or on + /// failure. + /// + Task AddAssetFromTemplate(Guid templateId, string templateName = null); + + /// + /// Registers a one-shot handler for the next AssetPickerWindow: selects the asset by + /// Name and confirms. Pass null to cancel the picker. + /// + Task QueueAssetPickerResponse(string assetName, double timeoutSeconds = 30); + + /// + /// Adds an entity to the open scene with a ModelComponent referencing + /// , at . Goes through + /// CreateEntityInRootCommand with a custom IEntityFactory. + /// + Task AddEntityToScene(string entityName, Guid modelAssetId, Stride.Core.Mathematics.Vector3 position); + /// Sets the process exit code and shuts the editor down. void Exit(int exitCode = 0); } diff --git a/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj index 0703976600..5cd4a9491f 100644 --- a/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj +++ b/sources/editor/Stride.GameStudio.AutoTesting/Stride.GameStudio.AutoTesting.csproj @@ -33,6 +33,8 @@ + + EnumerateActiveGames() if (previewSvc?.PreviewGame is { IsRunning: true } previewGame) list.Add(new WatchedGame(previewGame, previewGame.DrawTime?.FrameCount ?? 0)); - // 2) Each open asset editor's embedded game. Multiple scene/prefab/UI/sprite documents - // can be open simultaneously — collect each one's running game. - var aem = session.ServiceProvider.TryGet(); - if (aem is null) return list; - var assetEditorsField = aem.GetType().GetField("assetEditors", BindingFlags.NonPublic | BindingFlags.Instance); - if (assetEditorsField?.GetValue(aem) is not IDictionary assetEditors) return list; - foreach (var editorVm in assetEditors.Keys) + // 2) Each open asset editor's embedded game. + if (session.ServiceProvider.TryGet() is not AssetEditorsManager aem) return list; + foreach (var editorVm in aem.EditorViewModels) { - if (editorVm is null) continue; - try - { - // Walk declared-only because SceneEditorViewModel etc. shadow GameEditorViewModel.Controller - // with a more-derived return type — a flat GetProperty("Controller") triggers AmbiguousMatchException. - var controllerProp = FindMemberDeclaredOnly(editorVm.GetType(), t => t.GetProperty("Controller", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - if (controllerProp?.GetValue(editorVm) is not { } controller) continue; - var gameField = FindMemberDeclaredOnly(controller.GetType(), t => t.GetField("Game", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)); - if (gameField?.GetValue(controller) is EditorServiceGame game && game.IsRunning) - list.Add(new WatchedGame(game, game.DrawTime?.FrameCount ?? 0)); - } - catch (Exception ex) - { - host.Log($"WaitForRendering: skipping editor '{editorVm.GetType().Name}': {ex.GetType().Name}: {ex.Message}"); - } + if (editorVm is GameEditorViewModel { Controller: IEditorGameAccess access } && access.EditorGame is { IsRunning: true } game) + list.Add(new WatchedGame(game, game.DrawTime?.FrameCount ?? 0)); } return list; } - /// - /// Walks + base types, returning the first member found by . - /// Caller passes a DeclaredOnly lookup so each level is searched independently — sidesteps - /// AmbiguousMatchException from new-slot/shadowed members across the hierarchy. - /// - private static T? FindMemberDeclaredOnly(Type? t, Func lookup) where T : class - { - while (t is not null) - { - var hit = lookup(t); - if (hit is not null) return hit; - t = t.BaseType; - } - return null; - } - /// /// Returns when reads zero on two consecutive idle ticks. /// The two-tick rule absorbs the race where one drain seeds the queue from a follow-up. @@ -465,25 +443,23 @@ public async Task WaitForWindow(string windowTypeName, double timeoutSecon return false; } - public Task SelectTemplate(string templateGuid) => + public Task SelectTemplate(Guid templateId) => host.dispatcher.InvokeAsync(() => { - host.Log($"SelectTemplate: '{templateGuid}'"); + host.Log($"SelectTemplate: {templateId}"); var app = Application.Current; if (app is null) return false; var win = app.Windows.OfType().FirstOrDefault(); if (win is null) { host.Log("SelectTemplate: ProjectSelectionWindow not found"); return false; } var collection = win.Templates; if (collection is null) { host.Log("SelectTemplate: ProjectSelectionWindow.Templates is null"); return false; } - if (!Guid.TryParse(templateGuid, out var targetId)) - { host.Log($"SelectTemplate: '{templateGuid}' is not a valid GUID"); return false; } // Templates is the per-group filtered view; full set lives behind RootGroups. var candidates = collection.Templates .Concat(collection.RootGroups.SelectMany(g => g.GetTemplatesRecursively())) .ToList(); - var match = candidates.FirstOrDefault(t => t.Id == targetId); + var match = candidates.FirstOrDefault(t => t.Id == templateId); if (match is null) - { host.Log($"SelectTemplate: no template with Id={templateGuid} in {candidates.Count} candidates"); return false; } + { host.Log($"SelectTemplate: no template with Id={templateId} in {candidates.Count} candidates"); return false; } collection.SelectedTemplate = match; host.Log($"SelectTemplate: selected '{match.GetType().Name}' (Id={match.Id})"); return true; @@ -527,14 +503,13 @@ await host.dispatcher.InvokeAsync(() => win.SetCurrentValue(Window.LeftProperty, work.Left + Math.Max(0.0, (work.Width - w) / 2.0)); win.SetCurrentValue(Window.TopProperty, work.Top + Math.Max(0.0, (work.Height - h) / 2.0)); win.UpdateLayout(); - host.Log($"SetWindowSize: after — Width={win.Width} Height={win.Height} Actual={win.ActualWidth}x{win.ActualHeight} State={win.WindowState}"); }, DispatcherPriority.Render).Task.ConfigureAwait(false); public async Task CapturePanel(string idOrTitle, string name, int width = 1200, int height = 900) { host.Log($"CapturePanel: id='{idOrTitle}' name='{name}' size={width}x{height}"); var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png"); - object? anchorable = null; + LayoutAnchorable? anchorable = null; AnchorableState? originalState = null; try { @@ -603,80 +578,262 @@ public async Task CapturePanel(string idOrTitle, string name, int width = 1200, return null; } - /// Finds the FrameworkElement hosting an AvalonDock panel by ContentId. - private static FrameworkElement? FindPanelContent(string contentId) + /// Finds the AvalonDock with the matching ContentId. + private static LayoutAnchorable? FindAnchorable(string contentId) { var app = Application.Current; if (app is null) return null; foreach (var w in app.Windows.OfType()) { - var hit = SearchTree(w, contentId, returnElement: true) as FrameworkElement; + if (SearchTree(w, contentId, returnElement: false) is LayoutAnchorable hit) return hit; + } + return null; + } + + private static object? SearchTree(DependencyObject node, string idOrTitle, bool returnElement) + { + // Anchorables (panels) match by ContentId; documents (asset editors) typically have + // an empty ContentId and identify via Title (the asset URL). + if (node is FrameworkElement fe && fe.DataContext is LayoutContent lc + && (string.Equals(lc.ContentId, idOrTitle, StringComparison.Ordinal) + || string.Equals(lc.Title, idOrTitle, StringComparison.Ordinal))) + return returnElement ? fe : lc; + var count = VisualTreeHelper.GetChildrenCount(node); + for (var i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(node, i); + var hit = SearchTree(child, idOrTitle, returnElement); if (hit is not null) return hit; } return null; } - /// Finds the AvalonDock LayoutAnchorable (DataContext object with the matching ContentId). - private static object? FindAnchorable(string contentId) + private readonly record struct AnchorableState(bool WasAutoHidden, bool WasFloating, double OldFloatingWidth, double OldFloatingHeight); + + private static AnchorableState FloatAnchorable(LayoutAnchorable anchorable, int width, int height) + { + var state = new AnchorableState(anchorable.IsAutoHidden, anchorable.IsFloating, anchorable.FloatingWidth, anchorable.FloatingHeight); + // Auto-hidden panels must be expanded before Float() can move them; otherwise the + // anchorable stays parented to the auto-hide pane and Float() no-ops. + if (state.WasAutoHidden) anchorable.ToggleAutoHide(); + anchorable.FloatingWidth = width; + anchorable.FloatingHeight = height; + if (!state.WasFloating) anchorable.Float(); + return state; + } + + private static void RestoreAnchorable(LayoutAnchorable anchorable, AnchorableState state) + { + if (!state.WasFloating) anchorable.Dock(); + anchorable.FloatingWidth = state.OldFloatingWidth; + anchorable.FloatingHeight = state.OldFloatingHeight; + if (state.WasAutoHidden && !anchorable.IsAutoHidden) anchorable.ToggleAutoHide(); + } + + public Task RunProject() => + host.dispatcher.InvokeAsync(async () => + { + var debugging = TryGetDebugging(); + if (debugging is null) { host.Log("RunProject: GameStudioViewModel not found"); return -1; } + host.Log("RunProject: invoking RunProjectAsync"); + var (ok, proc) = await debugging.RunProjectAsync().ConfigureAwait(true); + if (!ok || proc is null) { host.Log("RunProject: failed"); return -1; } + host.Log($"RunProject: launched pid={proc.Id}"); + return proc.Id; + }).Task.Unwrap(); + + public async Task WaitForGameWindow(int pid, double timeoutSeconds = 60) + { + host.Log($"WaitForGameWindow: pid={pid} timeout={timeoutSeconds}s"); + Process proc; + try { proc = Process.GetProcessById(pid); } + catch (ArgumentException) { host.Log($"WaitForGameWindow: pid {pid} not found"); return IntPtr.Zero; } + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + if (proc.HasExited) { host.Log($"WaitForGameWindow: pid {pid} exited"); return IntPtr.Zero; } + proc.Refresh(); + var hwnd = proc.MainWindowHandle; + if (hwnd != IntPtr.Zero) + { + try { proc.WaitForInputIdle(2000); } catch { /* not a GUI app, or already idle */ } + host.Log($"WaitForGameWindow: hwnd=0x{hwnd.ToInt64():X}"); + return hwnd; + } + await Task.Delay(200).ConfigureAwait(false); + } + host.Log($"WaitForGameWindow: timed out after {timeoutSeconds}s"); + return IntPtr.Zero; + } + + public async Task WaitForGameFrames(IntPtr hwnd, int minFrames = 100, double postFirstFrameDelaySeconds = 2.0, double timeoutSeconds = 90) + { + host.Log($"WaitForGameFrames: hwnd=0x{hwnd.ToInt64():X} minFrames={minFrames} postFirstFrame={postFirstFrameDelaySeconds}s timeout={timeoutSeconds}s"); + await GraphicsCaptureClient.WaitForFramesAsync(hwnd, minFrames, postFirstFrameDelaySeconds, timeoutSeconds).ConfigureAwait(false); + } + + public async Task ScreenshotHwnd(IntPtr hwnd, string name) + { + if (hwnd == IntPtr.Zero) { host.Log($"ScreenshotHwnd('{name}'): hwnd is zero"); return; } + var path = Path.Combine(host.outputDir, ScreenshotsDir, name + ".png"); + try + { + await GraphicsCaptureClient.CaptureToPngAsync(hwnd, path).ConfigureAwait(false); + host.capturedNames.Add(name); + host.Log($"ScreenshotHwnd: wrote → {path}"); + } + catch (Exception ex) { host.Log($"ScreenshotHwnd('{name}') failed: {ex}"); } + } + + public async Task CloseGameWindow(int pid, double timeoutSeconds = 30) + { + host.Log($"CloseGameWindow: pid={pid} timeout={timeoutSeconds}s"); + Process proc; + try { proc = Process.GetProcessById(pid); } + catch (ArgumentException) { host.Log($"CloseGameWindow: pid {pid} not found"); return; } + if (proc.HasExited) { host.Log($"CloseGameWindow: pid {pid} already exited"); return; } + proc.Refresh(); + var hwnd = proc.MainWindowHandle; + if (hwnd != IntPtr.Zero) PostMessage(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + else host.Log("CloseGameWindow: no MainWindowHandle, will rely on Kill"); + if (!await Task.Run(() => proc.WaitForExit((int)(timeoutSeconds * 1000))).ConfigureAwait(false)) + { + host.Log("CloseGameWindow: WM_CLOSE timed out, killing"); + try { proc.Kill(entireProcessTree: true); } catch (Exception ex) { host.Log($"Kill failed: {ex.Message}"); } + } + // Process.ExitCode requires a handle the runtime retained from Start; pids opened via + // GetProcessById don't qualify. Just log exit. + host.Log($"CloseGameWindow: pid={pid} exited"); + } + + private const int WM_CLOSE = 0x0010; + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); + + private static DebuggingViewModel TryGetDebugging() { var app = Application.Current; if (app is null) return null; foreach (var w in app.Windows.OfType()) { - var hit = SearchTree(w, contentId, returnElement: false); - if (hit is not null) return hit; + if (w.DataContext is GameStudioViewModel gs) return gs.Debugging; } return null; } - private static object? SearchTree(DependencyObject node, string idOrTitle, bool returnElement) + public Task AddAssetFromTemplate(Guid templateId, string templateName = null) => + host.dispatcher.InvokeAsync(async () => + { + var session = TryGetSession(); + if (session is null) { host.Log("AddAssetFromTemplate: no session"); return Guid.Empty; } + // Procedural-model variants all share the generator's TemplateId; Name disambiguates. + var matches = session.FindTemplates(TemplateScope.Asset).Where(t => t.Id == templateId).ToList(); + var template = templateName is null + ? matches.FirstOrDefault() + : matches.FirstOrDefault(t => string.Equals(t.Name, templateName, StringComparison.Ordinal)); + if (template is null) + { + host.Log($"AddAssetFromTemplate: template id={templateId} name='{templateName ?? "*"}' not found ({matches.Count} sharing Id: {string.Join(",", matches.Select(t => t.Name))})"); + return Guid.Empty; + } + var assetView = session.ActiveAssetView; + if (assetView is null) { host.Log("AddAssetFromTemplate: ActiveAssetView is null"); return Guid.Empty; } + var templateVm = new TemplateDescriptionViewModel(session.ServiceProvider, template); + var created = await assetView.RunAssetTemplate(templateVm, null).ConfigureAwait(true); + if (created is null || created.Count == 0) { host.Log("AddAssetFromTemplate: RunAssetTemplate returned no asset"); return Guid.Empty; } + host.Log($"AddAssetFromTemplate: created '{created[0].Url}' (id={created[0].Id})"); + return (Guid)created[0].Id; + }).Task.Unwrap(); + + public async Task QueueAssetPickerResponse(string assetName, double timeoutSeconds = 30) { - if (node is FrameworkElement fe && fe.DataContext is { } dc) + host.Log($"QueueAssetPickerResponse: assetName='{assetName ?? ""}' timeout={timeoutSeconds}s"); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + // Poll for the AssetPickerWindow to appear, then resolve it on the dispatcher. + while (DateTime.UtcNow < deadline) { - var t = dc.GetType(); - // Anchorables (panels) match by ContentId; documents (asset editors) typically have - // an empty ContentId and identify via Title (the asset URL). - if (t.GetProperty("ContentId")?.GetValue(dc) is string cid && string.Equals(cid, idOrTitle, StringComparison.Ordinal)) - return returnElement ? fe : dc; - if (t.GetProperty("Title")?.GetValue(dc) is string title && string.Equals(title, idOrTitle, StringComparison.Ordinal)) - return returnElement ? fe : dc; + var resolved = await host.dispatcher.InvokeAsync(() => TryResolveAssetPicker(assetName)).Task.ConfigureAwait(false); + if (resolved) return; + await Task.Delay(150).ConfigureAwait(false); } - var count = VisualTreeHelper.GetChildrenCount(node); - for (var i = 0; i < count; i++) + host.Log($"QueueAssetPickerResponse: timed out — no AssetPickerWindow appeared within {timeoutSeconds}s"); + } + + private bool TryResolveAssetPicker(string assetName) + { + var picker = Application.Current?.Windows.OfType().FirstOrDefault(w => w.IsLoaded && w.IsVisible); + if (picker is null) return false; + if (assetName is null) { - var child = VisualTreeHelper.GetChild(node, i); - var hit = SearchTree(child, idOrTitle, returnElement); - if (hit is not null) return hit; + picker.RequestClose(DialogResult.Cancel); + host.Log("QueueAssetPickerResponse: cancelled picker"); + return true; } - return null; + var asset = picker.Session.AllAssets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.Ordinal)); + if (asset is null) + { + host.Log($"QueueAssetPickerResponse: asset '{assetName}' not found; cancelling"); + picker.RequestClose(DialogResult.Cancel); + return true; + } + picker.AssetView.SelectAssets(new[] { asset }); + picker.RequestClose(DialogResult.Ok); + host.Log($"QueueAssetPickerResponse: selected '{asset.Url}' (id={asset.Id}) and confirmed"); + return true; } - private readonly record struct AnchorableState(bool WasAutoHidden, bool WasFloating, double OldFloatingWidth, double OldFloatingHeight); + public Task AddEntityToScene(string entityName, Guid modelAssetId, Vector3 position) => + host.dispatcher.InvokeAsync(() => + { + var session = TryGetSession(); + if (session is null) { host.Log("AddEntityToScene: no session"); return false; } + var modelAsset = session.AllAssets.FirstOrDefault(a => (Guid)a.Id == modelAssetId); + if (modelAsset is null) { host.Log($"AddEntityToScene: model asset id={modelAssetId} not found"); return false; } + var sceneEditor = TryGetOpenSceneEditor(); + if (sceneEditor is null) { host.Log("AddEntityToScene: no SceneEditorViewModel open"); return false; } + var factory = new ModelEntityFactory(entityName, modelAsset.Id, modelAsset.Url, position); + sceneEditor.CreateEntityInRootCommand.Execute(factory); + host.Log($"AddEntityToScene: '{entityName}' factory dispatched (modelAsset='{modelAsset.Url}', position={position})"); + return true; + }).Task; - private static AnchorableState FloatAnchorable(object anchorable, int width, int height) - { - var t = anchorable.GetType(); - var wasAutoHidden = (bool?)t.GetProperty("IsAutoHidden")?.GetValue(anchorable) ?? false; - var wasFloating = (bool?)t.GetProperty("IsFloating")?.GetValue(anchorable) ?? false; - var oldFW = (double?)t.GetProperty("FloatingWidth")?.GetValue(anchorable) ?? 0; - var oldFH = (double?)t.GetProperty("FloatingHeight")?.GetValue(anchorable) ?? 0; - // Auto-hidden panels need to be expanded before Float() can move them, otherwise the - // anchorable is still parented to the auto-hide pane and the call no-ops. - if (wasAutoHidden) t.GetMethod("ToggleAutoHide")?.Invoke(anchorable, null); - t.GetProperty("FloatingWidth")?.SetValue(anchorable, (double)width); - t.GetProperty("FloatingHeight")?.SetValue(anchorable, (double)height); - if (!wasFloating) t.GetMethod("Float")?.Invoke(anchorable, null); - return new AnchorableState(wasAutoHidden, wasFloating, oldFW, oldFH); - } - - private static void RestoreAnchorable(object anchorable, AnchorableState state) - { - var t = anchorable.GetType(); - if (!state.WasFloating) t.GetMethod("Dock")?.Invoke(anchorable, null); - t.GetProperty("FloatingWidth")?.SetValue(anchorable, state.OldFloatingWidth); - t.GetProperty("FloatingHeight")?.SetValue(anchorable, state.OldFloatingHeight); - var nowAutoHidden = (bool?)t.GetProperty("IsAutoHidden")?.GetValue(anchorable) ?? false; - if (state.WasAutoHidden && !nowAutoHidden) t.GetMethod("ToggleAutoHide")?.Invoke(anchorable, null); + private static SceneEditorViewModel TryGetOpenSceneEditor() + { + var session = TryGetSession(); + if (session?.ServiceProvider.TryGet() is not AssetEditorsManager aem) return null; + return aem.EditorViewModels.OfType().FirstOrDefault(); + } + + /// + /// Entity with ModelComponent + transform pre-set. CreateEntityInRootCommand + /// preserves the factory-set position (its mouse-position branch is skipped). + /// + private sealed class ModelEntityFactory : IEntityFactory + { + private readonly string entityName; + private readonly Stride.Core.Assets.AssetId modelId; + private readonly string modelUrl; + private readonly Vector3 position; + + public ModelEntityFactory(string entityName, Stride.Core.Assets.AssetId modelId, string modelUrl, Vector3 position) + { + this.entityName = entityName; + this.modelId = modelId; + this.modelUrl = modelUrl; + this.position = position; + } + + public Task CreateEntity(EntityHierarchyItemViewModel parent) + { + var entity = new Entity(entityName); + entity.Transform.Position = position; + var modelRef = AttachedReferenceManager.CreateProxyObject(modelId, modelUrl); + entity.Add(new ModelComponent { Model = modelRef }); + return Task.FromResult(entity); + } } public void Exit(int newExitCode = 0) diff --git a/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs b/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs index c5ba02cd8c..eab9450655 100644 --- a/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs +++ b/sources/editor/Stride.GameStudio/AssetsEditors/AssetEditorsManager.cs @@ -31,6 +31,8 @@ internal sealed class AssetEditorsManager : IAssetEditorsManager, IDestroyable { private readonly ConditionalWeakTable registeredHandlers = []; private readonly Dictionary assetEditors = []; + + internal IEnumerable EditorViewModels => assetEditors.Keys; private readonly Dictionary openedAssets = []; // TODO have a base interface for all editors and factorize to make curve editor not be a special case anymore private Tuple curveEditor; diff --git a/sources/editor/Stride.GameStudio/Program.cs b/sources/editor/Stride.GameStudio/Program.cs index 28fef770e3..fcbb80cb0d 100644 --- a/sources/editor/Stride.GameStudio/Program.cs +++ b/sources/editor/Stride.GameStudio/Program.cs @@ -92,9 +92,7 @@ public static void Run(IList args, Action? appH } PrivacyPolicyHelper.RestartApplication = RestartApplication; - DiagLog("Calling EnsurePrivacyPolicyStride40"); PrivacyPolicyHelper.EnsurePrivacyPolicyStride40(); - DiagLog("EnsurePrivacyPolicyStride40 returned"); // We use MRU of the current version only when we're trying to reload last session. var mru = new MostRecentlyUsedFileCollection(InternalSettings.LoadProfileCopy, InternalSettings.MostRecentlyUsedSessions, InternalSettings.WriteFile); @@ -102,7 +100,6 @@ public static void Run(IList args, Action? appH EditorSettings.Initialize(); Thread.CurrentThread.Name = "Main thread"; - DiagLog("EditorSettings initialized"); // Install Metrics for the editor using (StrideGameStudio.MetricsClient = EditorSettings.EnableMetrics.GetValue() ? new MetricsClient(CommonApps.StrideEditorAppId) : null) @@ -159,7 +156,6 @@ public static void Run(IList args, Action? appH } } RuntimeHelpers.RunModuleConstructor(typeof(Asset).Module.ModuleHandle); - DiagLog("Asset module constructor ran"); //listen to logger for crash report GlobalLogger.GlobalMessageLogged += GlobalLoggerOnGlobalMessageLogged; @@ -169,7 +165,6 @@ public static void Run(IList args, Action? appH using (new WindowManager(mainDispatcher)) { - DiagLog("WindowManager created, constructing App"); app = new App { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.Activated += (sender, eventArgs) => { @@ -180,11 +175,9 @@ public static void Run(IList args, Action? appH StrideGameStudio.MetricsClient?.SetActiveState(false); }; - DiagLog("Calling app.InitializeComponent"); app.InitializeComponent(); - DiagLog("InitializeComponent returned, invoking appHosted"); appHosted?.Invoke(app, mainDispatcher); - DiagLog("appHosted returned, calling app.Run"); + DiagLog("calling app.Run"); app.Run(); DiagLog("app.Run returned"); } diff --git a/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs b/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs index 7fc96cc4f2..4cfafc71e9 100644 --- a/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs +++ b/sources/editor/Stride.GameStudio/ViewModels/DebuggingViewModel.cs @@ -144,6 +144,8 @@ public DebuggingViewModel(GameStudioViewModel editor, IDebugService debugService [NotNull] public ICommandBase ResetOutputTitleCommand { get; } + internal Task<(bool Success, Process Process)> RunProjectAsync() => BuildProject(true); + /// public override void Destroy() { @@ -340,7 +342,7 @@ await ServiceProvider.Get() try { // Build projects+assets (note: assets only would be enough) - if (!await BuildProjectCore(false)) + if (!(await BuildProjectCore(false)).Success) { return false; } @@ -356,19 +358,19 @@ await ServiceProvider.Get() } } - private async Task BuildProject(bool startProject) + private async Task<(bool Success, Process Process)> BuildProject(bool startProject) { if (BuildInProgress) - return false; + return (false, null); try { BuildInProgress = true; if (!await PrepareBuild()) - return false; + return (false, null); var jobToken = editor.Status.NotifyBackgroundJobStarted("Building...", JobPriority.Compile); - var result = false; + var result = (Success: false, Process: (Process)null); try { return result = await BuildProjectCore(startProject); @@ -376,7 +378,7 @@ private async Task BuildProject(bool startProject) finally { editor.Status.NotifyBackgroundJobFinished(jobToken); - editor.Status.PushDiscardableStatus(result ? "Build successful" : "Build failed"); + editor.Status.PushDiscardableStatus(result.Success ? "Build successful" : "Build failed"); } } finally @@ -391,8 +393,9 @@ private void RegisterBuildLogger(LoggerResult logger) logger.MessageLogged += (sender, e) => Dispatcher.InvokeAsync(() => OutputTitle = outputTitleBase + '*'); } - private async Task BuildProjectCore(bool startProject) + private async Task<(bool Success, Process Process)> BuildProjectCore(bool startProject) { + Process startedProcess = null; var logger = new LoggerResult(); RegisterBuildLogger(logger); @@ -433,7 +436,7 @@ private async Task BuildProjectCore(bool startProject) if (androidDevices.Length == 0) { logger.Error(Tr._p("Message", "No Android device found for execution.")); - return false; + return (false, null); } // On Android, directly install on device @@ -463,14 +466,14 @@ private async Task BuildProjectCore(bool startProject) default: logger.Error(string.Format(Tr._p("Message", "Platform {0} isn't supported for execution."), Session.CurrentProject.Platform)); - return false; + return (false, null); } } if (projectViewModel == null) { logger.Error(string.Format(Tr._p("Message", "Platform {0} isn't supported for execution."), Session.CurrentProject.Platform != PlatformType.Shared ? Session.CurrentProject.Platform : PlatformType.Windows)); - return false; + return (false, null); } // Build project @@ -478,7 +481,7 @@ private async Task BuildProjectCore(bool startProject) if (currentBuild == null) { logger.Error(string.Format(Tr._p("Message", "Unable to load and compile project {0}"), projectViewModel.ProjectPath)); - return false; + return (false, null); } var assemblyPath = currentBuild.AssemblyPath; @@ -499,7 +502,7 @@ private async Task BuildProjectCore(bool startProject) if (!File.Exists(assemblyPath)) { logger.Error(string.Format(Tr._p("Message", "Unable to reach to output executable: {0}"), assemblyPath)); - return false; + return (false, null); } var process = new Process { @@ -509,6 +512,7 @@ private async Task BuildProjectCore(bool startProject) } }; process.Start(); + startedProcess = process; } break; case PlatformType.Android: @@ -516,7 +520,7 @@ private async Task BuildProjectCore(bool startProject) if (!buildTask.ResultsByTarget.TryGetValue("GetAndroidPackage", out var targetResult)) { logger.Error(string.Format(Tr._p("Message", "Couldn't find Android package name for {0}."), Session.CurrentProject.Name)); - return false; + return (false, null); } var packageName = targetResult.Items[0].ItemSpec; @@ -526,14 +530,14 @@ private async Task BuildProjectCore(bool startProject) if (adbPath == null) { logger.Error(Tr._p("Message", @"Android tool ""adb"" couldn't found (no running process, in registry or on the PATH). Please add it to your PATH.")); - return false; + return (false, null); } // Run var adbResult = await Task.Run(() => ShellHelper.RunProcessAndGetOutput(adbPath, $@"shell monkey -p {packageName} -c android.intent.category.LAUNCHER 1")); if (adbResult.ExitCode != 0) { logger.Error(string.Format(Tr._p("Message", "Can't run Android app with adb: {0}"), string.Join(Environment.NewLine, adbResult.OutputErrors))); - return false; + return (false, null); } break; @@ -546,7 +550,7 @@ private async Task BuildProjectCore(bool startProject) if (!File.Exists(assemblyPath)) { logger.Error(Tr._p("Message", "Unable to reach to output executable: {0}")); - return false; + return (false, null); } } @@ -558,7 +562,7 @@ private async Task BuildProjectCore(bool startProject) if (!prompt.AreCredentialsValid) { logger.Error(string.Format(Tr._p("Message", "No credentials provided. To allow deployment, add your credentials."))); - return false; + return (false, null); } } @@ -567,7 +571,7 @@ private async Task BuildProjectCore(bool startProject) if (!launchApp) { logger.Error(string.Format(Tr._p("Message", "Unable to launch project {0}"), new UFile(assemblyPath).GetFileName())); - return false; + return (false, null); } break; @@ -583,7 +587,7 @@ private async Task BuildProjectCore(bool startProject) await ServiceProvider.Get().MessageBoxAsync(string.Format(Tr._p("Message", "An exception occurred while compiling the project: {0}"), e.FormatSummary(true)), MessageBoxButton.OK, MessageBoxImage.Information); } - return !currentBuild.IsCanceled && !logger.HasErrors; + return (!currentBuild.IsCanceled && !logger.HasErrors, startedProcess); } private async Task PrepareBuild() diff --git a/tests/editor/NewGameEditor.cs b/tests/editor/NewGameEditor.cs index eccf2ba2ee..5b40c58d32 100644 --- a/tests/editor/NewGameEditor.cs +++ b/tests/editor/NewGameEditor.cs @@ -5,6 +5,9 @@ // ProjectSelectionWindow, accept GameTemplateWindow defaults, wait for GameStudioWindow. using System; using System.Threading.Tasks; +using Stride.Assets; +using Stride.Assets.Presentation.Templates; +using Stride.Core.Mathematics; using Stride.GameStudio.AutoTesting; namespace Stride.Editor.Tests; @@ -25,7 +28,7 @@ public async Task Run(IUITestContext ctx) } await Task.Delay(TimeSpan.FromSeconds(1)); // let templates panel populate - if (!await ctx.SelectTemplate("81d2adea-37b1-4711-834c-0d73a05c206c")) + if (!await ctx.SelectTemplate(NewGameTemplateGenerator.TemplateId)) { ctx.Exit(1); return; @@ -45,6 +48,29 @@ public async Task Run(IUITestContext ctx) await ctx.WaitIdle(); await ctx.Screenshot("new-game-editor"); + + // Add a procedural capsule (template generator pops a material picker — pre-queue a + // response so it picks the NewGame template's default "Sphere Material"), then drop an + // entity referencing it into the scene where it casts a shadow on the default sphere. + var pickerTask = ctx.QueueAssetPickerResponse("Sphere Material"); + var capsuleId = await ctx.AddAssetFromTemplate(ProceduralModelFactoryTemplateGenerator.TemplateId, "Capsule"); + await pickerTask; + if (capsuleId == Guid.Empty) { ctx.Exit(1); return; } + await ctx.AddEntityToScene("Capsule", capsuleId, new Vector3(0, 0.8f, -1.2f)); + await ctx.WaitIdle(); + await ctx.CapturePanel(GameSettingsAsset.DefaultSceneLocation, "scene-with-capsule", 1400, 900); + + // F5 from GameStudio: build + launch the project's .exe; capture the game window once + // enough frames have rendered for post-effects to stabilise. + var pid = await ctx.RunProject(); + if (pid <= 0) { ctx.Exit(1); return; } + var hwnd = await ctx.WaitForGameWindow(pid); + if (hwnd == IntPtr.Zero) { ctx.Exit(1); return; } + await ctx.WaitForGameFrames(hwnd); + await ctx.ScreenshotHwnd(hwnd, "game-running"); + await ctx.CloseGameWindow(pid); + await ctx.CapturePanel("BuildLog", "build-log-after-run", 1200, 900); + ctx.Exit(); } } diff --git a/tests/editor/TopDownCreate.cs b/tests/editor/TopDownCreate.cs index 9dfb743655..0a0994971a 100644 --- a/tests/editor/TopDownCreate.cs +++ b/tests/editor/TopDownCreate.cs @@ -5,6 +5,7 @@ // ProjectSelectionWindow, accept the platform dialog, wait for GameStudioWindow. using System; using System.Threading.Tasks; +using Stride.Assets; using Stride.GameStudio.AutoTesting; namespace Stride.Editor.Tests; @@ -23,7 +24,7 @@ public async Task Run(IUITestContext ctx) } await Task.Delay(TimeSpan.FromSeconds(1)); // templates panel populate - if (!await ctx.SelectTemplate("A363FBC5-89EF-4E7A-B870-6D070813D034")) + if (!await ctx.SelectTemplate(new Guid("A363FBC5-89EF-4E7A-B870-6D070813D034"))) { ctx.Exit(1); return; @@ -46,8 +47,8 @@ public async Task Run(IUITestContext ctx) await ctx.CapturePanel("SolutionExplorer", "panel-solution", 700, 900); await ctx.CapturePanel("References", "panel-references", 700, 900); await ctx.CapturePanel("BuildLog", "panel-buildlog", 1200, 900); - // Scene editor document — Title is the asset URL ("MainScene" for TopDownRPG). - await ctx.CapturePanel("MainScene", "scene-main", 1400, 900); + // Scene editor document — Title is the asset URL. + await ctx.CapturePanel(GameSettingsAsset.DefaultSceneLocation, "scene-main", 1400, 900); ctx.Exit(); } From 64e7c095928bd8361fa471b3b9b269e78f1c7230 Mon Sep 17 00:00:00 2001 From: Virgile Bello Date: Sat, 9 May 2026 15:19:13 +0900 Subject: [PATCH 9/9] editor/autotest: dpi100 baselines for editor fixtures Captured from xen2/stride CI run 25592292751: - EmptyEditor: startup - NewGameEditor: new-game-editor, scene-with-capsule, game-running, build-log-after-run - TopDownCreate: main, scene-main, 5 docked panels - TopDownLoad: main --- tests/editor/baselines/dpi100/EmptyEditor/startup.png | 3 +++ .../baselines/dpi100/NewGameEditor/build-log-after-run.png | 3 +++ tests/editor/baselines/dpi100/NewGameEditor/game-running.png | 3 +++ .../editor/baselines/dpi100/NewGameEditor/new-game-editor.png | 3 +++ .../baselines/dpi100/NewGameEditor/scene-with-capsule.png | 3 +++ tests/editor/baselines/dpi100/TopDownCreate/main.png | 3 +++ tests/editor/baselines/dpi100/TopDownCreate/panel-assets.png | 3 +++ tests/editor/baselines/dpi100/TopDownCreate/panel-buildlog.png | 3 +++ .../editor/baselines/dpi100/TopDownCreate/panel-properties.png | 3 +++ .../editor/baselines/dpi100/TopDownCreate/panel-references.png | 3 +++ tests/editor/baselines/dpi100/TopDownCreate/panel-solution.png | 3 +++ tests/editor/baselines/dpi100/TopDownCreate/scene-main.png | 3 +++ tests/editor/baselines/dpi100/TopDownLoad/main.png | 3 +++ 13 files changed, 39 insertions(+) create mode 100644 tests/editor/baselines/dpi100/EmptyEditor/startup.png create mode 100644 tests/editor/baselines/dpi100/NewGameEditor/build-log-after-run.png create mode 100644 tests/editor/baselines/dpi100/NewGameEditor/game-running.png create mode 100644 tests/editor/baselines/dpi100/NewGameEditor/new-game-editor.png create mode 100644 tests/editor/baselines/dpi100/NewGameEditor/scene-with-capsule.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/main.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/panel-assets.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/panel-buildlog.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/panel-properties.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/panel-references.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/panel-solution.png create mode 100644 tests/editor/baselines/dpi100/TopDownCreate/scene-main.png create mode 100644 tests/editor/baselines/dpi100/TopDownLoad/main.png diff --git a/tests/editor/baselines/dpi100/EmptyEditor/startup.png b/tests/editor/baselines/dpi100/EmptyEditor/startup.png new file mode 100644 index 0000000000..91c2af2579 --- /dev/null +++ b/tests/editor/baselines/dpi100/EmptyEditor/startup.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:581b0d364579b54f7f7aece7c3f6334802fdac89abcea01cdd6ce88f56d2aa04 +size 100578 diff --git a/tests/editor/baselines/dpi100/NewGameEditor/build-log-after-run.png b/tests/editor/baselines/dpi100/NewGameEditor/build-log-after-run.png new file mode 100644 index 0000000000..a9c076dbe8 --- /dev/null +++ b/tests/editor/baselines/dpi100/NewGameEditor/build-log-after-run.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce52a0cec812e1239679d91c21ecdf0d506dfee41a32281caa44978e9cc6f6e6 +size 209538 diff --git a/tests/editor/baselines/dpi100/NewGameEditor/game-running.png b/tests/editor/baselines/dpi100/NewGameEditor/game-running.png new file mode 100644 index 0000000000..ea7635fc27 --- /dev/null +++ b/tests/editor/baselines/dpi100/NewGameEditor/game-running.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33283a0b46e1504625ac6fac2adf9c252184b427917e71550abf46f80dcee2b3 +size 317370 diff --git a/tests/editor/baselines/dpi100/NewGameEditor/new-game-editor.png b/tests/editor/baselines/dpi100/NewGameEditor/new-game-editor.png new file mode 100644 index 0000000000..837b16c04c --- /dev/null +++ b/tests/editor/baselines/dpi100/NewGameEditor/new-game-editor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:946b176b7b824edd986832fff138c891a8b7bf00cb4f8244e2cc9928d77f2dd4 +size 715781 diff --git a/tests/editor/baselines/dpi100/NewGameEditor/scene-with-capsule.png b/tests/editor/baselines/dpi100/NewGameEditor/scene-with-capsule.png new file mode 100644 index 0000000000..0d9675233d --- /dev/null +++ b/tests/editor/baselines/dpi100/NewGameEditor/scene-with-capsule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddce71cf65a661c18b45efde8de6f107e7a9ff6bba503b33eea50eb4392b9005 +size 418115 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/main.png b/tests/editor/baselines/dpi100/TopDownCreate/main.png new file mode 100644 index 0000000000..fd106fdd67 --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/main.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cde485c21a9f5f685c924475582cc0e2f4d20883901eb43788be4256872d61f +size 1226469 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/panel-assets.png b/tests/editor/baselines/dpi100/TopDownCreate/panel-assets.png new file mode 100644 index 0000000000..14a1c8ef51 --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/panel-assets.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf1b69324daa4e4ad82cece035a542553f4ae827094681fd8091b27ab1171eb4 +size 89722 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/panel-buildlog.png b/tests/editor/baselines/dpi100/TopDownCreate/panel-buildlog.png new file mode 100644 index 0000000000..aa85e6239f --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/panel-buildlog.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a8f9d492ca9d0b7b0c94be9004333f6d242a9a22eee2759c3ce0585684b4d29 +size 11864 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/panel-properties.png b/tests/editor/baselines/dpi100/TopDownCreate/panel-properties.png new file mode 100644 index 0000000000..df8415dd49 --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/panel-properties.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58d8130913cfc083dc95bdcf2a9dfa0b53ba76576049ea675970c454f03bae63 +size 10769 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/panel-references.png b/tests/editor/baselines/dpi100/TopDownCreate/panel-references.png new file mode 100644 index 0000000000..64ab93da9f --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/panel-references.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:820269e33f9bb8a8ca8c5a1be18ad2723b105c6219aae06f49e32b5579364d29 +size 179101 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/panel-solution.png b/tests/editor/baselines/dpi100/TopDownCreate/panel-solution.png new file mode 100644 index 0000000000..bee6cedb05 --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/panel-solution.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78545447f8297c1bdc2548589e006d84399952cf4fb5a91090da97be892abf1c +size 14407 diff --git a/tests/editor/baselines/dpi100/TopDownCreate/scene-main.png b/tests/editor/baselines/dpi100/TopDownCreate/scene-main.png new file mode 100644 index 0000000000..f3e20af4f8 --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownCreate/scene-main.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77ed9ae0dcc2559c5b242ee84bcb269d014a7742ab60c70043a44fc89e02f22b +size 629978 diff --git a/tests/editor/baselines/dpi100/TopDownLoad/main.png b/tests/editor/baselines/dpi100/TopDownLoad/main.png new file mode 100644 index 0000000000..c03e4b110c --- /dev/null +++ b/tests/editor/baselines/dpi100/TopDownLoad/main.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f69b9d5a0f74248a0b9b5329a8c6a1d26aed6d8492f3ea02c85c12584e8f25d +size 1216858