From 44ba8a04cedbb5282c4cd05ac8d389aeb5970fd8 Mon Sep 17 00:00:00 2001 From: Florian Rival Date: Sat, 8 Aug 2026 21:22:07 +0200 Subject: [PATCH] Add experimental gameplay tests (#8926) A gameplay test plays a game: it presses keys, moves the mouse, touches the screen, then checks that what should happen actually happens. For example, if the coin is collected, the score goes up, the enemy hurts the player... Tests run in a preview of the game, usually much faster than real time. [Read more about them on this page](https://wiki.gdevelop.io/gdevelop5/interface/gameplay-tests/) --- Core/GDCore/IDE/ProjectStripper.cpp | 5 + .../Project/EventsFunctionsExtension.cpp | 10 + .../GDCore/Project/EventsFunctionsExtension.h | 12 + Core/GDCore/Project/Project.cpp | 11 + Core/GDCore/Project/Project.h | 17 + Core/GDCore/Project/Test.cpp | 39 + Core/GDCore/Project/Test.h | 155 + Core/GDCore/Project/TestsContainer.h | 143 + Core/tests/TestsContainer.cpp | 124 + GDJS/GDJS/IDE/ExporterHelper.cpp | 4 + .../abstract-debugger-client.ts | 96 +- .../gameplay-tests/gameplay-test-runner.ts | 2547 +++++++++++++++++ GDJS/Runtime/profiler.ts | 97 +- GDJS/Runtime/runtimegame.ts | 6 +- GDJS/Runtime/scenestack.ts | 141 +- GDJS/tests/karma.conf.js | 1 + GDJS/tests/tests/gameplaytestharness.js | 927 ++++++ GDevelop.js/Bindings/Bindings.idl | 48 + GDevelop.js/Bindings/Wrapper.cpp | 3 + GDevelop.js/__tests__/Core.js | 30 + GDevelop.js/types.d.ts | 37 + .../types/gdeventsfunctionsextension.js | 1 + GDevelop.js/types/gdproject.js | 1 + GDevelop.js/types/gdtest.js | 24 + GDevelop.js/types/gdtestscontainer.js | 15 + GDevelop.js/types/libgdevelop.js | 2 + .../src/AiGeneration/AiRequestChat/index.js | 19 + .../src/AiGeneration/AskAiEditorContainer.js | 21 + newIDE/app/src/AiGeneration/AskAiPrefill.js | 40 + .../src/AiGeneration/AskAiStandAloneForm.js | 1 + newIDE/app/src/AiGeneration/Utils.js | 29 +- .../LocalCodeEditorAutocompletions.js | 15 + newIDE/app/src/CodeEditor/MonacoSetup.js | 81 + .../src/CodeEditor/PoppedOutMonacoEditor.js | 19 + newIDE/app/src/CodeEditor/index.js | 20 + newIDE/app/src/CommandPalette/CommandsList.js | 15 + newIDE/app/src/Debugger/index.js | 2 + .../EditorFunctionCallRunner.js | 6 + .../EditorFunctions/EditorFunctions.spec.js | 176 ++ .../src/EditorFunctions/GameplayTestTools.js | 368 +++ .../EditorFunctions/OutsideEditorChanges.js | 15 +- .../NonScriptableFunctionNames.js | 5 + .../SimplifiedProject/SimplifiedProject.js | 27 + newIDE/app/src/EditorFunctions/TestHelpers.js | 1 + newIDE/app/src/EditorFunctions/index.js | 56 + .../EventsFunctionsExtensionEditor/index.js | 51 + .../GameplayTestTreeViewItemContent.js | 255 ++ newIDE/app/src/EventsFunctionsList/index.js | 128 + .../BrowserPreviewDebuggerServer.js | 45 + .../BrowserS3PreviewLauncher/index.js | 6 + .../BrowserSWPreviewLauncher/index.js | 24 +- .../LocalPreviewDebuggerServer.js | 65 +- .../LocalPreviewLauncher/index.js | 26 +- .../ExportAndShare/PreviewLauncher.flow.js | 3 + .../GameplayTests/AreGameplayTestsEnabled.js | 10 + .../DefaultGameplayTestSource.js | 30 + .../src/GameplayTests/GameplayTestEditor.js | 222 ++ .../GameplayTestEditorToolbar.js | 96 + .../src/GameplayTests/GameplayTestFrame.js | 414 +++ .../GameplayTestFrame.module.css | 117 + .../GameplayTests/GameplayTestOutputPanel.js | 123 + .../GameplayTestOutputPanel.module.css | 84 + .../GameplayTests/GameplayTestProperties.js | 444 +++ .../GameplayTestProperties.module.css | 57 + .../src/GameplayTests/GameplayTestRunner.js | 696 +++++ .../GameplayTestStateInspectors.js | 231 ++ .../GameplayTestStateInspectors.spec.js | 153 + .../GameplayTestStatusIndicator.js | 167 ++ .../GameplayTestStatusIndicator.module.css | 80 + .../MainFrame/EditorContainers/BaseEditor.js | 8 + ...EventsFunctionsExtensionEditorContainer.js | 1 + .../GameplayTestEditorContainer.js | 276 ++ .../MainFrame/EditorTabs/EditorTabsHandler.js | 21 + .../EditorTabs/EditorTabsRenaming.js | 13 + newIDE/app/src/MainFrame/EditorTabsPane.js | 14 + .../src/MainFrame/LocalCliCommandRunner.js | 63 + newIDE/app/src/MainFrame/MainFrameCommands.js | 41 +- .../PoppedOutEditorContainerWindow.js | 4 + .../Preferences/PreferencesContext.js | 3 +- newIDE/app/src/MainFrame/PreviewState.js | 9 +- .../Toolbar/PreviewAndShareButtons.js | 20 +- newIDE/app/src/MainFrame/Toolbar/index.js | 45 +- newIDE/app/src/MainFrame/index.js | 281 +- .../ProjectManager/EnumerateProjectItems.js | 5 + .../GameplayTestTreeViewItemContent.js | 236 ++ newIDE/app/src/ProjectManager/index.js | 130 +- newIDE/app/src/UI/EditorBottomTabsSwitcher.js | 173 ++ newIDE/app/src/UI/ErrorBoundary.js | 1 + .../EventsFunctionsExtensionEditor.stories.js | 12 + .../EventsFunctionsList.stories.js | 5 + .../GameplayTestFrame.stories.js | 138 + .../GameplayTestOutputPanel.stories.js | 71 + .../GameplayTestProperties.stories.js | 352 +++ .../GameplayTestStatusIndicator.stories.js | 58 + .../MainFrameToolbar.stories.js | 1 + .../ProjectManager/ProjectManager.stories.js | 8 + 96 files changed, 10831 insertions(+), 97 deletions(-) create mode 100644 Core/GDCore/Project/Test.cpp create mode 100644 Core/GDCore/Project/Test.h create mode 100644 Core/GDCore/Project/TestsContainer.h create mode 100644 Core/tests/TestsContainer.cpp create mode 100644 GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts create mode 100644 GDJS/tests/tests/gameplaytestharness.js create mode 100644 GDevelop.js/types/gdtest.js create mode 100644 GDevelop.js/types/gdtestscontainer.js create mode 100644 newIDE/app/src/AiGeneration/AskAiPrefill.js create mode 100644 newIDE/app/src/EditorFunctions/GameplayTestTools.js create mode 100644 newIDE/app/src/EventsFunctionsList/GameplayTestTreeViewItemContent.js create mode 100644 newIDE/app/src/GameplayTests/AreGameplayTestsEnabled.js create mode 100644 newIDE/app/src/GameplayTests/DefaultGameplayTestSource.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestEditor.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestEditorToolbar.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestFrame.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestFrame.module.css create mode 100644 newIDE/app/src/GameplayTests/GameplayTestOutputPanel.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestOutputPanel.module.css create mode 100644 newIDE/app/src/GameplayTests/GameplayTestProperties.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestProperties.module.css create mode 100644 newIDE/app/src/GameplayTests/GameplayTestRunner.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestStateInspectors.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestStateInspectors.spec.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestStatusIndicator.js create mode 100644 newIDE/app/src/GameplayTests/GameplayTestStatusIndicator.module.css create mode 100644 newIDE/app/src/MainFrame/EditorContainers/GameplayTestEditorContainer.js create mode 100644 newIDE/app/src/ProjectManager/GameplayTestTreeViewItemContent.js create mode 100644 newIDE/app/src/UI/EditorBottomTabsSwitcher.js create mode 100644 newIDE/app/src/stories/componentStories/GameplayTests/GameplayTestFrame.stories.js create mode 100644 newIDE/app/src/stories/componentStories/GameplayTests/GameplayTestOutputPanel.stories.js create mode 100644 newIDE/app/src/stories/componentStories/GameplayTests/GameplayTestProperties.stories.js create mode 100644 newIDE/app/src/stories/componentStories/GameplayTests/GameplayTestStatusIndicator.stories.js diff --git a/Core/GDCore/IDE/ProjectStripper.cpp b/Core/GDCore/IDE/ProjectStripper.cpp index d13cd83269..e39033d7cd 100644 --- a/Core/GDCore/IDE/ProjectStripper.cpp +++ b/Core/GDCore/IDE/ProjectStripper.cpp @@ -21,6 +21,10 @@ void GD_CORE_API ProjectStripper::StripProjectForExport(gd::Project &project) { while (project.GetExternalEventsCount() > 0) project.RemoveExternalEvents(project.GetExternalEvents(0).GetName()); + // Tests are editor-only: their source is sent to the game at runtime by the + // test runner, so they are never included in exported or previewed games. + project.GetTests().ClearTests(); + gd::BehaviorDefaultFlagClearer behaviorDefaultFlagClearer; gd::WholeProjectBrowser wholeProjectBrowser; wholeProjectBrowser.ExposeObjects(project, behaviorDefaultFlagClearer); @@ -63,6 +67,7 @@ void GD_CORE_API ProjectStripper::StripProjectForExport(gd::Project &project) { } extension.GetEventsBasedBehaviors().Clear(); extension.GetEventsFunctions().ClearEventsFunctions(); + extension.GetTests().ClearTests(); } } diff --git a/Core/GDCore/Project/EventsFunctionsExtension.cpp b/Core/GDCore/Project/EventsFunctionsExtension.cpp index d5eb494d0a..7eb81326ab 100644 --- a/Core/GDCore/Project/EventsFunctionsExtension.cpp +++ b/Core/GDCore/Project/EventsFunctionsExtension.cpp @@ -54,6 +54,7 @@ void EventsFunctionsExtension::Init(const gd::EventsFunctionsExtension& other) { eventsBasedObjects = other.eventsBasedObjects; globalVariables = other.GetGlobalVariables(); sceneVariables = other.GetSceneVariables(); + tests = other.tests; } void EventsFunctionsExtension::SerializeTo(SerializerElement& element, bool isExternal) const { @@ -108,6 +109,10 @@ void EventsFunctionsExtension::SerializeTo(SerializerElement& element, bool isEx eventsFunctionsContainer.SerializeFoldersTo( element.AddChild("eventsFunctionsFolderStructure")); + if (tests.GetTestsCount() > 0) { + tests.SerializeTestsTo(element.AddChild("tests")); + } + eventsBasedBehaviors.SerializeElementsTo( "eventsBasedBehavior", element.AddChild("eventsBasedBehaviors")); if (isExternal) { @@ -263,6 +268,11 @@ void EventsFunctionsExtension::UnserializeExtensionImplementationFrom( // Just in case eventsFunctionsContainer.AddMissingFunctionsInRootFolder(); + tests.ClearTests(); + if (element.HasChild("tests")) { + tests.UnserializeTestsFrom(element.GetChild("tests")); + } + eventsBasedBehaviors.UnserializeElementsFrom( "eventsBasedBehavior", project, element.GetChild("eventsBasedBehaviors")); diff --git a/Core/GDCore/Project/EventsFunctionsExtension.h b/Core/GDCore/Project/EventsFunctionsExtension.h index c7ffc87880..a6a0cc1e24 100644 --- a/Core/GDCore/Project/EventsFunctionsExtension.h +++ b/Core/GDCore/Project/EventsFunctionsExtension.h @@ -13,6 +13,7 @@ #include "GDCore/Project/EventsBasedObject.h" #include "GDCore/Project/EventsFunctionsContainer.h" #include "GDCore/Project/EventsFunctionsExtensionChangelog.h" +#include "GDCore/Project/TestsContainer.h" #include "GDCore/Project/VariablesContainer.h" #include "GDCore/String.h" #include "GDCore/Tools/SerializableWithNameList.h" @@ -185,6 +186,16 @@ class GD_CORE_API EventsFunctionsExtension { return eventsBasedObjects; } + /** + * \brief Return a reference to the tests of the extension. + */ + gd::TestsContainer& GetTests() { return tests; } + + /** + * \brief Return a const reference to the tests of the extension. + */ + const gd::TestsContainer& GetTests() const { return tests; } + /** * \brief Sets an extension origin. This method is not present since the * beginning so the projects created before that will have extensions @@ -427,6 +438,7 @@ class GD_CORE_API EventsFunctionsExtension { gd::EventsFunctionsContainer eventsFunctionsContainer; gd::VariablesContainer globalVariables; gd::VariablesContainer sceneVariables; + gd::TestsContainer tests; ///< The tests of the extension. }; } // namespace gd diff --git a/Core/GDCore/Project/Project.cpp b/Core/GDCore/Project/Project.cpp index 328ff407ca..0f673aa228 100644 --- a/Core/GDCore/Project/Project.cpp +++ b/Core/GDCore/Project/Project.cpp @@ -918,6 +918,11 @@ void Project::UnserializeFrom(const SerializerElement& element) { externalEvents.UnserializeFrom(*this, externalEventElement); } + tests.ClearTests(); + if (element.HasChild("tests")) { + tests.UnserializeTestsFrom(element.GetChild("tests")); + } + externalLayouts.clear(); const SerializerElement& externalLayoutsElement = element.GetChild("externalLayouts", 0, "ExternalLayouts"); @@ -1171,6 +1176,10 @@ void Project::SerializeTo(SerializerElement& element) const { GetExternalEvents(i).SerializeTo( externalEventsElement.AddChild("externalEvents")); + if (tests.GetTestsCount() > 0) { + tests.SerializeTestsTo(element.AddChild("tests")); + } + SerializerElement& eventsFunctionsExtensionsElement = element.AddChild("eventsFunctionsExtensions"); eventsFunctionsExtensionsElement.ConsiderAsArrayOf( @@ -1294,6 +1303,8 @@ void Project::Init(const gd::Project& game) { externalEvents = gd::Clone(game.externalEvents); + tests = game.tests; + externalLayouts = gd::Clone(game.externalLayouts); eventsFunctionsExtensions = gd::Clone(game.eventsFunctionsExtensions); diff --git a/Core/GDCore/Project/Project.h b/Core/GDCore/Project/Project.h index 28687d516f..febc6d7c57 100644 --- a/Core/GDCore/Project/Project.h +++ b/Core/GDCore/Project/Project.h @@ -16,6 +16,7 @@ #include "GDCore/Project/ObjectsContainer.h" #include "GDCore/Project/PlatformSpecificAssets.h" #include "GDCore/Project/ResourcesContainer.h" +#include "GDCore/Project/TestsContainer.h" #include "GDCore/Project/VariablesContainer.h" #include "GDCore/Project/Watermark.h" #include "GDCore/Project/MemoryTrackedRegistry.h" @@ -761,6 +762,21 @@ class GD_CORE_API Project { void RemoveExternalEvents(const gd::String& name); ///@} + /** \name Tests management + * Members functions related to the tests of the project. + */ + ///@{ + /** + * \brief Return a reference to the tests of the project. + */ + gd::TestsContainer& GetTests() { return tests; } + + /** + * \brief Return a const reference to the tests of the project. + */ + const gd::TestsContainer& GetTests() const { return tests; } + ///@} + /** \name External layout management * Members functions related to external layout management. */ @@ -1194,6 +1210,7 @@ class GD_CORE_API Project { gd::Watermark watermark; std::vector > externalEvents; ///< List of all externals events + gd::TestsContainer tests; ///< The tests of the project. ExtensionProperties extensionProperties; ///< The properties of the extensions. gd::WholeProjectDiagnosticReport wholeProjectDiagnosticReport; diff --git a/Core/GDCore/Project/Test.cpp b/Core/GDCore/Project/Test.cpp new file mode 100644 index 0000000000..c16eb103d6 --- /dev/null +++ b/Core/GDCore/Project/Test.cpp @@ -0,0 +1,39 @@ +/* + * GDevelop Core + * Copyright 2008-present Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +#include "GDCore/Project/Test.h" + +#include "GDCore/Serialization/SerializerElement.h" + +namespace gd { + +Test::Test() : type("gameplay") {} + +void Test::SerializeTo(SerializerElement& element) const { + element.SetAttribute("name", name); + element.SetAttribute("type", type); + element.SetAttribute("description", description); + element.AddChild("source").SetMultilineStringValue(source); + if (!lastRunStatus.empty()) { + element.SetAttribute("lastRunStatus", lastRunStatus); + element.SetAttribute("lastRunAt", lastRunAt); + element.SetAttribute("lastRunDurationMs", lastRunDurationMs); + element.SetAttribute("lastRunFramesExecuted", lastRunFramesExecuted); + } +} + +void Test::UnserializeFrom(const SerializerElement& element) { + name = element.GetStringAttribute("name"); + type = element.GetStringAttribute("type", "gameplay"); + description = element.GetStringAttribute("description"); + source = element.GetChild("source").GetMultilineStringValue(); + lastRunStatus = element.GetStringAttribute("lastRunStatus", ""); + lastRunAt = element.GetDoubleAttribute("lastRunAt", 0); + lastRunDurationMs = element.GetDoubleAttribute("lastRunDurationMs", 0); + lastRunFramesExecuted = + element.GetIntAttribute("lastRunFramesExecuted", 0); +} + +} // namespace gd diff --git a/Core/GDCore/Project/Test.h b/Core/GDCore/Project/Test.h new file mode 100644 index 0000000000..58763bb4f1 --- /dev/null +++ b/Core/GDCore/Project/Test.h @@ -0,0 +1,155 @@ +/* + * GDevelop Core + * Copyright 2008-present Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +#pragma once + +#include "GDCore/String.h" + +namespace gd { +class SerializerElement; +} + +namespace gd { + +/** + * \brief A test attached to a project or an events based extension. + * + * A test is identified by its name and holds a JavaScript source that is run + * against the game at runtime by a test harness (for example, a gameplay test + * stepping frames, simulating inputs and asserting on the game state). + * + * The `type` allows different kinds of tests to share this container in the + * future - only "gameplay" is used for now. + * + * \ingroup PlatformDefinition + */ +class GD_CORE_API Test { + public: + Test(); + virtual ~Test(){}; + + /** + * \brief Return a pointer to a new Test constructed from this one. + */ + Test* Clone() const { return new Test(*this); }; + + /** + * \brief Get the name of the test. + */ + const gd::String& GetName() const { return name; }; + + /** + * \brief Change the name of the test. + */ + void SetName(const gd::String& name_) { name = name_; }; + + /** + * \brief Get the type of the test ("gameplay" for now). + */ + const gd::String& GetType() const { return type; }; + + /** + * \brief Change the type of the test. + */ + void SetType(const gd::String& type_) { type = type_; }; + + /** + * \brief Get the description of the test. + */ + const gd::String& GetDescription() const { return description; }; + + /** + * \brief Change the description of the test. + */ + void SetDescription(const gd::String& description_) { + description = description_; + }; + + /** + * \brief Get the JavaScript source of the test. + */ + const gd::String& GetSource() const { return source; }; + + /** + * \brief Change the JavaScript source of the test. + */ + void SetSource(const gd::String& source_) { source = source_; }; + + /** \name Last run summary + * A small summary of the last run of the test, persisted with the project + * (logs and screenshots are not persisted). + */ + ///@{ + /** + * \brief Get the status of the last run: empty if never run, or "passed", + * "failed", "error". + */ + const gd::String& GetLastRunStatus() const { return lastRunStatus; }; + + /** + * \brief Set the status of the last run. + */ + void SetLastRunStatus(const gd::String& lastRunStatus_) { + lastRunStatus = lastRunStatus_; + }; + + /** + * \brief Get the timestamp (in milliseconds since epoch) of the last run, + * or 0 if never run. + */ + double GetLastRunAt() const { return lastRunAt; }; + + /** + * \brief Set the timestamp (in milliseconds since epoch) of the last run. + */ + void SetLastRunAt(double lastRunAt_) { lastRunAt = lastRunAt_; }; + + /** + * \brief Get the duration (in milliseconds) of the last run. + */ + double GetLastRunDurationMs() const { return lastRunDurationMs; }; + + /** + * \brief Set the duration (in milliseconds) of the last run. + */ + void SetLastRunDurationMs(double lastRunDurationMs_) { + lastRunDurationMs = lastRunDurationMs_; + }; + + /** + * \brief Get the number of frames executed during the last run. + */ + int GetLastRunFramesExecuted() const { return lastRunFramesExecuted; }; + + /** + * \brief Set the number of frames executed during the last run. + */ + void SetLastRunFramesExecuted(int lastRunFramesExecuted_) { + lastRunFramesExecuted = lastRunFramesExecuted_; + }; + ///@} + + /** + * \brief Serialize the test. + */ + void SerializeTo(SerializerElement& element) const; + + /** + * \brief Unserialize the test. + */ + void UnserializeFrom(const SerializerElement& element); + + private: + gd::String name; + gd::String type; ///< "gameplay" for now - reserved for future test types. + gd::String description; + gd::String source; ///< The JavaScript source of the test. + gd::String lastRunStatus; ///< Empty, "passed", "failed" or "error". + double lastRunAt = 0; + double lastRunDurationMs = 0; + int lastRunFramesExecuted = 0; +}; + +} // namespace gd diff --git a/Core/GDCore/Project/TestsContainer.h b/Core/GDCore/Project/TestsContainer.h new file mode 100644 index 0000000000..6a5aa53d40 --- /dev/null +++ b/Core/GDCore/Project/TestsContainer.h @@ -0,0 +1,143 @@ +/* + * GDevelop Core + * Copyright 2008-present Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +#pragma once + +#include + +#include "GDCore/Project/Test.h" +#include "GDCore/String.h" +#include "GDCore/Tools/SerializableWithNameList.h" + +namespace gd { +class SerializerElement; +} + +namespace gd { + +/** + * \brief A container of tests (gd::Test), used by gd::Project and + * gd::EventsFunctionsExtension. + * + * \see gd::Test + * \ingroup PlatformDefinition + */ +class GD_CORE_API TestsContainer : private SerializableWithNameList { + public: + TestsContainer() {} + + TestsContainer(const TestsContainer& other) { Init(other); } + + TestsContainer& operator=(const TestsContainer& other) { + if (this != &other) { + Init(other); + } + return *this; + } + + /** \name Tests management + */ + ///@{ + /** + * \brief Check if a test with the specified name exists. + */ + bool HasTestNamed(const gd::String& name) const { return Has(name); } + + /** + * \brief Get the test with the specified name. + * + * \warning Trying to access a not existing test will result in + * undefined behavior. + */ + gd::Test& GetTest(const gd::String& name) { return Get(name); } + + /** + * \brief Get the test with the specified name. + * + * \warning Trying to access a not existing test will result in + * undefined behavior. + */ + const gd::Test& GetTest(const gd::String& name) const { return Get(name); } + + /** + * \brief Get the test at the specified index in the list. + * + * \warning Trying to access a not existing test will result in + * undefined behavior. + */ + gd::Test& GetTest(std::size_t index) { return Get(index); } + + /** + * \brief Get the test at the specified index in the list. + * + * \warning Trying to access a not existing test will result in + * undefined behavior. + */ + const gd::Test& GetTest(std::size_t index) const { return Get(index); } + + /** + * \brief Return the number of tests. + */ + std::size_t GetTestsCount() const { return GetCount(); } + + gd::Test& InsertNewTest(const gd::String& name, std::size_t position) { + return InsertNew(name, position); + } + gd::Test& InsertTest(const gd::Test& test, std::size_t position) { + return Insert(test, position); + } + void RemoveTest(const gd::String& name) { return Remove(name); } + void ClearTests() { return Clear(); } + void MoveTest(std::size_t oldIndex, std::size_t newIndex) { + return Move(oldIndex, newIndex); + }; + std::size_t GetTestPosition(const gd::Test& test) { + return GetPosition(test); + }; + + /** + * \brief Provide a raw access to the vector containing the tests. + */ + const std::vector>& GetInternalVector() const { + return elements; + }; + + /** + * \brief Provide a raw access to the vector containing the tests. + */ + std::vector>& GetInternalVector() { + return elements; + }; + ///@} + + /** \name Serialization + */ + ///@{ + /** + * \brief Serialize the tests. + */ + void SerializeTestsTo(SerializerElement& element) const { + return SerializeElementsTo("test", element); + }; + + /** + * \brief Unserialize the tests. + */ + void UnserializeTestsFrom(const SerializerElement& element) { + return UnserializeElementsFrom("test", element); + }; + ///@} + + protected: + /** + * Initialize object using another object. Used by copy-ctor and assign-op. + * Don't forget to update me if members were changed! + */ + void Init(const gd::TestsContainer& other) { + return SerializableWithNameList::Init(other); + }; +}; + +} // namespace gd diff --git a/Core/tests/TestsContainer.cpp b/Core/tests/TestsContainer.cpp new file mode 100644 index 0000000000..064347283e --- /dev/null +++ b/Core/tests/TestsContainer.cpp @@ -0,0 +1,124 @@ +/* + * GDevelop Core + * Copyright 2008-present Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +#include "GDCore/Project/TestsContainer.h" + +#include "GDCore/Project/EventsFunctionsExtension.h" +#include "GDCore/Project/Project.h" +#include "GDCore/Project/Test.h" +#include "GDCore/Serialization/Serializer.h" +#include "GDCore/Serialization/SerializerElement.h" +#include "catch.hpp" + +TEST_CASE("TestsContainer", "[common]") { + SECTION("Basic container operations") { + gd::TestsContainer tests; + REQUIRE(tests.GetTestsCount() == 0); + REQUIRE(tests.HasTestNamed("MyTest") == false); + + gd::Test& test = tests.InsertNewTest("MyTest", 0); + test.SetDescription("My description"); + test.SetSource("await harness.goToScene('Scene');"); + REQUIRE(tests.GetTestsCount() == 1); + REQUIRE(tests.HasTestNamed("MyTest") == true); + REQUIRE(tests.GetTest("MyTest").GetType() == "gameplay"); + REQUIRE(tests.GetTest(0).GetDescription() == "My description"); + + tests.InsertNewTest("MyTest2", 1); + tests.MoveTest(1, 0); + REQUIRE(tests.GetTest(0).GetName() == "MyTest2"); + + tests.RemoveTest("MyTest2"); + REQUIRE(tests.GetTestsCount() == 1); + REQUIRE(tests.HasTestNamed("MyTest2") == false); + } + + SECTION("Serialization round trip") { + gd::TestsContainer tests; + gd::Test& test = tests.InsertNewTest("MyTest", 0); + test.SetDescription("My description"); + test.SetSource("await harness.goToScene('Scene');\nharness.assert(true, 'ok');"); + test.SetLastRunStatus("passed"); + test.SetLastRunAt(1769700000000.0); + test.SetLastRunDurationMs(5400); + test.SetLastRunFramesExecuted(320); + tests.InsertNewTest("NeverRunTest", 1); + + gd::SerializerElement element; + tests.SerializeTestsTo(element); + + gd::TestsContainer unserializedTests; + unserializedTests.UnserializeTestsFrom(element); + REQUIRE(unserializedTests.GetTestsCount() == 2); + const gd::Test& unserializedTest = unserializedTests.GetTest("MyTest"); + REQUIRE(unserializedTest.GetType() == "gameplay"); + REQUIRE(unserializedTest.GetDescription() == "My description"); + REQUIRE(unserializedTest.GetSource() == + "await harness.goToScene('Scene');\nharness.assert(true, 'ok');"); + REQUIRE(unserializedTest.GetLastRunStatus() == "passed"); + REQUIRE(unserializedTest.GetLastRunAt() == 1769700000000.0); + REQUIRE(unserializedTest.GetLastRunDurationMs() == 5400); + REQUIRE(unserializedTest.GetLastRunFramesExecuted() == 320); + REQUIRE(unserializedTests.GetTest("NeverRunTest").GetLastRunStatus() == ""); + } + + SECTION("Project copy includes tests") { + gd::Project project; + project.GetTests().InsertNewTest("MyTest", 0).SetSource("// Some code"); + + gd::Project project2 = project; + REQUIRE(project2.GetTests().GetTestsCount() == 1); + REQUIRE(project2.GetTests().GetTest("MyTest").GetSource() == "// Some code"); + + // Check that the copy has not somehow shared the same pointers. + project.GetTests().GetTest("MyTest").SetSource("// Some other code"); + REQUIRE(project2.GetTests().GetTest("MyTest").GetSource() == "// Some code"); + } + + SECTION("Project serialization includes tests") { + gd::Project project; + project.GetTests().InsertNewTest("MyTest", 0).SetSource("// Some code"); + + gd::SerializerElement element; + project.SerializeTo(element); + + gd::Project project2; + project2.UnserializeFrom(element); + REQUIRE(project2.GetTests().GetTestsCount() == 1); + REQUIRE(project2.GetTests().GetTest("MyTest").GetSource() == "// Some code"); + + // A project with no tests does not serialize a "tests" element. + gd::Project emptyProject; + gd::SerializerElement emptyElement; + emptyProject.SerializeTo(emptyElement); + REQUIRE(emptyElement.HasChild("tests") == false); + + // Unserializing a project with no "tests" element clears the tests. + project2.UnserializeFrom(emptyElement); + REQUIRE(project2.GetTests().GetTestsCount() == 0); + } + + SECTION("EventsFunctionsExtension copy and serialization include tests") { + gd::EventsFunctionsExtension extension; + extension.GetTests().InsertNewTest("MyExtensionTest", 0).SetSource( + "// Extension test code"); + + gd::EventsFunctionsExtension extension2 = extension; + REQUIRE(extension2.GetTests().GetTestsCount() == 1); + REQUIRE(extension2.GetTests().GetTest("MyExtensionTest").GetSource() == + "// Extension test code"); + + gd::Project project; + gd::SerializerElement element; + extension.SerializeTo(element); + + gd::EventsFunctionsExtension unserializedExtension; + unserializedExtension.UnserializeFrom(project, element); + REQUIRE(unserializedExtension.GetTests().GetTestsCount() == 1); + REQUIRE( + unserializedExtension.GetTests().GetTest("MyExtensionTest").GetSource() == + "// Extension test code"); + } +} diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index 0bce9fd883..416fabae49 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -1225,6 +1225,10 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers, InsertUnique(includesFiles, "debugger-client/hot-reloader.js"); InsertUnique(includesFiles, "debugger-client/abstract-debugger-client.js"); InsertUnique(includesFiles, "debugger-client/InGameDebugger.js"); + // Gameplay tests can only be run when a debugger client is included + // (i.e: during previews), as the test scripts are sent over the + // debugger connection. + InsertUnique(includesFiles, "gameplay-tests/gameplay-test-runner.js"); } if (includeWebsocketDebuggerClient) { InsertUnique(includesFiles, "debugger-client/websocket-debugger-client.js"); diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 17ade76157..c996f72fad 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -1,6 +1,19 @@ namespace gdjs { const logger = new gdjs.Logger('Debugger client'); + /** The only debugger commands processed while a gameplay test is running: + * read-only inspection and the gameplay test commands themselves. Every + * other command is ignored (fail closed: a command added later cannot + * accidentally mutate the game state or stepping the harness owns). */ + const DEBUGGER_COMMANDS_ALLOWED_DURING_GAMEPLAY_TESTS = new Set([ + 'refresh', + 'getStatus', + 'profiler.start', + 'profiler.stop', + 'gameplayTest.run', + 'gameplayTest.stop', + ]); + const originalConsole = { log: console.log, info: console.info, @@ -246,6 +259,30 @@ namespace gdjs { return; } + // While a gameplay test runs, the harness owns the game stepping and + // state: only read-only and gameplay test commands are processed (an + // unpause would make the main loop step in parallel, a hot-reload + // would reset instances mid-test). + if ( + gdjs.gameplayTests && + gdjs.gameplayTests.isGameplayTestRunning() && + !DEBUGGER_COMMANDS_ALLOWED_DURING_GAMEPLAY_TESTS.has(data.command) + ) { + logger.warn( + `Ignored debugger command "${data.command}" while a gameplay test is running.` + ); + this._sendMessage( + circularSafeStringify({ + command: 'commandIgnored', + payload: { + ignoredCommand: data.command, + reason: 'gameplay-test-running', + }, + }) + ); + return; + } + try { if (data.command === 'play') { runtimeGame.pause(false); @@ -485,6 +522,37 @@ namespace gdjs { if (inGameEditor) { this.sendSelectionAABB(data.messageId); } + } else if (data.command === 'gameplayTest.run') { + if (gdjs.gameplayTests) { + gdjs.gameplayTests + .runGameplayTest(runtimeGame, data.payload, (frame) => { + that.sendGameplayTestProgress(data.messageId, frame); + }) + .then((result) => { + that.sendGameplayTestResult(data.messageId, result); + }) + .catch((error) => { + // `runGameplayTest` is not supposed to throw - this is a + // safety net so the editor always gets an answer. + that.sendGameplayTestResult(data.messageId, { + testName: (data.payload && data.payload.testName) || '', + status: 'error', + errors: ['Unexpected error while running the test: ' + error], + }); + }); + } else { + this.sendGameplayTestResult(data.messageId, { + testName: (data.payload && data.payload.testName) || '', + status: 'error', + errors: [ + 'Gameplay tests are not included in this preview - relaunch the preview from the editor.', + ], + }); + } + } else if (data.command === 'gameplayTest.stop') { + if (gdjs.gameplayTests) { + gdjs.gameplayTests.stopCurrentGameplayTest(); + } } else if (data.command === 'hardReload') { // This usually means that the preview was modified so much that an entire reload // is needed, or that the runtime itself could have been modified. @@ -930,6 +998,32 @@ namespace gdjs { ); } + /** + * Send a progress update about the gameplay test being run. + */ + sendGameplayTestProgress(messageId: number, frame: number): void { + this._sendMessage( + circularSafeStringify({ + command: 'gameplayTest.progress', + messageId, + payload: { frame }, + }) + ); + } + + /** + * Send the result of a gameplay test run. + */ + sendGameplayTestResult(messageId: number, result: Object): void { + this._sendMessage( + circularSafeStringify({ + command: 'gameplayTest.result', + messageId, + payload: result, + }) + ); + } + sendSelectionAABB(messageId: number): void { const inGameEditor = this._runtimegame.getInGameEditor(); if (!inGameEditor) { @@ -982,7 +1076,7 @@ namespace gdjs { * @param stats Other measures done during the profiler run. */ sendProfilerOutput( - framesAverageMeasures: FrameMeasure, + framesAverageMeasures: FrameMeasureOutput, stats: ProfilerStats ): void { this._sendMessage( diff --git a/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts b/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts new file mode 100644 index 0000000000..b98e43a368 --- /dev/null +++ b/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts @@ -0,0 +1,2547 @@ +/* + * GDevelop JS Platform + * Copyright 2013-present Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +namespace gdjs { + const logger = new gdjs.Logger('Gameplay tests'); + + /** + * Gameplay tests: run a JavaScript test script against the running game, + * stepping frames deterministically, simulating inputs and asserting on the + * game state. Used by the editor (and the AI) through the debugger client + * (`gameplayTest.run` command) - see `gdjs.gameplayTests.runGameplayTest`. + * + * @category Gameplay tests + */ + export namespace gameplayTests { + /** + * One readable state entry, derived by the editor from an extension's own + * declarations: `name` is the event-sheet name of a condition or + * expression ('IsOnFloor', 'CurrentSpeed', 'PropertyHealth'...) and + * `functionName` the runtime method evaluating it. + */ + export type GameplayTestStateInspectorEntry = { + name: string; + functionName: string; + kind: 'boolean' | 'number' | 'string'; + }; + + /** + * The state inspectors for the behavior and object types used in the + * project, computed by the editor from the extensions metadata (single + * source of truth) and sent with the run payload. + */ + export type GameplayTestStateInspectors = { + behaviors: { + [behaviorType: string]: Array; + }; + objects: { + [objectType: string]: Array; + }; + }; + + /** + * The state of an object or behavior: its conditions and expressions, + * evaluated at snapshot time, under the exact names used in the game's + * events. Reading an unknown name throws with the list of available ones. + */ + export type GameplayTestEvaluatedState = { + [conditionOrExpressionName: string]: boolean | number | string; + }; + + export type GameplayTestRunPayload = { + testName: string; + /** The body of `async (harness) => { ... }`. */ + source: string; + /** Readable state to evaluate on object/behavior snapshots. */ + stateInspectors?: GameplayTestStateInspectors; + /** Wall-clock timeout for the whole run. Default: 30000. */ + timeoutMs?: number; + /** Maximum number of frames stepped. Default: 20000. */ + maxFrames?: number; + /** + * Pace the run for a human watching it: game seconds simulated per + * real second (1 = normal speed, 4 = 4x...). Omitted: run as fast + * as possible. + */ + speedFactor?: number; + /** Maximum number of screenshots kept. Default: 5. */ + maxScreenshots?: number; + /** + * When true, the game is left paused and muted when the test finishes, + * instead of resuming. Used when the game only exists to run tests + * (the editor gameplay test frame), so the last frame stays visible. + */ + freezeWhenFinished?: boolean; + }; + + export type GameplayTestAssertion = { + message: string; + passed: boolean; + }; + + export type GameplayTestLog = { + level: 'log' | 'warn' | 'error'; + message: string; + }; + + export type GameplayTestEvent = { + frame: integer; + /** + * `sceneReset` is recorded when the running scene was replaced by a NEW + * instance of the same scene: objects are back to their initial state. + * Legitimate when the game restarts the level; a symptom of external + * interference otherwise. + */ + event: 'spawned' | 'removed' | 'stuck' | 'sceneChanged' | 'sceneReset'; + object?: string; + count?: integer; + sceneName?: string; + /** + * Who changed the scene: the test itself (`harness` - a `goToScene`), + * the game's own logic (`game` - a scene change/restart action), + * multiplayer state (`networkSync`), or something outside of the + * game (`external` - a symptom of interference with the test). + */ + cause?: 'harness' | 'game' | 'networkSync' | 'external' | 'unknown'; + /** For an `external` cause: where the change came from (call stack). */ + causeDetail?: string; + }; + + export type GameplayTestScreenshot = { + label: string; + frame: integer; + jpegBase64: string; + }; + + export type GameplayTestObjectSnapshot = { + id: integer; + name: string; + x: float; + y: float; + z?: float; + angle: float; + rotationX?: float; + rotationY?: float; + width: float; + height: float; + depth?: float; + centerX: float; + centerY: float; + centerZ?: float; + layer: string; + hidden: boolean; + animation?: string; + text?: string; + opacity?: float; + variables: Array; + /** The object's own conditions/expressions, evaluated (see + * `GameplayTestEvaluatedState`). */ + state: GameplayTestEvaluatedState; + behaviors: { + [behaviorName: string]: { + act: boolean; + /** The behavior's conditions/expressions, evaluated: current state + * and configuration, under their event-sheet names + * (e.g. `behaviors.PlatformerObject.state.IsOnFloor`). Prefer these + * over inferring state from coordinates. */ + state: GameplayTestEvaluatedState; + }; + }; + children?: { [objectName: string]: Array }; + }; + + export type GameplayTestNearbyObjectSnapshot = + GameplayTestObjectSnapshot & { + distance: float; + relativeX: float; + relativeY: float; + relativeZ?: float; + above: boolean; + below: boolean; + left: boolean; + right: boolean; + bearingFromReference: float; + }; + + /** + * The position of a target relative to a reference object (2D and 3D). + * How to move toward the target (which keys, when to jump...) is up to + * the test script - see `resetSceneAndProbeControls` to discover the + * controls and `makeProgressTracker` to detect a lack of progress. + */ + export type GameplayTestRelativePosition = { + relativeX: float; + relativeY: float; + relativeZ?: float; + /** Full distance to the target (3D when Z is available). */ + distance: float; + /** Distance to the target ignoring Z (equals `distance` in 2D). */ + horizontalDistance: float; + /** Difference between the reference object's angle (yaw) and the + * direction of the target, in degrees, normalized to [-180, 180]. */ + yawDiff: float; + /** Vertical aim difference in degrees (3D only, 0 in 2D). */ + pitchDiff: float; + dominantAxis: 'x' | 'y'; + /** True when the target is within `reachRadius` (default: 30). */ + reached: boolean; + targetX: float; + targetY: float; + targetZ?: float; + }; + + /** + * The measured effect of holding one key (or nothing, for the + * baseline): the net displacement after the probe, the extreme + * displacements observed during it (a jump shows as a negative + * `minDy` even if the object lands back), and the yaw change. + */ + export type GameplayTestControlProbeResult = { + dx: float; + dy: float; + dz?: float; + minDx: float; + maxDx: float; + minDy: float; + maxDy: float; + minDz?: float; + maxDz?: float; + yawDelta: float; + }; + + /** + * A flat, JSON-safe profiling summary, also attached to the test result + * (`result.profiles`). Sections are sorted by average time descending + * (nested sections flattened as "parent > child"); `maxTimeMs` is the + * worst single frame of a section - spikes that averages hide. + * `worstFrames` and the timeline use the harness frame numbers: + * correlate a spike with the `eventLog` frames. + */ + export type GameplayTestProfilingResult = { + /** The profiled window, in harness frame numbers (like `eventLog`). */ + startFrame: integer; + endFrame: integer; + avgStepTimeMs: number; + /** The worst single frame (total). */ + maxStepTimeMs: number; + sections: Array<{ name: string; avgTimeMs: number; maxTimeMs: number }>; + /** The most expensive profiled frames, worst first. */ + worstFrames: Array<{ frame: integer; timeMs: number }>; + /** The frame-by-frame timeline, in order. When the window is longer + * than 120 frames, each entry is the MAX of `frameTimesBucketSize` + * consecutive frames (spikes are preserved). */ + frameTimesMs: Array; + frameTimesBucketSize: integer; + /** Live instances per object at the time profiling stopped. */ + objectCounts: { [objectName: string]: integer }; + /** 3D renderer counters (last rendered frame), when the game uses 3D. */ + renderer: { + drawCalls: number; + triangles: number; + geometries: number; + textures: number; + } | null; + /** JS heap in use (Chromium only), to spot leaks across a long run. */ + jsHeapUsedMb?: number; + }; + + /** + * The outcome of `lookTowardWithMouseDelta`: whether the aim succeeded, + * the remaining aim error, and whether the game responded to the mouse + * at all. `sawYawResponse: false` usually means the game ignores mouse + * deltas (e.g. the pointer lock was never engaged: click once first). + * `sawPitchResponse: false` (with a yaw response) means the vertical + * aim could not be measured: the aim automatically fell back to + * yaw-only. + */ + export type GameplayTestAimResult = { + aimed: boolean; + yawDiff: float; + pitchDiff: float; + sawYawResponse: boolean; + sawPitchResponse: boolean; + }; + + export type GameplayTestProgressStatus = { + frame: integer; + distance: float; + reached: boolean; + /** True when the distance to the target shrank by less than + * `minProgress` over the last `windowFrames` frames. */ + stalled: boolean; + }; + + export type GameplayTestProgressTracker = { + /** Sample the current distance to the target and return the current + * progress status (null if the reference or target is gone). */ + update: () => GameplayTestProgressStatus | null; + /** Forget the history (call after switching to another target). */ + reset: () => void; + }; + + export type GameplayTestResult = { + testName: string; + status: 'passed' | 'failed' | 'error' | 'stopped' | 'timeout'; + framesExecuted: integer; + durationMs: number; + gameTimeMs: number; + assertions: Array; + errors: Array; + consoleLogs: Array; + eventLog: Array; + finalState: { + sceneName: string; + objectCounts: { [objectName: string]: integer }; + watchedObjects: { + [objectName: string]: Array; + }; + sceneVariables: Array; + }; + screenshots: Array; + /** The `stopProfiling()` summaries captured during the run. */ + profiles: Array; + performance: { + avgStepMs: number; + worstStepMs: number; + } | null; + }; + + const DEFAULT_TIMEOUT_MS = 30000; + const DEFAULT_MAX_FRAMES = 20000; + /** How long frames are stepped before yielding once to the browser: + * the game visibly plays (a rendered frame per refresh), stop/progress + * messages flow, while keeping near-full stepping throughput. */ + const YIELD_BUDGET_MS = 12; + const DEFAULT_MAX_SCREENSHOTS = 5; + const DEFAULT_PROBE_FRAMES = 30; + const MAX_PROFILING_SECTIONS = 50; + const MAX_PROFILING_TIMELINE_ENTRIES = 120; + const MAX_PROFILING_WORST_FRAMES = 5; + const MAX_PROFILES_PER_RESULT = 5; + /** The scene change cause declared by the harness (see + * `SceneStack.runWithSceneChangeCause`). */ + const GAMEPLAY_TEST_SCENE_CHANGE_CAUSE = 'gameplayTest'; + const DEFAULT_PROGRESS_WINDOW_FRAMES = 60; + const DEFAULT_PROGRESS_MIN_PROGRESS = 8; + const DEFAULT_REACH_RADIUS = 30; + const MAX_CONSOLE_LOGS = 100; + const MAX_CONSOLE_LOGS_TOTAL_CHARS = 8000; + const MAX_ASSERTIONS = 200; + const MAX_EVENT_LOG_ENTRIES = 500; + const MAX_ERRORS = 20; + const SCREENSHOT_MAX_SIZE = 512; + const DEFAULT_FRAME_DT_MS = 1000 / 60; + + // Keys that must never throw on the self-describing state, so language + // internals (JSON.stringify, await inspection, string coercion...) keep + // working transparently. + const STATE_SAFE_INSPECTION_KEYS: { [key: string]: boolean } = { + toJSON: true, + then: true, + constructor: true, + hasOwnProperty: true, + toString: true, + valueOf: true, + inspect: true, + }; + + /** + * Wrap an evaluated state so reading an unknown name (a typo, a wrong + * casing, a hallucinated key) throws immediately with the list of + * available names, instead of silently returning undefined. + */ + const makeSelfDescribingState = ( + state: GameplayTestEvaluatedState, + ownerDescription: string + ): GameplayTestEvaluatedState => { + return new Proxy(state, { + get(target, key) { + if ( + typeof key === 'string' && + !(key in target) && + !STATE_SAFE_INSPECTION_KEYS[key] + ) { + const availableKeys = Object.keys(target); + throw new Error( + `Unknown state "${key}" on ${ownerDescription}. Available: ` + + (availableKeys.length > 0 + ? availableKeys.join(', ') + : '(no state for this ' + ownerDescription + ')') + + '.' + ); + } + // @ts-ignore - keys are indexable. + return target[key]; + }, + }); + }; + + /** + * Evaluate the state inspector entries on an object or behavior: each + * entry calls the same public getter the game's events call. An entry + * must never break a snapshot: mismatching or throwing ones are skipped. + */ + const evaluateStateInspector = ( + target: any, + entries: Array | null, + ownerDescription: string + ): GameplayTestEvaluatedState => { + const state: GameplayTestEvaluatedState = {}; + if (entries) { + for (const entry of entries) { + const method = target[entry.functionName]; + if (typeof method !== 'function') continue; + try { + const value = method.call(target); + if ( + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + state[entry.name] = value; + } + } catch (error) { + // Skip the entry: an inspector must never break a snapshot. + } + } + } + return makeSelfDescribingState(state, ownerDescription); + }; + + class GameplayTestAssertionError extends Error { + isGameplayTestAssertionError = true; + } + class GameplayTestStoppedError extends Error { + isGameplayTestStoppedError = true; + } + class GameplayTestTimeoutError extends Error { + isGameplayTestTimeoutError = true; + } + + /** Map both GDevelop event-sheet key names and Web API key names to + * a location-aware key code usable with the InputManager. */ + const getLocationAwareKeyCodeForName = (keyName: string): number | null => { + const keysNameToCode: { [name: string]: number } = + gdjs.evtTools.input.keysNameToCode; + const webApiKeyNamesAliases: { [name: string]: string } = { + ArrowLeft: 'Left', + ArrowRight: 'Right', + ArrowUp: 'Up', + ArrowDown: 'Down', + Enter: 'Return', + Backspace: 'Back', + Shift: 'LShift', + ShiftLeft: 'LShift', + ShiftRight: 'RShift', + Control: 'LControl', + ControlLeft: 'LControl', + ControlRight: 'RControl', + Alt: 'LAlt', + AltLeft: 'LAlt', + AltRight: 'RAlt', + ' ': 'Space', + Minus: 'Dash', + Semicolon: 'SemiColon', + }; + let name = keyName; + if (webApiKeyNamesAliases.hasOwnProperty(name)) { + name = webApiKeyNamesAliases[name]; + } + // Web API "KeyA".."KeyZ" and "Digit0".."Digit9". + if (/^Key[A-Z]$/.test(name)) name = name[3].toLowerCase(); + if (/^Digit[0-9]$/.test(name)) name = 'Num' + name[5]; + // Single letters are stored lowercase, digits as "Num0".."Num9". + if (/^[A-Z]$/.test(name)) name = name.toLowerCase(); + if (/^[0-9]$/.test(name)) name = 'Num' + name; + + if (!keysNameToCode.hasOwnProperty(name)) return null; + return keysNameToCode[name]; + }; + + const mouseButtonNameToCode = (button: string): number => { + if (button === 'right') return gdjs.InputManager.MOUSE_RIGHT_BUTTON; + if (button === 'middle') return gdjs.InputManager.MOUSE_MIDDLE_BUTTON; + return gdjs.InputManager.MOUSE_LEFT_BUTTON; + }; + + const normalizeAngleDifference = (angleInDegrees: float): float => { + let angle = angleInDegrees % 360; + if (angle > 180) angle -= 360; + if (angle < -180) angle += 360; + return angle; + }; + + /** + * The object passed as `harness` to a gameplay test script. + */ + export class GameplayTestHarness { + _runtimeGame: gdjs.RuntimeGame; + _payload: GameplayTestRunPayload; + + // Run state: + _framesExecuted: integer = 0; + _gameTimeMs: number = 0; + _startTimeMs: number = 0; + _stopped: boolean = false; + _assertions: Array = []; + _consoleLogs: Array = []; + _consoleLogsTotalChars: number = 0; + _eventLog: Array = []; + _screenshots: Array = []; + _watchedObjectNames: Array = []; + /** The harness frame at which the current profiling started (see + * `startProfiling`), or null when not profiling. */ + _profilingStartFrame: integer | null = null; + /** The profiling summaries captured during the run (attached to the + * result as `profiles`). */ + _profiles: Array = []; + _timeoutMs: number; + _maxFrames: integer; + /** Last time the stepping loop yielded to the browser (see + * `_maybeYield`). */ + _lastYieldTimeMs: number = 0; + /** Game seconds simulated per real second, or null to run as fast + * as possible (see `_maybeYield`). */ + _paceSpeedFactor: float | null = null; + _paceReferenceWallTimeMs: number = 0; + _paceReferenceGameTimeMs: number = 0; + _maxScreenshots: integer; + _totalStepTimeMs: number = 0; + _worstStepTimeMs: number = 0; + _lastTrackedSceneName: string | null = null; + /** The scene instance itself is tracked (not only its name) so a + * replacement by a new instance of the SAME scene is detected too + * (recorded as a `sceneReset` event). */ + _lastTrackedScene: gdjs.RuntimeScene | null = null; + _lastTrackedObjectCounts: { [objectName: string]: integer } = {}; + _pointerLockRequestedByGame: boolean = false; + /** Whether the one-time "mouse deltas without pointer lock" hint was + * already recorded (see `setMouseDelta`). */ + _hasWarnedMouseDelta: boolean = false; + _onProgress: ((frame: integer) => void) | null = null; + _lastProgressTimeMs: number = 0; + /** Rejects the promise raced against the test script, so a stop + * interrupts the script even when it awaits something else than the + * harness (a timer, a fetch...). */ + _rejectOnStop: ((error: Error) => void) | null = null; + /** How to notify the input manager of the end of a stepped frame. + * Replaced when the game main loop's own call is neutralized. */ + _callOnFrameEnded: () => void; + + constructor( + runtimeGame: gdjs.RuntimeGame, + payload: GameplayTestRunPayload + ) { + this._runtimeGame = runtimeGame; + this._payload = payload; + this._timeoutMs = payload.timeoutMs || DEFAULT_TIMEOUT_MS; + this._maxFrames = payload.maxFrames || DEFAULT_MAX_FRAMES; + this._lastYieldTimeMs = Date.now(); + this._paceSpeedFactor = payload.speedFactor + ? Math.max(0.1, Math.min(100, payload.speedFactor)) + : null; + this._paceReferenceWallTimeMs = Date.now(); + this._paceReferenceGameTimeMs = 0; + this._maxScreenshots = + payload.maxScreenshots === undefined + ? DEFAULT_MAX_SCREENSHOTS + : payload.maxScreenshots; + const inputManager = runtimeGame.getInputManager(); + this._callOnFrameEnded = () => inputManager.onFrameEnded(); + } + + private _getCurrentScene(): gdjs.RuntimeScene { + const currentScene = this._runtimeGame + .getSceneStack() + .getCurrentScene(); + if (!currentScene) { + throw new Error( + 'No scene is running. Call `await harness.goToScene(sceneName)` first.' + ); + } + return currentScene; + } + + /** + * Request the test to stop as soon as possible: the next stepped frame + * throws, and any pending `await` of the script is interrupted (see + * `_rejectOnStop`). + */ + requestStop(): void { + this._stopped = true; + if (this._rejectOnStop) { + this._rejectOnStop( + new GameplayTestStoppedError('The test was stopped.') + ); + this._rejectOnStop = null; + } + } + + private _checkGuards(): void { + if (this._stopped) { + throw new GameplayTestStoppedError('The test was stopped.'); + } + if (this._framesExecuted >= this._maxFrames) { + throw new GameplayTestTimeoutError( + `The test reached the maximum number of frames (${this._maxFrames}).` + ); + } + if (Date.now() - this._startTimeMs > this._timeoutMs) { + throw new GameplayTestTimeoutError( + `The test timed out after ${this._timeoutMs}ms (wall-clock).` + ); + } + } + + private _recordEvent(event: GameplayTestEvent): void { + if (this._eventLog.length >= MAX_EVENT_LOG_ENTRIES) return; + this._eventLog.push(event); + } + + private _getObjectCounts(): { [objectName: string]: integer } { + const objectCounts: { [objectName: string]: integer } = {}; + const currentScene = this._runtimeGame + .getSceneStack() + .getCurrentScene(); + if (!currentScene) return objectCounts; + const objectNames: Array = []; + // Access the protected map of the instances of the container. + const instances = (currentScene as any)._instances as Hashtable< + Array + >; + instances.keys(objectNames); + for (const objectName of objectNames) { + const objectInstances = instances.get(objectName); + if (objectInstances.length > 0) { + objectCounts[objectName] = objectInstances.length; + } + } + return objectCounts; + } + + private _trackChangesAfterStep(): void { + const currentScene = this._runtimeGame + .getSceneStack() + .getCurrentScene(); + const sceneName = currentScene ? currentScene.getName() : ''; + if (currentScene !== this._lastTrackedScene) { + const lastChangeCause = this._runtimeGame + .getSceneStack() + .consumeLastSceneChangeCause(); + this._recordEvent({ + frame: this._framesExecuted, + // A new instance of the SAME scene means the scene was restarted + // (all objects back to their initial state): record it as a + // `sceneReset` so it never goes unnoticed in the event log. + event: + sceneName === this._lastTrackedSceneName + ? 'sceneReset' + : 'sceneChanged', + sceneName, + cause: !lastChangeCause + ? 'unknown' + : lastChangeCause.cause === GAMEPLAY_TEST_SCENE_CHANGE_CAUSE + ? 'harness' + : lastChangeCause.cause === 'game' || + lastChangeCause.cause === 'networkSync' + ? lastChangeCause.cause + : 'external', + ...(lastChangeCause && lastChangeCause.stack + ? { causeDetail: lastChangeCause.stack } + : {}), + }); + this._lastTrackedScene = currentScene; + this._lastTrackedSceneName = sceneName; + this._lastTrackedObjectCounts = this._getObjectCounts(); + return; + } + + const newCounts = this._getObjectCounts(); + for (const objectName in newCounts) { + const previousCount = this._lastTrackedObjectCounts[objectName] || 0; + if (newCounts[objectName] > previousCount) { + this._recordEvent({ + frame: this._framesExecuted, + event: 'spawned', + object: objectName, + count: newCounts[objectName], + }); + } + } + for (const objectName in this._lastTrackedObjectCounts) { + const newCount = newCounts[objectName] || 0; + if (newCount < this._lastTrackedObjectCounts[objectName]) { + this._recordEvent({ + frame: this._framesExecuted, + event: 'removed', + object: objectName, + count: newCount, + }); + } + } + this._lastTrackedObjectCounts = newCounts; + } + + /** + * Step a single game frame (game logic + rendering) with a fixed + * time delta. + */ + _stepSingleFrame(dtMs: float): void { + this._checkGuards(); + const stepStartTimeMs = Date.now(); + this._runtimeGame.getSceneStack().step(dtMs); + this._callOnFrameEnded(); + this._framesExecuted++; + this._gameTimeMs += dtMs; + const stepTimeMs = Date.now() - stepStartTimeMs; + this._totalStepTimeMs += stepTimeMs; + if (stepTimeMs > this._worstStepTimeMs) { + this._worstStepTimeMs = stepTimeMs; + } + this._trackChangesAfterStep(); + if (this._onProgress && Date.now() - this._lastProgressTimeMs > 500) { + this._lastProgressTimeMs = Date.now(); + this._onProgress(this._framesExecuted); + } + } + + private _waitForNextAnimationFrame(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(() => resolve(), 0); + } + }); + } + + /** + * Yield once to the browser when frames were stepped for more than + * `YIELD_BUDGET_MS` of wall-clock time: the game gets rendered (the + * test is visible while it runs) and pending events/messages (like a + * stop request) get processed - whatever the stepping pattern of the + * test script (one big `stepFrames`, a `stepUntil` or a manual + * `stepFrames(1)` loop). + * + * When the run is paced (`speedFactor` in the payload), also wait + * until the wall clock catches up with the game time simulated at + * the desired speed. + */ + private async _maybeYield(): Promise { + if (this._paceSpeedFactor !== null) { + const targetWallTimeMs = + this._paceReferenceWallTimeMs + + (this._gameTimeMs - this._paceReferenceGameTimeMs) / + this._paceSpeedFactor; + if (Date.now() < targetWallTimeMs) { + while (Date.now() < targetWallTimeMs) { + await this._waitForNextAnimationFrame(); + } + this._lastYieldTimeMs = Date.now(); + return; + } + // The run fell behind its pace (a heavy frame, or work outside of + // frame stepping like a scene load): re-anchor the pace instead + // of rushing at full speed to catch up. + this._paceReferenceWallTimeMs = Date.now(); + this._paceReferenceGameTimeMs = this._gameTimeMs; + } + if (Date.now() - this._lastYieldTimeMs < YIELD_BUDGET_MS) return; + await this._waitForNextAnimationFrame(); + this._lastYieldTimeMs = Date.now(); + } + + /** + * Load and start the given scene, replacing any running scene. + */ + async goToScene( + sceneName: string, + options?: { skipCreatingInstances?: boolean } + ): Promise { + if (!this._runtimeGame.hasScene(sceneName)) { + throw new Error( + `The scene "${sceneName}" does not exist in the game.` + ); + } + if (!this._runtimeGame.areSceneAssetsReady(sceneName)) { + await this._runtimeGame.loadSceneAssets(sceneName); + } + this._checkGuards(); + this._runtimeGame + .getSceneStack() + .runWithSceneChangeCause(GAMEPLAY_TEST_SCENE_CHANGE_CAUSE, () => + this._runtimeGame.getSceneStack().replace({ + sceneName, + clear: true, + skipCreatingInstances: options + ? options.skipCreatingInstances + : undefined, + }) + ); + // Step one frame so the scene is fully initialized ("beginning of + // scene" events have run) before the test continues. + await this.stepFrames(1); + } + + /** + * Step the given number of game frames. + */ + async stepFrames( + frameCount: integer, + options?: { + dtMs?: float; + onFrame?: (context: { frame: integer }) => void; + } + ): Promise { + const dtMs = (options && options.dtMs) || DEFAULT_FRAME_DT_MS; + for (let i = 0; i < frameCount; i++) { + this._stepSingleFrame(dtMs); + if (options && options.onFrame) { + options.onFrame({ frame: this._framesExecuted }); + } + await this._maybeYield(); + } + } + + /** + * Step frames until the condition returns true, or `maxFrames` frames + * were stepped. Returns true if the condition was met. + */ + async stepUntil( + condition: () => boolean, + options: { + maxFrames: integer; + onFrame?: (context: { frame: integer }) => void; + stuckDetection?: { + objectName: string; + windowFrames?: integer; + minDisplacement?: float; + onStuck?: (context: { + frame: integer; + x: float; + y: float; + z: float; + }) => void; + }; + } + ): Promise { + const stuckDetection = options.stuckDetection || null; + const windowFrames = + (stuckDetection && stuckDetection.windowFrames) || 30; + const minDisplacement = + (stuckDetection && stuckDetection.minDisplacement) || 5; + let lastCheckPosition: { x: float; y: float; z: float } | null = null; + let framesSinceLastCheck = 0; + + for (let i = 0; i < options.maxFrames; i++) { + if (condition()) return true; + this._stepSingleFrame(DEFAULT_FRAME_DT_MS); + if (options.onFrame) { + options.onFrame({ frame: this._framesExecuted }); + } + + if (stuckDetection) { + const instances = this.getObjects(stuckDetection.objectName); + if (instances.length > 0) { + const position = { + x: instances[0].x, + y: instances[0].y, + z: instances[0].z || 0, + }; + framesSinceLastCheck++; + if (framesSinceLastCheck >= windowFrames) { + if (lastCheckPosition) { + const displacement = Math.hypot( + position.x - lastCheckPosition.x, + position.y - lastCheckPosition.y, + position.z - lastCheckPosition.z + ); + if (displacement < minDisplacement) { + this._recordEvent({ + frame: this._framesExecuted, + event: 'stuck', + object: stuckDetection.objectName, + }); + // Give a clean slate to the `onStuck` handler. + this.releaseAllInputs(); + if (stuckDetection.onStuck) { + stuckDetection.onStuck({ + frame: this._framesExecuted, + ...position, + }); + } + } + } + lastCheckPosition = position; + framesSinceLastCheck = 0; + } + } + } + + await this._maybeYield(); + } + return condition(); + } + + /** + * Get the name of the scene being run. + */ + getSceneName(): string { + return this._getCurrentScene().getName(); + } + + /** + * Get the names of all the scenes on the scene stack (the last one + * is the current scene). + */ + getSceneStack(): Array { + return this._runtimeGame.getSceneStack().getAllSceneNames(); + } + + // INPUT: + + /** + * Press or release a keyboard key. Accepts GDevelop event-sheet key + * names ("Left", "Space", "a"...) and Web API names ("ArrowLeft"...). + */ + setKeyPressed(keyName: string, pressed: boolean): void { + const locationAwareKeyCode = getLocationAwareKeyCodeForName(keyName); + if (locationAwareKeyCode === null) { + throw new Error( + `Unknown key name: "${keyName}". Use GDevelop key names (like "Left", "Space", "a", "Num1") or Web API names (like "ArrowLeft").` + ); + } + const inputManager = this._runtimeGame.getInputManager(); + const rawKeyCode = locationAwareKeyCode % 1000; + const location = Math.floor(locationAwareKeyCode / 1000); + if (pressed) { + inputManager.onKeyPressed(rawKeyCode, location); + } else { + inputManager.onKeyReleased(rawKeyCode, location); + } + } + + /** + * Move the mouse cursor to a position, expressed in the scene + * coordinates of the given layer (pass the layer of the object you + * want to point at). + */ + setMousePosition(x: float, y: float, layerName: string = ''): void { + const currentScene = this._getCurrentScene(); + if (!currentScene.hasLayer(layerName)) { + throw new Error(`The layer "${layerName}" does not exist.`); + } + const layer = currentScene.getLayer(layerName); + const screenPosition = layer.convertInverseCoords(x, y, 0, [0, 0]); + this._runtimeGame + .getInputManager() + .onMouseMove(screenPosition[0], screenPosition[1]); + } + + /** + * Move the mouse cursor to a position in game resolution ("screen") + * coordinates. + */ + setMousePositionScreen(screenX: float, screenY: float): void { + this._runtimeGame.getInputManager().onMouseMove(screenX, screenY); + } + + /** + * Apply a mouse movement delta (for pointer-lock/FPS mouse look). + * Call once per frame, from `onFrame`. + */ + setMouseDelta(deltaX: float, deltaY: float): void { + if (!this._pointerLockRequestedByGame && !this._hasWarnedMouseDelta) { + this._hasWarnedMouseDelta = true; + this._recordConsoleLog( + 'warn', + 'setMouseDelta was called but the game never requested the pointer lock. ' + + 'If the camera does not rotate, the game probably engages mouse-look after a click: ' + + 'send one first (press, step 1 frame, release, step 1 frame).' + ); + } + const inputManager = this._runtimeGame.getInputManager(); + inputManager.onMouseMove( + inputManager.getMouseX(), + inputManager.getMouseY(), + { movementX: deltaX, movementY: deltaY } + ); + // Mouse-look extensions often listen to the canvas `pointermove` + // DOM events directly (instead of the input manager): dispatch a + // real event carrying the deltas so they receive them too. + const renderer = this._runtimeGame.getRenderer() as any; + const canvas = + typeof renderer.getCanvas === 'function' + ? renderer.getCanvas() + : null; + if (canvas && typeof PointerEvent !== 'undefined') { + const event = new PointerEvent('pointermove', { bubbles: true }); + Object.defineProperty(event, 'movementX', { value: deltaX }); + Object.defineProperty(event, 'movementY', { value: deltaY }); + canvas.dispatchEvent(event); + } + } + + /** + * Press or release a mouse button ('left', 'right' or 'middle'). + */ + setMouseButtonPressed( + pressed: boolean, + button: 'left' | 'right' | 'middle' = 'left' + ): void { + const inputManager = this._runtimeGame.getInputManager(); + const buttonCode = mouseButtonNameToCode(button); + if (pressed) { + inputManager.onMouseButtonPressed(buttonCode); + } else { + inputManager.onMouseButtonReleased(buttonCode); + } + } + + /** + * Start a touch at a position expressed in the scene coordinates of + * the given layer. + */ + touchStart( + identifier: integer, + x: float, + y: float, + layerName: string = '' + ): void { + const layer = this._getCurrentScene().getLayer(layerName); + const screenPosition = layer.convertInverseCoords(x, y, 0, [0, 0]); + this._runtimeGame + .getInputManager() + .onTouchStart(identifier, screenPosition[0], screenPosition[1]); + } + + /** + * Move a touch to a position expressed in the scene coordinates of + * the given layer. + */ + touchMove( + identifier: integer, + x: float, + y: float, + layerName: string = '' + ): void { + const layer = this._getCurrentScene().getLayer(layerName); + const screenPosition = layer.convertInverseCoords(x, y, 0, [0, 0]); + this._runtimeGame + .getInputManager() + .onTouchMove(identifier, screenPosition[0], screenPosition[1]); + } + + /** + * End a touch. + */ + touchEnd(identifier: integer): void { + this._runtimeGame.getInputManager().onTouchEnd(identifier); + } + + /** + * Get the game resolution width, in pixels. + */ + getGameResolutionWidth(): float { + return this._runtimeGame.getGameResolutionWidth(); + } + + /** + * Get the game resolution height, in pixels. + */ + getGameResolutionHeight(): float { + return this._runtimeGame.getGameResolutionHeight(); + } + + /** + * Release all pressed keys, mouse buttons and touches. + */ + releaseAllInputs(): void { + const inputManager = this._runtimeGame.getInputManager(); + inputManager.releaseAllPressedKeys(); + inputManager.onMouseButtonReleased(gdjs.InputManager.MOUSE_LEFT_BUTTON); + inputManager.onMouseButtonReleased( + gdjs.InputManager.MOUSE_RIGHT_BUTTON + ); + inputManager.onMouseButtonReleased( + gdjs.InputManager.MOUSE_MIDDLE_BUTTON + ); + for (const identifier of inputManager.getAllTouchIdentifiers()) { + // Public identifiers are raw identifiers + 2. + inputManager.onTouchEnd(identifier - 2); + } + } + + // INSPECTION: + + private _makeObjectSnapshot( + object: gdjs.RuntimeObject, + includeChildren: boolean + ): GameplayTestObjectSnapshot { + const anyObject = object as any; + const stateInspectors = this._payload.stateInspectors || null; + const behaviors: { + [behaviorName: string]: { + act: boolean; + state: GameplayTestEvaluatedState; + }; + } = {}; + // Access the protected list of behaviors of the object. + const objectBehaviors = + anyObject._behaviors as Array; + for (const behavior of objectBehaviors) { + behaviors[behavior.getName()] = { + act: behavior.activated(), + state: evaluateStateInspector( + behavior, + (stateInspectors && + stateInspectors.behaviors[(behavior as any).type]) || + null, + `the behavior "${behavior.getName()}"` + ), + }; + } + const objectState = evaluateStateInspector( + object, + (stateInspectors && stateInspectors.objects[object.type]) || null, + `the object "${object.getName()}"` + ); + + const snapshot: GameplayTestObjectSnapshot = { + id: object.id, + name: object.getName(), + x: object.getX(), + y: object.getY(), + angle: object.getAngle(), + width: object.getWidth(), + height: object.getHeight(), + centerX: object.getCenterXInScene(), + centerY: object.getCenterYInScene(), + layer: object.getLayer(), + hidden: object.isHidden(), + variables: object.getVariables().getNetworkSyncData({}), + state: objectState, + behaviors, + }; + if (typeof anyObject.getZ === 'function') { + snapshot.z = anyObject.getZ(); + if (typeof anyObject.getCenterZInScene === 'function') { + snapshot.centerZ = anyObject.getCenterZInScene(); + } + } + if (typeof anyObject.getRotationX === 'function') { + snapshot.rotationX = anyObject.getRotationX(); + } + if (typeof anyObject.getRotationY === 'function') { + snapshot.rotationY = anyObject.getRotationY(); + } + if (typeof anyObject.getDepth === 'function') { + snapshot.depth = anyObject.getDepth(); + } + if (typeof anyObject.getAnimationName === 'function') { + snapshot.animation = anyObject.getAnimationName(); + } + if (typeof anyObject.getText === 'function') { + snapshot.text = anyObject.getText(); + } else if (typeof anyObject.getString === 'function') { + snapshot.text = anyObject.getString(); + } + if (typeof anyObject.getOpacity === 'function') { + snapshot.opacity = anyObject.getOpacity(); + } + if ( + includeChildren && + typeof anyObject.getChildrenContainer === 'function' + ) { + const childrenContainer: gdjs.RuntimeInstanceContainer = + anyObject.getChildrenContainer(); + const children: { + [objectName: string]: Array; + } = {}; + for (const child of childrenContainer.getAdhocListOfAllInstances()) { + const childName = child.getName(); + if (!children[childName]) children[childName] = []; + children[childName].push(this._makeObjectSnapshot(child, false)); + } + snapshot.children = children; + } + return snapshot; + } + + private _getInstances(objectName: string): Array { + return this._getCurrentScene().getObjects(objectName) || []; + } + + /** + * Get a state snapshot of all the instances of an object. + * Instances are returned in an unspecified order. + */ + getObjects(objectName: string): Array { + return this._getInstances(objectName).map((object) => + this._makeObjectSnapshot(object, true) + ); + } + + /** + * Get the instances of `objectName` within `radius` of the first + * instance of `referenceObjectName`, sorted by distance. + */ + getNearby( + objectName: string, + referenceObjectName: string, + radius: float + ): Array { + const referenceInstances = this._getInstances(referenceObjectName); + if (referenceInstances.length === 0) return []; + const reference = this._makeObjectSnapshot( + referenceInstances[0], + false + ); + const referenceZ = reference.centerZ || 0; + + const nearby: Array = []; + for (const object of this._getInstances(objectName)) { + const snapshot = this._makeObjectSnapshot(object, true); + const relativeX = snapshot.centerX - reference.centerX; + const relativeY = snapshot.centerY - reference.centerY; + const relativeZ = (snapshot.centerZ || 0) - referenceZ; + const distance = Math.hypot(relativeX, relativeY, relativeZ); + if (distance > radius) continue; + nearby.push({ + ...snapshot, + distance, + relativeX, + relativeY, + relativeZ: snapshot.centerZ === undefined ? undefined : relativeZ, + above: relativeY < 0, + below: relativeY > 0, + left: relativeX < 0, + right: relativeX > 0, + bearingFromReference: gdjs.toDegrees( + Math.atan2(relativeY, relativeX) + ), + }); + } + nearby.sort((a, b) => a.distance - b.distance); + return nearby; + } + + /** + * Check if the straight segment between the first instance of + * `referenceObjectName` and the first instance of `targetObjectName` + * is clear of the given blocker objects. 2D ONLY: the test uses the + * 2D hitboxes in the X/Y plane and ignores Z. + */ + has2dLineOfSight( + referenceObjectName: string, + targetObjectName: string, + blockerObjectNames: Array + ): { + clear: boolean; + blockedBy?: string; + blockedAt?: { x: float; y: float }; + } { + const referenceInstances = this._getInstances(referenceObjectName); + const targetInstances = this._getInstances(targetObjectName); + if (referenceInstances.length === 0 || targetInstances.length === 0) { + return { clear: false, blockedBy: 'missing-object' }; + } + const x0 = referenceInstances[0].getCenterXInScene(); + const y0 = referenceInstances[0].getCenterYInScene(); + const x1 = targetInstances[0].getCenterXInScene(); + const y1 = targetInstances[0].getCenterYInScene(); + + let closestResult: { + blockedBy: string; + x: float; + y: float; + sqDistance: float; + } | null = null; + for (const blockerObjectName of blockerObjectNames) { + for (const blocker of this._getInstances(blockerObjectName)) { + const result = blocker.raycastTest(x0, y0, x1, y1, true); + if (result.collision) { + const sqDistance = + (result.closeX - x0) * (result.closeX - x0) + + (result.closeY - y0) * (result.closeY - y0); + if (!closestResult || sqDistance < closestResult.sqDistance) { + closestResult = { + blockedBy: blockerObjectName, + x: result.closeX, + y: result.closeY, + sqDistance, + }; + } + } + } + } + if (closestResult) { + return { + clear: false, + blockedBy: closestResult.blockedBy, + blockedAt: { x: closestResult.x, y: closestResult.y }, + }; + } + return { clear: true }; + } + + /** + * LAST RESORT: the raw `gdjs.RuntimeGame` being tested. Prefer the + * harness APIs (snapshots, inputs, stepping...) - direct mutations + * can invalidate what the test asserts. + */ + getRuntimeGame(): gdjs.RuntimeGame { + return this._runtimeGame; + } + + /** + * LAST RESORT: the raw `gdjs.RuntimeScene` currently being played + * (throws if no scene is running). Prefer the harness APIs. + */ + getCurrentRuntimeScene(): gdjs.RuntimeScene { + return this._getCurrentScene(); + } + + /** + * The raw `gdjs.RuntimeLayer` of the current scene, or null if the + * layer does not exist. Useful to check the layer visibility + * (`isVisible()`) or read the camera (`getCameraX()`, `getCameraZoom()`...). + */ + getRuntimeLayer(layerName: string): gdjs.RuntimeLayer | null { + const currentScene = this._getCurrentScene(); + if (!currentScene.hasLayer(layerName)) return null; + return currentScene.getLayer(layerName); + } + + /** + * The raw `gdjs.RuntimeObject` of an instance, or null if not found. + * Behaviors can be reached with `getBehavior(behaviorName)` (also null + * if not found). Prefer the harness APIs (snapshots, inputs...) - + * direct mutations can invalidate what the test asserts. + * @param objectIdOrName An instance id (from `getObjects`) or an object + * name (first instance). + */ + getRuntimeObject( + objectIdOrName: integer | string + ): gdjs.RuntimeObject | null { + const currentScene = this._getCurrentScene(); + if (typeof objectIdOrName === 'number') { + for (const candidate of currentScene.getAdhocListOfAllInstances()) { + if (candidate.id === objectIdOrName) { + return candidate; + } + } + return null; + } + return currentScene.getObjects(objectIdOrName)?.[0] || null; + } + + /** + * Get a scene variable, or undefined if it does not exist. + */ + getSceneVariable(variableName: string): Object | undefined { + return this._getCurrentScene() + .getVariables() + .getNetworkSyncData({}) + .find((variable) => (variable as any).name === variableName); + } + + /** + * Get a global variable, or undefined if it does not exist. + */ + getGlobalVariable(variableName: string): Object | undefined { + return this._runtimeGame + .getVariables() + .getNetworkSyncData({}) + .find((variable) => (variable as any).name === variableName); + } + + /** + * Include full snapshots of this object's instances in the final + * state of the test result. + */ + watch(objectName: string): void { + if (!this._watchedObjectNames.includes(objectName)) { + this._watchedObjectNames.push(objectName); + } + } + + // NAVIGATION INTENT: + + private _resolveNavigationTarget( + target: + | { name: string; id?: integer } + | { x: float; y: float; z?: float } + ): { x: float; y: float; z: float | undefined } | null { + if ('name' in target) { + const instances = this._getInstances(target.name); + let instance = + target.id !== undefined + ? instances.find((object) => object.id === target.id) + : instances[0]; + if (!instance) return null; + const anyInstance = instance as any; + return { + x: instance.getCenterXInScene(), + y: instance.getCenterYInScene(), + z: + typeof anyInstance.getCenterZInScene === 'function' + ? anyInstance.getCenterZInScene() + : undefined, + }; + } + return { x: target.x, y: target.y, z: target.z }; + } + + /** + * Get the position of a target (an object or a position) relative to + * the first instance of `referenceObjectName` (2D and 3D). Deciding + * how to move toward the target with the game's actual controls is + * the job of the test script (use `resetSceneAndProbeControls` to + * discover the controls, `makeProgressTracker` to detect a lack of + * progress). + */ + getRelativePosition( + referenceObjectName: string, + target: + | { name: string; id?: integer } + | { x: float; y: float; z?: float }, + options?: { reachRadius?: float } + ): GameplayTestRelativePosition | null { + const referenceInstances = this._getInstances(referenceObjectName); + if (referenceInstances.length === 0) return null; + const reference = referenceInstances[0]; + const anyReference = reference as any; + const resolvedTarget = this._resolveNavigationTarget(target); + if (!resolvedTarget) return null; + + const reachRadius = + (options && options.reachRadius) || DEFAULT_REACH_RADIUS; + + const referenceX = reference.getCenterXInScene(); + const referenceY = reference.getCenterYInScene(); + const referenceZ = + typeof anyReference.getCenterZInScene === 'function' + ? anyReference.getCenterZInScene() + : undefined; + + const relativeX = resolvedTarget.x - referenceX; + const relativeY = resolvedTarget.y - referenceY; + const relativeZ = + resolvedTarget.z !== undefined && referenceZ !== undefined + ? resolvedTarget.z - referenceZ + : undefined; + + const distance = Math.hypot(relativeX, relativeY, relativeZ || 0); + + const desiredAngle = gdjs.toDegrees(Math.atan2(relativeY, relativeX)); + const yawDiff = normalizeAngleDifference( + desiredAngle - reference.getAngle() + ); + const horizontalDistance = Math.hypot(relativeX, relativeY); + const desiredPitch = + relativeZ === undefined + ? 0 + : gdjs.toDegrees(Math.atan2(relativeZ, horizontalDistance)); + const currentPitch = + typeof anyReference.getRotationX === 'function' + ? anyReference.getRotationX() + : 0; + const pitchDiff = + relativeZ === undefined + ? 0 + : normalizeAngleDifference(desiredPitch - currentPitch); + + return { + relativeX, + relativeY, + relativeZ, + distance, + horizontalDistance, + yawDiff, + pitchDiff, + dominantAxis: Math.abs(relativeX) >= Math.abs(relativeY) ? 'x' : 'y', + reached: distance <= reachRadius, + targetX: resolvedTarget.x, + targetY: resolvedTarget.y, + targetZ: resolvedTarget.z, + }; + } + + /** + * Measure what each key actually does to the first instance of + * `objectName` (2D and 3D: dz is measured when the object has a Z + * coordinate): for the baseline (no key) and then each key, the + * scene is restarted, the key held for `frames` frames, and the + * displacement (net + extremes: a jump shows as a negative `minDy` + * even if the object lands back) and yaw change are measured. + * Compare each key's result to `baseline` (gravity or idle drift + * affects both). The scene is restarted again at the end, so call + * this BEFORE the scenario of the test. An entry is null if the + * instance disappeared during that probe. + */ + async resetSceneAndProbeControls( + objectName: string, + keyNames: Array, + options?: { frames?: integer } + ): Promise<{ + baseline: GameplayTestControlProbeResult | null; + keys: { [keyName: string]: GameplayTestControlProbeResult | null }; + }> { + const frames = (options && options.frames) || DEFAULT_PROBE_FRAMES; + const sceneName = this._getCurrentScene().getName(); + + const probe = async ( + keyName: string | null + ): Promise => { + await this.goToScene(sceneName); + this.releaseAllInputs(); + const getPosition = () => { + const instances = this._getInstances(objectName); + if (instances.length === 0) return null; + const instance = instances[0]; + const anyInstance = instance as any; + return { + x: instance.getX(), + y: instance.getY(), + z: + typeof anyInstance.getZ === 'function' + ? (anyInstance.getZ() as float) + : undefined, + angle: instance.getAngle(), + }; + }; + const start = getPosition(); + if (!start) { + throw new Error( + `No instance of "${objectName}" found to probe controls on (after restarting the scene "${sceneName}").` + ); + } + let minDx = 0; + let maxDx = 0; + let minDy = 0; + let maxDy = 0; + let minDz: float | undefined = start.z === undefined ? undefined : 0; + let maxDz: float | undefined = start.z === undefined ? undefined : 0; + if (keyName) this.setKeyPressed(keyName, true); + await this.stepFrames(frames, { + onFrame: () => { + const current = getPosition(); + if (!current) return; + minDx = Math.min(minDx, current.x - start.x); + maxDx = Math.max(maxDx, current.x - start.x); + minDy = Math.min(minDy, current.y - start.y); + maxDy = Math.max(maxDy, current.y - start.y); + if (current.z !== undefined && start.z !== undefined) { + minDz = Math.min(minDz || 0, current.z - start.z); + maxDz = Math.max(maxDz || 0, current.z - start.z); + } + }, + }); + if (keyName) this.setKeyPressed(keyName, false); + const end = getPosition(); + if (!end) return null; + return { + dx: end.x - start.x, + dy: end.y - start.y, + dz: + end.z !== undefined && start.z !== undefined + ? end.z - start.z + : undefined, + minDx, + maxDx, + minDy, + maxDy, + minDz, + maxDz, + yawDelta: normalizeAngleDifference(end.angle - start.angle), + }; + }; + + const baseline = await probe(null); + const keys: { + [keyName: string]: GameplayTestControlProbeResult | null; + } = {}; + for (const keyName of keyNames) { + keys[keyName] = await probe(keyName); + } + // Leave a clean state for the actual test scenario. + await this.goToScene(sceneName); + this.releaseAllInputs(); + return { baseline, keys }; + } + + /** + * Make a tracker measuring the progress of the first instance of + * `referenceObjectName` toward a target (the distance is 3D when the + * object has a Z coordinate). Call `update()` regularly (e.g. once + * per loop iteration): it reports the current `distance`, whether + * the target is `reached`, and whether progress `stalled` (distance + * shrank by less than `minProgress` over the last `windowFrames` + * frames - time to try an escape strategy). The first update of a + * stall also records a `stuck` event in the event log. Call + * `reset()` after switching to another target. + */ + makeProgressTracker( + referenceObjectName: string, + target: + | { name: string; id?: integer } + | { x: float; y: float; z?: float }, + options?: { + windowFrames?: integer; + minProgress?: float; + reachRadius?: float; + } + ): GameplayTestProgressTracker { + const windowFrames = + (options && options.windowFrames) || DEFAULT_PROGRESS_WINDOW_FRAMES; + const minProgress = + (options && options.minProgress) || DEFAULT_PROGRESS_MIN_PROGRESS; + const reachRadius = + (options && options.reachRadius) || DEFAULT_REACH_RADIUS; + + let samples: Array<{ frame: integer; distance: float }> = []; + let wasStalled = false; + + return { + update: () => { + const relativePosition = this.getRelativePosition( + referenceObjectName, + target, + { reachRadius } + ); + if (!relativePosition) return null; + const frame = this._framesExecuted; + const distance = relativePosition.distance; + samples.push({ frame, distance }); + // Keep only the window (plus the sample right before it, to + // always have a reference point `windowFrames` old). + while ( + samples.length > 1 && + samples[1].frame <= frame - windowFrames + ) { + samples.shift(); + } + const oldest = samples[0]; + const stalled = + frame - oldest.frame >= windowFrames && + oldest.distance - distance < minProgress; + if (stalled && !wasStalled) { + this._recordEvent({ + frame, + event: 'stuck', + object: referenceObjectName, + }); + } + wasStalled = stalled; + return { + frame, + distance, + reached: relativePosition.reached, + stalled, + }; + }, + reset: () => { + samples = []; + wasStalled = false; + }, + }; + } + + /** + * Turn the first instance of `referenceObjectName` toward the target, + * by applying mouse movement deltas (FPS/pointer-lock style, yaw and + * pitch in 3D) until it is aiming at it. The mouse sensitivity of the + * game is measured and adapted to live, per axis. When the vertical + * aim shows no measurable response (some games map the pitch to + * another axis), the vertical input is undone and the aim falls back + * to yaw-only, reported as `sawPitchResponse: false`. Returns null if + * the object or the target is missing. + */ + async lookTowardWithMouseDelta( + referenceObjectName: string, + target: + | { name: string; id?: integer } + | { x: float; y: float; z?: float }, + options?: { yawOnly?: boolean } + ): Promise { + const maxAimFrames = 180; + const toleranceDegrees = 3; + const responseThresholdDegrees = 0.1; + const maxPixelsPerDegree = 64; + // Frames tolerated with a pitch demand, a maxed-out gain and no + // measured response before giving up on the vertical aim. + const maxUnresponsivePitchFrames = 10; + // Pixels of mouse movement per degree of desired rotation: adapted + // live (per axis) by measuring the actual rotation achieved. + let yawPixelsPerDegree = 2; + let pitchPixelsPerDegree = 2; + + let sawYawResponse = false; + let sawPitchResponse = false; + let pitchGivenUp = false; + let unresponsivePitchFrames = 0; + let appliedPitchPixels = 0; + + const clamp = (value: float, maximum: float) => + Math.max(-maximum, Math.min(maximum, value)); + const makeResult = ( + aimed: boolean, + relativePosition: GameplayTestRelativePosition + ): GameplayTestAimResult => ({ + aimed, + yawDiff: relativePosition.yawDiff, + pitchDiff: relativePosition.pitchDiff, + sawYawResponse, + sawPitchResponse, + }); + + // Undo the vertical input applied so far: when the measured pitch + // never responded, the actual view may still have been pitched + // (e.g. toward the ground) - restore it. + const unwindAppliedPitch = async () => { + while (Math.abs(appliedPitchPixels) > 1) { + const chunk = clamp(-appliedPitchPixels, 100); + this.setMouseDelta(0, chunk); + appliedPitchPixels += chunk; + await this.stepFrames(1); + } + }; + + let lastRelativePosition: GameplayTestRelativePosition | null = null; + for (let i = 0; i < maxAimFrames; i++) { + const relativePosition = this.getRelativePosition( + referenceObjectName, + target + ); + if (!relativePosition) return null; + lastRelativePosition = relativePosition; + const yawDiff = relativePosition.yawDiff; + const wantPitch = !(options && options.yawOnly) && !pitchGivenUp; + const pitchDiff = wantPitch ? relativePosition.pitchDiff : 0; + if ( + Math.abs(yawDiff) <= toleranceDegrees && + Math.abs(pitchDiff) <= toleranceDegrees + ) { + return makeResult(true, relativePosition); + } + + const pitchDeltaPixels = clamp(pitchDiff * pitchPixelsPerDegree, 100); + this.setMouseDelta( + clamp(yawDiff * yawPixelsPerDegree, 100), + pitchDeltaPixels + ); + appliedPitchPixels += pitchDeltaPixels; + await this.stepFrames(1); + + const newRelativePosition = this.getRelativePosition( + referenceObjectName, + target + ); + if (newRelativePosition) { + const achievedYawRotation = Math.abs( + yawDiff - newRelativePosition.yawDiff + ); + if (achievedYawRotation >= responseThresholdDegrees) { + sawYawResponse = true; + } + if (Math.abs(yawDiff) > toleranceDegrees) { + if (achievedYawRotation < responseThresholdDegrees) { + // No response to the mouse: increase the gain (the game may + // have a low mouse sensitivity). + yawPixelsPerDegree = Math.min( + yawPixelsPerDegree * 2, + maxPixelsPerDegree + ); + } else { + const ratio = Math.abs(yawDiff) / achievedYawRotation; + yawPixelsPerDegree = Math.max( + 0.25, + Math.min( + maxPixelsPerDegree, + yawPixelsPerDegree * Math.min(2, ratio) + ) + ); + } + } + + if (wantPitch && Math.abs(pitchDiff) > toleranceDegrees) { + const achievedPitchRotation = Math.abs( + pitchDiff - newRelativePosition.pitchDiff + ); + if (achievedPitchRotation >= responseThresholdDegrees) { + sawPitchResponse = true; + unresponsivePitchFrames = 0; + const ratio = Math.abs(pitchDiff) / achievedPitchRotation; + pitchPixelsPerDegree = Math.max( + 0.25, + Math.min( + maxPixelsPerDegree, + pitchPixelsPerDegree * Math.min(2, ratio) + ) + ); + } else if (pitchPixelsPerDegree < maxPixelsPerDegree) { + pitchPixelsPerDegree = Math.min( + pitchPixelsPerDegree * 2, + maxPixelsPerDegree + ); + } else { + unresponsivePitchFrames++; + if (unresponsivePitchFrames >= maxUnresponsivePitchFrames) { + // The measured pitch never responds (the game may map the + // vertical aim to another axis): stop driving it - and + // undo what was applied, in case the actual view WAS + // pitched without the measure seeing it. + pitchGivenUp = true; + await unwindAppliedPitch(); + } + } + } + } + } + return lastRelativePosition + ? makeResult(false, lastRelativePosition) + : null; + } + + // SCENARIO SETUP: + + /** + * Create a new instance of an object at the given position. + * Use for test setup only - do not use it to fake a game behavior + * you are supposed to test. + */ + spawn( + objectName: string, + x: float, + y: float, + z?: float, + layerName?: string + ): GameplayTestObjectSnapshot { + const currentScene = this._getCurrentScene(); + const object = currentScene.createObject(objectName); + if (!object) { + throw new Error( + `Could not create an instance of "${objectName}" - check the object exists in the scene (or as a global object).` + ); + } + object.setX(x); + object.setY(y); + const anyObject = object as any; + if (z !== undefined && typeof anyObject.setZ === 'function') { + anyObject.setZ(z); + } + if (layerName !== undefined) { + object.setLayer(layerName); + } + return this._makeObjectSnapshot(object, false); + } + + /** + * Remove the instance with the given id (from `getObjects`). + */ + removeObject(id: integer): void { + const currentScene = this._getCurrentScene(); + for (const object of currentScene.getAdhocListOfAllInstances()) { + if (object.id === id) { + object.deleteFromScene(); + return; + } + } + throw new Error(`No instance with id ${id} found.`); + } + + /** + * Move the instance with the given id to a position. + * Use for test setup only. The move takes effect immediately (physics + * bodies included), but the game's logic keeps acting on the object + * each stepped frame (forces, AI...): to hold an object somewhere, + * re-apply the position every frame via `onFrame`. + */ + setObjectPosition(id: integer, x: float, y: float, z?: float): void { + const currentScene = this._getCurrentScene(); + for (const object of currentScene.getAdhocListOfAllInstances()) { + if (object.id === id) { + object.setX(x); + object.setY(y); + const anyObject = object as any; + if (z !== undefined && typeof anyObject.setZ === 'function') { + anyObject.setZ(z); + } + return; + } + } + throw new Error(`No instance with id ${id} found.`); + } + + private _setVariableFromValue( + variable: gdjs.Variable, + value: string | number | boolean + ): void { + if (typeof value === 'number') variable.setNumber(value); + else if (typeof value === 'boolean') variable.setBoolean(value); + else variable.setString(value); + } + + /** + * Set a scene variable (number, string or boolean). + * Use for test setup only. + */ + setSceneVariable( + variableName: string, + value: string | number | boolean + ): void { + this._setVariableFromValue( + this._getCurrentScene().getVariables().get(variableName), + value + ); + } + + /** + * Set a global variable (number, string or boolean). + * Use for test setup only. + */ + setGlobalVariable( + variableName: string, + value: string | number | boolean + ): void { + this._setVariableFromValue( + this._runtimeGame.getVariables().get(variableName), + value + ); + } + + /** + * Create the instances of an external layout in the current scene. + */ + loadExternalLayout( + externalLayoutName: string, + x: float = 0, + y: float = 0, + z: float = 0 + ): void { + const currentScene = this._getCurrentScene(); + const externalLayoutData = + this._runtimeGame.getExternalLayoutData(externalLayoutName); + if (!externalLayoutData) { + throw new Error( + `The external layout "${externalLayoutName}" does not exist.` + ); + } + currentScene.createObjectsFrom( + externalLayoutData.instances, + x, + y, + z, + /*trackByPersistentUuid=*/ false + ); + } + + // VERDICTS AND EVIDENCE: + + /** + * Record a named assertion. Throws immediately on failure, stopping + * the script (wrap in try/catch if the check is optional). + */ + assert(condition: boolean, message: string): void { + if (this._assertions.length >= MAX_ASSERTIONS) { + throw new GameplayTestAssertionError( + `Too many assertions (max ${MAX_ASSERTIONS}).` + ); + } + this._assertions.push({ message, passed: !!condition }); + if (!condition) { + throw new GameplayTestAssertionError(`Assertion failed: ${message}`); + } + } + + /** + * Unconditionally record a failure and throw immediately, stopping + * the script. + */ + fail(message: string): void { + this._assertions.push({ message, passed: false }); + throw new GameplayTestAssertionError(message); + } + + /** + * Take a screenshot of the game canvas (downscaled). It's returned + * in the test result. + */ + async takeScreenshot(label: string = ''): Promise { + if (this._screenshots.length >= this._maxScreenshots) { + logger.warn( + `Ignoring screenshot "${label}": already ${this._maxScreenshots} screenshots taken.` + ); + return; + } + const canvas = this._runtimeGame.getRenderer().getCanvas(); + if (!canvas) { + logger.warn('No canvas found: unable to take a screenshot.'); + return; + } + try { + const scale = Math.min( + 1, + SCREENSHOT_MAX_SIZE / Math.max(canvas.width, canvas.height, 1) + ); + const targetWidth = Math.max(1, Math.round(canvas.width * scale)); + const targetHeight = Math.max(1, Math.round(canvas.height * scale)); + const downscaledCanvas = document.createElement('canvas'); + downscaledCanvas.width = targetWidth; + downscaledCanvas.height = targetHeight; + const context = downscaledCanvas.getContext('2d'); + if (!context) return; + context.drawImage(canvas, 0, 0, targetWidth, targetHeight); + const dataUrl = downscaledCanvas.toDataURL('image/jpeg', 0.7); + this._screenshots.push({ + label, + frame: this._framesExecuted, + jpegBase64: dataUrl.replace(/^data:image\/jpeg;base64,/, ''), + }); + } catch (error) { + logger.warn('Error while taking a screenshot: ' + error); + } + } + + /** + * Start profiling the current scene (see `stopProfiling`). + */ + startProfiling(): void { + this._profilingStartFrame = this._framesExecuted; + this._runtimeGame.startCurrentSceneProfiler(() => {}); + } + + /** + * Stop profiling and return a flat, JSON-safe summary: average and + * worst-frame time per profiled section (events, physics, + * rendering... - nested sections are flattened as "parent > child", + * sorted by average time descending), the frame-by-frame timeline (to + * correlate a spike with the `eventLog` frames), the live object + * counts, and renderer/memory counters when available. + */ + stopProfiling(): GameplayTestProfilingResult | null { + const currentScene = this._runtimeGame + .getSceneStack() + .getCurrentScene(); + const profiler = currentScene ? currentScene.getProfiler() : null; + if (!profiler) return null; + const framesAverageMeasures = profiler.getFramesAverageMeasures(); + const framesMaxMeasures = profiler.getFramesMaxMeasures(); + const frameTimes = profiler.getFrameTimes(); + this._runtimeGame.stopCurrentSceneProfiler(); + + const roundMs = (timeMs: float) => Math.round(timeMs * 100) / 100; + const sections: Array<{ + name: string; + avgTimeMs: number; + maxTimeMs: number; + }> = []; + const visitSubsections = ( + averageMeasure: gdjs.FrameMeasureOutput, + maxMeasure: gdjs.FrameMeasureOutput | null, + path: string + ) => { + const subsections = averageMeasure.subsections; + for (const name in subsections) { + const fullName = path ? path + ' > ' + name : name; + const maxSubsection = + (maxMeasure && maxMeasure.subsections[name]) || null; + sections.push({ + name: fullName, + avgTimeMs: roundMs(subsections[name].time || 0), + maxTimeMs: roundMs((maxSubsection && maxSubsection.time) || 0), + }); + visitSubsections(subsections[name], maxSubsection, fullName); + } + }; + visitSubsections(framesAverageMeasures, framesMaxMeasures, ''); + sections.sort((a, b) => b.avgTimeMs - a.avgTimeMs); + + const threeRenderer = (this._runtimeGame.getRenderer() as any) + .getThreeRenderer + ? (this._runtimeGame.getRenderer() as any).getThreeRenderer() + : null; + const threeInfo = threeRenderer ? threeRenderer.info : null; + + const performanceMemory = + typeof performance !== 'undefined' && (performance as any).memory + ? (performance as any).memory + : null; + + // The frames profiled, in harness frame numbers (like `eventLog`): + // the frame times captured are for frames startFrame+1..endFrame. + const endFrame = this._framesExecuted; + const startFrame = + this._profilingStartFrame !== null + ? this._profilingStartFrame + : Math.max(0, endFrame - frameTimes.length); + this._profilingStartFrame = null; + + // The most expensive frames, worst first, with their harness frame + // numbers so they can be correlated with the `eventLog`. + const worstFrames = frameTimes + .map((timeMs, index) => ({ + frame: startFrame + 1 + index, + timeMs: roundMs(timeMs), + })) + .sort((a, b) => b.timeMs - a.timeMs) + .slice(0, MAX_PROFILING_WORST_FRAMES); + + // Keep the timeline compact: past 120 frames, downsample by buckets + // keeping the MAX of each bucket (spikes are preserved). + const frameTimesBucketSize = Math.max( + 1, + Math.ceil(frameTimes.length / MAX_PROFILING_TIMELINE_ENTRIES) + ); + const frameTimesMs: Array = []; + for (let i = 0; i < frameTimes.length; i += frameTimesBucketSize) { + let bucketMax = 0; + for ( + let j = i; + j < Math.min(i + frameTimesBucketSize, frameTimes.length); + j++ + ) { + bucketMax = Math.max(bucketMax, frameTimes[j]); + } + frameTimesMs.push(roundMs(bucketMax)); + } + + const profile: GameplayTestProfilingResult = { + startFrame, + endFrame, + avgStepTimeMs: roundMs(framesAverageMeasures.time || 0), + maxStepTimeMs: roundMs(framesMaxMeasures.time || 0), + sections: sections.slice(0, MAX_PROFILING_SECTIONS), + worstFrames, + frameTimesMs, + frameTimesBucketSize, + objectCounts: this._getObjectCounts(), + renderer: threeInfo + ? { + drawCalls: threeInfo.render.calls, + triangles: threeInfo.render.triangles, + geometries: threeInfo.memory.geometries, + textures: threeInfo.memory.textures, + } + : null, + ...(performanceMemory + ? { + jsHeapUsedMb: + Math.round( + (performanceMemory.usedJSHeapSize / (1024 * 1024)) * 10 + ) / 10, + } + : {}), + }; + // Also attach the profile to the test result (keeping the last + // ones), so it reaches the report even if the script does not log it. + this._profiles.push(profile); + if (this._profiles.length > MAX_PROFILES_PER_RESULT) { + this._profiles.shift(); + } + return profile; + } + + /** + * Record a console log in the test result (also shown in the + * browser console). Used by the `console` given to the script. + */ + _recordConsoleLog( + level: 'log' | 'warn' | 'error', + message: string + ): void { + if ( + this._consoleLogs.length >= MAX_CONSOLE_LOGS || + this._consoleLogsTotalChars >= MAX_CONSOLE_LOGS_TOTAL_CHARS + ) { + return; + } + const cappedMessage = message.slice(0, 1000); + this._consoleLogsTotalChars += cappedMessage.length; + this._consoleLogs.push({ level, message: cappedMessage }); + } + + /** Undo the pointer lock shim (see `_installPointerLockShim`). */ + _uninstallPointerLockShim: () => void = () => {}; + + /** + * Patch pointer lock during the test: `requestPointerLock` never + * really locks the OS mouse, but as soon as the game requests it, a + * locked pointer is seen by the game AND by anything reading + * `document.pointerLockElement` directly or listening to canvas + * `pointermove` events (like mouse-look extensions) - which then + * receive the `setMouseDelta` deltas as real DOM events. + */ + _installPointerLockShim(): void { + const restorers: Array<() => void> = []; + const harness = this; + const setFakePointerLock = (locked: boolean) => { + if (harness._pointerLockRequestedByGame === locked) return; + harness._pointerLockRequestedByGame = locked; + try { + document.dispatchEvent(new Event('pointerlockchange')); + } catch (error) { + // Ignore: no DOM support. + } + }; + + const renderer = this._runtimeGame.getRenderer() as any; + if (typeof renderer.requestPointerLock === 'function') { + const original = { + requestPointerLock: renderer.requestPointerLock, + exitPointerLock: renderer.exitPointerLock, + isPointerLocked: renderer.isPointerLocked, + }; + renderer.requestPointerLock = function () { + setFakePointerLock(true); + return true; + }; + renderer.exitPointerLock = function () { + setFakePointerLock(false); + }; + renderer.isPointerLocked = function () { + return harness._pointerLockRequestedByGame; + }; + restorers.push(() => { + renderer.requestPointerLock = original.requestPointerLock; + renderer.exitPointerLock = original.exitPointerLock; + renderer.isPointerLocked = original.isPointerLocked; + }); + } + + const canvas = + typeof renderer.getCanvas === 'function' + ? renderer.getCanvas() + : null; + if (canvas && typeof document !== 'undefined') { + const originalCanvasRequestPointerLock = canvas.requestPointerLock; + canvas.requestPointerLock = () => setFakePointerLock(true); + restorers.push(() => { + canvas.requestPointerLock = originalCanvasRequestPointerLock; + }); + + const originalDocumentExitPointerLock = document.exitPointerLock; + (document as any).exitPointerLock = () => setFakePointerLock(false); + restorers.push(() => { + (document as any).exitPointerLock = originalDocumentExitPointerLock; + }); + + try { + Object.defineProperty(document, 'pointerLockElement', { + get: () => (harness._pointerLockRequestedByGame ? canvas : null), + configurable: true, + }); + restorers.push(() => { + delete (document as any).pointerLockElement; + }); + } catch (error) { + // Ignore: `pointerLockElement` cannot be faked in this browser. + } + } + + this._uninstallPointerLockShim = () => { + restorers.forEach((restore) => restore()); + this._uninstallPointerLockShim = () => {}; + }; + } + + _makeResult( + status: GameplayTestResult['status'], + errors: Array + ): GameplayTestResult { + const currentScene = this._runtimeGame + .getSceneStack() + .getCurrentScene(); + const watchedObjects: { + [objectName: string]: Array; + } = {}; + if (currentScene) { + for (const objectName of this._watchedObjectNames) { + try { + watchedObjects[objectName] = this.getObjects(objectName); + } catch (error) { + // Ignore snapshot errors when building the result. + } + } + } + return { + testName: this._payload.testName, + status, + framesExecuted: this._framesExecuted, + durationMs: this._startTimeMs ? Date.now() - this._startTimeMs : 0, + gameTimeMs: Math.round(this._gameTimeMs), + assertions: this._assertions, + errors: errors.slice(0, MAX_ERRORS), + consoleLogs: this._consoleLogs, + eventLog: this._eventLog, + finalState: { + sceneName: currentScene ? currentScene.getName() : '', + objectCounts: this._getObjectCounts(), + watchedObjects, + sceneVariables: currentScene + ? currentScene.getVariables().getNetworkSyncData({}) + : [], + }, + screenshots: this._screenshots, + profiles: this._profiles, + performance: + this._framesExecuted > 0 + ? { + avgStepMs: + Math.round( + (this._totalStepTimeMs / this._framesExecuted) * 100 + ) / 100, + worstStepMs: this._worstStepTimeMs, + } + : null, + }; + } + } + + /** + * The gameplay test being currently run, if any. + */ + let currentlyRunningHarness: GameplayTestHarness | null = null; + + /** + * Request the running gameplay test (if any) to stop as soon as + * possible. + */ + export const stopCurrentGameplayTest = (): void => { + if (currentlyRunningHarness) { + currentlyRunningHarness.requestStop(); + } + }; + + /** + * True while a gameplay test is running (the harness owns the game + * stepping: external state mutations must be avoided). + */ + export const isGameplayTestRunning = (): boolean => + !!currentlyRunningHarness; + + /** + * Run a gameplay test script against the game and return its result. + * + * The game main loop keeps rendering (paused) while the test steps the + * game logic deterministically at full speed, yielding to the browser + * regularly so the run stays visible and interruptible (see + * `_maybeYield`). + */ + export const runGameplayTest = async ( + runtimeGame: gdjs.RuntimeGame, + payload: GameplayTestRunPayload, + onProgress?: (frame: integer) => void + ): Promise => { + if (currentlyRunningHarness) { + const failedResult = new GameplayTestHarness( + runtimeGame, + payload + )._makeResult('error', [ + 'A gameplay test is already running. Wait for it to finish or stop it first.', + ]); + return failedResult; + } + + const harness = new GameplayTestHarness(runtimeGame, payload); + currentlyRunningHarness = harness; + harness._onProgress = onProgress || null; + + // The source must be the BODY of `async (harness) => { ... }`, but + // AI models (and users pasting code) sometimes send the whole function + // instead. Evaluated as-is it would be a no-op expression: detect the + // wrapper and call it instead. + let source = payload.source; + if ( + /^\s*(?:async\s*)?(?:\(\s*harness\s*(?:,\s*console\s*)?\)|harness)\s*=>/.test( + source + ) + ) { + source = 'return (\n' + source + '\n)(harness, console);'; + } + + // Compile the script first, so a syntax error is reported cleanly. + let scriptFunction: Function; + try { + scriptFunction = new Function( + 'harness', + 'console', + '"use strict"; return (async () => {\n' + source + '\n})();' + ); + } catch (error) { + currentlyRunningHarness = null; + return harness._makeResult('error', [ + 'The test script could not be parsed: ' + error, + ]); + } + + const inputManager = runtimeGame.getInputManager(); + const wasPaused = runtimeGame.isPaused(); + + // Pause the game: the main loop keeps rendering (`renderWithoutStep`) + // but stops stepping the game logic - the harness owns stepping. + runtimeGame.pause(true); + + // The paused main loop still calls `onFrameEnded` every animation + // frame, which would clear the inputs simulated by the test between + // two manually stepped frames. Neutralize it during the test: the + // harness calls the original after each stepped frame instead. + const originalOnFrameEnded = inputManager.onFrameEnded.bind(inputManager); + inputManager.onFrameEnded = () => {}; + harness._callOnFrameEnded = originalOnFrameEnded; + + harness._installPointerLockShim(); + + // Capture the logs of the game itself (in addition to the `console` + // passed to the script). + const existingLoggerOutput = gdjs.Logger.getLoggerOutput(); + gdjs.Logger.setLoggerOutput({ + log: ( + group: string, + message: string, + type: 'info' | 'warning' | 'error' = 'info', + internal = true + ) => { + existingLoggerOutput.log(group, message, type, internal); + harness._recordConsoleLog( + type === 'warning' ? 'warn' : type === 'error' ? 'error' : 'log', + `[${group}] ${message}` + ); + }, + }); + + const stringifyConsoleArguments = (args: Array): string => + args + .map((value) => { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch (error) { + return String(value); + } + }) + .join(' '); + const scriptConsole = { + log: (...args: Array) => { + console.log(...args); + harness._recordConsoleLog('log', stringifyConsoleArguments(args)); + }, + warn: (...args: Array) => { + console.warn(...args); + harness._recordConsoleLog('warn', stringifyConsoleArguments(args)); + }, + error: (...args: Array) => { + console.error(...args); + harness._recordConsoleLog('error', stringifyConsoleArguments(args)); + }, + }; + + harness._startTimeMs = Date.now(); + let result: GameplayTestResult; + try { + // A wall-clock watchdog, in case the script awaits something that + // never resolves. A synchronous infinite loop can NOT be interrupted + // (this is a limit of running in the same thread as the game). + let watchdogTimeoutId: any = null; + const watchdog = new Promise((_, reject) => { + watchdogTimeoutId = setTimeout( + () => + reject( + new GameplayTestTimeoutError( + `The test timed out after ${harness._timeoutMs}ms (wall-clock).` + ) + ), + harness._timeoutMs + 1000 + ); + }); + // A stop rejects this promise, interrupting the script even when + // it awaits something else than the harness (a timer, a fetch...). + const stopSignal = new Promise((_, reject) => { + harness._rejectOnStop = reject; + }); + // Report the test as started (frame 0): a test can legitimately + // spend time without stepping any frame. + if (harness._onProgress) harness._onProgress(0); + try { + await Promise.race([ + scriptFunction(harness, scriptConsole), + watchdog, + stopSignal, + ]); + } finally { + if (watchdogTimeoutId) clearTimeout(watchdogTimeoutId); + harness._rejectOnStop = null; + } + + const hasFailedAssertion = harness._assertions.some( + (assertion) => !assertion.passed + ); + if ( + !hasFailedAssertion && + harness._framesExecuted === 0 && + harness._assertions.length === 0 + ) { + // A "passed" run that stepped no frame and asserted nothing is a + // no-op script, not a passing test: never report a false green. + result = harness._makeResult('error', [ + 'The test completed without stepping a single frame nor making any assertion — it did nothing. ' + + 'The test source must be the BODY of `async (harness) => { ... }` (statements starting with `await harness...`), not a function definition.', + ]); + } else { + result = harness._makeResult( + hasFailedAssertion ? 'failed' : 'passed', + [] + ); + } + } catch (error) { + // (No type annotation on `error`: this file must stay parseable by + // the older TypeScript bundled in the Monaco editor, which reads it + // to provide autocompletions in the test editor.) + if (error && error.isGameplayTestAssertionError) { + result = harness._makeResult('failed', [String(error.message)]); + } else if (error && error.isGameplayTestStoppedError) { + result = harness._makeResult('stopped', [ + 'The test was stopped before completing.', + ]); + } else if (error && error.isGameplayTestTimeoutError) { + result = harness._makeResult('timeout', [String(error.message)]); + } else { + result = harness._makeResult('error', [ + (error && error.stack ? String(error.stack) : String(error)).slice( + 0, + 2000 + ), + ]); + } + } finally { + // Restore everything, whatever happened: + try { + harness.releaseAllInputs(); + } catch (error) { + // Ignore errors during cleanup. + } + inputManager.onFrameEnded = originalOnFrameEnded; + harness._uninstallPointerLockShim(); + gdjs.Logger.setLoggerOutput(existingLoggerOutput); + if (payload.freezeWhenFinished) { + // Keep the game paused (the main loop keeps rendering the last + // frame) and muted, so it can stay visible without playing. + runtimeGame.pause(true); + try { + runtimeGame.getSoundManager().setGlobalVolume(0); + } catch (error) { + // Ignore errors while muting the game. + } + } else { + runtimeGame.pause(wasPaused); + } + currentlyRunningHarness = null; + } + + return result; + }; + } +} diff --git a/GDJS/Runtime/profiler.ts b/GDJS/Runtime/profiler.ts index 1f376045d5..ab82a0d808 100644 --- a/GDJS/Runtime/profiler.ts +++ b/GDJS/Runtime/profiler.ts @@ -16,6 +16,17 @@ namespace gdjs { subsections: Record; }; + /** + * Measures output by the profiler (see `getFramesAverageMeasures`): a + * plain tree without back-references, safe to serialize with + * `JSON.stringify`. + * @category Debugging > Profiler + */ + export type FrameMeasureOutput = { + time: float; + subsections: Record; + }; + /** * A basic profiling tool that can be used to measure time spent in sections of the engine. * @category Debugging > Profiler @@ -134,7 +145,7 @@ namespace gdjs { static _addAverageSectionTimes( section: FrameMeasure, - destinationSection: FrameMeasure, + destinationSection: FrameMeasureOutput, totalCount: integer, i: integer ): void { @@ -145,7 +156,6 @@ namespace gdjs { const destinationSubsections = destinationSection.subsections; const destinationSubsection = (destinationSubsections[sectionName] = destinationSubsections[sectionName] || { - parent: destinationSection, time: 0, subsections: {}, }); @@ -161,13 +171,12 @@ namespace gdjs { /** * Return the measures for all the section of the game during the frames - * captured. + * captured, as a plain tree (no back-references): safe to serialize + * with `JSON.stringify`. */ - getFramesAverageMeasures(): FrameMeasure { - const framesAverageMeasures = { - parent: null, + getFramesAverageMeasures(): FrameMeasureOutput { + const framesAverageMeasures: FrameMeasureOutput = { time: 0, - lastStartTime: 0, subsections: {}, }; for (let i = 0; i < this._framesCount; ++i) { @@ -181,6 +190,65 @@ namespace gdjs { return framesAverageMeasures; } + static _addMaxSectionTimes( + section: FrameMeasure, + destinationSection: FrameMeasureOutput + ): void { + destinationSection.time = Math.max( + destinationSection.time || 0, + section.time + ); + for (const sectionName in section.subsections) { + if (section.subsections.hasOwnProperty(sectionName)) { + const destinationSubsections = destinationSection.subsections; + const destinationSubsection = (destinationSubsections[sectionName] = + destinationSubsections[sectionName] || { + time: 0, + subsections: {}, + }); + Profiler._addMaxSectionTimes( + section.subsections[sectionName], + destinationSubsection + ); + } + } + } + + /** + * Return, for each section, the maximum time it took during a single + * captured frame - the "worst frame" per section, catching the spikes + * that averages hide. Plain tree, safe to serialize with + * `JSON.stringify`. + */ + getFramesMaxMeasures(): FrameMeasureOutput { + const framesMaxMeasures: FrameMeasureOutput = { + time: 0, + subsections: {}, + }; + for (let i = 0; i < this._framesCount; ++i) { + Profiler._addMaxSectionTimes( + this._framesMeasures[i], + framesMaxMeasures + ); + } + return framesMaxMeasures; + } + + /** + * Return the total time of each captured frame, in chronological order + * (up to the last 600 frames). + */ + getFrameTimes(): Array { + const frameTimes: Array = []; + const isBufferFull = this._framesCount >= this._maxFramesCount; + const startIndex = isBufferFull ? this._currentFrameIndex : 0; + for (let i = 0; i < this._framesCount; ++i) { + const index = (startIndex + i) % this._maxFramesCount; + frameTimes.push(this._framesMeasures[index].time); + } + return frameTimes; + } + /** * Get stats measured during the frames captured. */ @@ -198,15 +266,13 @@ namespace gdjs { */ static getProfilerSectionTexts( sectionName: string, - profilerSection: any, - outputs: any + profilerSection: FrameMeasureOutput, + outputs: Array, + parentTime?: float | null ): void { const percent = - profilerSection.parent && profilerSection.parent.time !== 0 - ? ( - (profilerSection.time / profilerSection.parent.time) * - 100 - ).toFixed(1) + parentTime && parentTime !== 0 + ? ((profilerSection.time / parentTime) * 100).toFixed(1) : '100%'; const time = profilerSection.time.toFixed(2); outputs.push(sectionName + ': ' + time + 'ms (' + percent + ')'); @@ -216,7 +282,8 @@ namespace gdjs { Profiler.getProfilerSectionTexts( subsectionName, profilerSection.subsections[subsectionName], - subsectionsOutputs + subsectionsOutputs, + profilerSection.time ); } } diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index af89440855..762d98e610 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -1193,6 +1193,10 @@ namespace gdjs { ) => Promise, progressCallback?: (progress: float) => void ): Promise { + // Remember if the game was already paused (e.g. by a gameplay test or + // the debugger), to restore that state - not blindly unpause - once + // the assets are loaded. + const wasPaused = this._paused; this.pause(true); const loadingScreen = new gdjs.LoadingScreenRenderer( this.getRenderer(), @@ -1221,7 +1225,7 @@ namespace gdjs { this._displayedLoadingScreen = null; if (!this._isInGameEdition) { - this.pause(false); + this.pause(wasPaused); } } diff --git a/GDJS/Runtime/scenestack.ts b/GDJS/Runtime/scenestack.ts index 8c29f58903..21356ced47 100644 --- a/GDJS/Runtime/scenestack.ts +++ b/GDJS/Runtime/scenestack.ts @@ -29,6 +29,14 @@ namespace gdjs { _isNextLayoutLoading: boolean = false; _sceneStackSyncDataToApply: SceneStackNetworkSyncData | null = null; _wasDisposed: boolean = false; + /** The cause attributed to the scene stack changes happening now (see + * `runWithSceneChangeCause`), or null when nothing declared one. */ + private _activeChangeCause: string | null = null; + /** The cause of the last stack change ('external' when nothing declared + * one), read with `consumeLastSceneChangeCause`. */ + private _lastChangeCause: string | null = null; + /** For an 'external' change: where it came from (call stack). */ + private _lastChangeCauseStack: string | null = null; /** * @param runtimeGame The runtime game that is using the scene stack @@ -40,6 +48,56 @@ namespace gdjs { this._runtimeGame = runtimeGame; } + /** + * Attribute any scene stack change done by `action` to `cause` (e.g. + * 'game', 'networkSync', 'gameplayTest'...). The stack labels its own + * changes: a change requested by the game logic during `step` is + * attributed to 'game', an applied network sync to 'networkSync'. A + * change happening outside of any declared cause is recorded as + * 'external' (with the call stack that made it). + */ + runWithSceneChangeCause(cause: string, action: () => T): T { + const previousCause = this._activeChangeCause; + this._activeChangeCause = cause; + try { + return action(); + } finally { + this._activeChangeCause = previousCause; + } + } + + /** + * Return the cause of the last scene stack change (and clear it): + * the cause declared with `runWithSceneChangeCause`, or 'external' + * (with the call stack of the change). Null if no change happened + * since the last call. + */ + consumeLastSceneChangeCause(): { + cause: string; + stack: string | null; + } | null { + if (this._lastChangeCause === null) return null; + const lastChangeCause = { + cause: this._lastChangeCause, + stack: this._lastChangeCauseStack, + }; + this._lastChangeCause = null; + this._lastChangeCauseStack = null; + return lastChangeCause; + } + + private _recordSceneStackChange(): void { + this._lastChangeCause = this._activeChangeCause || 'external'; + this._lastChangeCauseStack = this._activeChangeCause + ? null + : (new Error().stack || '') + .split('\n') + .slice(2, 5) + .map((line) => line.trim()) + .join(' ') + .slice(0, 300); + } + /** * Called by the RuntimeGame when the game resolution is changed. * Useful to notify scene and layers that resolution is changed, as they @@ -57,7 +115,10 @@ namespace gdjs { return false; } - const hasMadeChangeToStack = this.applyUpdateFromNetworkSyncDataIfAny(); + const hasMadeChangeToStack = this.runWithSceneChangeCause( + 'networkSync', + () => this.applyUpdateFromNetworkSyncDataIfAny() + ); if (hasMadeChangeToStack) { debugLogger.info( 'Scene stack has been updated from network sync data, skipping step.' @@ -68,32 +129,36 @@ namespace gdjs { return true; } - const currentScene = this._stack[this._stack.length - 1]; - if (currentScene.renderAndStep(elapsedTime)) { - const request = currentScene.getRequestedChange(); + // Any scene stack change from here comes from the game's own logic + // (a scene change/restart action). + return this.runWithSceneChangeCause('game', () => { + const currentScene = this._stack[this._stack.length - 1]; + if (currentScene.renderAndStep(elapsedTime)) { + const request = currentScene.getRequestedChange(); - // A scene change was requested by the current scene. - if (request === gdjs.SceneChangeRequest.STOP_GAME) { - this._runtimeGame.getRenderer().stopGame(); - return true; - } else if (request === gdjs.SceneChangeRequest.POP_SCENE) { - this.pop(); - } else if (request === gdjs.SceneChangeRequest.PUSH_SCENE) { - this.push(currentScene.getRequestedScene()); - } else if ( - request === gdjs.SceneChangeRequest.REPLACE_SCENE || - request === gdjs.SceneChangeRequest.CLEAR_SCENES - ) { - this.replace( - currentScene.getRequestedScene(), + // A scene change was requested by the current scene. + if (request === gdjs.SceneChangeRequest.STOP_GAME) { + this._runtimeGame.getRenderer().stopGame(); + return true; + } else if (request === gdjs.SceneChangeRequest.POP_SCENE) { + this.pop(); + } else if (request === gdjs.SceneChangeRequest.PUSH_SCENE) { + this.push(currentScene.getRequestedScene()); + } else if ( + request === gdjs.SceneChangeRequest.REPLACE_SCENE || request === gdjs.SceneChangeRequest.CLEAR_SCENES - ); - } else { - logger.error('Unrecognized change in scene stack: ' + request); + ) { + this.replace( + currentScene.getRequestedScene(), + request === gdjs.SceneChangeRequest.CLEAR_SCENES + ); + } else { + logger.error('Unrecognized change in scene stack: ' + request); + } } - } - return true; + return true; + }); } renderWithoutStep(): boolean { @@ -109,6 +174,7 @@ namespace gdjs { pop(popCount = 1): void { this._throwIfDisposed(); + this._recordSceneStackChange(); let hasDoneAnyChanges = false; for (let i = 0; i < popCount; ++i) { @@ -149,6 +215,7 @@ namespace gdjs { deprecatedExternalLayoutName?: string ): gdjs.RuntimeScene | null { this._throwIfDisposed(); + this._recordSceneStackChange(); const sceneName = typeof options === 'string' ? options : options.sceneName; @@ -185,15 +252,26 @@ namespace gdjs { } this._isNextLayoutLoading = true; + // The scene is created later (once assets are loaded): attribute that + // deferred change to the cause active NOW, at request time. + const requestChangeCause = this._activeChangeCause; this._runtimeGame.loadSceneAssets(sceneName).then(() => { - this._loadNewScene({ - sceneName, - externalLayoutName, - getExcludedObjectNames, - skipStoppingSoundsOnStartup, - skipCreatingInstances, - }); - this._isNextLayoutLoading = false; + const loadScene = () => { + this._loadNewScene({ + sceneName, + externalLayoutName, + getExcludedObjectNames, + skipStoppingSoundsOnStartup, + skipCreatingInstances, + }); + this._recordSceneStackChange(); + this._isNextLayoutLoading = false; + }; + if (requestChangeCause !== null) { + this.runWithSceneChangeCause(requestChangeCause, loadScene); + } else { + loadScene(); + } }); return null; @@ -247,6 +325,7 @@ namespace gdjs { options: ReplaceSceneOptions | string, deprecatedClear?: boolean ): gdjs.RuntimeScene | null { + this._recordSceneStackChange(); const clear = deprecatedClear || typeof options === 'string' ? false : options.clear; const newSceneName = diff --git a/GDJS/tests/karma.conf.js b/GDJS/tests/karma.conf.js index ee2e189d54..7a6a9af484 100644 --- a/GDJS/tests/karma.conf.js +++ b/GDJS/tests/karma.conf.js @@ -108,6 +108,7 @@ module.exports = function (config) { './newIDE/app/resources/GDJS/Runtime/events-tools/stringtools.js', './newIDE/app/resources/GDJS/Runtime/events-tools/windowtools.js', './newIDE/app/resources/GDJS/Runtime/debugger-client/hot-reloader.js', + './newIDE/app/resources/GDJS/Runtime/gameplay-tests/gameplay-test-runner.js', './newIDE/app/resources/GDJS/Runtime/affinetransformation.js', //Extensions: diff --git a/GDJS/tests/tests/gameplaytestharness.js b/GDJS/tests/tests/gameplaytestharness.js new file mode 100644 index 0000000000..6e29696b5b --- /dev/null +++ b/GDJS/tests/tests/gameplaytestharness.js @@ -0,0 +1,927 @@ +// @ts-check + +/** + * Tests for gdjs.gameplayTests (the gameplay test harness). + */ +describe('gdjs.gameplayTests', () => { + const createSceneData = (name) => + /** @type {any} */ ({ + r: 0, + v: 0, + b: 0, + mangledName: name, + name, + objects: [ + { + name: 'MyObject', + type: '', + behaviors: [], + variables: [], + effects: [], + }, + ], + objectsGroups: [], + layers: [{ name: '', visibility: true, effects: [], cameras: [] }], + instances: [], + behaviorsSharedData: [], + stopSoundsOnStartup: false, + title: '', + variables: [], + usedResources: [], + }); + + const makeRuntimeGame = () => + gdjs.getPixiRuntimeGame({ + layouts: [createSceneData('Scene 1'), createSceneData('Scene 2')], + }); + + const createSceneDataWithPlatformerObject = (name) => { + const sceneData = createSceneData(name); + sceneData.objects.push({ + name: 'Player', + type: '', + behaviors: [ + { + type: 'PlatformBehavior::PlatformerObjectBehavior', + name: 'PlatformerObject', + gravity: 1500, + maxFallingSpeed: 1500, + acceleration: 500, + deceleration: 1500, + maxSpeed: 500, + jumpSpeed: 900, + canGrabPlatforms: false, + ignoreDefaultControls: false, + slopeMaxAngle: 60, + jumpSustainTime: 0.2, + useLegacyTrajectory: false, + useRepeatedJump: false, + }, + ], + variables: [], + effects: [], + }); + return sceneData; + }; + + const createSceneDataWithInitialPlayerInstance = (name) => { + const sceneData = createSceneDataWithPlatformerObject(name); + sceneData.instances.push( + /** @type {any} */ ({ + persistentUuid: 'player-1', + layer: '', + locked: false, + name: 'Player', + x: 100, + y: 100, + angle: 0, + zOrder: 0, + customSize: false, + width: 0, + height: 0, + numberProperties: [], + stringProperties: [], + initialVariables: [], + }) + ); + return sceneData; + }; + + // The state inspectors as the editor would derive them from the extensions + // metadata (see `GameplayTestStateInspectors.js` in the editor). + const platformerStateInspectors = { + behaviors: { + 'PlatformBehavior::PlatformerObjectBehavior': [ + { name: 'IsOnFloor', functionName: 'isOnFloor', kind: 'boolean' }, + { name: 'IsJumping', functionName: 'isJumping', kind: 'boolean' }, + { name: 'IsFalling', functionName: 'isFalling', kind: 'boolean' }, + { name: 'CanJump', functionName: 'canJump', kind: 'boolean' }, + { + name: 'CurrentFallSpeed', + functionName: 'getCurrentFallSpeed', + kind: 'number', + }, + { name: 'Gravity', functionName: 'getGravity', kind: 'number' }, + // A stale entry (e.g. an outdated editor): silently skipped. + { name: 'DoesNotExist', functionName: 'doesNotExist', kind: 'number' }, + ], + }, + objects: { + '': [{ name: 'X', functionName: 'getX', kind: 'number' }], + }, + }; + + /** + * @param {gdjs.RuntimeGame} runtimeGame + * @param {string} source + * @param {Object=} extraPayload + */ + const runTestScript = (runtimeGame, source, extraPayload) => + gdjs.gameplayTests.runGameplayTest(runtimeGame, { + testName: 'Test', + source, + timeoutMs: 5000, + .../** @type {any} */ (extraPayload || {}), + }); + + it('runs a passing test and reports its result', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + await harness.stepFrames(5); + console.log('Hello from the test'); + harness.assert(harness.getSceneName() === 'Scene 1', 'Scene is running'); + ` + ); + + expect(result.status).to.be('passed'); + expect(result.framesExecuted).to.be(6); // 1 (goToScene) + 5. + expect(result.assertions.length).to.be(1); + expect(result.assertions[0].passed).to.be(true); + expect( + result.consoleLogs.some( + (log) => log.message.indexOf('Hello from the test') !== -1 + ) + ).to.be(true); + expect(result.finalState.sceneName).to.be('Scene 1'); + expect(result.gameTimeMs).to.be(Math.round((6 * 1000) / 60)); + }); + + it('reports a failed assertion and stops the script immediately', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.assert(false, 'This must fail'); + console.log('This must never be logged'); + ` + ); + + expect(result.status).to.be('failed'); + expect(result.assertions.length).to.be(1); + expect(result.assertions[0].passed).to.be(false); + expect( + result.consoleLogs.some( + (log) => log.message.indexOf('never be logged') !== -1 + ) + ).to.be(false); + }); + + it('reports a script error', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.thisMethodDoesNotExist(); + ` + ); + + expect(result.status).to.be('error'); + expect(result.errors.length).to.be(1); + }); + + it('reports a syntax error as an error', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript(runtimeGame, `this is not valid JS {`); + + expect(result.status).to.be('error'); + expect(result.errors[0]).to.contain('could not be parsed'); + }); + + it('auto-unwraps a source wrapped in `async (harness) => {...}` and runs it', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + `async (harness) => { + await harness.goToScene('Scene 1'); + await harness.stepFrames(3); + harness.assert(harness.getSceneName() === 'Scene 1', 'Scene is running'); + }` + ); + + expect(result.status).to.be('passed'); + expect(result.framesExecuted).to.be(4); // 1 (goToScene) + 3. + expect(result.assertions.length).to.be(1); + }); + + it('reports a no-op script (no frame stepped, no assertion) as an error', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + // A function definition that is never called: without the guard, this + // would complete instantly and be reported as a false "passed". + `const runIt = async () => { + await harness.stepFrames(5); + harness.assert(true, 'Never reached'); + };` + ); + + expect(result.status).to.be('error'); + expect(result.framesExecuted).to.be(0); + expect(result.errors[0]).to.contain('did nothing'); + }); + + it('evaluates readable object and behavior state in snapshots', async () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + layouts: [createSceneDataWithPlatformerObject('Scene 1')], + }); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + const player = harness.spawn('Player', 100, 50); + await harness.stepFrames(10); + + const snapshot = harness.getObjects('Player')[0]; + const state = snapshot.behaviors.PlatformerObject.state; + harness.assert(state.IsFalling === true, 'Falling'); + harness.assert(state.IsOnFloor === false, 'Not on floor'); + harness.assert(state.CurrentFallSpeed > 0, 'Fall speed > 0'); + harness.assert(state.Gravity === 1500, 'Configured gravity'); + harness.assert(!('DoesNotExist' in state), 'Stale entry skipped'); + harness.assert( + snapshot.behaviors.PlatformerObject.act === true, + 'Behavior activated' + ); + harness.assert(snapshot.state.X === snapshot.x, 'Object-level state'); + console.log(JSON.stringify(state)); + `, + { stateInspectors: platformerStateInspectors } + ); + + expect(result.status).to.be('passed'); + // The state serializes transparently (through the self-describing proxy). + expect( + result.consoleLogs.some( + (log) => log.message.indexOf('"IsFalling":true') !== -1 + ) + ).to.be(true); + }).timeout(10000); + + it('throws with the available names when reading an unknown state', async () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + layouts: [createSceneDataWithPlatformerObject('Scene 1')], + }); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.spawn('Player', 100, 50); + await harness.stepFrames(2); + // Wrong casing: must throw with the list of available names. + const isOnFloor = + harness.getObjects('Player')[0].behaviors.PlatformerObject.state + .isOnFloor; + `, + { stateInspectors: platformerStateInspectors } + ); + + expect(result.status).to.be('error'); + expect(result.errors[0]).to.contain('Unknown state "isOnFloor"'); + expect(result.errors[0]).to.contain('IsOnFloor'); + }).timeout(10000); + + it('gives the raw runtime objects and behaviors as escape hatches', async () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + layouts: [createSceneDataWithPlatformerObject('Scene 1')], + }); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + const spawned = harness.spawn('Player', 100, 50); + await harness.stepFrames(2); + + const playerByName = harness.getRuntimeObject('Player'); + harness.assert( + playerByName instanceof gdjs.RuntimeObject, + 'getRuntimeObject returns the gdjs.RuntimeObject' + ); + harness.assert( + harness.getRuntimeObject(spawned.id) === playerByName, + 'The same instance is found by id' + ); + harness.assert( + harness.getRuntimeObject(-1) === null && + harness.getRuntimeObject('Nothing') === null, + 'Unknown id or object name gives null' + ); + + const behavior = playerByName.getBehavior('PlatformerObject'); + harness.assert( + behavior instanceof gdjs.RuntimeBehavior, + 'getBehavior returns the gdjs.RuntimeBehavior' + ); + harness.assert( + !playerByName.getBehavior('Nope'), + 'Unknown behavior gives nothing' + ); + `, + { stateInspectors: platformerStateInspectors } + ); + + expect(result.status).to.be('passed'); + }).timeout(10000); + + it('gives the raw runtime game, scene and layers as escape hatches', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.assert( + harness.getRuntimeGame() instanceof gdjs.RuntimeGame, + 'getRuntimeGame returns the gdjs.RuntimeGame' + ); + harness.assert( + harness.getCurrentRuntimeScene() instanceof gdjs.RuntimeScene, + 'getCurrentRuntimeScene returns the gdjs.RuntimeScene' + ); + + const baseLayer = harness.getRuntimeLayer(''); + harness.assert( + baseLayer instanceof gdjs.RuntimeLayer, + 'getRuntimeLayer returns the gdjs.RuntimeLayer' + ); + harness.assert(baseLayer.isVisible(), 'Base layer starts visible'); + baseLayer.show(false); + harness.assert( + !harness.getRuntimeLayer('').isVisible(), + 'Layer visibility can be checked after being changed' + ); + harness.assert( + harness.getRuntimeLayer('Nope') === null, + 'Unknown layer gives null' + ); + ` + ); + + expect(result.status).to.be('passed'); + }).timeout(10000); + + it('stops with a timeout when the maximum frames count is reached', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + await harness.stepFrames(100000); + `, + { maxFrames: 50 } + ); + + expect(result.status).to.be('timeout'); + expect(result.framesExecuted).to.be(50); + }); + + it('can be stopped while the script awaits something else than the harness', async () => { + const runtimeGame = makeRuntimeGame(); + const resultPromise = runTestScript( + runtimeGame, + ` + console.log('Before the long wait'); + await new Promise((resolve) => setTimeout(resolve, 60 * 1000)); + console.log('This must never be logged'); + `, + { timeoutMs: 120 * 1000 } + ); + + // Let the script start and reach its `await`. + await new Promise((resolve) => setTimeout(resolve, 50)); + gdjs.gameplayTests.stopCurrentGameplayTest(); + + const result = await resultPromise; + expect(result.status).to.be('stopped'); + expect( + result.consoleLogs.some( + (log) => log.message.indexOf('Before the long wait') !== -1 + ) + ).to.be(true); + expect( + result.consoleLogs.some( + (log) => log.message.indexOf('never be logged') !== -1 + ) + ).to.be(false); + }); + + it('leaves the game paused when freezeWhenFinished is set', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + `, + { freezeWhenFinished: true } + ); + + expect(result.status).to.be('passed'); + expect(runtimeGame.isPaused()).to.be(true); + }); + + it('supports scene changes, spawning objects and reading them back', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 2'); + const spawned = harness.spawn('MyObject', 100, 200); + harness.assert(spawned.name === 'MyObject', 'Object was spawned'); + await harness.stepFrames(1); + + const objects = harness.getObjects('MyObject'); + harness.assert(objects.length === 1, 'One instance is live'); + harness.assert(objects[0].x === 100, 'X position is set'); + harness.assert(objects[0].y === 200, 'Y position is set'); + harness.watch('MyObject'); + + harness.setSceneVariable('Score', 42); + const score = harness.getSceneVariable('Score'); + harness.assert(!!score && score.value === 42, 'Scene variable is set'); + ` + ); + + expect(result.status).to.be('passed'); + expect(result.finalState.sceneName).to.be('Scene 2'); + expect(result.finalState.objectCounts['MyObject']).to.be(1); + expect(result.finalState.watchedObjects['MyObject'].length).to.be(1); + expect(result.finalState.watchedObjects['MyObject'][0].x).to.be(100); + expect( + result.eventLog.some( + (event) => event.event === 'spawned' && event.object === 'MyObject' + ) + ).to.be(true); + }); + + it('records a sceneReset event when the same scene is restarted', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + await harness.stepFrames(2); + // Restart the same scene: objects are back to their initial state. + await harness.goToScene('Scene 1'); + await harness.stepFrames(2); + harness.assert(true, 'done'); + ` + ); + + expect(result.status).to.be('passed'); + const sceneEvents = result.eventLog.filter( + (event) => event.event === 'sceneChanged' || event.event === 'sceneReset' + ); + expect(sceneEvents.length).to.be(2); + expect(sceneEvents[0].event).to.be('sceneChanged'); + expect(sceneEvents[0].sceneName).to.be('Scene 1'); + expect(sceneEvents[0].cause).to.be('harness'); + expect(sceneEvents[1].event).to.be('sceneReset'); + expect(sceneEvents[1].sceneName).to.be('Scene 1'); + expect(sceneEvents[1].cause).to.be('harness'); + }); + + it('returns a flat JSON-safe profiling summary', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.startProfiling(); + await harness.stepFrames(10); + const profile = harness.stopProfiling(); + harness.assert(!!profile, 'A profile is returned'); + harness.assert( + typeof profile.avgStepTimeMs === 'number', + 'avgStepTimeMs is a number' + ); + harness.assert(Array.isArray(profile.sections), 'sections is an array'); + harness.assert( + profile.sections.every( + (section) => + typeof section.name === 'string' && + typeof section.avgTimeMs === 'number' && + typeof section.maxTimeMs === 'number' && + section.maxTimeMs >= section.avgTimeMs + ), + 'sections have a name, an avgTimeMs and a maxTimeMs >= avgTimeMs' + ); + harness.assert( + profile.maxStepTimeMs >= profile.avgStepTimeMs, + 'The worst frame is at least the average' + ); + harness.assert( + Array.isArray(profile.frameTimesMs) && + profile.frameTimesMs.length === 10 && + profile.frameTimesMs.every((time) => typeof time === 'number'), + 'The frame-by-frame timeline is returned (10 profiled frames)' + ); + harness.assert( + profile.frameTimesBucketSize === 1, + 'A short window is not downsampled' + ); + harness.assert( + profile.startFrame === 1 && profile.endFrame === 11, + 'The profiled window is reported in harness frames (got ' + + profile.startFrame + '..' + profile.endFrame + ')' + ); + harness.assert( + Array.isArray(profile.worstFrames) && + profile.worstFrames.length === 5 && + profile.worstFrames.every( + (worst) => + worst.frame > profile.startFrame && + worst.frame <= profile.endFrame && + typeof worst.timeMs === 'number' + ), + 'The worst frames are reported with harness frame numbers' + ); + harness.assert( + profile.objectCounts && typeof profile.objectCounts === 'object', + 'Object counts are returned' + ); + harness.assert(profile.renderer === null, 'No 3D renderer in this game'); + // The whole profile is JSON-safe (no circular structure). + harness.assert( + JSON.stringify(profile).length > 0, + 'The profile can be stringified' + ); + ` + ); + + expect(result.status).to.be('passed'); + }); + + it('reports an aim result object with the mouse responsiveness', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.spawn('MyObject', 100, 200); + await harness.stepFrames(1); + + // This game has no mouse-look: the aim fails and reports that the + // mouse showed no response. + const aim = await harness.lookTowardWithMouseDelta('MyObject', { x: 100, y: 600 }); + harness.assert(!!aim, 'An aim result is returned'); + harness.assert(aim.aimed === false, 'The aim did not succeed'); + harness.assert(aim.sawYawResponse === false, 'No yaw response was seen'); + harness.assert(typeof aim.yawDiff === 'number', 'The remaining yawDiff is reported'); + + const missing = await harness.lookTowardWithMouseDelta('Nothing', { x: 0, y: 0 }); + harness.assert(missing === null, 'A missing object gives null'); + `, + { timeoutMs: 20000 } + ); + + expect(result.status).to.be('passed'); + // The one-time hint about mouse deltas without pointer lock is recorded. + expect( + result.consoleLogs.some( + (log) => + log.level === 'warn' && + log.message.indexOf('never requested the pointer lock') !== -1 + ) + ).to.be(true); + }); + + it('records a sceneChanged event when another scene replaces the current one', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + await harness.stepFrames(2); + await harness.goToScene('Scene 2'); + await harness.stepFrames(2); + harness.assert(true, 'done'); + ` + ); + + expect(result.status).to.be('passed'); + const sceneEvents = result.eventLog.filter( + (event) => event.event === 'sceneChanged' || event.event === 'sceneReset' + ); + expect(sceneEvents.length).to.be(2); + expect(sceneEvents[0].event).to.be('sceneChanged'); + expect(sceneEvents[0].sceneName).to.be('Scene 1'); + expect(sceneEvents[1].event).to.be('sceneChanged'); + expect(sceneEvents[1].sceneName).to.be('Scene 2'); + }); + + it('reports the relative position of a target (pure geometry, no advice)', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + harness.spawn('MyObject', 100, 200); + await harness.stepFrames(1); + + const rel = harness.getRelativePosition('MyObject', { x: 400, y: 200 }); + harness.assert(!!rel, 'Relative position is computed'); + harness.assert(Math.abs(rel.relativeX - 300) < 1, 'relativeX is 300'); + harness.assert(Math.abs(rel.relativeY) < 1, 'relativeY is 0'); + harness.assert(Math.abs(rel.distance - 300) < 1, 'distance is 300'); + harness.assert( + Math.abs(rel.horizontalDistance - 300) < 1, + 'horizontalDistance equals distance in 2D' + ); + harness.assert(rel.dominantAxis === 'x', 'dominant axis is x'); + harness.assert(rel.reached === false, 'target is not reached'); + harness.assert( + !('shouldMoveRight' in rel) && !('shouldJump' in rel), + 'no navigation advice fields' + ); + + const closeRel = harness.getRelativePosition('MyObject', { x: 110, y: 200 }); + harness.assert(closeRel.reached === true, 'close target is reached'); + + const missing = harness.getRelativePosition('MyObject', { name: 'Nothing' }); + harness.assert(missing === null, 'missing target gives null'); + ` + ); + + expect(result.status).to.be('passed'); + }); + + it('resets the scene and probes controls, measuring each key effect against a baseline', async () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + layouts: [createSceneDataWithInitialPlayerInstance('Scene 1')], + }); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + const probes = await harness.resetSceneAndProbeControls('Player', ['Right', 'Left'], { + frames: 30, + }); + harness.assert(!!probes.baseline, 'Baseline was measured'); + harness.assert(!!probes.keys.Right && !!probes.keys.Left, 'Both keys were measured'); + // The player free-falls in this scene: the fall affects the baseline + // and every key the same way, only dx differs. + harness.assert( + probes.keys.Right.dx - probes.baseline.dx > 20, + 'Right moves the player right vs baseline' + ); + harness.assert( + probes.keys.Left.dx - probes.baseline.dx < -20, + 'Left moves the player left vs baseline' + ); + harness.assert( + Math.abs(probes.keys.Right.dy - probes.baseline.dy) < 5, + 'Right does not change the fall' + ); + harness.assert( + probes.keys.Right.maxDx >= probes.keys.Right.dx - 1, + 'Extremes are tracked' + ); + ` + ); + + expect(result.status).to.be('passed'); + // Each probe (baseline + 2 keys + final cleanup) restarts the scene. + const resets = result.eventLog.filter( + (event) => event.event === 'sceneReset' + ); + expect(resets.length >= 3).to.be(true); + }).timeout(10000); + + it('tracks progress toward a target and detects stalls', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + const spawned = harness.spawn('MyObject', 0, 0); + await harness.stepFrames(1); + + const tracker = harness.makeProgressTracker( + 'MyObject', + { x: 500, y: 0 }, + { windowFrames: 10, minProgress: 5, reachRadius: 30 } + ); + + // Moving toward the target: never stalled. + let sawStallWhileMoving = false; + for (let i = 0; i < 20; i++) { + harness.setObjectPosition(spawned.id, i * 10, 0); + await harness.stepFrames(1); + const progress = tracker.update(); + if (progress && progress.stalled) sawStallWhileMoving = true; + } + harness.assert(!sawStallWhileMoving, 'No stall while progressing'); + + // Standing still: a stall is detected after the window. + let stalledAfterStop = false; + for (let i = 0; i < 15; i++) { + await harness.stepFrames(1); + const progress = tracker.update(); + if (progress && progress.stalled) stalledAfterStop = true; + } + harness.assert(stalledAfterStop, 'Stall detected when not progressing'); + + // Reaching the target. + harness.setObjectPosition(spawned.id, 495, 0); + await harness.stepFrames(1); + const finalProgress = tracker.update(); + harness.assert(!!finalProgress && finalProgress.reached, 'Target reached'); + + // reset() forgets the stall history. + tracker.reset(); + const afterReset = tracker.update(); + harness.assert(!!afterReset && !afterReset.stalled, 'No stall after reset'); + ` + ); + + expect(result.status).to.be('passed'); + expect( + result.eventLog.some( + (event) => event.event === 'stuck' && event.object === 'MyObject' + ) + ).to.be(true); + }); + + it('paces the run when a speedFactor is set in the payload', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + await harness.stepFrames(12); + harness.assert(true, 'done'); + `, + { speedFactor: 1 } + ); + + expect(result.status).to.be('passed'); + // 13 frames at normal speed take ~216ms of wall-clock time (a run at + // full speed takes a few milliseconds). + expect(result.durationMs >= 120).to.be(true); + }); + + it('supports stepUntil with a condition', async () => { + const runtimeGame = makeRuntimeGame(); + const result = await runTestScript( + runtimeGame, + ` + await harness.goToScene('Scene 1'); + let frames = 0; + const done = await harness.stepUntil(() => frames >= 10, { + maxFrames: 100, + onFrame: () => frames++, + }); + harness.assert(done === true, 'Condition was reached'); + harness.assert(frames === 10, 'Stepped 10 frames'); + ` + ); + + expect(result.status).to.be('passed'); + }); + + describe('GameplayTestHarness inputs', () => { + /** + * @param {gdjs.RuntimeGame} runtimeGame + */ + const makeStartedHarness = (runtimeGame) => { + const harness = new gdjs.gameplayTests.GameplayTestHarness(runtimeGame, { + testName: 'Test', + source: '', + timeoutMs: 5000, + }); + harness._startTimeMs = Date.now(); + return harness; + }; + + it('simulates keyboard keys (with GDevelop and Web API names)', async () => { + const runtimeGame = makeRuntimeGame(); + const inputManager = runtimeGame.getInputManager(); + const harness = makeStartedHarness(runtimeGame); + await harness.goToScene('Scene 1'); + + harness.setKeyPressed('Space', true); + expect(inputManager.isKeyPressed(32)).to.be(true); + expect(inputManager.wasKeyJustPressed(32)).to.be(true); + await harness.stepFrames(1); + expect(inputManager.isKeyPressed(32)).to.be(true); + expect(inputManager.wasKeyJustPressed(32)).to.be(false); + harness.setKeyPressed('Space', false); + expect(inputManager.isKeyPressed(32)).to.be(false); + + // Web API names are accepted: + harness.setKeyPressed('ArrowLeft', true); + expect(inputManager.isKeyPressed(37)).to.be(true); + harness.setKeyPressed('ArrowLeft', false); + + // Location aware keys: + harness.setKeyPressed('LShift', true); + expect(inputManager.isKeyPressed(1016)).to.be(true); + harness.setKeyPressed('RShift', true); + expect(inputManager.isKeyPressed(2016)).to.be(true); + + // Unknown key names throw: + expect(() => harness.setKeyPressed('NotAKey', true)).to.throwError(); + + harness.releaseAllInputs(); + expect(inputManager.isKeyPressed(1016)).to.be(false); + expect(inputManager.isKeyPressed(2016)).to.be(false); + }); + + it('records the cause of scene changes (harness vs external)', async () => { + const runtimeGame = makeRuntimeGame(); + const harness = makeStartedHarness(runtimeGame); + await harness.goToScene('Scene 1'); + await harness.stepFrames(2); + // An external actor (not the harness, not the game logic) replaces + // the scene. + runtimeGame.getSceneStack().replace({ + sceneName: 'Scene 1', + clear: true, + }); + await harness.stepFrames(1); + + const sceneEvents = harness._eventLog.filter( + (event) => + event.event === 'sceneChanged' || event.event === 'sceneReset' + ); + expect(sceneEvents.length).to.be(2); + expect(sceneEvents[0].event).to.be('sceneChanged'); + expect(sceneEvents[0].cause).to.be('harness'); + expect(sceneEvents[1].event).to.be('sceneReset'); + expect(sceneEvents[1].cause).to.be('external'); + expect(typeof sceneEvents[1].causeDetail).to.be('string'); + }); + + it('fakes pointer lock at the DOM level and feeds mouse deltas as pointermove events', async () => { + const runtimeGame = makeRuntimeGame(); + // Attach a canvas like a real game has (tests run without one). + const canvas = document.createElement('canvas'); + /** @type {any} */ (runtimeGame.getRenderer())._gameCanvas = canvas; + + // A DOM listener like the ones of mouse-look extensions: accumulates + // movement only while the pointer is locked. + let movementX = 0; + let movementY = 0; + canvas.addEventListener('pointermove', (event) => { + if (document.pointerLockElement === canvas) { + movementX += event.movementX || 0; + movementY += event.movementY || 0; + } + }); + + const harness = makeStartedHarness(runtimeGame); + harness._installPointerLockShim(); + try { + await harness.goToScene('Scene 1'); + + // Deltas sent before the game requests the lock are not accumulated + // by the listener (the extension sees an unlocked pointer). + harness.setMouseDelta(100, 100); + expect(movementX).to.be(0); + + // The game (or an extension) requests the pointer lock: no real + // lock happens, but the DOM reports one. + canvas.requestPointerLock(); + expect(document.pointerLockElement).to.be(canvas); + + harness.setMouseDelta(12, -6); + harness.setMouseDelta(3, 0); + expect(movementX).to.be(15); + expect(movementY).to.be(-6); + + document.exitPointerLock(); + expect(document.pointerLockElement).to.be(null); + } finally { + harness._uninstallPointerLockShim(); + } + + // The document is restored once the test is done. + expect(document.pointerLockElement).to.be(null); + expect(canvas.requestPointerLock).to.not.be(undefined); + }); + + it('simulates mouse buttons and position', async () => { + const runtimeGame = makeRuntimeGame(); + const inputManager = runtimeGame.getInputManager(); + const harness = makeStartedHarness(runtimeGame); + await harness.goToScene('Scene 1'); + + harness.setMouseButtonPressed(true, 'left'); + expect(inputManager.isMouseButtonPressed(0)).to.be(true); + harness.setMouseButtonPressed(false, 'left'); + expect(inputManager.isMouseButtonPressed(0)).to.be(false); + + harness.setMousePositionScreen(120, 60); + expect(inputManager.getMouseX()).to.be(120); + expect(inputManager.getMouseY()).to.be(60); + + // World position on the base layer (camera is centered by default, + // so this maps back to game resolution coordinates). + harness.setMousePosition(100, 50, ''); + expect(typeof inputManager.getMouseX()).to.be('number'); + }); + }); +}); diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 6699c1f78b..735fbe4c35 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -662,6 +662,8 @@ interface Project { void RemoveExternalEvents([Const] DOMString name); unsigned long GetExternalEventsPosition([Const] DOMString name); + [Ref] TestsContainer GetTests(); + boolean HasExternalLayoutNamed([Const] DOMString name); [Ref] ExternalLayout GetExternalLayout([Const] DOMString name); [Ref] ExternalLayout GetExternalLayoutAt(unsigned long index); @@ -1084,6 +1086,50 @@ interface ExternalEvents { void UnserializeFrom([Ref] Project project, [Const, Ref] SerializerElement element); }; +interface Test { + void Test(); + + void SetName([Const] DOMString name); + [Const, Ref] DOMString GetName(); + + void SetType([Const] DOMString type); + [Const, Ref] DOMString GetType(); + + void SetDescription([Const] DOMString description); + [Const, Ref] DOMString GetDescription(); + + void SetSource([Const] DOMString source); + [Const, Ref] DOMString GetSource(); + + void SetLastRunStatus([Const] DOMString lastRunStatus); + [Const, Ref] DOMString GetLastRunStatus(); + + void SetLastRunAt(double lastRunAt); + double GetLastRunAt(); + + void SetLastRunDurationMs(double lastRunDurationMs); + double GetLastRunDurationMs(); + + void SetLastRunFramesExecuted(long lastRunFramesExecuted); + long GetLastRunFramesExecuted(); + + void SerializeTo([Ref] SerializerElement element); + void UnserializeFrom([Const, Ref] SerializerElement element); +}; + +interface TestsContainer { + [Ref] Test InsertNewTest([Const] DOMString name, unsigned long pos); + [Ref] Test InsertTest([Const, Ref] Test test, unsigned long pos); + boolean HasTestNamed([Const] DOMString name); + [Ref] Test GetTest([Const] DOMString name); + [Ref] Test GetTestAt(unsigned long pos); + void RemoveTest([Const] DOMString name); + void ClearTests(); + void MoveTest(unsigned long oldIndex, unsigned long newIndex); + unsigned long GetTestsCount(); + unsigned long GetTestPosition([Const, Ref] Test test); +}; + interface ExternalLayout { void ExternalLayout(); @@ -3705,6 +3751,8 @@ interface EventsFunctionsExtension { [Ref] EventsBasedBehaviorsList GetEventsBasedBehaviors(); [Ref] EventsBasedObjectsList GetEventsBasedObjects(); + [Ref] TestsContainer GetTests(); + void SerializeTo([Ref] SerializerElement element); void SerializeToExternal([Ref] SerializerElement element); void UnserializeFrom([Ref] Project project, [Const, Ref] SerializerElement element); diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index 321c087f7c..d12beb8537 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -78,6 +78,8 @@ #include #include #include +#include +#include #include #include #include @@ -960,6 +962,7 @@ typedef std::vector VectorPropertyDescriptorChoice #define RemoveAt Remove #define GetEventsFunctionAt GetEventsFunction #define GetVariantAt GetVariant +#define GetTestAt GetTest #define GetEffectAt GetEffect #define GetParameterAt GetParameter diff --git a/GDevelop.js/__tests__/Core.js b/GDevelop.js/__tests__/Core.js index 561eb0cb10..7196d3600f 100644 --- a/GDevelop.js/__tests__/Core.js +++ b/GDevelop.js/__tests__/Core.js @@ -99,6 +99,36 @@ describe('libGD.js', function () { expect(project.hasExternalEventsNamed('My events')).toBe(false); }); + it('handles tests', function () { + const tests = project.getTests(); + expect(tests.hasTestNamed('My test')).toBe(false); + + const test = tests.insertNewTest('My test', 0); + expect(tests.hasTestNamed('My test')).toBe(true); + expect(tests.getTestsCount()).toBe(1); + expect(test.getName()).toBe('My test'); + expect(test.getType()).toBe('gameplay'); + test.setDescription('My description'); + test.setSource("await harness.goToScene('Scene');"); + expect(tests.getTest('My test').getDescription()).toBe('My description'); + expect(tests.getTestAt(0).getSource()).toBe( + "await harness.goToScene('Scene');" + ); + expect(test.getLastRunStatus()).toBe(''); + test.setLastRunStatus('passed'); + test.setLastRunAt(1769700000000); + test.setLastRunDurationMs(5400); + test.setLastRunFramesExecuted(320); + expect(test.getLastRunStatus()).toBe('passed'); + expect(test.getLastRunAt()).toBe(1769700000000); + expect(test.getLastRunDurationMs()).toBe(5400); + expect(test.getLastRunFramesExecuted()).toBe(320); + + tests.removeTest('My test'); + expect(tests.hasTestNamed('My test')).toBe(false); + expect(tests.getTestsCount()).toBe(0); + }); + it('handles external layouts', function () { expect(project.hasExternalLayoutNamed('My layout')).toBe(false); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index df2b682a7a..a732c08c2e 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -644,6 +644,7 @@ export class Project extends EmscriptenObject { insertNewExternalEvents(name: string, position: number): ExternalEvents; removeExternalEvents(name: string): void; getExternalEventsPosition(name: string): number; + getTests(): TestsContainer; hasExternalLayoutNamed(name: string): boolean; getExternalLayout(name: string): ExternalLayout; getExternalLayoutAt(index: number): ExternalLayout; @@ -913,6 +914,41 @@ export class ExternalEvents extends EmscriptenObject { unserializeFrom(project: Project, element: SerializerElement): void; } +export class Test extends EmscriptenObject { + constructor(); + setName(name: string): void; + getName(): string; + setType(type: string): void; + getType(): string; + setDescription(description: string): void; + getDescription(): string; + setSource(source: string): void; + getSource(): string; + setLastRunStatus(lastRunStatus: string): void; + getLastRunStatus(): string; + setLastRunAt(lastRunAt: number): void; + getLastRunAt(): number; + setLastRunDurationMs(lastRunDurationMs: number): void; + getLastRunDurationMs(): number; + setLastRunFramesExecuted(lastRunFramesExecuted: number): void; + getLastRunFramesExecuted(): number; + serializeTo(element: SerializerElement): void; + unserializeFrom(element: SerializerElement): void; +} + +export class TestsContainer extends EmscriptenObject { + insertNewTest(name: string, pos: number): Test; + insertTest(test: Test, pos: number): Test; + hasTestNamed(name: string): boolean; + getTest(name: string): Test; + getTestAt(pos: number): Test; + removeTest(name: string): void; + clearTests(): void; + moveTest(oldIndex: number, newIndex: number): void; + getTestsCount(): number; + getTestPosition(test: Test): number; +} + export class ExternalLayout extends EmscriptenObject { constructor(); setName(name: string): void; @@ -2729,6 +2765,7 @@ export class EventsFunctionsExtension extends EmscriptenObject { getSceneVariables(): VariablesContainer; getEventsBasedBehaviors(): EventsBasedBehaviorsList; getEventsBasedObjects(): EventsBasedObjectsList; + getTests(): TestsContainer; serializeTo(element: SerializerElement): void; serializeToExternal(element: SerializerElement): void; unserializeFrom(project: Project, element: SerializerElement): void; diff --git a/GDevelop.js/types/gdeventsfunctionsextension.js b/GDevelop.js/types/gdeventsfunctionsextension.js index 237a603dff..70761c0c5f 100644 --- a/GDevelop.js/types/gdeventsfunctionsextension.js +++ b/GDevelop.js/types/gdeventsfunctionsextension.js @@ -41,6 +41,7 @@ declare class gdEventsFunctionsExtension { getSceneVariables(): gdVariablesContainer; getEventsBasedBehaviors(): gdEventsBasedBehaviorsList; getEventsBasedObjects(): gdEventsBasedObjectsList; + getTests(): gdTestsContainer; serializeTo(element: gdSerializerElement): void; serializeToExternal(element: gdSerializerElement): void; unserializeFrom(project: gdProject, element: gdSerializerElement): void; diff --git a/GDevelop.js/types/gdproject.js b/GDevelop.js/types/gdproject.js index 9473301eeb..d12ea54c47 100644 --- a/GDevelop.js/types/gdproject.js +++ b/GDevelop.js/types/gdproject.js @@ -87,6 +87,7 @@ declare class gdProject { insertNewExternalEvents(name: string, position: number): gdExternalEvents; removeExternalEvents(name: string): void; getExternalEventsPosition(name: string): number; + getTests(): gdTestsContainer; hasExternalLayoutNamed(name: string): boolean; getExternalLayout(name: string): gdExternalLayout; getExternalLayoutAt(index: number): gdExternalLayout; diff --git a/GDevelop.js/types/gdtest.js b/GDevelop.js/types/gdtest.js new file mode 100644 index 0000000000..603c3d82e9 --- /dev/null +++ b/GDevelop.js/types/gdtest.js @@ -0,0 +1,24 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdTest { + constructor(): void; + setName(name: string): void; + getName(): string; + setType(type: string): void; + getType(): string; + setDescription(description: string): void; + getDescription(): string; + setSource(source: string): void; + getSource(): string; + setLastRunStatus(lastRunStatus: string): void; + getLastRunStatus(): string; + setLastRunAt(lastRunAt: number): void; + getLastRunAt(): number; + setLastRunDurationMs(lastRunDurationMs: number): void; + getLastRunDurationMs(): number; + setLastRunFramesExecuted(lastRunFramesExecuted: number): void; + getLastRunFramesExecuted(): number; + serializeTo(element: gdSerializerElement): void; + unserializeFrom(element: gdSerializerElement): void; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/gdtestscontainer.js b/GDevelop.js/types/gdtestscontainer.js new file mode 100644 index 0000000000..1ab15681a1 --- /dev/null +++ b/GDevelop.js/types/gdtestscontainer.js @@ -0,0 +1,15 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdTestsContainer { + insertNewTest(name: string, pos: number): gdTest; + insertTest(test: gdTest, pos: number): gdTest; + hasTestNamed(name: string): boolean; + getTest(name: string): gdTest; + getTestAt(pos: number): gdTest; + removeTest(name: string): void; + clearTests(): void; + moveTest(oldIndex: number, newIndex: number): void; + getTestsCount(): number; + getTestPosition(test: gdTest): number; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/libgdevelop.js b/GDevelop.js/types/libgdevelop.js index 05341ab623..4ab74d7c86 100644 --- a/GDevelop.js/types/libgdevelop.js +++ b/GDevelop.js/types/libgdevelop.js @@ -113,6 +113,8 @@ declare class libGDevelop { CustomObjectConfiguration: Class; Layout: Class; ExternalEvents: Class; + Test: Class; + TestsContainer: Class; ExternalLayout: Class; Effect: Class; EffectsContainer: Class; diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/index.js b/newIDE/app/src/AiGeneration/AiRequestChat/index.js index 1ff9064380..a8bb3b0446 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/index.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/index.js @@ -382,6 +382,7 @@ type Props = {| export type AiRequestChatInterface = {| resetUserInput: (aiRequestId: string | null) => void, + setUserInput: (aiRequestId: string | null, userRequestText: string) => void, |}; export const AiRequestChat: React.ComponentType<{ @@ -531,6 +532,9 @@ export const AiRequestChat: React.ComponentType<{ ] = React.useState<{ [string]: string }>({}); const scrollViewRef = React.useRef(null); + const newChatTextFieldRef = React.useRef( + null + ); const existingChatTextFieldRef = React.useRef( null ); @@ -635,6 +639,20 @@ export const AiRequestChat: React.ComponentType<{ scrollToBottom(); }, + setUserInput: (aiRequestId: string | null, userRequestText: string) => { + onUserRequestTextChange(userRequestText, aiRequestId || ''); + + scrollToBottom(); + + // Focus the field so the user can complete the text right away + // (after a render, as the field might just be shown). + const textFieldRef = aiRequestId + ? existingChatTextFieldRef + : newChatTextFieldRef; + setTimeout(() => { + if (textFieldRef.current) textFieldRef.current.focus(); + }, 50); + }, })); const errorText = lastSendError ? ( @@ -890,6 +908,7 @@ export const AiRequestChat: React.ComponentType<{ > {!shouldReplaceFormWithCreditsOrSubscriptionPrompt ? ( void, onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise, + onWillDeleteGameplayTest: ( + changes: WillDeleteGameplayTestChanges + ) => Promise, onWillDeleteObject: (changes: WillDeleteObjectChanges) => void, onWillInstallExtension: (extensionNames: Array) => void, onExtensionInstalled: (extensionNames: Array) => void, @@ -261,6 +266,7 @@ export const AskAiEditor: React.ComponentType = React.memo( onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, onWillInstallExtension, onExtensionInstalled, @@ -930,6 +936,7 @@ export const AskAiEditor: React.ComponentType = React.memo( onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, i18n, onWillInstallExtension, @@ -996,6 +1003,19 @@ export const AskAiEditor: React.ComponentType = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps [setSelectedAiRequestId, selectedAiRequest] ); + // Start a new chat with a pre-filled user request, when asked from + // elsewhere in the editor ("Edit with AI" buttons...). + React.useEffect( + () => + registerAskAiPrefillListener((userRequestText: string) => { + onStartOrOpenChat({ aiRequestId: null }); + if (aiRequestChatRef.current) { + aiRequestChatRef.current.setUserInput(null, userRequestText); + } + }), + [onStartOrOpenChat] + ); + const onStartNewChat = React.useCallback( () => { onStartOrOpenChat({ @@ -1647,6 +1667,7 @@ export const renderAskAiEditorContainer = ( props.onProjectItemRenamedOutsideEditor } onWillDeleteScene={props.onWillDeleteScene} + onWillDeleteGameplayTest={props.onWillDeleteGameplayTest} onWillDeleteObject={props.onWillDeleteObject} onWillInstallExtension={props.onWillInstallExtension} onExtensionInstalled={props.onExtensionInstalled} diff --git a/newIDE/app/src/AiGeneration/AskAiPrefill.js b/newIDE/app/src/AiGeneration/AskAiPrefill.js new file mode 100644 index 0000000000..f4f5aa8b65 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AskAiPrefill.js @@ -0,0 +1,40 @@ +// @flow + +// Allow any part of the editor to ask the Ask AI editor to start a new chat +// with a pre-filled user request ("Edit with AI" buttons...). The Ask AI +// editor may not be mounted yet when the pre-fill is requested (the tab is +// usually being opened at the same time): the request is kept pending until +// it registers. + +let pendingPrefilledUserRequestText: string | null = null; +let listener: null | ((userRequestText: string) => void) = null; + +/** + * Ask the Ask AI editor to start a new chat with this pre-filled user + * request (delivered as soon as it is mounted). + */ +export const requestAskAiPrefill = (userRequestText: string) => { + if (listener) { + listener(userRequestText); + } else { + pendingPrefilledUserRequestText = userRequestText; + } +}; + +/** + * Called by the Ask AI editor to receive the pre-fill requests. Returns the + * function to unregister. Any pending request is delivered immediately. + */ +export const registerAskAiPrefillListener = ( + newListener: (userRequestText: string) => void +): (() => void) => { + listener = newListener; + if (pendingPrefilledUserRequestText !== null) { + const userRequestText = pendingPrefilledUserRequestText; + pendingPrefilledUserRequestText = null; + newListener(userRequestText); + } + return () => { + if (listener === newListener) listener = null; + }; +}; diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index c02f5119d7..60f745567b 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -591,6 +591,7 @@ export const AskAiStandAloneForm = ({ onObjectGroupsModifiedOutsideEditor: () => {}, onProjectItemRenamedOutsideEditor: () => {}, onWillDeleteScene: () => Promise.resolve(), + onWillDeleteGameplayTest: () => Promise.resolve(), onWillDeleteObject: () => {}, onWillInstallExtension, onExtensionInstalled, diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index dd16f6f67c..e021a84944 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -8,6 +8,7 @@ import { type ObjectGroupsOutsideEditorChanges, type ProjectItemRenamedOutsideEditorChanges, type WillDeleteSceneChanges, + type WillDeleteGameplayTestChanges, type WillDeleteObjectChanges, } from '../EditorFunctions/OutsideEditorChanges'; import { @@ -19,6 +20,7 @@ import { updateAiRequestMessage, } from '../Utils/GDevelopServices/Generation'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import { areGameplayTestsEnabled } from '../GameplayTests/AreGameplayTestsEnabled'; import { processEditorFunctionCalls } from '../EditorFunctions/EditorFunctionCallRunner'; import { type EditorCallbacks, @@ -101,7 +103,12 @@ export const useRefreshLimits = ( // The tools of the orchestrator AND of the sub-agents it creates server-side. // Only bump it once the matching prompts and generation-api are deployed; // reverting it is the flip-back (every past version stays served). -export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v13'; +// v14 adds gameplay tests (`run_tests` + the tester sub-agent) and is only +// used in development while the feature is being finished (see +// `areGameplayTestsEnabled`). +export const AI_ORCHESTRATOR_TOOLS_VERSION: string = areGameplayTestsEnabled() + ? 'v14' + : 'v13'; /** * A pending request for the user to approve (or refuse) a project-modifying @@ -130,7 +137,17 @@ const doesFunctionCallModifyProject = ( editorFunctions[functionCall.name] || editorFunctionsWithoutProject[functionCall.name] || null; - return !!(editorFunctionDef && editorFunctionDef.modifiesProject); + if (!editorFunctionDef) return false; + if (editorFunctionDef.getModifiesProject) { + try { + return editorFunctionDef.getModifiesProject( + JSON.parse(functionCall.arguments) + ); + } catch (error) { + return !!editorFunctionDef.modifiesProject; + } + } + return !!editorFunctionDef.modifiesProject; }; /** @@ -230,6 +247,7 @@ export const useProcessFunctionCalls = ({ onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, onWillInstallExtension, onExtensionInstalled, @@ -272,6 +290,9 @@ export const useProcessFunctionCalls = ({ changes: ProjectItemRenamedOutsideEditorChanges ) => void, onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise, + onWillDeleteGameplayTest: ( + changes: WillDeleteGameplayTestChanges + ) => Promise, onWillDeleteObject: (changes: WillDeleteObjectChanges) => void, onWillInstallExtension: (extensionNames: Array) => void, onExtensionInstalled: (extensionNames: Array) => void, @@ -592,6 +613,7 @@ export const useProcessFunctionCalls = ({ // Not coalesced: must run before the scene is actually deleted so // the tab can be closed while the gdLayout is still valid. onWillDeleteScene, + onWillDeleteGameplayTest, // Not coalesced: must run before the object is actually deleted so // editors can safely read it to close a dialog/panel referring to it. onWillDeleteObject, @@ -647,6 +669,7 @@ export const useProcessFunctionCalls = ({ onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, ensureExtensionInstalled, onWillInstallExtension, @@ -1402,6 +1425,8 @@ export type OpenAskAiOptions = {| aiRequestId?: string | null, // If null, a new request will be created. paneIdentifier?: 'left' | 'center' | 'right', continueProcessingFunctionCallsOnMount?: boolean, + // When set, a new chat is started with this text pre-filled in the input. + prefilledUserRequest?: string, |}; export type NewAiRequestOptions = {| diff --git a/newIDE/app/src/CodeEditor/LocalCodeEditorAutocompletions.js b/newIDE/app/src/CodeEditor/LocalCodeEditorAutocompletions.js index 52869225c3..73a741f4ee 100644 --- a/newIDE/app/src/CodeEditor/LocalCodeEditorAutocompletions.js +++ b/newIDE/app/src/CodeEditor/LocalCodeEditorAutocompletions.js @@ -115,6 +115,7 @@ export const setupAutocompletions = (monaco: any) => { const extensionsPath = path.join(runtimePath, 'Extensions'); const eventToolsPath = path.join(runtimePath, 'events-tools'); const inGameEditorPath = path.join(runtimePath, 'InGameEditor'); + const gameplayTestsPath = path.join(runtimePath, 'gameplay-tests'); const threeTypesPath = path.join(runtimeTypesPath, 'three'); const pixiTypesPath = path.join(runtimeTypesPath, 'pixi'); @@ -126,6 +127,7 @@ export const setupAutocompletions = (monaco: any) => { importAllJsFilesFromFolder(runtimeFontfaceobserverFontManagerPath); importAllJsFilesFromFolder(eventToolsPath); importAllJsFilesFromFolder(inGameEditorPath); + importAllJsFilesFromFolder(gameplayTestsPath); importAllJsFilesFromFolderRecursively(threeTypesPath); importAllJsFilesFromFolderRecursively(pixiTypesPath); @@ -170,5 +172,18 @@ var eventsFunctionContext = {}; `, 'this-mock-the-context-of-events.js' ); + + monaco.languages.typescript.javascriptDefaults.addExtraLib( + ` +/** + * The harness driving the game in a gameplay test: step frames, simulate + * the player inputs and inspect the objects of the scene. + * (Only defined in gameplay test scripts.) + * @type {gdjs.gameplayTests.GameplayTestHarness} + */ +var harness; +`, + 'this-mock-the-context-of-gameplay-tests.js' + ); }); }; diff --git a/newIDE/app/src/CodeEditor/MonacoSetup.js b/newIDE/app/src/CodeEditor/MonacoSetup.js index 7f3b51820e..e74110bd6e 100644 --- a/newIDE/app/src/CodeEditor/MonacoSetup.js +++ b/newIDE/app/src/CodeEditor/MonacoSetup.js @@ -44,6 +44,87 @@ export const initializeCompletions = (monaco: any) => { setupAutocompletions(monaco); }; +// The version of Monaco used does not support ignoring some diagnostics +// per model (`diagnosticCodesToIgnore` appeared in a later version, and +// would be global anyway). To avoid showing diagnostics that don't apply +// to a specific editor (like "'await' expression is only allowed within an +// async function" in a gameplay test, whose code is actually run inside an +// async function), `setModelMarkers` is patched to filter the markers of +// the registered models. +const patchedMonacoInstances: WeakSet = new WeakSet(); +const suppressedMessagesByMonacoInstance: WeakMap< + any, + Map> +> = new WeakMap(); + +const patchSetModelMarkersToFilterSuppressedMessages = (monaco: any) => { + if (patchedMonacoInstances.has(monaco)) return; + patchedMonacoInstances.add(monaco); + + const originalSetModelMarkers = monaco.editor.setModelMarkers; + monaco.editor.setModelMarkers = (model: any, owner: string, markers: any) => { + const suppressedMessagesByModelUri = suppressedMessagesByMonacoInstance.get( + monaco + ); + const suppressedMessages = + suppressedMessagesByModelUri && model + ? suppressedMessagesByModelUri.get(model.uri.toString()) + : null; + const filteredMarkers = suppressedMessages + ? markers.filter( + marker => + !suppressedMessages.some(suppressedMessage => + marker.message.includes(suppressedMessage) + ) + ) + : markers; + originalSetModelMarkers.call(monaco.editor, model, owner, filteredMarkers); + }; +}; + +/** + * Don't show the diagnostics whose message contains one of the given texts, + * for the given model. Used by editors whose code is run in a way the + * TypeScript language service can not know about (see `CodeEditor`). + */ +export const suppressDiagnosticsMessagesForModel = ( + monaco: any, + model: any, + suppressedMessages: Array +) => { + if (!model) return; + patchSetModelMarkersToFilterSuppressedMessages(monaco); + + let suppressedMessagesByModelUri = suppressedMessagesByMonacoInstance.get( + monaco + ); + if (!suppressedMessagesByModelUri) { + suppressedMessagesByModelUri = new Map(); + suppressedMessagesByMonacoInstance.set( + monaco, + suppressedMessagesByModelUri + ); + } + suppressedMessagesByModelUri.set(model.uri.toString(), suppressedMessages); +}; + +/** + * Stop filtering the diagnostics of the given model (when the editor that + * asked for it is unmounted). + */ +export const unsuppressDiagnosticsMessagesForModel = ( + monaco: any, + model: any +) => { + if (!model) return; + const suppressedMessagesByModelUri = suppressedMessagesByMonacoInstance.get( + monaco + ); + if (suppressedMessagesByModelUri) { + suppressedMessagesByModelUri.delete(model.uri.toString()); + } +}; + /** * Enable JS type error diagnostics. This won't work for .d.ts classes/functions * for some reason. See: diff --git a/newIDE/app/src/CodeEditor/PoppedOutMonacoEditor.js b/newIDE/app/src/CodeEditor/PoppedOutMonacoEditor.js index 84f4983529..c720786363 100644 --- a/newIDE/app/src/CodeEditor/PoppedOutMonacoEditor.js +++ b/newIDE/app/src/CodeEditor/PoppedOutMonacoEditor.js @@ -6,6 +6,8 @@ import { initializeCompletions, enableJsTypeDiagnostics, applyElectronClipboardPatch, + suppressDiagnosticsMessagesForModel, + unsuppressDiagnosticsMessagesForModel, baseEditorOptions, } from './MonacoSetup'; @@ -28,6 +30,8 @@ type Props = {| onEditorMounted?: () => void, onFocus: () => void, onBlur: () => void, + /** See `CodeEditor`. */ + suppressedDiagnosticsMessages?: Array, |}; type State = {| @@ -186,6 +190,12 @@ class PoppedOutMonacoEditor extends React.Component { cursorLine: cursorPosition.lineNumber, }); } + if (this._monaco) { + unsuppressDiagnosticsMessagesForModel( + this._monaco, + this._editor.getModel() + ); + } this._editor.dispose(); this._editor = null; } @@ -230,6 +240,15 @@ class PoppedOutMonacoEditor extends React.Component { applyElectronClipboardPatch(this._editor, monaco); + const { suppressedDiagnosticsMessages } = this.props; + if (suppressedDiagnosticsMessages) { + suppressDiagnosticsMessagesForModel( + monaco, + this._editor.getModel(), + suppressedDiagnosticsMessages + ); + } + this._editor.onDidChangeModelContent(() => { if (!this._editor) return; this._isChangingValue = true; diff --git a/newIDE/app/src/CodeEditor/index.js b/newIDE/app/src/CodeEditor/index.js index eabfcb69eb..17b1e738b9 100644 --- a/newIDE/app/src/CodeEditor/index.js +++ b/newIDE/app/src/CodeEditor/index.js @@ -13,6 +13,8 @@ import { initializeCompletions, enableJsTypeDiagnostics, applyElectronClipboardPatch, + suppressDiagnosticsMessagesForModel, + unsuppressDiagnosticsMessagesForModel, baseEditorOptions, } from './MonacoSetup'; @@ -36,6 +38,13 @@ export type Props = {| onEditorMounted?: () => void, onFocus: () => void, onBlur: () => void, + /** + * Diagnostics whose message contains one of these texts are not shown + * in this editor. Useful when the code is run in a way the TypeScript + * language service can not know about (a gameplay test, for example, is + * run inside an async function, so top-level `await` is allowed). + */ + suppressedDiagnosticsMessages?: Array, |}; export const CodeEditor = ({ @@ -50,6 +59,7 @@ export const CodeEditor = ({ onEditorMounted, onFocus, onBlur, + suppressedDiagnosticsMessages, }: Props): React.Node => { const [MonacoEditor, setMonacoEditor] = React.useState(null); const [error, setError] = React.useState(null); @@ -84,6 +94,13 @@ export const CodeEditor = ({ if (preferences.showJsTypeError) { enableJsTypeDiagnostics(monaco); } + if (suppressedDiagnosticsMessages) { + suppressDiagnosticsMessagesForModel( + monaco, + editor.getModel(), + suppressedDiagnosticsMessages + ); + } editor.setScrollTop(initialScrollTop); editor.setPosition({ @@ -101,6 +118,7 @@ export const CodeEditor = ({ preferences.showJsTypeError, setUpEditorFocus, setUpSaveOnEditorBlur, + suppressedDiagnosticsMessages, ] ); @@ -148,6 +166,7 @@ export const CodeEditor = ({ cursorColumn: cursorPosition.column, cursorLine: cursorPosition.lineNumber, }); + unsuppressDiagnosticsMessagesForModel(monaco, editor.getModel()); }, [saveEditorState] ); @@ -176,6 +195,7 @@ export const CodeEditor = ({ onEditorMounted={onEditorMounted} onFocus={onFocus} onBlur={onBlur} + suppressedDiagnosticsMessages={suppressedDiagnosticsMessages} /> ); } diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index 131a3984fb..c56fd7297a 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -36,6 +36,9 @@ export type CommandName = | 'OPEN_EXTERNAL_EVENTS' | 'OPEN_EXTERNAL_LAYOUT' | 'OPEN_EXTENSION' + | 'OPEN_GAMEPLAY_TEST' + | 'RUN_GAMEPLAY_TEST' + | 'RUN_ALL_GAMEPLAY_TESTS' | 'OPEN_SCENE_PROPERTIES' | 'OPEN_SCENE_VARIABLES' | 'OPEN_OBJECTS_PANEL' @@ -239,6 +242,18 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open external layout...`, }, OPEN_EXTENSION: { area: 'IDE', displayText: t`Open extension...` }, + OPEN_GAMEPLAY_TEST: { + area: 'IDE', + displayText: t`Open gameplay test...`, + }, + RUN_GAMEPLAY_TEST: { + area: 'PROJECT', + displayText: t`Run gameplay test...`, + }, + RUN_ALL_GAMEPLAY_TESTS: { + area: 'PROJECT', + displayText: t`Run all gameplay tests`, + }, // Scene editor commands OPEN_SCENE_PROPERTIES: { diff --git a/newIDE/app/src/Debugger/index.js b/newIDE/app/src/Debugger/index.js index 23ac8a665e..4b00a39c2a 100644 --- a/newIDE/app/src/Debugger/index.js +++ b/newIDE/app/src/Debugger/index.js @@ -18,6 +18,8 @@ import { } from '../ExportAndShare/PreviewLauncher.flow'; import { type Log, LogsManager } from './DebuggerConsole'; +// Mirrors `gdjs.FrameMeasureOutput`: a plain tree (no back-references), +// as sent by the game's profiler. export type ProfilerMeasuresSection = {| time: number, subsections: { [string]: ProfilerMeasuresSection }, diff --git a/newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js b/newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js index b630724870..84179cd1f4 100644 --- a/newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js +++ b/newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js @@ -25,6 +25,7 @@ import { type ObjectGroupsOutsideEditorChanges, type ProjectItemRenamedOutsideEditorChanges, type WillDeleteSceneChanges, + type WillDeleteGameplayTestChanges, type WillDeleteObjectChanges, } from './OutsideEditorChanges'; import PixiResourcesLoader from '../ObjectsRendering/PixiResourcesLoader'; @@ -63,6 +64,9 @@ type ProcessEditorFunctionCallsOptions = {| changes: ProjectItemRenamedOutsideEditorChanges ) => void, onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise, + onWillDeleteGameplayTest: ( + changes: WillDeleteGameplayTestChanges + ) => Promise, onWillDeleteObject: (changes: WillDeleteObjectChanges) => void, ensureExtensionInstalled: ( options: EnsureExtensionInstalledOptions @@ -93,6 +97,7 @@ export const processEditorFunctionCalls = async ({ onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, relatedAiRequestId, getRelatedAiRequestLastMessages, @@ -215,6 +220,7 @@ export const processEditorFunctionCalls = async ({ onObjectGroupsModifiedOutsideEditor, onProjectItemRenamedOutsideEditor, onWillDeleteScene, + onWillDeleteGameplayTest, onWillDeleteObject, ensureExtensionInstalled, onWillInstallExtension, diff --git a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js index 32847e52b1..9056292803 100644 --- a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js +++ b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js @@ -6117,4 +6117,180 @@ describe('editorFunctions', () => { expect(result.message).toContain('Resource not found: "ghost.png"'); }); }); + describe('change_gameplay_tests', () => { + let project: gdProject; + + beforeEach(() => { + // $FlowFixMe[invalid-constructor] + project = new gd.ProjectHelper.createNewGDJSProject(); + const tests = project.getTests(); + tests.insertNewTest('First test', 0).setDescription('First.'); + tests.insertNewTest('Second test', 1); + tests.insertNewTest('Debug test', 2); + }); + + afterEach(() => { + project.delete(); + }); + + it('deletes a test (and notifies the editor first, so tabs can close)', async () => { + const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project); + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...fakeOptions, + args: { + scope: { type: 'project' }, + changes: [{ test_name: 'Debug test', delete_this_test: true }], + }, + } + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('Deleted the test "Debug test"'); + expect(project.getTests().hasTestNamed('Debug test')).toBe(false); + expect(fakeOptions.onWillDeleteGameplayTest).toHaveBeenCalledWith({ + gameplayTestProjectItemName: 'Debug test', + }); + expect(result.tests).toEqual([ + { test_name: 'First test', description: 'First.' }, + { test_name: 'Second test', description: '' }, + ]); + }); + + it('renames a test (and notifies the editor so tabs are renamed)', async () => { + const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project); + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...fakeOptions, + args: { + scope: { type: 'project' }, + changes: [ + { + test_name: 'First test', + changed_properties: [ + { property_name: 'name', new_value: 'Renamed test' }, + { property_name: 'description', new_value: 'Updated.' }, + ], + }, + ], + }, + } + ); + + expect(result.success).toBe(true); + expect(project.getTests().hasTestNamed('First test')).toBe(false); + expect(project.getTests().hasTestNamed('Renamed test')).toBe(true); + expect( + project + .getTests() + .getTest('Renamed test') + .getDescription() + ).toBe('Updated.'); + expect( + fakeOptions.onProjectItemRenamedOutsideEditor + ).toHaveBeenCalledWith({ + kind: 'gameplay-test', + oldName: 'First test', + newName: 'Renamed test', + }); + }); + + it('refuses a rename colliding with an existing test', async () => { + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scope: { type: 'project' }, + changes: [ + { + test_name: 'First test', + changed_properties: [ + { property_name: 'name', new_value: 'Second test' }, + ], + }, + ], + }, + } + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('already exists'); + expect(project.getTests().hasTestNamed('First test')).toBe(true); + }); + + it('reorders a test with the index property (clamped)', async () => { + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scope: { type: 'project' }, + changes: [ + { + test_name: 'Debug test', + changed_properties: [ + { property_name: 'index', new_value: '0' }, + ], + }, + { + test_name: 'First test', + changed_properties: [ + { property_name: 'index', new_value: '999' }, + ], + }, + ], + }, + } + ); + + expect(result.success).toBe(true); + expect(result.tests).toEqual([ + { test_name: 'Debug test', description: '' }, + { test_name: 'Second test', description: '' }, + { test_name: 'First test', description: 'First.' }, + ]); + }); + + it('rejects an unknown test with the list of existing ones, and an unknown property', async () => { + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scope: { type: 'project' }, + changes: [ + { test_name: 'Ghost test', delete_this_test: true }, + { + test_name: 'First test', + changed_properties: [ + { property_name: 'source', new_value: 'await 1;' }, + ], + }, + ], + }, + } + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('Unknown test "Ghost test"'); + expect(result.message).toContain('"First test"'); + expect(result.message).toContain('Unknown property "source"'); + // The valid part of the batch still reports the project as modified: no + // change was applied here, so it is not. + expect(result.meta && result.meta.didModifyProject).toBe(false); + }); + + it('fails on an unknown scope', async () => { + const result: EditorFunctionGenericOutput = await editorFunctions.change_gameplay_tests.launchFunction( + { + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scope: { type: 'extension', extension_name: 'NotAnExtension' }, + changes: [{ test_name: 'First test', delete_this_test: true }], + }, + } + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('does not exist'); + }); + }); }); diff --git a/newIDE/app/src/EditorFunctions/GameplayTestTools.js b/newIDE/app/src/EditorFunctions/GameplayTestTools.js new file mode 100644 index 0000000000..c82f4d8427 --- /dev/null +++ b/newIDE/app/src/EditorFunctions/GameplayTestTools.js @@ -0,0 +1,368 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import { type EditorFunction, type EditorFunctionGenericOutput } from '.'; +import { + runProjectGameplayTests, + getTestsContainer, + getGameplayTestProjectItemName, + getGameplayTestScopeDescription, + type GameplayTestResult, + type GameplayTestScope, +} from '../GameplayTests/GameplayTestRunner'; +import { mapFor } from '../Utils/MapFor'; + +const makeFailure = (message: string): EditorFunctionGenericOutput => ({ + success: false, + message, +}); + +/** + * Parse the `scope` tool argument ({ type: 'project' } or + * { type: 'extension', extension_name }) into a `GameplayTestScope`, or null + * when malformed. + */ +const parseScopeArgument = (scopeArgument: mixed): GameplayTestScope | null => { + if (!scopeArgument || typeof scopeArgument !== 'object') return null; + if (scopeArgument.type === 'project') return { type: 'project' }; + if ( + scopeArgument.type === 'extension' && + typeof scopeArgument.extension_name === 'string' && + scopeArgument.extension_name + ) { + return { type: 'extension', extensionName: scopeArgument.extension_name }; + } + return null; +}; + +const invalidScopeFailure = () => + makeFailure( + "Invalid `scope`: pass { type: 'project' } or { type: 'extension', extension_name: '...' }." + ); + +/** + * The output sent to the AI for a gameplay test run: the full result of the + * run, with console logs flattened to strings. + */ +const makeGameplayTestOutput = ( + result: GameplayTestResult, + didModifyProject: boolean +): EditorFunctionGenericOutput => { + return { + success: result.status === 'passed', + status: result.status, + testName: result.testName, + framesExecuted: result.framesExecuted, + durationMs: result.durationMs, + gameTimeMs: result.gameTimeMs, + assertions: result.assertions, + errors: result.errors, + consoleLogs: result.consoleLogs.map(log => `[${log.level}] ${log.message}`), + eventLog: result.eventLog, + finalState: result.finalState, + screenshots: result.screenshots, + performance: result.performance, + meta: didModifyProject ? { didModifyProject: true } : undefined, + }; +}; + +/** + * Run a gameplay test on the game (used by the AI "tester" agent). When + * `source` is given, the test is created or updated (unless `persist` is + * false) and then run. + */ +export const runGameplayTest: EditorFunction = { + renderForEditor: ({ args }) => { + const testName = args.test_name || ''; + return { + text: + args.source && args.persist !== false ? ( + Save and run the gameplay test {testName}. + ) : ( + Run the gameplay test {testName}. + ), + }; + }, + launchFunction: async ({ project, args }) => { + const scope = parseScopeArgument(args.scope); + if (!scope) return invalidScopeFailure(); + const testName = args.test_name; + if (typeof testName !== 'string' || !testName) { + return makeFailure('Missing or invalid `test_name` argument.'); + } + const source = typeof args.source === 'string' ? args.source : null; + const persist = args.persist !== false; + const timeoutMs = + typeof args.timeout_ms === 'number' + ? Math.min(Math.max(args.timeout_ms, 1000), 120000) + : undefined; + const screenshots = + args.screenshots === 'on-failure' ? 'on-failure' : 'off'; + + const testsContainer = getTestsContainer(project, scope); + if (!testsContainer) { + return makeFailure( + `Unknown scope: ${getGameplayTestScopeDescription( + scope + )} does not exist in the project.` + ); + } + + let didModifyProject = false; + if (source !== null && persist) { + const test = testsContainer.hasTestNamed(testName) + ? testsContainer.getTest(testName) + : testsContainer.insertNewTest( + testName, + testsContainer.getTestsCount() + ); + if (test.getSource() !== source) { + test.setSource(source); + didModifyProject = true; + } + if ( + typeof args.description === 'string' && + args.description !== test.getDescription() + ) { + test.setDescription(args.description); + didModifyProject = true; + } + } else if (source === null && !testsContainer.hasTestNamed(testName)) { + return makeFailure( + `No test named "${testName}" in ${getGameplayTestScopeDescription( + scope + )} - pass its code as \`source\` to create it.` + ); + } + + try { + const results = await runProjectGameplayTests({ + project, + tests: [ + { + scope, + testName, + ...(source !== null ? { source } : {}), + }, + ], + options: { + timeoutMs, + screenshots, + }, + }); + if (!results[0]) { + return makeFailure('The gameplay test did not return a result.'); + } + return makeGameplayTestOutput(results[0], didModifyProject); + } catch (error) { + return makeFailure( + 'Unable to run the gameplay test: ' + (error.message || String(error)) + ); + } + }, + modifiesProject: false, + // Only persisting a new/changed test modifies the project (and so requires + // an approval when auto-edit is off). Just running a test does not - and + // neither does running an unsaved source with `persist: false` (temporary + // probes and diagnostics). + getModifiesProject: (args: Object) => + typeof args.source === 'string' && args.persist !== false, +}; + +// Cap on the ordered test list returned after changes (mirrors the array +// truncation of `read_game_project_json`). +const MAX_LISTED_TESTS = 50; + +/** + * Delete gameplay tests or change their properties (name, description, + * index). Never their source: test code must go through the gameplay test + * runner (`run_gameplay_test`) so it is always executed and verified. + */ +export const changeGameplayTests: EditorFunction = { + renderForEditor: ({ args }) => { + const changesCount = Array.isArray(args.changes) ? args.changes.length : 0; + return { + text: ( + Change the gameplay tests ({changesCount} change(s)). + ), + }; + }, + launchFunction: async ({ + project, + args, + onProjectItemRenamedOutsideEditor, + onWillDeleteGameplayTest, + }) => { + const scope = parseScopeArgument(args.scope); + if (!scope) return invalidScopeFailure(); + const testsContainer = getTestsContainer(project, scope); + if (!testsContainer) { + return makeFailure( + `Unknown scope: ${getGameplayTestScopeDescription( + scope + )} does not exist in the project.` + ); + } + const changes = Array.isArray(args.changes) ? args.changes : null; + if (!changes || changes.length === 0) { + return makeFailure( + 'Missing or empty `changes` array: provide at least one change ({test_name, delete_this_test?, changed_properties?}).' + ); + } + + const listExistingTestNames = (): string => { + const names = mapFor(0, testsContainer.getTestsCount(), i => + testsContainer.getTestAt(i).getName() + ); + return names.length === 0 + ? '(no test in this scope)' + : names + .slice(0, MAX_LISTED_TESTS) + .map(name => `"${name}"`) + .join(', '); + }; + + const changeMessages = []; + let allChangesApplied = true; + let didModifyProject = false; + const failChange = (message: string) => { + allChangesApplied = false; + changeMessages.push(message); + }; + + for (const change of changes) { + const testName = + change && typeof change.test_name === 'string' + ? change.test_name + : null; + if (!testName) { + failChange('Invalid change: missing `test_name`.'); + continue; + } + if (!testsContainer.hasTestNamed(testName)) { + failChange( + `Unknown test "${testName}" in ${getGameplayTestScopeDescription( + scope + )}. Existing tests: ${listExistingTestNames()}.` + ); + continue; + } + + if (change.delete_this_test === true) { + // Close any open tab bound to the test BEFORE deleting it, so no + // editor is left rendering a dangling test. + await onWillDeleteGameplayTest({ + gameplayTestProjectItemName: getGameplayTestProjectItemName( + scope, + testName + ), + }); + testsContainer.removeTest(testName); + didModifyProject = true; + changeMessages.push(`Deleted the test "${testName}".`); + continue; + } + + const changedProperties = Array.isArray(change.changed_properties) + ? change.changed_properties + : null; + if (!changedProperties || changedProperties.length === 0) { + failChange( + `No-op change for "${testName}": provide \`changed_properties\` or \`delete_this_test: true\`.` + ); + continue; + } + + const test = testsContainer.getTest(testName); + let currentName = testName; + for (const changedProperty of changedProperties) { + const propertyName = + changedProperty && typeof changedProperty.property_name === 'string' + ? changedProperty.property_name + : null; + const newValue = + changedProperty && typeof changedProperty.new_value === 'string' + ? changedProperty.new_value + : null; + if (!propertyName || newValue === null) { + failChange( + `Invalid property change for "${currentName}": provide \`property_name\` and \`new_value\` (as a string).` + ); + continue; + } + if (propertyName === 'name') { + const newName = newValue.trim(); + if (!newName) { + failChange(`Cannot rename "${currentName}" to an empty name.`); + continue; + } + if (newName === currentName) continue; + if (testsContainer.hasTestNamed(newName)) { + failChange( + `Cannot rename "${currentName}" to "${newName}": a test with this name already exists in ${getGameplayTestScopeDescription( + scope + )}.` + ); + continue; + } + const oldName = currentName; + test.setName(newName); + currentName = newName; + didModifyProject = true; + onProjectItemRenamedOutsideEditor({ + kind: 'gameplay-test', + oldName: getGameplayTestProjectItemName(scope, oldName), + newName: getGameplayTestProjectItemName(scope, newName), + }); + changeMessages.push(`Renamed "${oldName}" to "${newName}".`); + } else if (propertyName === 'description') { + test.setDescription(newValue); + didModifyProject = true; + changeMessages.push(`Updated the description of "${currentName}".`); + } else if (propertyName === 'index') { + const requestedIndex = parseInt(newValue, 10); + if (Number.isNaN(requestedIndex)) { + failChange( + `Invalid index "${newValue}" for "${currentName}": provide a number (as a string).` + ); + continue; + } + const newIndex = Math.max( + 0, + Math.min(testsContainer.getTestsCount() - 1, requestedIndex) + ); + const oldIndex = testsContainer.getTestPosition(test); + if (newIndex !== oldIndex) { + testsContainer.moveTest(oldIndex, newIndex); + didModifyProject = true; + } + changeMessages.push(`Moved "${currentName}" to index ${newIndex}.`); + } else { + failChange( + `Unknown property "${propertyName}" for "${currentName}": only 'name', 'description' and 'index' can be changed. In particular the source can NOT be changed here: test code must go through \`run_gameplay_test\` so it is executed and verified.` + ); + } + } + } + + const testsCount = testsContainer.getTestsCount(); + const tests = mapFor(0, Math.min(testsCount, MAX_LISTED_TESTS), i => { + const test = testsContainer.getTestAt(i); + return { test_name: test.getName(), description: test.getDescription() }; + }); + if (testsCount > MAX_LISTED_TESTS) { + changeMessages.push( + `(Only the first ${MAX_LISTED_TESTS} of the ${testsCount} tests of the scope are listed.)` + ); + } + + return { + success: allChangesApplied, + message: changeMessages.join('\n'), + tests, + // A partially-failed batch may still have applied some changes. + meta: { didModifyProject }, + }; + }, + modifiesProject: true, +}; diff --git a/newIDE/app/src/EditorFunctions/OutsideEditorChanges.js b/newIDE/app/src/EditorFunctions/OutsideEditorChanges.js index c031d3c21a..6c5f16623d 100644 --- a/newIDE/app/src/EditorFunctions/OutsideEditorChanges.js +++ b/newIDE/app/src/EditorFunctions/OutsideEditorChanges.js @@ -18,9 +18,13 @@ export type ObjectGroupsOutsideEditorChanges = {| scene: gdLayout, |}; -// Only scenes are renamed outside the editor for now; extend as needed. -export type RenamableProjectItemKind = 'scene'; +// Only scenes and gameplay tests are renamed outside the editor for now; +// extend as needed. +export type RenamableProjectItemKind = 'scene' | 'gameplay-test'; +// For 'gameplay-test', the names are the tab "project item names" (the test +// name for a project test, `ExtensionName::TestName` for an extension test — +// see `getGameplayTestProjectItemName`). export type ProjectItemRenamedOutsideEditorChanges = {| kind: RenamableProjectItemKind, oldName: string, @@ -33,6 +37,13 @@ export type WillDeleteSceneChanges = {| scene: gdLayout, |}; +// Called before the gameplay test is actually deleted, so any tab bound to it +// can be closed first. The name is the tab "project item name" (the test name +// for a project test, `ExtensionName::TestName` for an extension test). +export type WillDeleteGameplayTestChanges = {| + gameplayTestProjectItemName: string, +|}; + // Called before the object is actually deleted, so editors can still safely // compare/read it (e.g. to close a dialog/panel referring to it) without // risking a dangling reference. diff --git a/newIDE/app/src/EditorFunctions/ScriptExecution/NonScriptableFunctionNames.js b/newIDE/app/src/EditorFunctions/ScriptExecution/NonScriptableFunctionNames.js index ec1fef0a87..026eba1e26 100644 --- a/newIDE/app/src/EditorFunctions/ScriptExecution/NonScriptableFunctionNames.js +++ b/newIDE/app/src/EditorFunctions/ScriptExecution/NonScriptableFunctionNames.js @@ -31,4 +31,9 @@ export const NON_SCRIPTABLE_FUNCTION_NAMES: Set = new Set([ // an edit/explorer script (v1 scope). 'generate_events', 'add_scene_events', + // Gameplay tests launch a real game preview (seconds long): they are plain + // tool calls (`run_tests` for the orchestrator, `run_gameplay_test` for the + // tester agent), never called from inside a script. + 'run_tests', + 'run_gameplay_test', ]); diff --git a/newIDE/app/src/EditorFunctions/SimplifiedProject/SimplifiedProject.js b/newIDE/app/src/EditorFunctions/SimplifiedProject/SimplifiedProject.js index 0533657c22..63ae9ac036 100644 --- a/newIDE/app/src/EditorFunctions/SimplifiedProject/SimplifiedProject.js +++ b/newIDE/app/src/EditorFunctions/SimplifiedProject/SimplifiedProject.js @@ -56,6 +56,14 @@ type SimplifiedResource = {| metadata?: string, |}; +type SimplifiedTest = {| + testName: string, + type: string, + description?: string, + lastRunStatus?: string, + lastRunAt?: number, +|}; + type SimplifiedProject = {| properties: {| name: string, @@ -70,6 +78,7 @@ type SimplifiedProject = {| scenes: Array, globalVariables: Array, resources: Array, + tests?: Array, |}; type ProjectSpecificExtensionsSummary = {| @@ -431,6 +440,24 @@ export const makeSimplifiedProjectBuilder = ( ), }; + const projectTests = project.getTests(); + if (projectTests.getTestsCount() > 0) { + simplifiedProject.tests = mapFor(0, projectTests.getTestsCount(), i => { + const test = projectTests.getTestAt(i); + const simplifiedTest: SimplifiedTest = { + testName: test.getName(), + type: test.getType(), + }; + if (test.getDescription()) + simplifiedTest.description = test.getDescription(); + if (test.getLastRunStatus()) { + simplifiedTest.lastRunStatus = test.getLastRunStatus(); + simplifiedTest.lastRunAt = test.getLastRunAt(); + } + return simplifiedTest; + }); + } + return simplifiedProject; }; diff --git a/newIDE/app/src/EditorFunctions/TestHelpers.js b/newIDE/app/src/EditorFunctions/TestHelpers.js index 73801b4bdf..27b3850a50 100644 --- a/newIDE/app/src/EditorFunctions/TestHelpers.js +++ b/newIDE/app/src/EditorFunctions/TestHelpers.js @@ -50,6 +50,7 @@ export const makeFakeLaunchFunctionOptionsWithoutProject = (): LaunchFunctionOpt }, onObjectsModifiedOutsideEditor: jest.fn(), onWillDeleteScene: jest.fn(), + onWillDeleteGameplayTest: jest.fn(), onWillDeleteObject: jest.fn(), onWillInstallExtension: jest.fn(), onExtensionInstalled: jest.fn(), diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 85e078618b..5ff4df0a1c 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -30,6 +30,7 @@ import { } from './ApplyEventsChanges'; import { isBehaviorDefaultCapability } from '../BehaviorsEditor/EnumerateBehaviorsMetadata'; import { renameResourcesInProject } from '../ResourcesList/ResourceUtils'; +import { runGameplayTest, changeGameplayTests } from './GameplayTestTools'; import { Trans } from '@lingui/macro'; import { type I18n as I18nType } from '@lingui/core'; import Link from '../UI/Link'; @@ -67,6 +68,7 @@ import type { ObjectGroupsOutsideEditorChanges, ProjectItemRenamedOutsideEditorChanges, WillDeleteSceneChanges, + WillDeleteGameplayTestChanges, WillDeleteObjectChanges, } from './OutsideEditorChanges'; import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset'; @@ -157,6 +159,18 @@ export type EditorFunctionGenericOutput = {| lastCalledFunctionName: string | null, |} | null, message?: string, + // `run_gameplay_test` output payload. Present only for gameplay test runs. + status?: string, + testName?: string, + framesExecuted?: number, + durationMs?: number, + gameTimeMs?: number, + assertions?: Array, + errors?: Array, + eventLog?: Array, + finalState?: Object | null, + screenshots?: Array, + performance?: Object | null, // Set to true (v12+) when a mutating call was a no-op because the requested // state already matched the current state. Lets the no-op rate be counted // from `functionCallRecords`/CloudWatch without any new telemetry. @@ -182,6 +196,9 @@ export type EditorFunctionGenericOutput = {| behaviorName: string, behaviorType: string, |}>, + // `change_gameplay_tests`: the ordered tests of the scope after the changes + // (capped), so renames/reorders/deletions are self-verifying. + tests?: Array<{| test_name: string, description: string |}>, variables?: Array, reminder?: string, animationNames?: string, @@ -360,6 +377,9 @@ export type LaunchFunctionOptionsWithoutProject = {| changes: ProjectItemRenamedOutsideEditorChanges ) => void, onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise, + onWillDeleteGameplayTest: ( + changes: WillDeleteGameplayTestChanges + ) => Promise, onWillDeleteObject: (changes: WillDeleteObjectChanges) => void, ensureExtensionInstalled: ( options: EnsureExtensionInstalledOptions @@ -415,6 +435,11 @@ export type EditorFunction = {| ) => Promise, /** True if this function modifies the project (triggers unsaved changes tracking). */ modifiesProject: boolean, + /** + * Optional: refine `modifiesProject` per call from its (parsed) arguments - + * used to gate edits behind a user confirmation when auto-edit is off. + */ + getModifiesProject?: (args: any) => boolean, |}; /** @@ -436,6 +461,11 @@ export type EditorFunctionWithoutProject = {| ) => Promise, /** True if this function modifies the project (triggers unsaved changes tracking). */ modifiesProject: boolean, + /** + * Optional: refine `modifiesProject` per call from its (parsed) arguments - + * used to gate edits behind a user confirmation when auto-edit is off. + */ + getModifiesProject?: (args: any) => boolean, |}; /** @@ -8672,6 +8702,29 @@ const runEditAgent: EditorFunction = { modifiesProject: true, }; +const runTests: EditorFunction = { + renderForEditor: ({ args }) => { + const newTest = SafeExtractor.extractObjectProperty(args, 'new_test'); + const newTestName = newTest + ? SafeExtractor.extractStringProperty(newTest, 'name') + : null; + if (newTestName) { + return { + text: Running the gameplay test {newTestName}., + }; + } + return { + text: Running gameplay tests., + }; + }, + launchFunction: async ({ args }) => { + return makeGenericFailure( + `Unable to run gameplay tests - this is handled server-side.` + ); + }, + modifiesProject: false, +}; + const readGameProjectJson: EditorFunction = { renderForEditor: ({ args }) => { return { @@ -8841,6 +8894,9 @@ export const editorFunctions: { [string]: EditorFunction } = { run_explorer_agent: runExplorerAgent, run_edit_agent: runEditAgent, + run_tests: runTests, + run_gameplay_test: runGameplayTest, + change_gameplay_tests: changeGameplayTests, read_game_project_json: readGameProjectJson, search_object_asset_store: searchObjectAssetStore, search_resource_store: searchResourceStore, diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/index.js b/newIDE/app/src/EventsFunctionsExtensionEditor/index.js index 261512cb92..cfaade27ff 100644 --- a/newIDE/app/src/EventsFunctionsExtensionEditor/index.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/index.js @@ -6,6 +6,7 @@ import { type I18n as I18nType } from '@lingui/core'; import * as React from 'react'; import EventsSheet, { type EventsSheetInterface } from '../EventsSheet'; +import { type GameplayTestsCallbacks } from '../GameplayTests/GameplayTestRunner'; import EditorMosaic, { type EditorMosaicInterface, type EditorMosaicNode, @@ -106,6 +107,7 @@ type Props = {| onEventBasedObjectTypeChanged: () => void, onWillInstallExtension: (extensionNames: Array) => void, onExtensionInstalled: (extensionNames: Array) => void, + gameplayTestsCallbacks: GameplayTestsCallbacks, |}; type State = {| @@ -838,6 +840,50 @@ export default class EventsFunctionsExtensionEditor extends React.Component< } }; + // Gameplay tests: delegate to the MainFrame-provided callbacks, bound to + // this extension (its name is the tests "scope"). + _onOpenGameplayTest = (testName: string) => { + this.props.gameplayTestsCallbacks.onOpenGameplayTest( + { + type: 'extension', + extensionName: this.props.eventsFunctionsExtension.getName(), + }, + testName + ); + }; + + _onRenameGameplayTest = (oldName: string, newName: string) => { + this.props.gameplayTestsCallbacks.onRenameGameplayTest( + { + type: 'extension', + extensionName: this.props.eventsFunctionsExtension.getName(), + }, + oldName, + newName + ); + if (this.eventsFunctionList) this.eventsFunctionList.forceUpdateList(); + }; + + _onDeleteGameplayTest = (test: gdTest) => { + this.props.gameplayTestsCallbacks.onDeleteGameplayTest( + { + type: 'extension', + extensionName: this.props.eventsFunctionsExtension.getName(), + }, + test + ); + }; + + _onRunGameplayTest = (testName: string) => { + this.props.gameplayTestsCallbacks.onRunGameplayTest( + { + type: 'extension', + extensionName: this.props.eventsFunctionsExtension.getName(), + }, + testName + ); + }; + _onEventsBasedObjectPasted = ( eventsBasedObject: gdEventsBasedObject, sourceExtensionName: string, @@ -1769,6 +1815,11 @@ export default class EventsFunctionsExtensionEditor extends React.Component< onEventsBasedObjectRenamed={this._onEventsBasedObjectRenamed} onEventsBasedObjectPasted={this._onEventsBasedObjectPasted} onAddEventsBasedObject={this._onAddEventsBasedObject} + // Gameplay tests + onOpenGameplayTest={this._onOpenGameplayTest} + onRenameGameplayTest={this._onRenameGameplayTest} + onDeleteGameplayTest={this._onDeleteGameplayTest} + onRunGameplayTest={this._onRunGameplayTest} onSelectExtensionProperties={() => this._editOptions(true)} onSelectExtensionGlobalVariables={() => this._openVariableEditorDialog({ diff --git a/newIDE/app/src/EventsFunctionsList/GameplayTestTreeViewItemContent.js b/newIDE/app/src/EventsFunctionsList/GameplayTestTreeViewItemContent.js new file mode 100644 index 0000000000..08465f6bfc --- /dev/null +++ b/newIDE/app/src/EventsFunctionsList/GameplayTestTreeViewItemContent.js @@ -0,0 +1,255 @@ +// @flow +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; + +import * as React from 'react'; +import newNameGenerator from '../Utils/NewNameGenerator'; +import Clipboard from '../Utils/Clipboard'; +import { SafeExtractor } from '../Utils/SafeExtractor'; +import { + serializeToJSObject, + unserializeFromJSObject, +} from '../Utils/Serializer'; +import { + type TreeViewItemContent, + type TreeItemProps, + extensionTestsRootFolderId, +} from '.'; +import { type HTMLDataset } from '../Utils/HTMLDataset'; +import { type MenuButton } from '../UI/TreeView'; +import IconButton from '../UI/IconButton'; +import PlayIcon from '../UI/CustomSvgIcons/Preview'; + +// The same clipboard kind as the project tests (in the Project Manager), so +// tests can be copied between the project and extensions. +const GAMEPLAY_TEST_CLIPBOARD_KIND = 'Gameplay test'; + +export type GameplayTestCallbacks = {| + onOpenGameplayTest: (testName: string) => void, + onRenameGameplayTest: (oldName: string, newName: string) => void, + onDeleteGameplayTest: (test: gdTest) => void, + onRunGameplayTest: (testName: string) => void | Promise, +|}; + +export type GameplayTestProps = {| + ...TreeItemProps, + testsContainer: gdTestsContainer, + ...GameplayTestCallbacks, +|}; + +export const getGameplayTestTreeViewItemId = (test: gdTest): string => { + // Pointers are used because they stay the same even when the names are + // changed. + return `gameplay-test-${test.ptr}`; +}; + +export class GameplayTestTreeViewItemContent implements TreeViewItemContent { + test: gdTest; + props: GameplayTestProps; + + constructor(test: gdTest, props: GameplayTestProps) { + this.test = test; + this.props = props; + } + + isDescendantOf(itemContent: TreeViewItemContent): boolean { + return itemContent.getId() === extensionTestsRootFolderId; + } + + getName(): string | React.Node { + return this.test.getName(); + } + + getId(): string { + return getGameplayTestTreeViewItemId(this.test); + } + + getHtmlId(index: number): ?string { + return `gameplay-test-item-${index}`; + } + + getThumbnail(): ?string { + return null; + } + + getDataset(): ?HTMLDataset { + return { + 'gameplay-test': this.test.getName(), + }; + } + + onSelect(): void {} + + onClick(): void { + this.props.onOpenGameplayTest(this.test.getName()); + } + + rename(newName: string): void { + const oldName = this.test.getName(); + if (oldName === newName) { + return; + } + this.props.onRenameGameplayTest(oldName, newName); + } + + edit(): void { + this.props.editName(this.getId()); + } + + buildMenuTemplate(i18n: I18nType, index: number): any { + return [ + { + label: i18n._(t`Run`), + click: () => this.props.onRunGameplayTest(this.test.getName()), + }, + { + type: 'separator', + }, + { + label: i18n._(t`Rename`), + click: () => this.edit(), + accelerator: 'F2', + }, + { + label: i18n._(t`Delete`), + click: () => this.delete(), + accelerator: 'Backspace', + }, + { + type: 'separator', + }, + { + label: i18n._(t`Copy`), + click: () => this.copy(), + accelerator: 'CmdOrCtrl+C', + }, + { + label: i18n._(t`Cut`), + click: () => this.cut(), + accelerator: 'CmdOrCtrl+X', + }, + { + label: i18n._(t`Paste`), + enabled: Clipboard.has(GAMEPLAY_TEST_CLIPBOARD_KIND), + click: () => this.paste(), + accelerator: 'CmdOrCtrl+V', + }, + { + label: i18n._(t`Duplicate`), + click: () => this._duplicate(), + }, + ]; + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return ( + { + e.stopPropagation(); + this.props.onRunGameplayTest(this.test.getName()); + }} + tooltip={t`Run the test`} + > + + + ); + } + + delete(): void { + this.props.onDeleteGameplayTest(this.test); + } + + getIndex(): number { + return this.props.testsContainer.getTestPosition(this.test); + } + + moveAt( + destinationItemContent: TreeViewItemContent, + where: 'before' | 'inside' | 'after', + animateFolder: (folder: gdFunctionFolderOrFunction) => void + ): void { + const originIndex = this.getIndex(); + const destinationIndex = + destinationItemContent.getIndex() + (where === 'after' ? 1 : 0); + this.props.testsContainer.moveTest( + originIndex, + // When moving the item down, it must not be counted. + destinationIndex + (destinationIndex <= originIndex ? 0 : -1) + ); + this._onGameplayTestModified(); + } + + copy(): void { + Clipboard.set(GAMEPLAY_TEST_CLIPBOARD_KIND, { + test: serializeToJSObject(this.test), + name: this.test.getName(), + }); + } + + cut(): void { + this.copy(); + this.delete(); + } + + paste(): void { + if (!Clipboard.has(GAMEPLAY_TEST_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(GAMEPLAY_TEST_CLIPBOARD_KIND); + const copiedTest = SafeExtractor.extractObjectProperty( + clipboardContent, + 'test' + ); + const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); + if (!name || !copiedTest) return; + + const testsContainer = this.props.testsContainer; + const newName = newNameGenerator(name, name => + testsContainer.hasTestNamed(name) + ); + + const newTest = testsContainer.insertNewTest(newName, this.getIndex() + 1); + + unserializeFromJSObject(newTest, copiedTest, 'unserializeFrom'); + // Unserialization has overwritten the name. + newTest.setName(newName); + + this._onGameplayTestModified(); + this.props.editName(getGameplayTestTreeViewItemId(newTest)); + } + + _duplicate(): void { + this.copy(); + this.paste(); + } + + _onGameplayTestModified() { + if (this.props.unsavedChanges) + this.props.unsavedChanges.triggerUnsavedChanges(); + this.props.forceUpdate(); + } + + getRightButton(i18n: I18nType): ?MenuButton { + return null; + } + + getEventsFunctionsContainer(): ?gdEventsFunctionsContainer { + return null; + } + + getFunctionFolderOrFunction(): gdFunctionFolderOrFunction | null { + return null; + } + + getEventsFunction(): ?gdEventsFunction { + return null; + } + + getEventsBasedBehavior(): ?gdEventsBasedBehavior { + return null; + } + + getEventsBasedObject(): ?gdEventsBasedObject { + return null; + } +} diff --git a/newIDE/app/src/EventsFunctionsList/index.js b/newIDE/app/src/EventsFunctionsList/index.js index 0f57f446c7..a18e6537f8 100644 --- a/newIDE/app/src/EventsFunctionsList/index.js +++ b/newIDE/app/src/EventsFunctionsList/index.js @@ -55,6 +55,14 @@ import { type EventsBasedObjectCallbacks, type EventsBasedObjectCreationParameters, } from './EventsBasedObjectTreeViewItemContent'; +import { + GameplayTestTreeViewItemContent, + getGameplayTestTreeViewItemId, + type GameplayTestProps, + type GameplayTestCallbacks, +} from './GameplayTestTreeViewItemContent'; +import { DEFAULT_GAMEPLAY_TEST_SOURCE } from '../GameplayTests/DefaultGameplayTestSource'; +import { areGameplayTestsEnabled } from '../GameplayTests/AreGameplayTestsEnabled'; import { type HTMLDataset } from '../Utils/HTMLDataset'; import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; import useAlertDialog from '../UI/Alert/useAlertDialog'; @@ -72,9 +80,11 @@ export const extensionConfigurationRootFolderId = 'extension-configuration'; export const extensionObjectsRootFolderId = 'extension-objects'; export const extensionBehaviorsRootFolderId = 'extension-behaviors'; export const extensionFunctionsRootFolderId = 'extension-functions'; +export const extensionTestsRootFolderId = 'extension-tests'; const extensionObjectsEmptyPlaceholderId = 'extension-objects-placeholder'; const extensionBehaviorsEmptyPlaceholderId = 'extension-behaviors-placeholder'; const extensionFunctionsEmptyPlaceholderId = 'extension-functions-placeholder'; +const extensionTestsEmptyPlaceholderId = 'extension-tests-placeholder'; const styles = { listContainer: { @@ -661,6 +671,8 @@ type Props = {| // Free functions selectedEventsFunction: ?gdEventsFunction, ...EventsFunctionCallbacks, + // Gameplay tests + ...GameplayTestCallbacks, onSelectExtensionProperties: () => void, onSelectExtensionGlobalVariables: () => void, onSelectExtensionSceneVariables: () => void, @@ -692,6 +704,10 @@ const EventsFunctionsList = React.forwardRef< onEventsBasedObjectRenamed, onEventsBasedObjectPasted, onAddEventsBasedObject, + onOpenGameplayTest, + onRenameGameplayTest, + onDeleteGameplayTest, + onRunGameplayTest, selectedEventsFunction, selectedEventsBasedBehavior, selectedEventsBasedObject, @@ -969,6 +985,48 @@ const EventsFunctionsList = React.forwardRef< ] ); + const addNewGameplayTest = React.useCallback( + () => { + const testsContainer = eventsFunctionsExtension.getTests(); + + const name = newNameGenerator('MyTest', name => + testsContainer.hasTestNamed(name) + ); + const newTest = testsContainer.insertNewTest( + name, + testsContainer.getTestsCount() + ); + newTest.setSource(DEFAULT_GAMEPLAY_TEST_SOURCE); + if (unsavedChanges) { + unsavedChanges.triggerUnsavedChanges(); + } + forceUpdate(); + + const testItemId = getGameplayTestTreeViewItemId(newTest); + + if (treeViewRef.current) { + treeViewRef.current.openItems([ + testItemId, + extensionTestsRootFolderId, + ]); + } + // Scroll to the new test (after a new render was done). + setTimeout(() => { + scrollToItem(testItemId); + }, 100); // A few ms is enough for a new render to be done. + + // We focus it so the user can edit the name directly. + editName(testItemId); + }, + [ + editName, + eventsFunctionsExtension, + forceUpdate, + scrollToItem, + unsavedChanges, + ] + ); + const addNewEventsBasedObject = React.useCallback( () => { onAddEventsBasedObject( @@ -1334,6 +1392,39 @@ const EventsFunctionsList = React.forwardRef< ] ); + const testsContainer = eventsFunctionsExtension.getTests(); + + const gameplayTestProps = React.useMemo( + () => ({ + ...treeItemProps, + testsContainer, + onOpenGameplayTest, + onRenameGameplayTest, + onDeleteGameplayTest, + onRunGameplayTest, + }), + [ + treeItemProps, + testsContainer, + onOpenGameplayTest, + onRenameGameplayTest, + onDeleteGameplayTest, + onRunGameplayTest, + ] + ); + + const gameplayTestTreeViewItems = mapFor( + 0, + testsContainer.getTestsCount(), + i => + new LeafTreeViewItem( + new GameplayTestTreeViewItemContent( + testsContainer.getTestAt(i), + gameplayTestProps + ) + ) + ); + const objectTreeViewItems = mapFor( 0, eventBasedObjects.size(), @@ -1441,6 +1532,33 @@ const EventsFunctionsList = React.forwardRef< behaviorTreeViewItems; }, }, + ...(areGameplayTestsEnabled() + ? [ + { + isRoot: true, + content: new LabelTreeViewItemContent( + extensionTestsRootFolderId, + i18n._(t`Gameplay tests`), + { + icon: , + label: i18n._(t`Add a gameplay test`), + click: addNewGameplayTest, + } + ), + getChildren(i18n: I18nType): ?Array { + return gameplayTestTreeViewItems.length === 0 + ? [ + new PlaceHolderTreeViewItem( + extensionTestsEmptyPlaceholderId, + i18n._(t`Start by adding a new gameplay test.`) + ), + ] + : // $FlowFixMe[incompatible-type] + gameplayTestTreeViewItems; + }, + }, + ] + : []), { isRoot: true, content: new LabelTreeViewItemContent( @@ -1518,11 +1636,13 @@ const EventsFunctionsList = React.forwardRef< [ addNewEventsBasedObject, addNewEventsBehavior, + addNewGameplayTest, onSelectExtensionProperties, onSelectExtensionGlobalVariables, onSelectExtensionSceneVariables, objectTreeViewItems, behaviorTreeViewItems, + gameplayTestTreeViewItems, addNewEventsFunction, eventsFunctionsExtension, addFolder, @@ -1543,6 +1663,13 @@ const EventsFunctionsList = React.forwardRef< destinationItem.content.getEventsFunctionsContainer() ); } + // Gameplay tests between themselves + if (item.content.getId().startsWith('gameplay-test-')) { + return ( + where !== 'inside' && + destinationItem.content.getId().startsWith('gameplay-test-') + ); + } // Behaviors or Objects return ( !destinationItem.content.getEventsFunction() && @@ -1613,6 +1740,7 @@ const EventsFunctionsList = React.forwardRef< extensionBehaviorsRootFolderId, extensionFunctionsRootFolderId, extensionConfigurationRootFolderId, + ...(areGameplayTestsEnabled() ? [extensionTestsRootFolderId] : []), ...objectTreeViewItems.map(item => item.content.getId()), ...behaviorTreeViewItems.map(item => item.content.getId()), ...objectTreeViewItems diff --git a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserPreview/BrowserPreviewDebuggerServer.js b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserPreview/BrowserPreviewDebuggerServer.js index bc837e3948..cdc220874e 100644 --- a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserPreview/BrowserPreviewDebuggerServer.js +++ b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserPreview/BrowserPreviewDebuggerServer.js @@ -18,15 +18,20 @@ const existingPreviewWindows: { } = {}; let embbededGameFrameWindow: WindowProxy | null = null; +let gameplayTestFrameWindow: WindowProxy | null = null; const getExistingDebuggerIds = (): Array => [ ...getExistingEmbeddedGameFrameDebuggerIds(), + ...getExistingGameplayTestFrameDebuggerIds(), ...getExistingPreviewDebuggerIds(), ]; const getExistingEmbeddedGameFrameDebuggerIds = (): Array => embbededGameFrameWindow ? ['embedded-game-frame'] : []; +const getExistingGameplayTestFrameDebuggerIds = (): Array => + gameplayTestFrameWindow ? ['gameplay-test-frame'] : []; + const getExistingPreviewDebuggerIds = (): Array => Object.keys(existingPreviewWindows).map(key => key); @@ -36,6 +41,9 @@ const getDebuggerIdForPreviewWindow = ( if (embbededGameFrameWindow && embbededGameFrameWindow === previewWindow) { return 'embedded-game-frame'; } + if (gameplayTestFrameWindow && gameplayTestFrameWindow === previewWindow) { + return 'gameplay-test-frame'; + } for (const id in existingPreviewWindows) { if (existingPreviewWindows[id] === previewWindow) { @@ -134,6 +142,8 @@ class BrowserPreviewDebuggerServer { const theWindow = id === 'embedded-game-frame' ? embbededGameFrameWindow + : id === 'gameplay-test-frame' + ? gameplayTestFrameWindow : existingPreviewWindows[id]; if (!theWindow) return; @@ -195,6 +205,36 @@ class BrowserPreviewDebuggerServer { ); embbededGameFrameWindow = window; } + registerGameplayTestFrame(window: WindowProxy) { + if (window === gameplayTestFrameWindow) return; + + console.info( + 'Registered the gameplay test frame window in the debugger server.' + ); + gameplayTestFrameWindow = window; + callbacksList.forEach(({ onConnectionOpened }) => + onConnectionOpened({ + id: 'gameplay-test-frame', + debuggerIds: getExistingDebuggerIds(), + }) + ); + } + unregisterGameplayTestFrame(window: WindowProxy) { + if (gameplayTestFrameWindow !== window) { + if (!!gameplayTestFrameWindow) { + console.warn( + 'The gameplay test frame window to unregister is not the same as the one registered. Ignoring the unregistration.' + ); + } + return; + } + + console.info( + 'Unregistered the gameplay test frame window in the debugger server.' + ); + gameplayTestFrameWindow = null; + notifyConnectionClosed('gameplay-test-frame'); + } unregisterEmbeddedGameFrame(window: WindowProxy) { if (embbededGameFrameWindow !== window) { if (!!embbededGameFrameWindow) { @@ -238,6 +278,11 @@ class BrowserPreviewDebuggerServer { notifyConnectionClosed('embedded-game-frame'); } + if (gameplayTestFrameWindow) { + gameplayTestFrameWindow = null; + notifyConnectionClosed('gameplay-test-frame'); + } + responseCallbacks.clear(); } } diff --git a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher/index.js b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher/index.js index f29ee8227a..0e5ea4424b 100644 --- a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher/index.js +++ b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher/index.js @@ -82,6 +82,12 @@ export default class BrowserS3PreviewLauncher extends React.Component< error: null, }); + if (previewOptions.isForGameplayTest) { + throw new Error( + 'Gameplay tests are not supported with this legacy preview launcher.' + ); + } + const debuggerIds = previewOptions.isForInGameEdition ? this.getPreviewDebuggerServer().getExistingEmbeddedGameFrameDebuggerIds() : this.getPreviewDebuggerServer().getExistingPreviewDebuggerIds(); diff --git a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js index 6da24474b8..75dab34015 100644 --- a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js +++ b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js @@ -23,6 +23,7 @@ import { getBrowserSWPreviewRootUrl, } from './BrowserSWPreviewIndexedDB'; import { setEmbeddedGameFramePreviewLocation } from '../../../EmbeddedGame/EmbeddedGameFrame'; +import { setGameplayTestFramePreviewLocation } from '../../../GameplayTests/GameplayTestFrame'; import { immediatelyOpenNewPreviewWindow } from '../BrowserPreview/BrowserPreviewWindow'; const gd: libGDevelop = global.gd; @@ -30,8 +31,10 @@ let nextPreviewId = 1; const prepareExporter = async ({ isForInGameEdition, + isForGameplayTest, }: { isForInGameEdition: boolean, + isForGameplayTest: boolean, }): Promise<{| outputDir: string, exporter: gdjsExporter, @@ -43,7 +46,11 @@ const prepareExporter = async ({ const baseUrl = getBrowserSWPreviewBaseUrl(); const rootUrl = getBrowserSWPreviewRootUrl(); const outputDir = `${baseUrl}/${ - isForInGameEdition ? 'in-game-editor-preview' : 'preview' + isForGameplayTest + ? 'gameplay-test-preview' + : isForInGameEdition + ? 'in-game-editor-preview' + : 'preview' }`; console.log( @@ -140,7 +147,10 @@ export default class BrowserSWPreviewLauncher extends React.Component< const debuggerIds = previewOptions.isForInGameEdition ? this.getPreviewDebuggerServer().getExistingEmbeddedGameFrameDebuggerIds() : this.getPreviewDebuggerServer().getExistingPreviewDebuggerIds(); - const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length; + const shouldHotReload = + previewOptions.hotReload && + !previewOptions.isForGameplayTest && + !!debuggerIds.length; try { await this.getPreviewDebuggerServer().startServer({ @@ -162,6 +172,7 @@ export default class BrowserSWPreviewLauncher extends React.Component< browserSWFileSystem, } = await prepareExporter({ isForInGameEdition: previewOptions.isForInGameEdition, + isForGameplayTest: !!previewOptions.isForGameplayTest, }); const previewExportOptions = new gd.PreviewExportOptions( @@ -295,7 +306,14 @@ export default class BrowserSWPreviewLauncher extends React.Component< }); } - if (shouldHotReload) { + if (previewOptions.isForGameplayTest) { + // The preview is shown in (and run by) the gameplay test frame: + // no window to open. + setGameplayTestFramePreviewLocation({ + previewIndexHtmlLocation: + outputDir + '/index.html?previewId=' + previewId, + }); + } else if (shouldHotReload) { const projectDataElement = new gd.SerializerElement(); exporter.serializeProjectData( project, diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js index 2be5bd4c68..5cab9600f5 100644 --- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js @@ -17,16 +17,21 @@ const responseCallbacks = new Map void>(); let nextMessageWithResponseId = 1; let embeddedGameFrameWindow: WindowProxy | null = null; +let gameplayTestFrameWindow: WindowProxy | null = null; let isWindowMessageListenerRegistered = false; const getExistingDebuggerIds = (): Array => [ ...getExistingEmbeddedGameFrameDebuggerIds(), + ...getExistingGameplayTestFrameDebuggerIds(), ...getExistingPreviewDebuggerIds(), ]; const getExistingEmbeddedGameFrameDebuggerIds = (): Array => embeddedGameFrameWindow ? ['embedded-game-frame'] : []; +const getExistingGameplayTestFrameDebuggerIds = (): Array => + gameplayTestFrameWindow ? ['gameplay-test-frame'] : []; + const getExistingPreviewDebuggerIds = (): Array => debuggerIds; const handleParsedMessage = ( @@ -87,20 +92,26 @@ class LocalPreviewDebuggerServer { if (!isWindowMessageListenerRegistered) { window.addEventListener('message', event => { - if (!embeddedGameFrameWindow) return; - if (event.source !== embeddedGameFrameWindow) return; + const id = + embeddedGameFrameWindow && event.source === embeddedGameFrameWindow + ? 'embedded-game-frame' + : gameplayTestFrameWindow && + event.source === gameplayTestFrameWindow + ? 'gameplay-test-frame' + : null; + if (!id) return; let parsedMessage = null; try { parsedMessage = JSON.parse(event.data); } catch (error) { console.warn( - 'Error while parsing a message received from the embedded game frame:', + 'Error while parsing a message received from an embedded frame:', error ); } - handleParsedMessage('embedded-game-frame', parsedMessage); + handleParsedMessage(id, parsedMessage); }); isWindowMessageListenerRegistered = true; } @@ -204,6 +215,17 @@ class LocalPreviewDebuggerServer { embeddedGameFrameWindow.postMessage(message, '*'); return; } + if (id === 'gameplay-test-frame') { + if (!gameplayTestFrameWindow) { + console.error( + 'Cannot send message to the gameplay test frame as it is not registered.' + ); + return; + } + + gameplayTestFrameWindow.postMessage(message, '*'); + return; + } if (!ipcRenderer) return; if (debuggerServerState === 'stopped') { @@ -287,6 +309,36 @@ class LocalPreviewDebuggerServer { embeddedGameFrameWindow = null; notifyConnectionClosed('embedded-game-frame'); } + registerGameplayTestFrame(embeddedWindow: WindowProxy) { + if (embeddedWindow === gameplayTestFrameWindow) return; + + if (gameplayTestFrameWindow) { + console.warn( + 'A gameplay test frame window was already registered. It will be replaced by the new one.' + ); + } + + gameplayTestFrameWindow = embeddedWindow; + callbacksList.forEach(({ onConnectionOpened }) => + onConnectionOpened({ + id: 'gameplay-test-frame', + debuggerIds: getExistingDebuggerIds(), + }) + ); + } + unregisterGameplayTestFrame(embeddedWindow: WindowProxy) { + if (gameplayTestFrameWindow !== embeddedWindow) { + if (!!gameplayTestFrameWindow) { + console.warn( + 'The gameplay test frame window to unregister is not the same as the one registered. Ignoring the unregistration.' + ); + } + return; + } + + gameplayTestFrameWindow = null; + notifyConnectionClosed('gameplay-test-frame'); + } closeAllConnections() { const previousDebuggerIds = [...debuggerIds]; debuggerIds.length = 0; @@ -305,6 +357,11 @@ class LocalPreviewDebuggerServer { embeddedGameFrameWindow = null; notifyConnectionClosed('embedded-game-frame'); } + + if (gameplayTestFrameWindow) { + gameplayTestFrameWindow = null; + notifyConnectionClosed('gameplay-test-frame'); + } } } diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js index 924d156790..c38cbc0d63 100644 --- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js @@ -22,6 +22,7 @@ import { import Window from '../../../Utils/Window'; import { getIDEVersionWithHash } from '../../../Version'; import { setEmbeddedGameFramePreviewLocation } from '../../../EmbeddedGame/EmbeddedGameFrame'; +import { setGameplayTestFramePreviewLocation } from '../../../GameplayTests/GameplayTestFrame'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); const ipcRenderer = electron ? electron.ipcRenderer : null; @@ -51,8 +52,10 @@ type State = {| const prepareExporter = async ({ isForInGameEdition, + isForGameplayTest, }: { isForInGameEdition: boolean, + isForGameplayTest: boolean, }): Promise<{| outputDir: string, exporter: gdjsExporter, @@ -67,7 +70,11 @@ const prepareExporter = async ({ const fileSystem = assignIn(new gd.AbstractFileSystemJS(), localFileSystem); const outputDir = path.join( fileSystem.getTempDir(), - isForInGameEdition ? 'in-game-editor-preview' : 'preview' + isForGameplayTest + ? 'gameplay-test-preview' + : isForInGameEdition + ? 'in-game-editor-preview' + : 'preview' ); const exporter = new gd.Exporter(fileSystem, gdjsRoot); @@ -247,6 +254,7 @@ export default class LocalPreviewLauncher extends React.Component< const { outputDir, exporter, gdjsRoot } = await prepareExporter({ isForInGameEdition: previewOptions.isForInGameEdition, + isForGameplayTest: !!previewOptions.isForGameplayTest, }); var previewStartTime = performance.now(); @@ -269,7 +277,7 @@ export default class LocalPreviewLauncher extends React.Component< ); } - if (previewOptions.isForInGameEdition) { + if (previewOptions.isForInGameEdition || previewOptions.isForGameplayTest) { previewExportOptions.useWindowMessageDebuggerClient(); } else { const previewDebuggerServerAddress = getDebuggerServerAddress(); @@ -306,7 +314,10 @@ export default class LocalPreviewLauncher extends React.Component< const debuggerIds = previewOptions.isForInGameEdition ? this.getPreviewDebuggerServer().getExistingEmbeddedGameFrameDebuggerIds() : this.getPreviewDebuggerServer().getExistingPreviewDebuggerIds(); - const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length; + const shouldHotReload = + previewOptions.hotReload && + !previewOptions.isForGameplayTest && + !!debuggerIds.length; if (shouldHotReload) { previewExportOptions.setShouldClearExportFolder( previewOptions.shouldHardReload @@ -444,7 +455,14 @@ export default class LocalPreviewLauncher extends React.Component< }); } - if (previewOptions.numberOfWindows >= 1) { + if (previewOptions.isForGameplayTest) { + // The preview is shown in (and run by) the gameplay test frame: + // no window to open. The previewId in the URL forces the frame to + // reload the game when the same preview is re-exported. + setGameplayTestFramePreviewLocation({ + previewIndexHtmlLocation: `file://${outputDir}/index.html?previewId=${previewId}`, + }); + } else if (previewOptions.numberOfWindows >= 1) { this._openPreviewWindow(project, outputDir, previewOptions); } } diff --git a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js index b30e07ff90..842414c869 100644 --- a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js +++ b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js @@ -60,6 +60,7 @@ export type PreviewOptions = {| playerToken: string, }, isForInGameEdition: boolean, + isForGameplayTest: boolean, editorId: string, getIsMenuBarHiddenInPreview: () => boolean, getIsAlwaysOnTopInPreview: () => boolean, @@ -142,6 +143,8 @@ export interface PreviewDebuggerServer { registerCallbacks(callbacks: PreviewDebuggerServerCallbacks): () => void; registerEmbeddedGameFrame(window: WindowProxy): void; unregisterEmbeddedGameFrame(window: WindowProxy): void; + registerGameplayTestFrame(window: WindowProxy): void; + unregisterGameplayTestFrame(window: WindowProxy): void; closeAllConnections(): void; } diff --git a/newIDE/app/src/GameplayTests/AreGameplayTestsEnabled.js b/newIDE/app/src/GameplayTests/AreGameplayTestsEnabled.js new file mode 100644 index 0000000000..94d2e1e34f --- /dev/null +++ b/newIDE/app/src/GameplayTests/AreGameplayTestsEnabled.js @@ -0,0 +1,10 @@ +// @flow +import Window from '../Utils/Window'; + +/** + * Gameplay tests are still under development: the UI (project manager and + * extension editor sections, command palette commands) and the AI tools + * version exposing them (v14) are only enabled in development, so the editor + * can be deployed without the feature being visible. + */ +export const areGameplayTestsEnabled = (): boolean => Window.isDev(); diff --git a/newIDE/app/src/GameplayTests/DefaultGameplayTestSource.js b/newIDE/app/src/GameplayTests/DefaultGameplayTestSource.js new file mode 100644 index 0000000000..2cfaedf42d --- /dev/null +++ b/newIDE/app/src/GameplayTests/DefaultGameplayTestSource.js @@ -0,0 +1,30 @@ +// @flow + +/** + * The source given to a newly created gameplay test: a tiny, working + * starting point, with the most useful parts of the `harness` API shown + * as comments. Written for a human creating a test manually (the AI + * always writes the full source itself). + */ +export const DEFAULT_GAMEPLAY_TEST_SOURCE = `// This script plays the game and checks it behaves as expected. +// It runs inside the game: use \`harness\` to play frames, simulate +// the player inputs and inspect the objects of the scene. + +// The game starts on its first scene. You can jump to any scene: +// await harness.goToScene('MyLevel'); + +// Play one second of the game (60 frames): +await harness.stepFrames(60); + +// Simulate the player pressing a key (also see setMousePosition, +// setMouseButtonPressed, touchStart...): +// harness.setKeyPressed('Right', true); +// await harness.stepFrames(30); +// harness.setKeyPressed('Right', false); + +// Inspect objects and check the game state - a failed assert fails the test: +// const players = harness.getObjects('Player'); +// harness.assert(players.length === 1, 'The player is in the scene'); + +console.log('Scene at the end of the test:', harness.getSceneName()); +`; diff --git a/newIDE/app/src/GameplayTests/GameplayTestEditor.js b/newIDE/app/src/GameplayTests/GameplayTestEditor.js new file mode 100644 index 0000000000..1a375ea46c --- /dev/null +++ b/newIDE/app/src/GameplayTests/GameplayTestEditor.js @@ -0,0 +1,222 @@ +// @flow +import { t, Trans } from '@lingui/macro'; +import * as React from 'react'; +import { CodeEditor } from '../CodeEditor'; +import EditorBottomTabsSwitcher, { + type EditorBottomTab, +} from '../UI/EditorBottomTabsSwitcher'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import useForceUpdate from '../Utils/UseForceUpdate'; +import EditorMosaic, { + type EditorMosaicInterface, + type EditorMosaicNode, +} from '../UI/EditorMosaic'; +import { FullSizeMeasurer } from '../UI/FullSizeMeasurer'; +import Background from '../UI/Background'; +import { Column } from '../UI/Grid'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; +import EditIcon from '../UI/CustomSvgIcons/Edit'; +import ConsoleIcon from '../UI/CustomSvgIcons/Console'; +import { + type GameplayTestResult, + type GameplayTestScope, +} from './GameplayTestRunner'; +import { GameplayTestProperties } from './GameplayTestProperties'; +import { type GameplayTestRunSpeedOptions } from './GameplayTestEditorToolbar'; + +export type GameplayTestEditorInterface = {| + forceUpdate: () => void, + togglePropertiesPanel: () => void, + isPropertiesPanelShown: () => boolean, +|}; + +const initialMosaicEditorNodes: EditorMosaicNode = { + direction: 'row', + first: 'test-code', + second: 'test-properties', + splitPercentage: 70, +}; + +type Props = {| + project: gdProject, + test: gdTest, + scope: GameplayTestScope, + isRunning: boolean, + runningFrame: number | null, + lastResult: GameplayTestResult | null, + onRunTest: (options: GameplayTestRunSpeedOptions) => void | Promise, + onStopTest: () => void, + onEditWithAi: () => void, + onTestModified: () => void, + onOpenedEditorsChanged: () => void, +|}; + +/** + * The editor content of a gameplay test: a code editor and a properties + * panel (description, run button and outcome of the last run). + */ +const GameplayTestEditor: React.ComponentType<{ + ...Props, + +ref?: React.RefSetter, +}> = React.forwardRef( + (props: Props, ref) => { + const { + test, + scope, + isRunning, + runningFrame, + lastResult, + onRunTest, + onStopTest, + onEditWithAi, + onTestModified, + } = props; + const { + getDefaultEditorMosaicNode, + setDefaultEditorMosaicNode, + } = React.useContext(PreferencesContext); + const editorMosaicRef = React.useRef(null); + const { isMobile } = useResponsiveWindowSize(); + // On small screens, the editors are shown one at a time, switched with + // bottom tabs — the properties (description, run button, last outcome) + // by default. + const [currentBottomTab, setCurrentBottomTab] = React.useState< + 'test-properties' | 'test-code' + >('test-properties'); + const { onOpenedEditorsChanged } = props; + const forceUpdate = useForceUpdate(); + React.useImperativeHandle(ref, () => ({ + forceUpdate, + togglePropertiesPanel: () => { + if (isMobile) { + setCurrentBottomTab(currentTab => + currentTab === 'test-properties' ? 'test-code' : 'test-properties' + ); + onOpenedEditorsChanged(); + return; + } + if (editorMosaicRef.current) + editorMosaicRef.current.toggleEditor('test-properties', 'right'); + }, + isPropertiesPanelShown: () => + isMobile + ? currentBottomTab === 'test-properties' + : !!editorMosaicRef.current && + editorMosaicRef.current + .getOpenedEditorNames() + .includes('test-properties'), + })); + + const renderProperties = () => ( + + + + ); + + const renderCodeEditor = () => ( + // `overflow: hidden` + `minWidth: 0` so the code editor can never grow + // past the available width (notably on small screens). + + + {({ width, height }) => ( + { + test.setSource(source); + onTestModified(); + }} + initialScrollTop={0} + initialCursorColumn={0} + initialCursorLine={0} + saveEditorState={() => {}} + onFocus={() => {}} + onBlur={() => {}} + width={width} + height={height} + // The test script is run inside an async function (see + // `gdjs.gameplayTests.runGameplayTest`), so top-level `await` + // is allowed in it. + suppressedDiagnosticsMessages={[ + 'only allowed within an async function', + ]} + /> + )} + + + ); + + const editors: { [string]: any } = { + 'test-code': { + type: 'primary', + noTitleBar: true, + renderEditor: renderCodeEditor, + }, + 'test-properties': { + type: 'secondary', + title: t`Test properties`, + renderEditor: renderProperties, + }, + }; + + if (isMobile) { + const bottomTabs: Array< + EditorBottomTab<'test-properties' | 'test-code'> + > = [ + { + value: 'test-properties', + label: Properties, + getIcon: ({ color, fontSize }) => ( + + ), + renderEditor: renderProperties, + }, + { + value: 'test-code', + label: Code, + getIcon: ({ color, fontSize }) => ( + + ), + renderEditor: renderCodeEditor, + }, + ]; + return ( + { + setCurrentBottomTab(newTab); + onOpenedEditorsChanged(); + }} + /> + ); + } + + return ( + + setDefaultEditorMosaicNode('gameplay-test-editor', node) + } + /> + ); + } +); + +export default GameplayTestEditor; diff --git a/newIDE/app/src/GameplayTests/GameplayTestEditorToolbar.js b/newIDE/app/src/GameplayTests/GameplayTestEditorToolbar.js new file mode 100644 index 0000000000..8386e0391d --- /dev/null +++ b/newIDE/app/src/GameplayTests/GameplayTestEditorToolbar.js @@ -0,0 +1,96 @@ +// @flow +import { Trans, t } from '@lingui/macro'; +import * as React from 'react'; +import { type I18n as I18nType } from '@lingui/core'; +import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; +import { ToolbarGroup } from '../UI/Toolbar'; +import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu'; +import FlatButton from '../UI/FlatButton'; +import IconButton from '../UI/IconButton'; +import PlayIcon from '../UI/CustomSvgIcons/Preview'; +import StopIcon from '../UI/CustomSvgIcons/Stop'; +import PropertiesPanelIcon from '../UI/CustomSvgIcons/Edit'; + +export type GameplayTestRunSpeedOptions = {| + // Game seconds simulated per real second (1 = normal speed, 4 = 4x...). + // null: run as fast as possible. + speedFactor: number | null, +|}; + +/** + * The run speed choices shared by every "Run the test" split button. + */ +export const buildRunTestSpeedMenuTemplate = ( + i18n: I18nType, + onRunTest: (options: GameplayTestRunSpeedOptions) => void | Promise +): Array => [ + { + label: i18n._(t`Run as quickly as possible`), + click: () => onRunTest({ speedFactor: null }), + }, + { + label: i18n._(t`Run at 4x speed`), + click: () => onRunTest({ speedFactor: 4 }), + }, + { + label: i18n._(t`Run at normal speed`), + click: () => onRunTest({ speedFactor: 1 }), + }, +]; + +type Props = {| + onRunTest: (options: GameplayTestRunSpeedOptions) => void | Promise, + onStopTest: () => void, + isRunning: boolean, + canRun: boolean, + onToggleProperties: () => void, + isPropertiesShown: boolean, +|}; + +export const Toolbar = ({ + onRunTest, + onStopTest, + isRunning, + canRun, + onToggleProperties, + isPropertiesShown, +}: Props): React.Node => { + return ( + + + + + {isRunning ? ( + } + label={Stop the test} + /> + ) : ( + onRunTest({ speedFactor: null })} + icon={} + label={Run the test} + disabled={!canRun} + buildMenuTemplate={(i18n: I18nType) => + buildRunTestSpeedMenuTemplate(i18n, onRunTest) + } + /> + )} + + ); +}; + +export default Toolbar; diff --git a/newIDE/app/src/GameplayTests/GameplayTestFrame.js b/newIDE/app/src/GameplayTests/GameplayTestFrame.js new file mode 100644 index 0000000000..ab0dd9df58 --- /dev/null +++ b/newIDE/app/src/GameplayTests/GameplayTestFrame.js @@ -0,0 +1,414 @@ +// @flow +import { Trans, t } from '@lingui/macro'; +import * as React from 'react'; +import classNames from 'classnames'; +import { type PreviewDebuggerServer } from '../ExportAndShare/PreviewLauncher.flow'; +import Text from '../UI/Text'; +import IconButton from '../UI/IconButton'; +import { textEllipsisStyle } from '../UI/TextEllipsis'; +import MinimizeIcon from '../UI/CustomSvgIcons/Minimize'; +import MaximizeIcon from '../UI/CustomSvgIcons/Maximize'; +import StopIcon from '../UI/CustomSvgIcons/Stop'; +import CrossIcon from '../UI/CustomSvgIcons/Cross'; +import { + formatRunDuration, + GameplayTestStatusChip, + isGameplayTestStatusInProgress, + type GameplayTestDisplayStatus, +} from './GameplayTestStatusIndicator'; +import classes from './GameplayTestFrame.module.css'; + +/** The status of the run displayed on the gameplay test frame. */ +export type GameplayTestFrameRunStatus = {| + testName: string, + status: GameplayTestDisplayStatus, + /** The frame reached by the test, if it started playing. */ + frame: number | null, + durationMs: number | null, + /** The position of the test in the batch being run (0-based) and its size. */ + testIndex: number, + testsCount: number, +|}; + +// Distance kept between the frame and the borders of the window. +const windowMargin = 12; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +type Position = {| left: number, bottom: number |}; + +const clampPositionToWindow = ( + position: Position, + element: HTMLElement | null +): Position => { + if (!element) return position; + const { width, height } = element.getBoundingClientRect(); + return { + left: clamp( + position.left, + windowMargin, + Math.max(windowMargin, window.innerWidth - width - windowMargin) + ), + bottom: clamp( + position.bottom, + windowMargin, + Math.max(windowMargin, window.innerHeight - height - windowMargin) + ), + }; +}; + +type GameplayTestFrameLayoutProps = {| + runStatus: GameplayTestFrameRunStatus | null, + isMinimized: boolean, + onToggleMinimized: () => void, + onStopRequested: () => void, + /** + * The game itself (an iframe running the preview). It is always rendered, + * even when minimized, so that the test keeps running. + */ + children: React.Node, +|}; + +/** + * The floating window shown while a gameplay test is running: a draggable + * title bar with the status of the run, the game itself and a summary of + * the run. + * + * Kept separate from `GameplayTestFrame` so that it can be shown in Storybook + * without a running preview. + */ +export const GameplayTestFrameLayout = ({ + runStatus, + isMinimized, + onToggleMinimized, + onStopRequested, + children, +}: GameplayTestFrameLayoutProps): React.Node => { + const containerRef = React.useRef(null); + const [position, setPosition] = React.useState({ + left: windowMargin, + bottom: windowMargin, + }); + const [isDragging, setIsDragging] = React.useState(false); + const dragOrigin = React.useRef<{| + pointerId: number, + clientX: number, + clientY: number, + position: Position, + |} | null>(null); + + // Keep the frame inside the window when it is resized (or when the frame + // grows back after being minimized). + React.useEffect(() => { + const onWindowResized = () => { + setPosition(position => + clampPositionToWindow(position, containerRef.current) + ); + }; + window.addEventListener('resize', onWindowResized); + return () => window.removeEventListener('resize', onWindowResized); + }, []); + React.useEffect( + () => { + setPosition(position => + clampPositionToWindow(position, containerRef.current) + ); + }, + [isMinimized] + ); + + const onPointerDown = React.useCallback( + (event: PointerEvent) => { + if (event.button !== 0) return; + const currentTarget = event.currentTarget; + if (!(currentTarget instanceof HTMLElement)) return; + + dragOrigin.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + position, + }; + // $FlowFixMe[incompatible-type] - the Flow definition of `setPointerCapture` wrongly takes a string. + currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + }, + [position] + ); + + const onPointerMove = React.useCallback((event: PointerEvent) => { + const origin = dragOrigin.current; + if (!origin || origin.pointerId !== event.pointerId) return; + + setPosition( + clampPositionToWindow( + { + left: origin.position.left + (event.clientX - origin.clientX), + // The frame is anchored to the bottom of the window. + bottom: origin.position.bottom - (event.clientY - origin.clientY), + }, + containerRef.current + ) + ); + }, []); + + const onPointerUp = React.useCallback((event: PointerEvent) => { + const origin = dragOrigin.current; + if (!origin || origin.pointerId !== event.pointerId) return; + + dragOrigin.current = null; + setIsDragging(false); + }, []); + + const isInProgress = runStatus + ? isGameplayTestStatusInProgress(runStatus.status) + : false; + + return ( +
+
+
+ + + {runStatus && runStatus.testName ? ( + runStatus.testName + ) : ( + Gameplay test + )} + + {runStatus && runStatus.testsCount > 1 && ( + + + {runStatus.testIndex + 1}/{runStatus.testsCount} + + + )} +
+
+ + {isMinimized ? ( + + ) : ( + + )} + + + {isInProgress ? ( + + ) : ( + + )} + +
+
+
+ {children} +
+
+ + {runStatus && runStatus.frame !== null && ( + + {isInProgress ? ( + frame {runStatus.frame} + ) : ( + + {runStatus.frame} frames in{' '} + {formatRunDuration(runStatus.durationMs || 0)} + + )} + + )} +
+
+ ); +}; + +let onSetGameplayTestFramePreviewLocation: + | null + | ((previewIndexHtmlLocation: string) => void) = null; +let onSetGameplayTestFrameRunStatus: + | null + | ((runStatus: GameplayTestFrameRunStatus | null) => void) = null; + +/** + * Point the gameplay test frame to a preview (and show it). + * Called by the preview launchers when launching a preview + * with `isForGameplayTest`. + */ +export const setGameplayTestFramePreviewLocation = ({ + previewIndexHtmlLocation, +}: {| + previewIndexHtmlLocation: string, +|}) => { + if (!onSetGameplayTestFramePreviewLocation) + throw new Error('No GameplayTestFrame registered.'); + onSetGameplayTestFramePreviewLocation(previewIndexHtmlLocation); +}; + +/** + * Close the gameplay test frame (unloading the game running in it). + */ +export const clearGameplayTestFramePreview = () => { + if (!onSetGameplayTestFramePreviewLocation) return; + onSetGameplayTestFramePreviewLocation(''); +}; + +/** + * Update the status of the run displayed on the gameplay test frame. + */ +export const setGameplayTestFrameRunStatus = ( + runStatus: GameplayTestFrameRunStatus | null +) => { + if (!onSetGameplayTestFrameRunStatus) return; + onSetGameplayTestFrameRunStatus(runStatus); +}; + +type Props = {| + previewDebuggerServer: ?PreviewDebuggerServer, + onStopRequested: () => void, +|}; + +/** + * The floating window showing the game while a gameplay test is running, + * with controls to stop the test or minimize the window (the test keeps + * running when minimized). + */ +export const GameplayTestFrame = ({ + previewDebuggerServer, + onStopRequested, +}: Props): React.Node => { + const iframeRef = React.useRef(null); + const [ + previewIndexHtmlLocation, + setPreviewIndexHtmlLocation, + ] = React.useState(''); + const [ + runStatus, + setRunStatus, + ] = React.useState(null); + const [isMinimized, setIsMinimized] = React.useState(false); + + React.useEffect(() => { + onSetGameplayTestFramePreviewLocation = ( + newPreviewIndexHtmlLocation: string + ) => { + setPreviewIndexHtmlLocation(newPreviewIndexHtmlLocation); + setIsMinimized(false); + // Don't show the status of a previous run when the frame is closed + // then shown again. + if (!newPreviewIndexHtmlLocation) setRunStatus(null); + }; + onSetGameplayTestFrameRunStatus = setRunStatus; + return () => { + onSetGameplayTestFramePreviewLocation = null; + onSetGameplayTestFrameRunStatus = null; + }; + }, []); + + // Register the iframe window in the debugger as soon as the iframe is shown. + React.useEffect(() => { + const iframe = iframeRef.current; + if (previewDebuggerServer && iframe && !!previewIndexHtmlLocation) + previewDebuggerServer.registerGameplayTestFrame(iframe.contentWindow); + }); + + // Unregister the iframe window when the frame is closed or unmounted. + React.useEffect( + () => { + const iframe = iframeRef.current; + const previousPreviewDebuggerServer = previewDebuggerServer; + return () => { + if (previousPreviewDebuggerServer && iframe) { + previousPreviewDebuggerServer.unregisterGameplayTestFrame( + iframe.contentWindow + ); + } + }; + }, + [previewDebuggerServer, previewIndexHtmlLocation] + ); + + if (!previewIndexHtmlLocation) return null; + + const isInProgress = runStatus + ? isGameplayTestStatusInProgress(runStatus.status) + : false; + + return ( + setIsMinimized(!isMinimized)} + onStopRequested={() => { + if (isInProgress) { + // Stop the test (and the whole run) - the frame stays open, + // showing the outcome. + onStopRequested(); + } else { + // The run is finished: the button closes the frame, unloading + // the game running in it. + setPreviewIndexHtmlLocation(''); + setRunStatus(null); + } + }} + > +