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); + } + }} + > +