Add experimental gameplay tests (#8926)

A gameplay test plays a game: it presses keys, moves the mouse, touches the screen, then checks that what should happen actually happens. For example, if the coin is collected, the score goes up, the enemy hurts the player... Tests run in a preview of the game, usually much faster than real time. [Read more about them on this page](https://wiki.gdevelop.io/gdevelop5/interface/gameplay-tests/)
This commit is contained in:
Florian Rival
2026-08-08 21:22:07 +02:00
committed by GitHub
parent accc32ee48
commit 44ba8a04ce
96 changed files with 10831 additions and 97 deletions
+5
View File
@@ -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();
}
}
@@ -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"));
@@ -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
+11
View File
@@ -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);
+17
View File
@@ -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<std::unique_ptr<gd::ExternalEvents> >
externalEvents; ///< List of all externals events
gd::TestsContainer tests; ///< The tests of the project.
ExtensionProperties
extensionProperties; ///< The properties of the extensions.
gd::WholeProjectDiagnosticReport wholeProjectDiagnosticReport;
+39
View File
@@ -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
+155
View File
@@ -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
+143
View File
@@ -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 <vector>
#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<gd::Test> {
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<std::unique_ptr<gd::Test>>& GetInternalVector() const {
return elements;
};
/**
* \brief Provide a raw access to the vector containing the tests.
*/
std::vector<std::unique_ptr<gd::Test>>& 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<gd::Test>::Init(other);
};
};
} // namespace gd
+124
View File
@@ -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");
}
}
+4
View File
@@ -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");
@@ -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(
File diff suppressed because it is too large Load Diff
+82 -15
View File
@@ -16,6 +16,17 @@ namespace gdjs {
subsections: Record<string, FrameMeasure>;
};
/**
* 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<string, FrameMeasureOutput>;
};
/**
* 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<float> {
const frameTimes: Array<float> = [];
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<string>,
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
);
}
}
+5 -1
View File
@@ -1193,6 +1193,10 @@ namespace gdjs {
) => Promise<void>,
progressCallback?: (progress: float) => void
): Promise<void> {
// 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);
}
}
+110 -31
View File
@@ -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<T>(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 =
+1
View File
@@ -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:
+927
View File
@@ -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');
});
});
});
+48
View File
@@ -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);
+3
View File
@@ -78,6 +78,8 @@
#include <GDCore/Project/EventsFunctionsExtension.h>
#include <GDCore/Project/ExternalEvents.h>
#include <GDCore/Project/ExternalLayout.h>
#include <GDCore/Project/Test.h>
#include <GDCore/Project/TestsContainer.h>
#include <GDCore/Project/FunctionFolderOrFunction.h>
#include <GDCore/Project/InitialInstance.h>
#include <GDCore/Project/InitialInstancesContainer.h>
@@ -960,6 +962,7 @@ typedef std::vector<gd::PropertyDescriptorChoice> VectorPropertyDescriptorChoice
#define RemoveAt Remove
#define GetEventsFunctionAt GetEventsFunction
#define GetVariantAt GetVariant
#define GetTestAt GetTest
#define GetEffectAt GetEffect
#define GetParameterAt GetParameter
+30
View File
@@ -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);
+37
View File
@@ -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;
@@ -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;
+1
View File
@@ -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;
+24
View File
@@ -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;
};
+15
View File
@@ -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;
};
+2
View File
@@ -113,6 +113,8 @@ declare class libGDevelop {
CustomObjectConfiguration: Class<gdCustomObjectConfiguration>;
Layout: Class<gdLayout>;
ExternalEvents: Class<gdExternalEvents>;
Test: Class<gdTest>;
TestsContainer: Class<gdTestsContainer>;
ExternalLayout: Class<gdExternalLayout>;
Effect: Class<gdEffect>;
EffectsContainer: Class<gdEffectsContainer>;
@@ -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<ScrollViewInterface | null>(null);
const newChatTextFieldRef = React.useRef<CompactTextAreaFieldWithControlsInterface | null>(
null
);
const existingChatTextFieldRef = React.useRef<CompactTextAreaFieldWithControlsInterface | null>(
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 ? (
<CompactTextAreaFieldWithControls
ref={newChatTextFieldRef}
maxLength={6000}
value={userRequestTextPerAiRequestId[''] || ''}
disabled={isWorking}
@@ -12,11 +12,13 @@ import {
type ObjectGroupsOutsideEditorChanges,
type ProjectItemRenamedOutsideEditorChanges,
type WillDeleteSceneChanges,
type WillDeleteGameplayTestChanges,
type WillDeleteObjectChanges,
} from '../EditorFunctions/OutsideEditorChanges';
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import Paper from '../UI/Paper';
import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat';
import { registerAskAiPrefillListener } from './AskAiPrefill';
import {
addMessageToAiRequest,
createAiRequest,
@@ -159,6 +161,9 @@ type Props = {|
changes: ProjectItemRenamedOutsideEditorChanges
) => void,
onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
onWillDeleteObject: (changes: WillDeleteObjectChanges) => void,
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
@@ -261,6 +266,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
onObjectGroupsModifiedOutsideEditor,
onProjectItemRenamedOutsideEditor,
onWillDeleteScene,
onWillDeleteGameplayTest,
onWillDeleteObject,
onWillInstallExtension,
onExtensionInstalled,
@@ -930,6 +936,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
onObjectGroupsModifiedOutsideEditor,
onProjectItemRenamedOutsideEditor,
onWillDeleteScene,
onWillDeleteGameplayTest,
onWillDeleteObject,
i18n,
onWillInstallExtension,
@@ -996,6 +1003,19 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
// 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}
@@ -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;
};
};
@@ -591,6 +591,7 @@ export const AskAiStandAloneForm = ({
onObjectGroupsModifiedOutsideEditor: () => {},
onProjectItemRenamedOutsideEditor: () => {},
onWillDeleteScene: () => Promise.resolve(),
onWillDeleteGameplayTest: () => Promise.resolve(),
onWillDeleteObject: () => {},
onWillInstallExtension,
onExtensionInstalled,
+27 -2
View File
@@ -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<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
onWillDeleteObject: (changes: WillDeleteObjectChanges) => void,
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => 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 = {|
@@ -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'
);
});
};
+81
View File
@@ -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<any> = new WeakSet();
const suppressedMessagesByMonacoInstance: WeakMap<
any,
Map<string, Array<string>>
> = 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<string>
) => {
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:
@@ -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<string>,
|};
type State = {|
@@ -186,6 +190,12 @@ class PoppedOutMonacoEditor extends React.Component<Props, State> {
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<Props, State> {
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;
+20
View File
@@ -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<string>,
|};
export const CodeEditor = ({
@@ -50,6 +59,7 @@ export const CodeEditor = ({
onEditorMounted,
onFocus,
onBlur,
suppressedDiagnosticsMessages,
}: Props): React.Node => {
const [MonacoEditor, setMonacoEditor] = React.useState<any>(null);
const [error, setError] = React.useState<Error | null>(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}
/>
);
}
@@ -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: {
+2
View File
@@ -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 },
@@ -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<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
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,
@@ -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');
});
});
});
@@ -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 ? (
<Trans>Save and run the gameplay test {testName}.</Trans>
) : (
<Trans>Run the gameplay test {testName}.</Trans>
),
};
},
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: (
<Trans>Change the gameplay tests ({changesCount} change(s)).</Trans>
),
};
},
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,
};
@@ -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.
@@ -31,4 +31,9 @@ export const NON_SCRIPTABLE_FUNCTION_NAMES: Set<string> = 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',
]);
@@ -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<SimplifiedScene>,
globalVariables: Array<SimplifiedVariable>,
resources: Array<SimplifiedResource>,
tests?: Array<SimplifiedTest>,
|};
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;
};
@@ -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(),
+56
View File
@@ -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<Object>,
errors?: Array<string>,
eventLog?: Array<Object>,
finalState?: Object | null,
screenshots?: Array<Object>,
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<SimplifiedVariable>,
reminder?: string,
animationNames?: string,
@@ -360,6 +377,9 @@ export type LaunchFunctionOptionsWithoutProject = {|
changes: ProjectItemRenamedOutsideEditorChanges
) => void,
onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
onWillDeleteObject: (changes: WillDeleteObjectChanges) => void,
ensureExtensionInstalled: (
options: EnsureExtensionInstalledOptions
@@ -415,6 +435,11 @@ export type EditorFunction = {|
) => Promise<EditorFunctionGenericOutput>,
/** 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<EditorFunctionGenericOutput>,
/** 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: <Trans>Running the gameplay test {newTestName}.</Trans>,
};
}
return {
text: <Trans>Running gameplay tests.</Trans>,
};
},
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,
@@ -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<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => 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({
@@ -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<void>,
|};
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 (
<IconButton
size="small"
onClick={(e: any) => {
e.stopPropagation();
this.props.onRunGameplayTest(this.test.getName());
}}
tooltip={t`Run the test`}
>
<PlayIcon fontSize="small" />
</IconButton>
);
}
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;
}
}
+128
View File
@@ -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<GameplayTestProps>(
() => ({
...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: <Add />,
label: i18n._(t`Add a gameplay test`),
click: addNewGameplayTest,
}
),
getChildren(i18n: I18nType): ?Array<TreeViewItem> {
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
@@ -18,15 +18,20 @@ const existingPreviewWindows: {
} = {};
let embbededGameFrameWindow: WindowProxy | null = null;
let gameplayTestFrameWindow: WindowProxy | null = null;
const getExistingDebuggerIds = (): Array<DebuggerId> => [
...getExistingEmbeddedGameFrameDebuggerIds(),
...getExistingGameplayTestFrameDebuggerIds(),
...getExistingPreviewDebuggerIds(),
];
const getExistingEmbeddedGameFrameDebuggerIds = (): Array<DebuggerId> =>
embbededGameFrameWindow ? ['embedded-game-frame'] : [];
const getExistingGameplayTestFrameDebuggerIds = (): Array<DebuggerId> =>
gameplayTestFrameWindow ? ['gameplay-test-frame'] : [];
const getExistingPreviewDebuggerIds = (): Array<DebuggerId> =>
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();
}
}
@@ -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();
@@ -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,
@@ -17,16 +17,21 @@ const responseCallbacks = new Map<number, (value: Object) => void>();
let nextMessageWithResponseId = 1;
let embeddedGameFrameWindow: WindowProxy | null = null;
let gameplayTestFrameWindow: WindowProxy | null = null;
let isWindowMessageListenerRegistered = false;
const getExistingDebuggerIds = (): Array<DebuggerId> => [
...getExistingEmbeddedGameFrameDebuggerIds(),
...getExistingGameplayTestFrameDebuggerIds(),
...getExistingPreviewDebuggerIds(),
];
const getExistingEmbeddedGameFrameDebuggerIds = (): Array<DebuggerId> =>
embeddedGameFrameWindow ? ['embedded-game-frame'] : [];
const getExistingGameplayTestFrameDebuggerIds = (): Array<DebuggerId> =>
gameplayTestFrameWindow ? ['gameplay-test-frame'] : [];
const getExistingPreviewDebuggerIds = (): Array<DebuggerId> => 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');
}
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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();
@@ -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());
`;
@@ -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<void>,
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<GameplayTestEditorInterface>,
}> = React.forwardRef<Props, GameplayTestEditorInterface>(
(props: Props, ref) => {
const {
test,
scope,
isRunning,
runningFrame,
lastResult,
onRunTest,
onStopTest,
onEditWithAi,
onTestModified,
} = props;
const {
getDefaultEditorMosaicNode,
setDefaultEditorMosaicNode,
} = React.useContext(PreferencesContext);
const editorMosaicRef = React.useRef<?EditorMosaicInterface>(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 = () => (
<Background>
<GameplayTestProperties
test={test}
scope={scope}
isRunning={isRunning}
runningFrame={runningFrame}
lastResult={lastResult}
onRunTest={onRunTest}
onStopTest={onStopTest}
onEditWithAi={onEditWithAi}
onTestModified={onTestModified}
/>
</Background>
);
const renderCodeEditor = () => (
// `overflow: hidden` + `minWidth: 0` so the code editor can never grow
// past the available width (notably on small screens).
<Column expand noMargin noOverflowParent>
<FullSizeMeasurer>
{({ width, height }) => (
<CodeEditor
value={test.getSource()}
onChange={(source: string) => {
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',
]}
/>
)}
</FullSizeMeasurer>
</Column>
);
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: <Trans>Properties</Trans>,
getIcon: ({ color, fontSize }) => (
<EditIcon color={color} fontSize={fontSize} />
),
renderEditor: renderProperties,
},
{
value: 'test-code',
label: <Trans>Code</Trans>,
getIcon: ({ color, fontSize }) => (
<ConsoleIcon color={color} fontSize={fontSize} />
),
renderEditor: renderCodeEditor,
},
];
return (
<EditorBottomTabsSwitcher
tabs={bottomTabs}
currentTab={currentBottomTab}
onChangeTab={newTab => {
setCurrentBottomTab(newTab);
onOpenedEditorsChanged();
}}
/>
);
}
return (
<EditorMosaic
ref={editorMosaicRef}
editors={editors}
centralNodeId="test-code"
initialNodes={
getDefaultEditorMosaicNode('gameplay-test-editor') ||
initialMosaicEditorNodes
}
onOpenedEditorsChanged={props.onOpenedEditorsChanged}
onPersistNodes={node =>
setDefaultEditorMosaicNode('gameplay-test-editor', node)
}
/>
);
}
);
export default GameplayTestEditor;
@@ -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<void>
): Array<MenuItemTemplate> => [
{
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<void>,
onStopTest: () => void,
isRunning: boolean,
canRun: boolean,
onToggleProperties: () => void,
isPropertiesShown: boolean,
|};
export const Toolbar = ({
onRunTest,
onStopTest,
isRunning,
canRun,
onToggleProperties,
isPropertiesShown,
}: Props): React.Node => {
return (
<ToolbarGroup lastChild>
<IconButton
size="small"
color="default"
onClick={onToggleProperties}
selected={isPropertiesShown}
tooltip={
isPropertiesShown
? t`Close Properties Panel`
: t`Open Properties Panel`
}
>
<PropertiesPanelIcon />
</IconButton>
{isRunning ? (
<FlatButton
primary
onClick={onStopTest}
leftIcon={<StopIcon />}
label={<Trans>Stop the test</Trans>}
/>
) : (
<RaisedButtonWithSplitMenu
primary
onClick={() => onRunTest({ speedFactor: null })}
icon={<PlayIcon />}
label={<Trans>Run the test</Trans>}
disabled={!canRun}
buildMenuTemplate={(i18n: I18nType) =>
buildRunTestSpeedMenuTemplate(i18n, onRunTest)
}
/>
)}
</ToolbarGroup>
);
};
export default Toolbar;
@@ -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<HTMLDivElement | null>(null);
const [position, setPosition] = React.useState<Position>({
left: windowMargin,
bottom: windowMargin,
});
const [isDragging, setIsDragging] = React.useState<boolean>(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 (
<div
ref={containerRef}
className={classNames({
[classes.container]: true,
[classes.minimized]: isMinimized,
[classes.dragging]: isDragging,
})}
style={{ left: position.left, bottom: position.bottom }}
>
<div className={classes.header}>
<div
className={classes.dragHandle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<span className={classes.grip} />
<Text
noMargin
size="body-small"
style={textEllipsisStyle}
tooltip={runStatus ? runStatus.testName : undefined}
>
{runStatus && runStatus.testName ? (
runStatus.testName
) : (
<Trans>Gameplay test</Trans>
)}
</Text>
{runStatus && runStatus.testsCount > 1 && (
<Text
noMargin
size="body-small"
color="secondary"
noShrink
style={{ fontVariantNumeric: 'tabular-nums' }}
>
<Trans>
{runStatus.testIndex + 1}/{runStatus.testsCount}
</Trans>
</Text>
)}
</div>
<div className={classes.headerButtons}>
<IconButton
size="small"
tooltip={isMinimized ? t`Show the game` : t`Minimize`}
onClick={onToggleMinimized}
>
{isMinimized ? (
<MaximizeIcon className={classes.headerIcon} />
) : (
<MinimizeIcon className={classes.headerIcon} />
)}
</IconButton>
<IconButton
size="small"
tooltip={isInProgress ? t`Stop the test` : t`Close`}
onClick={onStopRequested}
>
{isInProgress ? (
<StopIcon className={classes.headerIcon} />
) : (
<CrossIcon className={classes.headerIcon} />
)}
</IconButton>
</div>
</div>
<div
className={classNames({
[classes.gameArea]: true,
[classes.hiddenGameArea]: isMinimized,
})}
>
{children}
</div>
<div className={classes.footer}>
<GameplayTestStatusChip
size="small"
status={runStatus ? runStatus.status : 'launching'}
/>
{runStatus && runStatus.frame !== null && (
<Text
noMargin
size="body-small"
color="secondary"
noShrink
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{isInProgress ? (
<Trans>frame {runStatus.frame}</Trans>
) : (
<Trans>
{runStatus.frame} frames in{' '}
{formatRunDuration(runStatus.durationMs || 0)}
</Trans>
)}
</Text>
)}
</div>
</div>
);
};
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<HTMLIFrameElement | null>(null);
const [
previewIndexHtmlLocation,
setPreviewIndexHtmlLocation,
] = React.useState<string>('');
const [
runStatus,
setRunStatus,
] = React.useState<GameplayTestFrameRunStatus | null>(null);
const [isMinimized, setIsMinimized] = React.useState<boolean>(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 (
<GameplayTestFrameLayout
runStatus={runStatus}
isMinimized={isMinimized}
onToggleMinimized={() => 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);
}
}}
>
<iframe
ref={iframeRef}
title="Gameplay Test"
src={previewIndexHtmlLocation}
tabIndex={-1}
// The test simulates all the inputs itself: never let the user
// interact with (or focus) the game.
className={classes.gameIframe}
/>
</GameplayTestFrameLayout>
);
};
@@ -0,0 +1,117 @@
.container {
position: fixed;
z-index: 1500;
display: flex;
flex-direction: column;
min-width: 240px;
width: max-content;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--theme-dialog-separator-color);
background-color: var(--theme-dialog-background-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
/* Dragging the frame should never select the text of its title bar. */
user-select: none;
}
.container.minimized {
min-width: 220px;
}
.header {
display: flex;
align-items: center;
gap: 4px;
padding-left: 6px;
min-width: 0;
background-color: var(--theme-surface-alternate-canvas-background-color);
border-bottom: 1px solid var(--theme-dialog-separator-color);
}
.dragHandle {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 4px 0;
cursor: grab;
touch-action: none;
}
.container.dragging .dragHandle {
cursor: grabbing;
}
.container.dragging {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
}
/* Two columns of three dots, hinting that the frame can be moved. */
.grip {
flex-shrink: 0;
width: 6px;
height: 12px;
background-image: radial-gradient(
circle at center,
var(--theme-text-secondary-color) 1px,
transparent 1.2px
);
background-size: 3px 4px;
opacity: 0.6;
}
.dragHandle:hover .grip {
opacity: 1;
}
.headerButtons {
display: flex;
align-items: center;
flex-shrink: 0;
}
.headerIcon {
font-size: 16px;
}
.gameArea {
position: relative;
display: flex;
width: 320px;
height: 180px;
background-color: #000;
}
/*
* When minimized, the game is kept in a 1x1 pixel visible area: `display: none`
* (or a zero size) would stop `requestAnimationFrame` in the game - and so the
* test being run.
*/
.hiddenGameArea {
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.gameIframe {
display: block;
width: 100%;
height: 100%;
border: none;
/* The test simulates all the inputs itself: never let the user interact
with the game. */
pointer-events: none;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 4px 8px 4px 6px;
background-color: var(--theme-surface-canvas-background-color);
border-top: 1px solid var(--theme-dialog-separator-color);
}
@@ -0,0 +1,123 @@
// @flow
import { t } from '@lingui/macro';
import * as React from 'react';
import classNames from 'classnames';
import Text from '../UI/Text';
import IconButton from '../UI/IconButton';
import Copy from '../UI/CustomSvgIcons/Copy';
import { copyTextToClipboard } from '../Utils/Clipboard';
import classes from './GameplayTestOutputPanel.module.css';
export type GameplayTestOutputLine = {|
level: 'log' | 'info' | 'warn' | 'error',
message: string,
/** A prefix shown before the message, in a dimmed color (frame number...). */
prefix?: ?string,
|};
const levelClasses = {
log: classes.log,
info: classes.info,
warn: classes.warn,
error: classes.error,
};
type Props = {|
lines: Array<GameplayTestOutputLine>,
/** Shown instead of the output when there is nothing to display. */
placeholder: React.Node,
/** Show a button to copy the whole output to the clipboard. */
canCopy?: boolean,
maxHeight?: number,
|};
/**
* A terminal-like panel displaying the output of a gameplay test run:
* errors of the test or console logs of the game.
*/
export const GameplayTestOutputPanel = ({
lines,
placeholder,
canCopy,
maxHeight,
}: Props): React.Node => {
if (!lines.length) {
return (
<div className={classes.placeholder}>
<Text noMargin size="body-small" color="secondary" align="center">
{placeholder}
</Text>
</div>
);
}
return (
<div className={classes.container}>
{canCopy && (
<div className={classes.copyButtonContainer}>
<IconButton
size="small"
tooltip={t`Copy the output`}
onClick={() => {
copyTextToClipboard(
lines
.map(
line =>
(line.prefix ? line.prefix + ' ' : '') + line.message
)
.join('\n')
);
}}
>
<Copy className={classes.copyIcon} />
</IconButton>
</div>
)}
<div
className={classes.output}
style={maxHeight ? { maxHeight } : undefined}
>
{lines.map((line, index) => (
<div
key={index}
className={classNames({
[classes.line]: true,
[levelClasses[line.level] || classes.log]: true,
})}
>
{!!line.prefix && (
<span className={classes.linePrefix}>
<Text
noMargin
displayInlineAsSpan
color="inherit"
size="body-small"
style={{
fontFamily: '"Lucida Console", Monaco, monospace',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{line.prefix}
</Text>
</span>
)}
<Text
noMargin
color="inherit"
size="body-small"
allowSelection
style={{
fontFamily: '"Lucida Console", Monaco, monospace',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{line.message}
</Text>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,84 @@
.container {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 4px;
background-color: var(--theme-surface-window-background-color);
border: 1px solid var(--theme-list-item-separator-color);
}
.output {
display: flex;
flex-direction: column;
min-width: 0;
max-height: 180px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.line {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
padding: 1px 8px;
color: var(--theme-text-secondary-color);
}
.line:not(:last-child) {
border-bottom: 1px solid color-mix(in srgb, var(--theme-list-item-separator-color) 50%, transparent);
}
.line.info {
color: var(--theme-text-default-color);
}
.line.warn {
color: var(--theme-message-warning-color);
background-color: color-mix(in srgb, var(--theme-message-warning-color) 8%, transparent);
}
.line.error {
color: var(--theme-message-error-color);
background-color: color-mix(in srgb, var(--theme-message-error-color) 8%, transparent);
}
.linePrefix {
flex-shrink: 0;
opacity: 0.6;
}
.copyButtonContainer {
position: absolute;
top: 0;
right: 0;
z-index: 1;
opacity: 0;
transition: opacity 0.1s;
background: linear-gradient(
to left,
var(--theme-surface-window-background-color) 60%,
transparent
);
border-top-right-radius: 4px;
}
.container:hover .copyButtonContainer,
.container:focus-within .copyButtonContainer {
opacity: 1;
}
.copyIcon {
font-size: 16px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 8px;
border-radius: 4px;
border: 1px dashed var(--theme-list-item-separator-color);
}
@@ -0,0 +1,444 @@
// @flow
import { Trans, t } from '@lingui/macro';
import {
buildRunTestSpeedMenuTemplate,
type GameplayTestRunSpeedOptions,
} from './GameplayTestEditorToolbar';
import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu';
import { I18n } from '@lingui/react';
import * as React from 'react';
import { Column, Line, Spacer, marginsSize } from '../UI/Grid';
import { ColumnStackLayout, LineStackLayout } from '../UI/Layout';
import Text from '../UI/Text';
import ScrollView from '../UI/ScrollView';
import ErrorBoundary from '../UI/ErrorBoundary';
import FlatButton from '../UI/FlatButton';
import LinearProgress from '../UI/LinearProgress';
import CompactTextField from '../UI/CompactTextField';
import { CompactTextAreaField } from '../UI/CompactTextAreaField';
import { TopLevelCollapsibleSection } from '../CompactPropertiesEditor/TopLevelCollapsibleSection';
import { textEllipsisStyle } from '../UI/TextEllipsis';
import useForceUpdate from '../Utils/UseForceUpdate';
import { getRelativeOrAbsoluteDisplayDate } from '../Utils/DateDisplay';
import PreviewIcon from '../UI/CustomSvgIcons/Preview';
import StopIcon from '../UI/CustomSvgIcons/Stop';
import CheckIcon from '../UI/CustomSvgIcons/Check';
import CrossIcon from '../UI/CustomSvgIcons/Cross';
import RobotIcon from '../ProjectCreation/RobotIcon';
import {
type GameplayTestResult,
type GameplayTestScope,
} from './GameplayTestRunner';
import {
formatRunDuration,
GameplayTestStatusChip,
getDisplayStatusFromTest,
type GameplayTestDisplayStatus,
} from './GameplayTestStatusIndicator';
import {
GameplayTestOutputPanel,
type GameplayTestOutputLine,
} from './GameplayTestOutputPanel';
import classes from './GameplayTestProperties.module.css';
const styles = {
icon: { fontSize: 18 },
scrollView: { paddingTop: marginsSize, overflowX: 'hidden' },
};
/** A row of the result summary: a dimmed label, and its value on the right. */
const SummaryRow = ({
label,
children,
}: {|
label: React.Node,
children: React.Node,
|}) => (
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
expand
>
<Text noMargin size="body-small" color="secondary">
{label}
</Text>
<Text
noMargin
size="body-small"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{children}
</Text>
</LineStackLayout>
);
const AssertionRow = ({
passed,
message,
}: {|
passed: boolean,
message: string,
|}) => (
<div
className={
passed
? `${classes.assertion} ${classes.assertionPassed}`
: `${classes.assertion} ${classes.assertionFailed}`
}
>
{passed ? (
<CheckIcon className={classes.assertionIcon} />
) : (
<CrossIcon className={classes.assertionIcon} />
)}
<Text noMargin size="body-small" color="primary" allowSelection>
{message}
</Text>
</div>
);
type SectionName =
| 'description'
| 'result'
| 'assertions'
| 'errors'
| 'console'
| 'screenshots';
const defaultFoldedSections: { [SectionName]: boolean } = {
description: false,
result: false,
assertions: false,
errors: false,
console: true,
screenshots: false,
};
type Props = {|
test: gdTest,
scope: GameplayTestScope,
isRunning: boolean,
/** The frame currently reached by the running test, if known. */
runningFrame?: number | null,
lastResult: GameplayTestResult | null,
onRunTest: (options: GameplayTestRunSpeedOptions) => void | Promise<void>,
onStopTest: () => void,
onEditWithAi: () => void,
onTestModified: () => void,
|};
/**
* The properties panel of a gameplay test: its name/description, the button to
* run it and everything showing the outcome of the last run (status,
* assertions, errors, console logs and screenshots).
*/
export const GameplayTestProperties = ({
test,
scope,
isRunning,
runningFrame,
lastResult,
onRunTest,
onStopTest,
onEditWithAi,
onTestModified,
}: Props): React.Node => {
const forceUpdate = useForceUpdate();
const [foldedSections, setFoldedSections] = React.useState<{
[SectionName]: boolean,
}>(defaultFoldedSections);
const toggleSection = React.useCallback((sectionName: SectionName) => {
setFoldedSections(foldedSections => ({
...foldedSections,
[sectionName]: !foldedSections[sectionName],
}));
}, []);
const status: GameplayTestDisplayStatus = isRunning
? runningFrame != null
? 'running'
: 'launching'
: lastResult
? lastResult.status
: getDisplayStatusFromTest(test);
const assertions = lastResult ? lastResult.assertions : [];
const passedAssertionsCount = assertions.filter(assertion => assertion.passed)
.length;
const errorLines: Array<GameplayTestOutputLine> = (lastResult
? lastResult.errors
: []
).map(error => ({ level: 'error', message: error }));
const consoleLines: Array<GameplayTestOutputLine> = (lastResult
? lastResult.consoleLogs
: []
).map(consoleLog => ({
level: consoleLog.level === 'log' ? 'log' : consoleLog.level,
message: consoleLog.message,
}));
const screenshots = lastResult ? lastResult.screenshots : [];
// The last run summary persisted on the test is used when the test was not
// run in this editor session (`lastResult` is only kept in memory).
const lastRunAt = test.getLastRunAt();
const durationMs = lastResult
? lastResult.durationMs
: test.getLastRunDurationMs();
const framesExecuted = lastResult
? lastResult.framesExecuted
: test.getLastRunFramesExecuted();
const hasRunSummary = status !== 'never-run' && !isRunning;
return (
<ErrorBoundary
componentTitle={<Trans>Gameplay test properties</Trans>}
scope="gameplay-test-editor-properties"
>
<ScrollView autoHideScrollbar style={styles.scrollView}>
<Column expand noMargin noOverflowParent id="gameplay-test-properties">
<ColumnStackLayout expand noOverflowParent>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<LineStackLayout noMargin alignItems="center">
<PreviewIcon style={styles.icon} />
<Text size="body" noMargin>
<Trans>Gameplay test</Trans>
</Text>
</LineStackLayout>
<GameplayTestStatusChip size="small" status={status} />
</LineStackLayout>
<CompactTextField
value={test.getName()}
onChange={() => {}}
disabled
/>
<Text noMargin size="body-small" color="secondary">
{scope.type === 'project' ? (
<Trans>Test of the project</Trans>
) : (
<Trans>Test of the extension {scope.extensionName}</Trans>
)}
</Text>
{isRunning ? (
<ColumnStackLayout noMargin>
<FlatButton
fullWidth
primary
leftIcon={<StopIcon />}
label={<Trans>Stop the test</Trans>}
onClick={onStopTest}
/>
<Line noMargin alignItems="center">
<LinearProgress variant="indeterminate" />
</Line>
<Text noMargin size="body-small" color="secondary">
{runningFrame != null ? (
<Trans>Playing the game - frame {runningFrame}</Trans>
) : (
<Trans>Starting the game...</Trans>
)}
</Text>
</ColumnStackLayout>
) : (
<RaisedButtonWithSplitMenu
primary
fullWidth
icon={<PreviewIcon />}
label={<Trans>Run the test</Trans>}
onClick={() => onRunTest({ speedFactor: null })}
buildMenuTemplate={i18n =>
buildRunTestSpeedMenuTemplate(i18n, onRunTest)
}
/>
)}
<FlatButton
fullWidth
color="ai"
leftIcon={<RobotIcon size={16} />}
label={<Trans>Edit with AI</Trans>}
onClick={onEditWithAi}
/>
</ColumnStackLayout>
<TopLevelCollapsibleSection
title={<Trans>Description</Trans>}
isFolded={foldedSections.description}
toggleFolded={() => toggleSection('description')}
renderContent={() => (
<CompactTextAreaField
value={test.getDescription()}
onChange={(text: string) => {
test.setDescription(text);
onTestModified();
forceUpdate();
}}
rows={3}
placeholder={t`What does this test verify?`}
/>
)}
/>
<TopLevelCollapsibleSection
title={<Trans>Last run</Trans>}
isFolded={foldedSections.result}
toggleFolded={() => toggleSection('result')}
renderContent={() => (
<ColumnStackLayout noMargin noOverflowParent>
<Line noMargin>
<GameplayTestStatusChip status={status} />
</Line>
{hasRunSummary ? (
<ColumnStackLayout noMargin noOverflowParent>
{!!lastRunAt && (
<SummaryRow label={<Trans>Ran</Trans>}>
<I18n>
{({ i18n }) =>
getRelativeOrAbsoluteDisplayDate({
i18n,
dateAsNumber: lastRunAt,
relativeLimit: 'currentWeek',
sameDayFormat: 'timeAgo',
sameWeekFormat: 'timeAgo',
dayBeforeFormat: 'yesterdayAndHour',
})
}
</I18n>
</SummaryRow>
)}
<SummaryRow label={<Trans>Duration</Trans>}>
{formatRunDuration(durationMs)}
</SummaryRow>
<SummaryRow label={<Trans>Frames played</Trans>}>
{framesExecuted || 0}
</SummaryRow>
{!!assertions.length && (
<SummaryRow label={<Trans>Assertions</Trans>}>
<Trans>
{passedAssertionsCount} of {assertions.length} passed
</Trans>
</SummaryRow>
)}
</ColumnStackLayout>
) : (
!isRunning && (
<Text noMargin size="body-small" color="secondary">
<Trans>
Run the test to see here how the game behaved.
</Trans>
</Text>
)
)}
</ColumnStackLayout>
)}
/>
<TopLevelCollapsibleSection
title={<Trans>Assertions</Trans>}
isFolded={foldedSections.assertions}
toggleFolded={() => toggleSection('assertions')}
renderContent={() =>
assertions.length ? (
<div className={classes.assertionsList}>
{assertions.map((assertion, index) => (
<AssertionRow
key={index}
passed={assertion.passed}
message={assertion.message}
/>
))}
</div>
) : (
<Text noMargin size="body-small" color="secondary">
{lastResult ? (
<Trans>This run did not check anything.</Trans>
) : (
<Trans>
The checks made by the test (with `harness.assert`) will
be listed here.
</Trans>
)}
</Text>
)
}
/>
<TopLevelCollapsibleSection
title={<Trans>Errors</Trans>}
isFolded={foldedSections.errors}
toggleFolded={() => toggleSection('errors')}
renderContent={() => (
<GameplayTestOutputPanel
lines={errorLines}
canCopy
placeholder={
lastResult ? (
<Trans>No error: the test ran until the end.</Trans>
) : (
<Trans>
Errors of the test and of the game will be shown here.
</Trans>
)
}
/>
)}
/>
<TopLevelCollapsibleSection
title={<Trans>Console</Trans>}
isFolded={foldedSections.console}
toggleFolded={() => toggleSection('console')}
renderContent={() => (
<GameplayTestOutputPanel
lines={consoleLines}
canCopy
placeholder={
lastResult ? (
<Trans>
The game did not log anything during this run.
</Trans>
) : (
<Trans>
Everything logged by the game with `console.log` while the
test runs will be shown here.
</Trans>
)
}
/>
)}
/>
{!!screenshots.length && (
<TopLevelCollapsibleSection
title={<Trans>Screenshots</Trans>}
isFolded={foldedSections.screenshots}
toggleFolded={() => toggleSection('screenshots')}
renderContent={() => (
<div className={classes.screenshotsList}>
{screenshots.map((screenshot, index) => (
<div className={classes.screenshot} key={index}>
<img
className={classes.screenshotImage}
src={`data:image/jpeg;base64,${screenshot.jpegBase64}`}
alt={screenshot.label}
/>
<Text
noMargin
size="body-small"
color="secondary"
style={textEllipsisStyle}
>
{screenshot.label || (
<Trans>Frame {screenshot.frame}</Trans>
)}
</Text>
</div>
))}
</div>
)}
/>
)}
<Spacer />
</Column>
</ScrollView>
</ErrorBoundary>
);
};
@@ -0,0 +1,57 @@
.assertionsList {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.assertion {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
padding: 3px 6px;
border-radius: 4px;
border-left: 2px solid transparent;
}
.assertionIcon {
font-size: 16px;
flex-shrink: 0;
align-self: center;
}
.assertionPassed {
color: var(--theme-success-color);
border-left-color: var(--theme-success-color);
background-color: color-mix(in srgb, var(--theme-success-color) 8%, transparent);
}
.assertionFailed {
color: var(--theme-error-color);
border-left-color: var(--theme-error-color);
background-color: color-mix(in srgb, var(--theme-error-color) 10%, transparent);
}
.screenshotsList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
min-width: 0;
}
.screenshot {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.screenshotImage {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--theme-list-item-separator-color);
background-color: var(--theme-surface-window-background-color);
}
@@ -0,0 +1,696 @@
// @flow
import * as React from 'react';
import {
type PreviewDebuggerServer,
type PreviewLauncherInterface,
} from '../ExportAndShare/PreviewLauncher.flow';
import {
enumerateGameplayTestStateInspectors,
type GameplayTestStateInspectors,
} from './GameplayTestStateInspectors';
import {
clearGameplayTestFramePreview,
setGameplayTestFrameRunStatus,
type GameplayTestFrameRunStatus,
} from './GameplayTestFrame';
export type GameplayTestScope =
| {| type: 'project' |}
| {| type: 'extension', extensionName: string |};
export const projectGameplayTestScope: GameplayTestScope = { type: 'project' };
/**
* A short human-readable description of a scope, for error messages.
*/
export const getGameplayTestScopeDescription = (
scope: GameplayTestScope
): string =>
scope.type === 'project'
? 'the project'
: `the extension "${scope.extensionName}"`;
export type GameplayTestAssertion = {|
message: string,
passed: boolean,
|};
export type GameplayTestResult = {
testName: string,
status: 'passed' | 'failed' | 'error' | 'stopped' | 'timeout',
framesExecuted: number,
durationMs: number,
gameTimeMs: number,
assertions: Array<GameplayTestAssertion>,
errors: Array<string>,
consoleLogs: Array<{ level: 'log' | 'warn' | 'error', message: string }>,
eventLog: Array<Object>,
finalState: Object | null,
screenshots: Array<{ label: string, frame: number, jpegBase64: string }>,
// The `stopProfiling()` summaries captured during the run.
profiles: Array<Object>,
performance: Object | null,
};
export type GameplayTestToRun = {|
scope: GameplayTestScope,
testName: string,
// When provided, this source is run (used for unsaved test code or tests
// being created by the AI). Otherwise the source of the stored test is used.
source?: string,
|};
export type GameplayTestRunOptions = {|
timeoutMs?: number,
screenshots?: 'off' | 'on-failure',
// 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,
onTestStarted?: (test: GameplayTestToRun) => void,
onProgress?: (test: GameplayTestToRun, frame: number) => void,
|};
/**
* Callbacks to open/rename/delete/run gameplay tests of any scope, provided
* by the MainFrame (which owns the editor tabs and the runner) to the
* editors listing tests.
*/
export type GameplayTestsCallbacks = {|
onOpenGameplayTest: (scope: GameplayTestScope, testName: string) => void,
onRenameGameplayTest: (
scope: GameplayTestScope,
oldName: string,
newName: string
) => void,
onDeleteGameplayTest: (scope: GameplayTestScope, test: gdTest) => void,
onRunGameplayTest: (
scope: GameplayTestScope,
testName: string
) => void | Promise<void>,
|};
const GAMEPLAY_TEST_FRAME_DEBUGGER_ID = 'gameplay-test-frame';
const GAME_READY_TIMEOUT_MS = 60 * 1000;
const GAME_READY_POLL_INTERVAL_MS = 300;
const RESULT_EXTRA_TIMEOUT_MS = 10 * 1000;
const DEFAULT_TIMEOUT_MS = 30 * 1000;
// Paced runs are slow by design (a human is watching): give them room.
// They can always be stopped with the stop button.
const PACED_RUN_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
let nextRunMessageId = 1;
// Only one gameplay test run (which can run multiple tests sequentially)
// at a time: subsequent calls are queued.
let lastRunPromise: Promise<mixed> = Promise.resolve();
// The stop controller of the batch being currently run, allowing to stop
// it even when the game is not started yet (still exporting or booting)
// or between two tests.
type BatchStopController = {|
stopRequested: boolean,
abortBootWait: () => void,
|};
let currentBatchStopController: BatchStopController | null = null;
// Whether a gameplay test batch is currently running (exporting, booting
// or running tests). Launching or hot-reloading previews meanwhile would
// interfere with the run: use `useIsGameplayTestRunInProgress` to disable
// these actions in the UI (the game also ignores state-mutating debugger
// commands while a test runs, as a backstop).
let isRunInProgress = false;
const runInProgressListeners: Set<() => void> = new Set();
const setRunInProgress = (running: boolean) => {
if (isRunInProgress === running) return;
isRunInProgress = running;
runInProgressListeners.forEach(listener => listener());
};
/** Non-hook variant, for code outside React components. */
export const getIsGameplayTestRunInProgress = (): boolean => isRunInProgress;
export const useIsGameplayTestRunInProgress = (): boolean => {
const [running, setRunning] = React.useState(isRunInProgress);
React.useEffect(() => {
const listener = () => setRunning(isRunInProgress);
runInProgressListeners.add(listener);
listener();
return () => {
runInProgressListeners.delete(listener);
};
}, []);
return running;
};
const makeResultWithoutRun = (
testName: string,
status: 'error' | 'stopped',
errorMessage: string
): GameplayTestResult => ({
testName,
status,
framesExecuted: 0,
durationMs: 0,
gameTimeMs: 0,
assertions: [],
errors: [errorMessage],
consoleLogs: [],
eventLog: [],
finalState: null,
screenshots: [],
profiles: [],
performance: null,
});
const makeErrorResult = (
testName: string,
errorMessage: string
): GameplayTestResult => makeResultWithoutRun(testName, 'error', errorMessage);
const makeStoppedResult = (testName: string): GameplayTestResult =>
makeResultWithoutRun(
testName,
'stopped',
'The test was not run because the run was stopped.'
);
/**
* Get the tests container for a scope ('project' or an extension name),
* or null if the scope does not exist.
*/
export const getTestsContainer = (
project: gdProject,
scope: GameplayTestScope
): gdTestsContainer | null => {
if (scope.type === 'project') return project.getTests();
if (project.hasEventsFunctionsExtensionNamed(scope.extensionName)) {
return project.getEventsFunctionsExtension(scope.extensionName).getTests();
}
return null;
};
/**
* The name of a gameplay test in editor tabs is either the name of a
* project test, or `ExtensionName::TestName` for an extension test.
*/
export const getGameplayTestProjectItemName = (
scope: GameplayTestScope,
testName: string
): string =>
scope.type === 'project' ? testName : scope.extensionName + '::' + testName;
/**
* Update the last run summary persisted on a test.
*/
export const updateTestLastRun = (
project: gdProject,
test: GameplayTestToRun,
result: GameplayTestResult
): void => {
const testsContainer = getTestsContainer(project, test.scope);
if (!testsContainer || !testsContainer.hasTestNamed(test.testName)) return;
const storedTest = testsContainer.getTest(test.testName);
storedTest.setLastRunStatus(result.status);
storedTest.setLastRunAt(Date.now());
storedTest.setLastRunDurationMs(result.durationMs);
storedTest.setLastRunFramesExecuted(result.framesExecuted);
};
/**
* Wait for the game in the gameplay test frame to be booted, by polling
* it with `getStatus` until it answers.
*/
const waitForGameToBeReady = async (
previewDebuggerServer: PreviewDebuggerServer,
stopController: BatchStopController
): Promise<void> => {
const startTime = Date.now();
return new Promise((resolve, reject) => {
let pollIntervalId: ?IntervalID = null;
const unregisterCallbacks: () => void = previewDebuggerServer.registerCallbacks(
{
onErrorReceived: () => {},
onServerStateChanged: () => {},
onConnectionClosed: () => {},
onConnectionOpened: () => {},
onConnectionErrored: () => {},
onHandleParsedMessage: ({ id, parsedMessage }) => {
if (id !== GAMEPLAY_TEST_FRAME_DEBUGGER_ID) return;
// Any message coming from the frame means the game (and its
// debugger client) is up.
if (pollIntervalId !== null) clearInterval(pollIntervalId);
unregisterCallbacks();
resolve();
},
}
);
// Allow a stop to interrupt the wait (the game may take a long time
// to export and boot).
stopController.abortBootWait = () => {
if (pollIntervalId !== null) clearInterval(pollIntervalId);
unregisterCallbacks();
const error = new Error('The gameplay test run was stopped.');
// $FlowFixMe[prop-missing] - tag the error so the runner knows this is a stop, not a failure.
error.isStopRequested = true;
reject(error);
};
pollIntervalId = setInterval(() => {
if (Date.now() - startTime > GAME_READY_TIMEOUT_MS) {
if (pollIntervalId !== null) clearInterval(pollIntervalId);
unregisterCallbacks();
reject(
new Error(
'The game preview for the gameplay test did not boot in time.'
)
);
return;
}
previewDebuggerServer.sendMessage(GAMEPLAY_TEST_FRAME_DEBUGGER_ID, {
command: 'getStatus',
});
}, GAME_READY_POLL_INTERVAL_MS);
});
};
const runSingleTest = async ({
previewDebuggerServer,
test,
source,
timeoutMs,
screenshots,
speedFactor,
stateInspectors,
onProgress,
}: {|
previewDebuggerServer: PreviewDebuggerServer,
test: GameplayTestToRun,
source: string,
timeoutMs: number,
screenshots: 'off' | 'on-failure',
speedFactor: number | null,
stateInspectors: GameplayTestStateInspectors,
onProgress: ?(test: GameplayTestToRun, frame: number) => void,
|}): Promise<GameplayTestResult> => {
const messageId = 'gameplay-test-' + nextRunMessageId++;
return new Promise(resolve => {
let watchdogTimeoutId: ?TimeoutID = null;
const unregisterCallbacks: () => void = previewDebuggerServer.registerCallbacks(
{
onErrorReceived: () => {},
onServerStateChanged: () => {},
onConnectionClosed: ({ id }) => {
if (id !== GAMEPLAY_TEST_FRAME_DEBUGGER_ID) return;
finish(
makeErrorResult(
test.testName,
'The game preview was closed while the test was running.'
)
);
},
onConnectionOpened: () => {},
onConnectionErrored: () => {},
onHandleParsedMessage: ({ id, parsedMessage }) => {
if (id !== GAMEPLAY_TEST_FRAME_DEBUGGER_ID) return;
if (parsedMessage.messageId !== messageId) return;
if (parsedMessage.command === 'gameplayTest.progress') {
if (onProgress && parsedMessage.payload) {
onProgress(test, parsedMessage.payload.frame || 0);
}
} else if (parsedMessage.command === 'gameplayTest.result') {
finish({
...makeErrorResult(test.testName, ''),
errors: [],
...(parsedMessage.payload || {}),
});
}
},
}
);
const finish = (result: GameplayTestResult) => {
if (watchdogTimeoutId !== null) clearTimeout(watchdogTimeoutId);
unregisterCallbacks();
resolve(result);
};
// An editor-side watchdog, in case the game dies without sending
// its result.
watchdogTimeoutId = setTimeout(() => {
finish(
makeErrorResult(
test.testName,
`No result received from the game after ${timeoutMs +
RESULT_EXTRA_TIMEOUT_MS}ms - the game may have crashed or been closed.`
)
);
}, timeoutMs + RESULT_EXTRA_TIMEOUT_MS);
const payload: Object = {
testName: test.testName,
source,
timeoutMs,
// Readable state for object/behavior snapshots, derived from the
// extensions metadata.
stateInspectors,
// The game in the gameplay test frame only exists to run tests: leave
// it paused and muted when the test finishes, showing the last frame.
freezeWhenFinished: true,
};
if (speedFactor) payload.speedFactor = speedFactor;
if (screenshots === 'off') payload.maxScreenshots = 0;
previewDebuggerServer.sendMessage(GAMEPLAY_TEST_FRAME_DEBUGGER_ID, {
command: 'gameplayTest.run',
messageId,
payload,
});
});
};
/**
* Run gameplay tests sequentially in a fresh preview of the project,
* displayed in the gameplay test frame. Runs are globally serialized:
* a second call waits for the first one to complete.
*
* The last-run summary of each stored test is updated: the caller is
* responsible for triggering the unsaved changes tracking and UI refreshes.
*/
export const runGameplayTests = async ({
project,
tests,
previewLauncher,
previewDebuggerServer,
options,
}: {|
project: gdProject,
tests: Array<GameplayTestToRun>,
previewLauncher: PreviewLauncherInterface,
previewDebuggerServer: PreviewDebuggerServer,
options: GameplayTestRunOptions,
|}): Promise<Array<GameplayTestResult>> => {
const runPromise = lastRunPromise.then(
async (): Promise<Array<GameplayTestResult>> => {
const results: Array<GameplayTestResult> = [];
const timeoutMs =
options.timeoutMs ||
(options.speedFactor
? PACED_RUN_DEFAULT_TIMEOUT_MS
: DEFAULT_TIMEOUT_MS);
const stopController: BatchStopController = {
stopRequested: false,
abortBootWait: () => {},
};
currentBatchStopController = stopController;
setRunInProgress(true);
let anyTestRan = false;
// Close any frame left open by a previous run, so the new preview
// always loads in a fresh frame (and a stale game can never answer
// in place of the new one).
clearGameplayTestFramePreview();
// Readable state for object/behavior snapshots, derived once per batch
// from the extensions metadata.
const stateInspectors = enumerateGameplayTestStateInspectors(project);
// Resolve the sources of the tests first, so an unknown test does not
// interrupt the batch in the middle.
const testsWithSources = tests.map(test => {
if (test.source !== undefined) {
return { test, source: test.source, error: null };
}
const testsContainer = getTestsContainer(project, test.scope);
if (!testsContainer) {
return {
test,
source: null,
error: `The scope (${getGameplayTestScopeDescription(
test.scope
)}) does not exist in the project.`,
};
}
if (!testsContainer.hasTestNamed(test.testName)) {
return {
test,
source: null,
error: `No test named "${
test.testName
}" in ${getGameplayTestScopeDescription(test.scope)}.`,
};
}
return {
test,
source: testsContainer.getTest(test.testName).getSource(),
error: null,
};
});
const firstTest = testsWithSources[0];
if (firstTest) {
setGameplayTestFrameRunStatus({
testName: firstTest.test.testName,
status: 'launching',
frame: null,
durationMs: null,
testIndex: 0,
testsCount: testsWithSources.length,
});
}
try {
// Export and launch a fresh preview into the gameplay test frame.
await previewLauncher.launchPreview(
// $FlowFixMe[prop-missing] - the launchers accept partial preview options for gameplay tests.
({
project,
sceneName: project.getFirstLayout(),
externalLayoutName: null,
eventsBasedObjectType: null,
eventsBasedObjectVariantName: null,
networkPreview: false,
hotReload: false,
shouldReloadProjectData: true,
shouldReloadLibraries: true,
shouldGenerateScenesEventsCode: true,
shouldReloadResources: false,
shouldHardReload: false,
fullLoadingScreen: false,
fallbackAuthor: null,
authenticatedPlayer: null,
isForInGameEdition: false,
isForGameplayTest: true,
editorId: '',
getIsMenuBarHiddenInPreview: () => true,
getIsAlwaysOnTopInPreview: () => false,
captureOptions: null,
onCaptureFinished: async () => {},
inAppTutorialMessageInPreview: '',
inAppTutorialMessagePositionInPreview: '',
editorCameraState3D: null,
inGameEditorSettings: null,
numberOfWindows: 0,
previewWindows: null,
}: any)
);
await waitForGameToBeReady(previewDebuggerServer, stopController);
for (
let testIndex = 0;
testIndex < testsWithSources.length;
testIndex++
) {
const { test, source, error } = testsWithSources[testIndex];
if (stopController.stopRequested) {
// The run was stopped: don't run the remaining tests.
results.push(makeStoppedResult(test.testName));
continue;
}
if (error !== null || source === null) {
results.push(
makeErrorResult(test.testName, error || 'No source found.')
);
continue;
}
if (options.onTestStarted) options.onTestStarted(test);
anyTestRan = true;
const frameRunStatus: GameplayTestFrameRunStatus = {
testName: test.testName,
status: 'launching',
frame: null,
durationMs: null,
testIndex,
testsCount: testsWithSources.length,
};
setGameplayTestFrameRunStatus(frameRunStatus);
const result = await runSingleTest({
previewDebuggerServer,
test,
source,
timeoutMs,
screenshots: options.screenshots || 'off',
speedFactor: options.speedFactor || null,
stateInspectors,
onProgress: (test: GameplayTestToRun, frame: number) => {
setGameplayTestFrameRunStatus({
...frameRunStatus,
status: 'running',
frame,
});
if (options.onProgress) options.onProgress(test, frame);
},
});
updateTestLastRun(project, test, result);
setGameplayTestFrameRunStatus({
...frameRunStatus,
status: result.status,
frame: result.framesExecuted,
durationMs: result.durationMs,
});
results.push(result);
}
} catch (error) {
if (error.isStopRequested) {
// The run was stopped while the game was still exporting or
// booting: mark the tests that could not be run as stopped.
for (const { test } of testsWithSources.slice(results.length)) {
results.push(makeStoppedResult(test.testName));
}
} else {
const errorMessage =
'Unable to run the gameplay tests: ' +
(error.message || String(error));
console.error('[GameplayTestRunner] ' + errorMessage, error);
// Fill the results of the tests that could not be run.
for (const { test } of testsWithSources.slice(results.length)) {
results.push(makeErrorResult(test.testName, errorMessage));
}
}
} finally {
currentBatchStopController = null;
setRunInProgress(false);
// When at least one test ran, the frame stays open (showing the
// frozen game and the outcome of the run) until its close button
// is used or another run starts. Otherwise (the game could not
// boot, or the run was stopped before the first test), close it.
if (!anyTestRan) {
clearGameplayTestFramePreview();
}
}
return results;
}
);
// Keep the queue going even if this run fails.
lastRunPromise = runPromise.catch(() => {});
return runPromise;
};
/**
* Stop the gameplay test run being currently run, if any: the test being
* run in the game is interrupted, the remaining tests of the batch are not
* run, and a run still exporting or booting the game is aborted.
*/
export const stopRunningGameplayTest = (
previewDebuggerServer: PreviewDebuggerServer
): void => {
if (currentBatchStopController) {
currentBatchStopController.stopRequested = true;
currentBatchStopController.abortBootWait();
}
previewDebuggerServer.sendMessage(GAMEPLAY_TEST_FRAME_DEBUGGER_ID, {
command: 'gameplayTest.stop',
});
};
/**
* Close the gameplay test frame (unloading the game running in it).
*/
export const closeGameplayTestFrame = (): void => {
clearGameplayTestFramePreview();
};
// The dependencies needed to run gameplay tests are registered by the
// MainFrame (which owns the preview launcher), so that any part of the
// editor (project manager, test editor, command palette, CLI, AI function
// calls) can run tests without threading everything through props.
type GameplayTestRunnerDependencies = {|
getPreviewLauncher: () => ?PreviewLauncherInterface,
// Called after tests were run (last-run summaries were updated on the
// project): trigger unsaved changes and refresh the UI.
onTestsRunFinished: () => void,
|};
let gameplayTestRunnerDependencies: GameplayTestRunnerDependencies | null = null;
export const registerGameplayTestRunnerDependencies = (
dependencies: GameplayTestRunnerDependencies | null
): void => {
gameplayTestRunnerDependencies = dependencies;
};
/**
* Run gameplay tests using the dependencies registered by the MainFrame.
* See `runGameplayTests`.
*/
export const runProjectGameplayTests = async ({
project,
tests,
options,
}: {|
project: gdProject,
tests: Array<GameplayTestToRun>,
options: GameplayTestRunOptions,
|}): Promise<Array<GameplayTestResult>> => {
const dependencies = gameplayTestRunnerDependencies;
if (!dependencies) {
throw new Error(
'Gameplay tests can not be run (no editor registered to run them).'
);
}
const previewLauncher = dependencies.getPreviewLauncher();
const previewDebuggerServer = previewLauncher
? previewLauncher.getPreviewDebuggerServer()
: null;
if (!previewLauncher || !previewDebuggerServer) {
throw new Error(
'Gameplay tests can not be run (no preview launcher available).'
);
}
try {
return await runGameplayTests({
project,
tests,
previewLauncher,
previewDebuggerServer,
options,
});
} finally {
dependencies.onTestsRunFinished();
}
};
/**
* Ask the game to stop the gameplay test being currently run, if any,
* using the dependencies registered by the MainFrame.
*/
export const stopRunningProjectGameplayTest = (): void => {
const dependencies = gameplayTestRunnerDependencies;
if (!dependencies) return;
const previewLauncher = dependencies.getPreviewLauncher();
const previewDebuggerServer = previewLauncher
? previewLauncher.getPreviewDebuggerServer()
: null;
if (!previewDebuggerServer) return;
stopRunningGameplayTest(previewDebuggerServer);
};
@@ -0,0 +1,231 @@
// @flow
import { mapFor } from '../Utils/MapFor';
const gd: libGDevelop = global.gd;
// Keep snapshots bounded for expression-heavy behaviors/objects.
const MAX_ENTRIES_PER_TYPE = 40;
/**
* One readable state entry: `name` is the event-sheet name of a condition or
* expression ('IsOnFloor', 'CurrentSpeed', 'PropertyHealth'...) and
* `functionName` the runtime method the gameplay test harness evaluates on
* the object or behavior instance.
*/
export type GameplayTestStateInspectorEntry = {|
name: string,
functionName: string,
kind: 'boolean' | 'number' | 'string',
|};
/**
* The state inspectors for the behavior and object types used in a project,
* derived from the extensions' own declarations (single source of truth:
* built-in, TS-based and events-based extensions all declare their
* conditions/expressions the same way). Sent with the gameplay test run
* payload so snapshots can expose a readable `state`.
*/
export type GameplayTestStateInspectors = {|
behaviors: { [behaviorType: string]: Array<GameplayTestStateInspectorEntry> },
objects: { [objectType: string]: Array<GameplayTestStateInspectorEntry> },
|};
const shortName = (fullType: string): string => {
const separatorIndex = fullType.lastIndexOf('::');
return separatorIndex === -1
? fullType
: fullType.substring(separatorIndex + 2);
};
/**
* Whether the instruction/expression can be blindly evaluated on an instance:
* its only parameters are the implicit ones — the object (and the behavior
* when `isBehavior`) for built-in declarations, or nothing at all plus the
* code-only `eventsFunctionContext` for events-based functions (their
* generated methods tolerate its absence). Anything else (comparison
* operands, target objects, a code-only scene...) would need arguments we
* cannot invent.
*/
const hasOnlyImplicitParameters = (
metadata: gdInstructionMetadata | gdExpressionMetadata,
isBehavior: boolean
): boolean => {
let userParameterIndex = 0;
for (let index = 0; index < metadata.getParametersCount(); index++) {
const parameter = metadata.getParameter(index);
if (parameter.isCodeOnly()) {
if (parameter.getType() !== 'eventsFunctionContext') return false;
continue;
}
if (userParameterIndex === 0) {
if (!gd.ParameterMetadata.isObject(parameter.getType())) return false;
} else if (userParameterIndex === 1 && isBehavior) {
if (parameter.getType() !== 'behavior') return false;
} else {
return false;
}
userParameterIndex++;
}
return isBehavior
? userParameterIndex === 2 || userParameterIndex === 0
: userParameterIndex <= 1;
};
/**
* Property getters of events-based behaviors/objects are declared hidden and
* private (they are internal to the extension), but they ARE the state a
* test wants to inspect ('PropertyHealth', 'SharedPropertyMaxLives'...):
* they get an exception to the hidden/private filter.
*/
const isPropertyGetterName = (name: string): boolean =>
name.startsWith('Property') || name.startsWith('SharedProperty');
const pushConditionEntries = (
entries: Array<GameplayTestStateInspectorEntry>,
conditions: gdMapStringInstructionMetadata,
isBehavior: boolean
) => {
const conditionTypes = conditions.keys();
for (let i = 0; i < conditionTypes.size(); i++) {
const metadata = conditions.get(conditionTypes.at(i));
const name = shortName(conditionTypes.at(i));
if (
(metadata.isHidden() || metadata.isPrivate()) &&
!isPropertyGetterName(name)
)
continue;
if (!hasOnlyImplicitParameters(metadata, isBehavior)) continue;
const functionName = metadata.getFunctionName();
if (!functionName) continue;
if (entries.some(entry => entry.name === name)) continue;
entries.push({ name, functionName, kind: 'boolean' });
}
};
const pushExpressionEntries = (
entries: Array<GameplayTestStateInspectorEntry>,
expressions: gdMapStringExpressionMetadata,
isBehavior: boolean,
kind: 'number' | 'string'
) => {
const expressionTypes = expressions.keys();
for (let i = 0; i < expressionTypes.size(); i++) {
const metadata = expressions.get(expressionTypes.at(i));
const name = shortName(expressionTypes.at(i));
if (
(!metadata.isShown() || metadata.isPrivate()) &&
!isPropertyGetterName(name)
)
continue;
if (!hasOnlyImplicitParameters(metadata, isBehavior)) continue;
const functionName = metadata.getFunctionName();
if (!functionName) continue;
if (entries.some(entry => entry.name === name)) continue;
entries.push({ name, functionName, kind });
}
};
const enumerateForType = (
type: string,
isBehavior: boolean
): Array<GameplayTestStateInspectorEntry> => {
const entries: Array<GameplayTestStateInspectorEntry> = [];
const allExtensions = gd
.asPlatform(gd.JsPlatform.get())
.getAllPlatformExtensions();
for (let i = 0; i < allExtensions.size(); i++) {
const extension = allExtensions.at(i);
pushConditionEntries(
entries,
isBehavior
? extension.getAllConditionsForBehavior(type)
: extension.getAllConditionsForObject(type),
isBehavior
);
pushExpressionEntries(
entries,
isBehavior
? extension.getAllExpressionsForBehavior(type)
: extension.getAllExpressionsForObject(type),
isBehavior,
'number'
);
pushExpressionEntries(
entries,
isBehavior
? extension.getAllStrExpressionsForBehavior(type)
: extension.getAllStrExpressionsForObject(type),
isBehavior,
'string'
);
}
return entries.slice(0, MAX_ENTRIES_PER_TYPE);
};
/**
* Collect the object types and behavior types used in the project: global
* objects, every scene's objects, and the child objects of events-based
* (custom) objects.
*/
const getUsedTypes = (
project: gdProject
): {| behaviorTypes: Set<string>, objectTypes: Set<string> |} => {
const behaviorTypes: Set<string> = new Set();
const objectTypes: Set<string> = new Set();
const visitObjectsContainer = (objectsContainer: gdObjectsContainer) => {
mapFor(0, objectsContainer.getObjectsCount(), i => {
const object = objectsContainer.getObjectAt(i);
objectTypes.add(object.getType());
const behaviorNames = object.getAllBehaviorNames();
mapFor(0, behaviorNames.size(), j => {
const behavior = object.getBehavior(behaviorNames.at(j));
behaviorTypes.add(behavior.getTypeName());
});
});
};
visitObjectsContainer(project.getObjects());
mapFor(0, project.getLayoutsCount(), i => {
visitObjectsContainer(project.getLayoutAt(i).getObjects());
});
// Child objects of events-based (custom) objects: their snapshots appear
// as `children` of custom object instances.
mapFor(0, project.getEventsFunctionsExtensionsCount(), i => {
const eventsFunctionsExtension = project.getEventsFunctionsExtensionAt(i);
const eventsBasedObjects = eventsFunctionsExtension.getEventsBasedObjects();
mapFor(0, eventsBasedObjects.getCount(), j => {
visitObjectsContainer(eventsBasedObjects.getAt(j).getObjects());
});
});
return { behaviorTypes, objectTypes };
};
/**
* Derive the state inspectors for every behavior and object type used in the
* project, from the extensions metadata: the zero-parameter conditions (as
* booleans) and expressions (as numbers/strings) that the gameplay test
* harness can evaluate on live instances, under their event-sheet names.
*/
export const enumerateGameplayTestStateInspectors = (
project: gdProject
): GameplayTestStateInspectors => {
const { behaviorTypes, objectTypes } = getUsedTypes(project);
const behaviors: {
[behaviorType: string]: Array<GameplayTestStateInspectorEntry>,
} = {};
for (const behaviorType of behaviorTypes) {
const entries = enumerateForType(behaviorType, true);
if (entries.length > 0) behaviors[behaviorType] = entries;
}
const objects: {
[objectType: string]: Array<GameplayTestStateInspectorEntry>,
} = {};
for (const objectType of objectTypes) {
const entries = enumerateForType(objectType, false);
if (entries.length > 0) objects[objectType] = entries;
}
return { behaviors, objects };
};
@@ -0,0 +1,153 @@
// @flow
import { enumerateGameplayTestStateInspectors } from './GameplayTestStateInspectors';
import {
reloadProjectEventsFunctionsExtensionMetadata,
type EventsFunctionCodeWriter,
} from '../EventsFunctionsExtensionsLoader';
import { makeFakeI18n } from '../EditorFunctions/TestHelpers';
const gd: libGDevelop = global.gd;
const fakeEventsFunctionCodeWriter: EventsFunctionCodeWriter = {
getIncludeFileFor: (functionName: string) => `fake-${functionName}.js`,
writeFunctionCode: () => Promise.resolve(),
writeBehaviorCode: () => Promise.resolve(),
writeObjectCode: () => Promise.resolve(),
};
describe('enumerateGameplayTestStateInspectors', () => {
let project: gdProject;
beforeEach(() => {
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
});
afterEach(() => {
project.delete();
});
it('derives readable state entries for a built-in (TS-based) behavior', () => {
const layout = project.insertNewLayout('Scene', 0);
const object = layout
.getObjects()
.insertNewObject(project, 'Sprite', 'Player', 0);
object.addNewBehavior(
project,
'PlatformBehavior::PlatformerObjectBehavior',
'PlatformerObject'
);
const inspectors = enumerateGameplayTestStateInspectors(project);
const entries =
inspectors.behaviors['PlatformBehavior::PlatformerObjectBehavior'];
expect(entries).toBeDefined();
// Zero-parameter conditions become booleans, under their event names,
// mapped to the real runtime getters.
expect(entries).toContainEqual({
name: 'IsOnFloor',
functionName: 'isOnFloor',
kind: 'boolean',
});
expect(entries).toContainEqual({
name: 'IsJumping',
functionName: 'isJumping',
kind: 'boolean',
});
// Zero-parameter expressions become numbers (configuration and dynamic
// state alike).
const currentFallSpeed = entries.find(
entry => entry.name === 'CurrentFallSpeed' && entry.kind === 'number'
);
expect(currentFallSpeed).toBeDefined();
// The comparison CONDITION of the same name takes operands, so the
// number entry (from the expression) must be the only one kept.
expect(
entries.filter(entry => entry.name === 'CurrentFallSpeed')
).toHaveLength(1);
// Parameterized conditions are excluded (nothing to call them with).
expect(entries.some(entry => entry.name === 'IsOnFloorObject')).toBe(false);
});
it('derives state entries for events-based behaviors and objects (functions and properties)', () => {
const extension = project.insertNewEventsFunctionsExtension('MyExt', 0);
extension.setName('MyExt');
// An events-based behavior with a property and a zero-parameter
// condition function.
const eventsBasedBehavior = extension
.getEventsBasedBehaviors()
.insertNew('MyBehavior', 0);
eventsBasedBehavior.setObjectType('');
const healthProperty = eventsBasedBehavior
.getPropertyDescriptors()
.insertNew('Health', 0);
healthProperty.setType('Number');
const isDeadFunction = eventsBasedBehavior
.getEventsFunctions()
.insertNewEventsFunction('IsDead', 0);
isDeadFunction.setFunctionType(gd.EventsFunction.Condition);
// An events-based (custom) object with a property.
const eventsBasedObject = extension
.getEventsBasedObjects()
.insertNew('MyButton', 0);
const pressedProperty = eventsBasedObject
.getPropertyDescriptors()
.insertNew('IsPressed', 0);
pressedProperty.setType('Boolean');
// Register the generated metadata (as the editor does at project load).
reloadProjectEventsFunctionsExtensionMetadata(
project,
extension,
fakeEventsFunctionCodeWriter,
makeFakeI18n()
);
// Use the behavior and the custom object in a scene.
const layout = project.insertNewLayout('Scene', 0);
const object = layout
.getObjects()
.insertNewObject(project, 'Sprite', 'Enemy', 0);
object.addNewBehavior(project, 'MyExt::MyBehavior', 'MyBehavior');
layout
.getObjects()
.insertNewObject(project, 'MyExt::MyButton', 'Button', 1);
const inspectors = enumerateGameplayTestStateInspectors(project);
const behaviorEntries = inspectors.behaviors['MyExt::MyBehavior'];
expect(behaviorEntries).toBeDefined();
// The property generates a readable numeric getter.
const propertyHealth = behaviorEntries.find(
entry => entry.name === 'PropertyHealth'
);
expect(propertyHealth).toBeDefined();
expect(propertyHealth && propertyHealth.kind).toBe('number');
expect(propertyHealth && propertyHealth.functionName).toBeTruthy();
// The condition function is exposed as a boolean, mapped to the
// generated method.
const isDead = behaviorEntries.find(entry => entry.name === 'IsDead');
expect(isDead).toBeDefined();
expect(isDead && isDead.kind).toBe('boolean');
expect(isDead && isDead.functionName).toBeTruthy();
const objectEntries = inspectors.objects['MyExt::MyButton'];
expect(objectEntries).toBeDefined();
const propertyIsPressed = objectEntries.find(
entry => entry.name === 'PropertyIsPressed'
);
expect(propertyIsPressed).toBeDefined();
expect(propertyIsPressed && propertyIsPressed.kind).toBe('boolean');
});
it('only includes types used in the project', () => {
project.insertNewLayout('Scene', 0);
const inspectors = enumerateGameplayTestStateInspectors(project);
expect(
inspectors.behaviors['PlatformBehavior::PlatformerObjectBehavior']
).toBeUndefined();
});
});
@@ -0,0 +1,167 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import classNames from 'classnames';
import Text from '../UI/Text';
import CheckCircleFilled from '../UI/CustomSvgIcons/CheckCircleFilled';
import ErrorFilled from '../UI/CustomSvgIcons/ErrorFilled';
import WarningRound from '../UI/CustomSvgIcons/WarningRound';
import History from '../UI/CustomSvgIcons/History';
import Stop from '../UI/CustomSvgIcons/Stop';
import classes from './GameplayTestStatusIndicator.module.css';
/**
* The status of a test as displayed in the editor: the statuses reported by
* the game (see `GameplayTestResult`), plus the transient statuses of a run
* being launched and the "never run" case.
*/
export type GameplayTestDisplayStatus =
| 'never-run'
| 'launching'
| 'running'
| 'passed'
| 'failed'
| 'error'
| 'stopped'
| 'timeout';
export const getDisplayStatusFromTest = (
test: gdTest
): GameplayTestDisplayStatus => {
const lastRunStatus = test.getLastRunStatus();
if (
lastRunStatus === 'passed' ||
lastRunStatus === 'failed' ||
lastRunStatus === 'error' ||
lastRunStatus === 'stopped' ||
lastRunStatus === 'timeout'
) {
return lastRunStatus;
}
return 'never-run';
};
/** Format the duration of a run, to be shown next to its status. */
export const formatRunDuration = (durationMs: number): string => {
if (!durationMs) return '-';
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
return `${(durationMs / 1000).toFixed(durationMs < 10000 ? 2 : 1)}s`;
};
export const isGameplayTestStatusInProgress = (
status: GameplayTestDisplayStatus
): boolean => status === 'launching' || status === 'running';
const statusClasses = {
'never-run': classes.neverRun,
launching: classes.inProgress,
running: classes.inProgress,
passed: classes.passed,
failed: classes.failed,
error: classes.error,
stopped: classes.stopped,
timeout: classes.timeout,
};
export const getGameplayTestStatusLabel = (
status: GameplayTestDisplayStatus
): React.Node => {
switch (status) {
case 'passed':
return <Trans>Passed</Trans>;
case 'failed':
return <Trans>Failed</Trans>;
case 'error':
return <Trans>Error</Trans>;
case 'stopped':
return <Trans>Stopped</Trans>;
case 'timeout':
return <Trans>Timed out</Trans>;
case 'launching':
return <Trans>Starting the game...</Trans>;
case 'running':
return <Trans>Running...</Trans>;
case 'never-run':
default:
return <Trans>Never run</Trans>;
}
};
const renderStatusIcon = (status: GameplayTestDisplayStatus) => {
switch (status) {
case 'passed':
return <CheckCircleFilled className={classes.icon} />;
case 'failed':
return <ErrorFilled className={classes.icon} />;
case 'error':
return <WarningRound className={classes.icon} />;
case 'timeout':
return <History className={classes.icon} />;
case 'stopped':
return <Stop className={classes.icon} />;
case 'launching':
case 'running':
return <span className={classes.spinner} />;
case 'never-run':
default:
return <span className={classes.emptyDot} />;
}
};
type ChipProps = {|
status: GameplayTestDisplayStatus,
/** An additional detail shown next to the status (frames, duration...). */
details?: React.Node,
size?: 'small' | 'default',
|};
/**
* A pill showing the status of a gameplay test run, with a colored icon.
* Used in the test properties panel and on the gameplay test frame.
*/
export const GameplayTestStatusChip = ({
status,
details,
size,
}: ChipProps): React.Node => (
<span
className={classNames({
[classes.chip]: true,
[statusClasses[status]]: true,
[classes.small]: size === 'small',
})}
>
{renderStatusIcon(status)}
<Text
noMargin
color="inherit"
size={size === 'small' ? 'body-small' : 'body'}
>
{getGameplayTestStatusLabel(status)}
</Text>
{details && (
<Text noMargin color="inherit" size="body-small" style={{ opacity: 0.8 }}>
{details}
</Text>
)}
</span>
);
/**
* Just the colored icon of a status, to be shown in dense lists
* (project manager, extension editor...).
*/
export const GameplayTestStatusIcon = ({
status,
}: {|
status: GameplayTestDisplayStatus,
|}): React.Node => (
<span
className={classNames({
[classes.iconOnly]: true,
[statusClasses[status]]: true,
})}
>
{renderStatusIcon(status)}
</span>
);
@@ -0,0 +1,80 @@
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 3px 10px 3px 6px;
border-radius: 999px;
/* The status color is set by the variants below and used by the icon
(via currentColor), the label and the translucent background. */
color: var(--theme-text-default-color);
background-color: color-mix(in srgb, currentColor 14%, transparent);
}
.chip.small {
gap: 4px;
padding: 2px 8px 2px 4px;
}
.iconOnly {
display: inline-flex;
align-items: center;
}
.icon {
font-size: 18px;
flex-shrink: 0;
}
.chip.small .icon {
font-size: 16px;
}
.passed {
color: var(--theme-success-color);
}
.failed {
color: var(--theme-error-color);
}
.error,
.timeout {
color: var(--theme-warning-color);
}
.stopped,
.neverRun {
color: var(--theme-text-secondary-color);
}
.inProgress {
color: var(--theme-primary-light);
}
.emptyDot {
width: 10px;
height: 10px;
margin: 4px;
border-radius: 50%;
border: 1.5px solid currentColor;
opacity: 0.7;
flex-shrink: 0;
}
.spinner {
width: 12px;
height: 12px;
margin: 3px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
animation: gameplay-test-spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes gameplay-test-spin {
to {
transform: rotate(360deg);
}
}
@@ -23,6 +23,7 @@ import { type GamesPlatformFrameTools } from './HomePage/PlaySection/UseGamesPla
import { type ObjectWithContext } from '../../ObjectsList/EnumerateObjects';
import { type CreateProjectResult } from '../../Utils/UseCreateProject';
import { type OpenAskAiOptions } from '../../AiGeneration/Utils';
import { type GameplayTestsCallbacks } from '../../GameplayTests/GameplayTestRunner';
import type { NavigateToEventFromGlobalSearchParams } from '../../Utils/Search';
import type {
SceneEventsOutsideEditorChanges,
@@ -31,6 +32,7 @@ import type {
ObjectGroupsOutsideEditorChanges,
ProjectItemRenamedOutsideEditorChanges,
WillDeleteSceneChanges,
WillDeleteGameplayTestChanges,
WillDeleteObjectChanges,
} from '../../EditorFunctions/OutsideEditorChanges';
@@ -116,6 +118,9 @@ export type RenderEditorContainerProps = {|
onOpenAskAi: (?OpenAskAiOptions) => void,
onCloseAskAi: () => void,
// Gameplay tests management:
gameplayTestsCallbacks: GameplayTestsCallbacks,
// Events function management:
onLoadEventsFunctionsExtensions: ({|
shouldHotReloadEditor: boolean,
@@ -237,6 +242,9 @@ export type RenderEditorContainerProps = {|
changes: ProjectItemRenamedOutsideEditorChanges
) => void,
onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
onWillDeleteObject: (changes: WillDeleteObjectChanges) => void,
// Events editing
@@ -270,6 +270,7 @@ export class EventsFunctionsExtensionEditorContainer extends React.Component<Ren
onBehaviorEdited={this._reloadExtensionMetadata}
onObjectEdited={this._reloadExtensionMetadata}
onFunctionEdited={this._reloadExtensionMetadata}
gameplayTestsCallbacks={this.props.gameplayTestsCallbacks}
ref={editor => (this.editor = editor)}
unsavedChanges={this.props.unsavedChanges}
onOpenCustomObjectEditor={eventsBasedObject => {
@@ -0,0 +1,276 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import {
type RenderEditorContainerProps,
type RenderEditorContainerPropsWithRef,
} from './BaseEditor';
import {
type SceneEventsOutsideEditorChanges,
type InstancesOutsideEditorChanges,
type ObjectsOutsideEditorChanges,
type ObjectGroupsOutsideEditorChanges,
type WillDeleteObjectChanges,
} from '../../EditorFunctions/OutsideEditorChanges';
import { type ObjectWithContext } from '../../ObjectsList/EnumerateObjects';
import { type HotReloadSteps } from '../../EmbeddedGame/EmbeddedGameFrame';
import GameplayTestEditor, {
type GameplayTestEditorInterface,
} from '../../GameplayTests/GameplayTestEditor';
import {
runProjectGameplayTests,
stopRunningProjectGameplayTest,
getTestsContainer,
type GameplayTestResult,
type GameplayTestScope,
} from '../../GameplayTests/GameplayTestRunner';
import {
Toolbar,
type GameplayTestRunSpeedOptions,
} from '../../GameplayTests/GameplayTestEditorToolbar';
import Background from '../../UI/Background';
import EmptyMessage from '../../UI/EmptyMessage';
import { Column } from '../../UI/Grid';
const styles = {
container: {
display: 'flex',
flex: 1,
minWidth: 0,
},
};
const parseGameplayTestProjectItemName = (
projectItemName: string
): {| scope: GameplayTestScope, testName: string |} => {
const separatorIndex = projectItemName.indexOf('::');
if (separatorIndex === -1)
return { scope: { type: 'project' }, testName: projectItemName };
return {
scope: {
type: 'extension',
extensionName: projectItemName.substring(0, separatorIndex),
},
testName: projectItemName.substring(separatorIndex + 2),
};
};
type State = {|
isRunning: boolean,
/** The frame reached by the test being run, if it started playing. */
runningFrame: number | null,
lastResult: GameplayTestResult | null,
|};
export class GameplayTestEditorContainer extends React.Component<
RenderEditorContainerProps,
State
> {
editor: ?GameplayTestEditorInterface;
// $FlowFixMe[missing-local-annot]
state = {
isRunning: false,
runningFrame: null,
lastResult: null,
};
shouldComponentUpdate(nextProps: RenderEditorContainerProps): any {
// We stop updates when the component is inactive.
// If it's active, was active or becoming active again we let update propagate.
return this.props.isActive || nextProps.isActive;
}
getProject(): ?gdProject {
return this.props.project;
}
updateToolbar() {
this.props.setToolbar(
<Toolbar
onRunTest={this.runTest}
onStopTest={this.stopTest}
isRunning={this.state.isRunning}
canRun={!!this.getGameplayTest()}
onToggleProperties={this.togglePropertiesPanel}
isPropertiesShown={
this.editor ? this.editor.isPropertiesPanelShown() : true
}
/>
);
}
togglePropertiesPanel = () => {
if (this.editor) this.editor.togglePropertiesPanel();
};
forceUpdateEditor() {
if (this.editor) this.editor.forceUpdate();
}
selectAllInsideEditor() {
// No thing to be done.
}
onEventsBasedObjectChildrenEdited(
eventsBasedObject: gdEventsBasedObject,
options?: {| editedObject?: ?gdObject, hasResourceChanged?: boolean |}
) {
// No thing to be done.
}
onSceneObjectEdited(
scene: gdLayout,
objectWithContext: ObjectWithContext,
hasResourceChanged?: boolean
) {
// No thing to be done.
}
onSceneObjectsDeleted(scene: gdLayout) {
// No thing to be done.
}
onSceneEventsModifiedOutsideEditor(changes: SceneEventsOutsideEditorChanges) {
// No thing to be done.
}
notifyChangesToInGameEditor(hotReloadSteps: HotReloadSteps) {
// No thing to be done.
}
switchInGameEditorIfNoHotReloadIsNeeded() {}
onInstancesModifiedOutsideEditor(changes: InstancesOutsideEditorChanges) {
// No thing to be done.
}
onObjectsModifiedOutsideEditor(changes: ObjectsOutsideEditorChanges) {
// No thing to be done.
}
onWillDeleteObject(changes: WillDeleteObjectChanges) {
// No thing to be done.
}
onObjectGroupsModifiedOutsideEditor(
changes: ObjectGroupsOutsideEditorChanges
) {
// No thing to be done.
}
getScopeAndTestName(): {| scope: GameplayTestScope, testName: string |} {
return parseGameplayTestProjectItemName(this.props.projectItemName || '');
}
getGameplayTest(): ?gdTest {
const { project } = this.props;
if (!project) return null;
const { scope, testName } = this.getScopeAndTestName();
const testsContainer = getTestsContainer(project, scope);
if (!testsContainer || !testsContainer.hasTestNamed(testName)) return null;
return testsContainer.getTest(testName);
}
onTestModified = () => {
if (this.props.unsavedChanges) {
this.props.unsavedChanges.triggerUnsavedChanges();
}
};
runTest = async (runOptions: GameplayTestRunSpeedOptions) => {
const { project } = this.props;
const test = this.getGameplayTest();
if (!project || !test || this.state.isRunning) return;
const { speedFactor } = runOptions;
const { scope, testName } = this.getScopeAndTestName();
this.setState(
{ isRunning: true, runningFrame: null, lastResult: null },
() => this.updateToolbar()
);
try {
const results = await runProjectGameplayTests({
project,
tests: [{ scope, testName }],
options: {
...(speedFactor ? { speedFactor } : {}),
onProgress: (test, frame) => this.setState({ runningFrame: frame }),
},
});
this.setState({ lastResult: results[0] || null });
} catch (error) {
console.error('Error while running the gameplay test:', error);
} finally {
this.setState({ isRunning: false, runningFrame: null }, () =>
this.updateToolbar()
);
if (this.editor) this.editor.forceUpdate();
}
};
stopTest = () => {
stopRunningProjectGameplayTest();
};
editWithAi = () => {
const test = this.getGameplayTest();
if (!test) return;
const { scope, testName } = this.getScopeAndTestName();
const prompt = `Edit the gameplay test "${testName}" ${
scope.type === 'project'
? 'of the project'
: `in the extension "${scope.extensionName}"`
} to `;
this.props.onOpenAskAi({ prefilledUserRequest: prompt });
};
render(): any {
const { project, projectItemName } = this.props;
const test = this.getGameplayTest();
if (!test || !project) {
return (
<div style={styles.container}>
<Background>
<Column expand alignItems="center" justifyContent="center">
<EmptyMessage>
<Trans>
No gameplay test called {projectItemName} was found.
</Trans>
</EmptyMessage>
</Column>
</Background>
</div>
);
}
const { scope } = this.getScopeAndTestName();
return (
<div style={styles.container}>
<GameplayTestEditor
ref={editor => (this.editor = editor)}
project={project}
test={test}
scope={scope}
isRunning={this.state.isRunning}
runningFrame={this.state.runningFrame}
lastResult={this.state.lastResult}
onRunTest={this.runTest}
onStopTest={this.stopTest}
onEditWithAi={this.editWithAi}
onTestModified={this.onTestModified}
onOpenedEditorsChanged={() => this.updateToolbar()}
/>
</div>
);
}
}
export const renderGameplayTestEditorContainer = (
props: RenderEditorContainerPropsWithRef
): React.Node => <GameplayTestEditorContainer {...props} />;
@@ -16,6 +16,7 @@ import {
import { type AskAiEditorInterface } from '../../AiGeneration/AskAiEditorContainer';
import { type HTMLDataset } from '../../Utils/HTMLDataset';
import { CustomObjectEditorContainer } from '../EditorContainers/CustomObjectEditorContainer';
import { GameplayTestEditorContainer } from '../EditorContainers/GameplayTestEditorContainer';
// Supported editors
type EditorRef =
@@ -24,6 +25,7 @@ type EditorRef =
| EventsFunctionsExtensionEditorContainer
| ExternalEventsEditorContainer
| ExternalLayoutEditorContainer
| GameplayTestEditorContainer
| ResourcesEditorContainer
| SceneEditorContainer
| HomePageEditorInterface
@@ -38,6 +40,7 @@ export type EditorKind =
| 'external events'
| 'events functions extension'
| 'custom object'
| 'gameplay-test'
| 'debugger'
| 'resources'
| 'global-search'
@@ -616,6 +619,24 @@ export const closeExternalEventsTabs = (
});
};
export const closeGameplayTestTabs = (
state: EditorTabsState,
gameplayTestProjectItemName: string
): {
panes: {
[paneIdentifier: string]: { currentTab: number, editors: Array<EditorTab> },
},
} => {
return closeTabsExceptIf(
state,
editorTab =>
!(
editorTab.kind === 'gameplay-test' &&
editorTab.projectItemName === gameplayTestProjectItemName
)
);
};
export const closeEventsFunctionsExtensionTabs = (
state: EditorTabsState,
eventsFunctionsExtensionName: string
@@ -44,6 +44,19 @@ export const getRenamedExternalEventsTabProjectItemName = (
? newName
: null;
/**
* Gameplay test tabs are named after the test (`TestName` for project tests,
* `ExtensionName::TestName` for extension tests).
*/
export const getRenamedGameplayTestTabProjectItemName = (
tab: RenamableTab,
oldProjectItemName: string,
newProjectItemName: string
): ?string =>
tab.kind === 'gameplay-test' && tab.projectItemName === oldProjectItemName
? newProjectItemName
: null;
/**
* Renaming an extension affects its extension tab and every custom-object tab
* whose name starts with that extension (`extension::object[::variant]`).
@@ -31,6 +31,7 @@ import {
type ObjectGroupsOutsideEditorChanges,
type ProjectItemRenamedOutsideEditorChanges,
type WillDeleteSceneChanges,
type WillDeleteGameplayTestChanges,
type WillDeleteObjectChanges,
} from '../EditorFunctions/OutsideEditorChanges';
import { type NavigateToEventFromGlobalSearchParams } from '../Utils/Search';
@@ -64,6 +65,7 @@ import DrawerTopBar from '../UI/DrawerTopBar';
import { type FloatingPaneState } from './PanesContainer';
import { type CreateProjectResult } from '../Utils/UseCreateProject';
import { type OpenAskAiOptions } from '../AiGeneration/Utils';
import { type GameplayTestsCallbacks } from '../GameplayTests/GameplayTestRunner';
import { type ToolbarButtonConfig } from './CustomToolbarButton';
import { type TriggerNpmScript } from './NpmScriptRunner/useNpmScriptRunner';
@@ -145,6 +147,7 @@ export type EditorTabsPaneCommonProps = {|
onQuitVersionHistory: () => Promise<void>,
onOpenAskAi: (?OpenAskAiOptions) => void,
onCloseAskAi: () => void,
gameplayTestsCallbacks: GameplayTestsCallbacks,
getStorageProvider: () => StorageProvider,
setPreviewedLayout: ({|
layoutName: string | null,
@@ -291,6 +294,9 @@ export type EditorTabsPaneCommonProps = {|
changes: ProjectItemRenamedOutsideEditorChanges
) => void,
onWillDeleteScene: (changes: WillDeleteSceneChanges) => Promise<void>,
onWillDeleteGameplayTest: (
changes: WillDeleteGameplayTestChanges
) => Promise<void>,
onWillDeleteObject: (changes: WillDeleteObjectChanges) => void,
onWillInstallExtension: (extensionNames: Array<string>) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
@@ -356,6 +362,7 @@ const EditorTabsPane: React.ComponentType<{
onQuitVersionHistory,
onOpenAskAi,
onCloseAskAi,
gameplayTestsCallbacks,
getStorageProvider,
setPreviewedLayout,
openExternalEvents,
@@ -410,6 +417,7 @@ const EditorTabsPane: React.ComponentType<{
onObjectGroupsModifiedOutsideEditor,
onProjectItemRenamedOutsideEditor,
onWillDeleteScene,
onWillDeleteGameplayTest,
onWillDeleteObject,
onWillInstallExtension,
onExtensionInstalled,
@@ -708,6 +716,10 @@ const EditorTabsPane: React.ComponentType<{
currentTab ? currentTab.key : null
)
}
showPreviewAndShareButtons={
// A gameplay test is run with its own button: no preview or share.
!currentTab || currentTab.kind !== 'gameplay-test'
}
canSave={canSave}
onSave={saveProject}
openShareDialog={() =>
@@ -770,6 +782,7 @@ const EditorTabsPane: React.ComponentType<{
setPreviewedLayout,
onOpenAskAi,
onCloseAskAi,
gameplayTestsCallbacks,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {
@@ -878,6 +891,7 @@ const EditorTabsPane: React.ComponentType<{
onObjectGroupsModifiedOutsideEditor: onObjectGroupsModifiedOutsideEditor,
onProjectItemRenamedOutsideEditor: onProjectItemRenamedOutsideEditor,
onWillDeleteScene: onWillDeleteScene,
onWillDeleteGameplayTest: onWillDeleteGameplayTest,
onWillDeleteObject: onWillDeleteObject,
onWillInstallExtension: onWillInstallExtension,
onExtensionInstalled: onExtensionInstalled,
@@ -10,6 +10,10 @@ import PreferencesContext, {
type Preferences,
} from './Preferences/PreferencesContext';
import { scanProjectForValidationErrors } from '../Utils/EventsValidationScanner';
import {
runProjectGameplayTests,
type GameplayTestToRun,
} from '../GameplayTests/GameplayTestRunner';
import Window from '../Utils/Window';
import optionalRequire from '../Utils/OptionalRequire';
import { type FileMetadata } from '../ProjectsStorage';
@@ -130,6 +134,65 @@ const runners: { [commandName: string]: CliCommandRunner } = {
throw new Error('[CLI] Extension imported but project save failed.');
}
},
RUN_ALL_TESTS: async (project, i18n, { commandArgs }) => {
// Run the gameplay tests of the project and of every extension,
// optionally filtered by names passed via --cmd-args.
const tests: Array<GameplayTestToRun> = [];
const projectTests = project.getTests();
for (let i = 0; i < projectTests.getTestsCount(); i++) {
tests.push({
scope: { type: 'project' },
testName: projectTests.getTestAt(i).getName(),
});
}
for (
let extensionIndex = 0;
extensionIndex < project.getEventsFunctionsExtensionsCount();
extensionIndex++
) {
const extension = project.getEventsFunctionsExtensionAt(extensionIndex);
const extensionTests = extension.getTests();
for (let i = 0; i < extensionTests.getTestsCount(); i++) {
tests.push({
scope: { type: 'extension', extensionName: extension.getName() },
testName: extensionTests.getTestAt(i).getName(),
});
}
}
const filteredTests = commandArgs.length
? tests.filter(test => commandArgs.includes(test.testName))
: tests;
if (filteredTests.length === 0) {
console.info('[CLI] No gameplay tests to run.');
return;
}
const results = await runProjectGameplayTests({
project,
tests: filteredTests,
options: {},
});
let failedCount = 0;
for (const result of results) {
const passed = result.status === 'passed';
if (!passed) failedCount++;
console.info(
`[CLI] ${passed ? 'PASSED' : 'FAILED'} (${result.status}): ${
result.testName
} (${result.framesExecuted} frames, ${Math.round(
result.durationMs
)}ms)${result.errors.length ? ' - ' + result.errors.join(' | ') : ''}`
);
}
console.info(
`[CLI] ${results.length - failedCount}/${
results.length
} gameplay tests passed.`
);
if (failedCount > 0) {
throw new Error(`${failedCount} gameplay test(s) failed.`);
}
},
};
export const getAwaitableCliRunner = (commandName: string): ?CliCommandRunner =>
+40 -1
View File
@@ -10,14 +10,17 @@ import {
enumerateExternalEvents,
enumerateExternalLayouts,
enumerateEventsFunctionsExtensions,
enumerateGameplayTests,
} from '../ProjectManager/EnumerateProjectItems';
import { areGameplayTestsEnabled } from '../GameplayTests/AreGameplayTestsEnabled';
import { type FileMetadata } from '../ProjectsStorage';
type Item =
| gdLayout
| gdExternalEvents
| gdExternalLayout
| gdEventsFunctionsExtension;
| gdEventsFunctionsExtension
| gdTest;
/**
* Helper function to generate options list
@@ -66,6 +69,9 @@ type CommandHandlers = {|
onOpenExternalEvents: string => void,
onOpenExternalLayout: string => void,
onOpenEventsFunctionsExtension: string => void,
onOpenGameplayTest: string => void,
onRunGameplayTest: string => void | Promise<void>,
onRunAllGameplayTests: () => void | Promise<void>,
onOpenCommandPalette: () => void,
onOpenProfile: () => void,
onRestartInGameEditor: (reason: string) => void,
@@ -223,6 +229,39 @@ const useMainFrameCommands = (handlers: CommandHandlers) => {
),
});
const gameplayTestCommandsEnabled =
!!handlers.project && areGameplayTestsEnabled();
useCommandWithOptions('OPEN_GAMEPLAY_TEST', gameplayTestCommandsEnabled, {
generateOptions: React.useCallback(
() =>
generateProjectItemOptions(
handlers.project,
enumerateGameplayTests,
handlers.onOpenGameplayTest
),
[handlers.project, handlers.onOpenGameplayTest]
),
});
const { onRunGameplayTest } = handlers;
useCommandWithOptions('RUN_GAMEPLAY_TEST', gameplayTestCommandsEnabled, {
generateOptions: React.useCallback(
() =>
generateProjectItemOptions(
handlers.project,
enumerateGameplayTests,
(testName: string) => {
onRunGameplayTest(testName);
}
),
[handlers.project, onRunGameplayTest]
),
});
useCommand('RUN_ALL_GAMEPLAY_TESTS', gameplayTestCommandsEnabled, {
handler: handlers.onRunAllGameplayTests,
});
useCommandWithOptions('OPEN_EXTERNAL_LAYOUT', !!handlers.project, {
generateOptions: React.useCallback(
() =>
@@ -138,6 +138,7 @@ const PoppedOutEditorContainerWindow = (props: Props): React.Node => {
ref={toolbarRef}
hidden={false}
showProjectButtons={false}
showPreviewAndShareButtons={false}
canSave={props.canSave}
onSave={props.saveProject}
openShareDialog={() => props.openShareDialog()}
@@ -220,6 +221,7 @@ const PoppedOutEditorContainerWindow = (props: Props): React.Node => {
projectItemName: editorTab.projectItemName,
setPreviewedLayout: props.setPreviewedLayout,
onOpenAskAi: props.onOpenAskAi,
gameplayTestsCallbacks: props.gameplayTestsCallbacks,
onCloseAskAi: props.onCloseAskAi,
onOpenExternalEvents: props.openExternalEvents,
onOpenEvents: (sceneName: string) => {
@@ -360,6 +362,8 @@ const PoppedOutEditorContainerWindow = (props: Props): React.Node => {
onProjectItemRenamedOutsideEditor:
props.onProjectItemRenamedOutsideEditor,
onWillDeleteScene: props.onWillDeleteScene,
onWillDeleteGameplayTest:
props.onWillDeleteGameplayTest,
onWillDeleteObject: props.onWillDeleteObject,
onWillInstallExtension: props.onWillInstallExtension,
onExtensionInstalled: props.onExtensionInstalled,
@@ -50,7 +50,8 @@ export type EditorMosaicName =
| 'scene-editor'
| 'debugger'
| 'resources-editor'
| 'events-functions-extension-editor';
| 'events-functions-extension-editor'
| 'gameplay-test-editor';
export type InAppTutorialUserProgress = {|
step: number,
+7 -2
View File
@@ -145,6 +145,9 @@ export const usePreviewDebuggerServerWatcher = (
console.info('Hard reloading all previews...');
previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => {
// The gameplay test frame is only driven by the gameplay test runner.
if (debuggerId === 'gameplay-test-frame') return;
previewDebuggerServer.sendMessage(debuggerId, {
command: 'hardReload',
});
@@ -153,11 +156,13 @@ export const usePreviewDebuggerServerWatcher = (
[previewDebuggerServer]
);
// The gameplay test frame is not counted as a running preview: it's
// entirely driven by the gameplay test runner (no hot-reload/update).
const hasNonEditionPreviewsRunning = Object.keys(debuggerStatus).some(
key => !debuggerStatus[key].isInGameEdition
key => key !== 'gameplay-test-frame' && !debuggerStatus[key].isInGameEdition
);
const nonEditionPreviewsCount = Object.keys(debuggerStatus).filter(
key => !debuggerStatus[key].isInGameEdition
key => key !== 'gameplay-test-frame' && !debuggerStatus[key].isInGameEdition
).length;
return {
@@ -11,6 +11,7 @@ import FlatButtonWithSplitMenu from '../../UI/FlatButtonWithSplitMenu';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import ResponsiveRaisedButton from '../../UI/ResponsiveRaisedButton';
import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
import { useIsGameplayTestRunInProgress } from '../../GameplayTests/GameplayTestRunner';
export type PreviewAndShareButtonsProps = {|
onPreviewWithoutHotReload: (?{ numberOfWindows: number }) => Promise<void>,
@@ -50,6 +51,9 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
}: PreviewAndShareButtonsProps) {
const preferences = React.useContext(PreferencesContext);
const { isMobile } = useResponsiveWindowSize();
// Launching or hot-reloading a preview while a gameplay test runs would
// interfere with it (the game also ignores these commands as a backstop).
const isGameplayTestRunInProgress = useIsGameplayTestRunInProgress();
const previewBuildMenuTemplate = React.useCallback(
(i18n: I18nType) =>
@@ -57,11 +61,12 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
{
label: i18n._(t`Start Network Preview (Preview over WiFi/LAN)`),
click: onNetworkPreview,
enabled: canDoNetworkPreview,
enabled: canDoNetworkPreview && !isGameplayTestRunInProgress,
},
{
label: i18n._(t`Start Preview and Debugger`),
click: onOpenDebugger,
enabled: !isGameplayTestRunInProgress,
},
preferences.values.openDiagnosticReportAutomatically
? null
@@ -70,7 +75,7 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
click: async () => {
await onLaunchPreviewWithDiagnosticReport();
},
enabled: !hasPreviewsRunning,
enabled: !hasPreviewsRunning && !isGameplayTestRunInProgress,
},
{
label: i18n._(t`Launch preview in...`),
@@ -80,28 +85,28 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
click: async () => {
await onPreviewWithoutHotReload({ numberOfWindows: 1 });
},
enabled: isPreviewEnabled,
enabled: isPreviewEnabled && !isGameplayTestRunInProgress,
},
{
label: i18n._(t`2 previews in 2 windows`),
click: async () => {
await onPreviewWithoutHotReload({ numberOfWindows: 2 });
},
enabled: isPreviewEnabled,
enabled: isPreviewEnabled && !isGameplayTestRunInProgress,
},
{
label: i18n._(t`3 previews in 3 windows`),
click: async () => {
onPreviewWithoutHotReload({ numberOfWindows: 3 });
},
enabled: isPreviewEnabled,
enabled: isPreviewEnabled && !isGameplayTestRunInProgress,
},
{
label: i18n._(t`4 previews in 4 windows`),
click: async () => {
onPreviewWithoutHotReload({ numberOfWindows: 4 });
},
enabled: isPreviewEnabled,
enabled: isPreviewEnabled && !isGameplayTestRunInProgress,
},
],
},
@@ -161,6 +166,7 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
onPreviewWithoutHotReload,
isPreviewEnabled,
hasPreviewsRunning,
isGameplayTestRunInProgress,
preferences.values.openDiagnosticReportAutomatically,
onLaunchPreviewWithDiagnosticReport,
previewState.overridenPreviewLayoutName,
@@ -188,7 +194,7 @@ const PreviewAndShareButtons: React.ComponentType<PreviewAndShareButtonsProps> =
onClick={
hasPreviewsRunning ? onHotReloadPreview : onPreviewWithoutHotReload
}
disabled={!isPreviewEnabled}
disabled={!isPreviewEnabled || isGameplayTestRunInProgress}
icon={hasPreviewsRunning ? <UpdateIcon /> : <PreviewIcon />}
label={
!isMobile ? (
+25 -20
View File
@@ -21,6 +21,7 @@ import { type TriggerNpmScript } from '../NpmScriptRunner/useNpmScriptRunner';
export type MainFrameToolbarProps = {|
showProjectButtons: boolean,
showPreviewAndShareButtons: boolean,
openShareDialog: () => void,
onSave: (options?: {|
skipNewVersionWarning: boolean,
@@ -149,26 +150,30 @@ export default (React.forwardRef<MainFrameToolbarProps, ToolbarInterface>(
projectPath={props.projectPath}
triggerNpmScript={props.triggerNpmScript}
/>
<ToolbarGroup>
<Spacer />
<PreviewAndShareButtons
onPreviewWithoutHotReload={props.onPreviewWithoutHotReload}
onOpenDebugger={props.onOpenDebugger}
onNetworkPreview={props.onNetworkPreview}
onHotReloadPreview={props.onHotReloadPreview}
onLaunchPreviewWithDiagnosticReport={
props.onLaunchPreviewWithDiagnosticReport
}
setPreviewOverride={props.setPreviewOverride}
canDoNetworkPreview={props.canDoNetworkPreview}
isPreviewEnabled={props.isPreviewEnabled}
previewState={props.previewState}
hasPreviewsRunning={props.hasPreviewsRunning}
openShareDialog={props.openShareDialog}
isSharingEnabled={props.isSharingEnabled}
/>
<Spacer />
</ToolbarGroup>
{props.showPreviewAndShareButtons ? (
<ToolbarGroup>
<Spacer />
<PreviewAndShareButtons
onPreviewWithoutHotReload={props.onPreviewWithoutHotReload}
onOpenDebugger={props.onOpenDebugger}
onNetworkPreview={props.onNetworkPreview}
onHotReloadPreview={props.onHotReloadPreview}
onLaunchPreviewWithDiagnosticReport={
props.onLaunchPreviewWithDiagnosticReport
}
setPreviewOverride={props.setPreviewOverride}
canDoNetworkPreview={props.canDoNetworkPreview}
isPreviewEnabled={props.isPreviewEnabled}
previewState={props.previewState}
hasPreviewsRunning={props.hasPreviewsRunning}
openShareDialog={props.openShareDialog}
isSharingEnabled={props.isSharingEnabled}
/>
<Spacer />
</ToolbarGroup>
) : (
<ToolbarGroup />
)}
</>
) : null}
{editorToolbar || <ToolbarGroup />}
+279 -2
View File
@@ -13,6 +13,7 @@ import ExternalEventsIcon from '../UI/CustomSvgIcons/ExternalEvents';
import ExternalLayoutIcon from '../UI/CustomSvgIcons/ExternalLayout';
import ExtensionIcon from '../UI/CustomSvgIcons/Extension';
import SearchIcon from '../UI/CustomSvgIcons/Search';
import PreviewIcon from '../UI/CustomSvgIcons/Preview';
import ProjectTitlebar from './ProjectTitlebar';
import PreferencesDialog from './Preferences/PreferencesDialog';
import AboutDialog from './AboutDialog';
@@ -35,6 +36,7 @@ import {
renameEditorTabs,
closeExternalLayoutTabs,
closeExternalEventsTabs,
closeGameplayTestTabs,
closeEventsFunctionsExtensionTabs,
closeCustomObjectTab,
closeEventsBasedObjectVariantTab,
@@ -63,6 +65,21 @@ import { renderSceneEditorContainer } from './EditorContainers/SceneEditorContai
import { renderExternalLayoutEditorContainer } from './EditorContainers/ExternalLayoutEditorContainer';
import { renderEventsFunctionsExtensionEditorContainer } from './EditorContainers/EventsFunctionsExtensionEditorContainer';
import { renderCustomObjectEditorContainer } from './EditorContainers/CustomObjectEditorContainer';
import { renderGameplayTestEditorContainer } from './EditorContainers/GameplayTestEditorContainer';
import { GameplayTestFrame } from '../GameplayTests/GameplayTestFrame';
import {
getGameplayTestProjectItemName,
getIsGameplayTestRunInProgress,
getTestsContainer,
registerGameplayTestRunnerDependencies,
useIsGameplayTestRunInProgress,
runProjectGameplayTests,
type GameplayTestScope,
stopRunningProjectGameplayTest,
type GameplayTestToRun,
type GameplayTestsCallbacks,
} from '../GameplayTests/GameplayTestRunner';
import { renderHomePageContainer } from './EditorContainers/HomePage';
import { type OpenAskAiOptions } from '../AiGeneration/Utils';
import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr';
@@ -74,11 +91,13 @@ import {
getRenamedLayoutTabProjectItemName,
getRenamedExternalLayoutTabProjectItemName,
getRenamedExternalEventsTabProjectItemName,
getRenamedGameplayTestTabProjectItemName,
getRenamedExtensionTabProjectItemName,
getRenamedEventsBasedObjectTabProjectItemName,
type RenamableTab,
} from './EditorTabs/EditorTabsRenaming';
import { renderAskAiEditorContainer } from '../AiGeneration/AskAiEditorContainer';
import { requestAskAiPrefill } from '../AiGeneration/AskAiPrefill';
import { renderResourcesEditorContainer } from './EditorContainers/ResourcesEditorContainer';
import { renderGlobalEventsSearchEditorContainer } from './EditorContainers/GlobalEventsSearchEditorContainer';
import { type RenderEditorContainerPropsWithRef } from './EditorContainers/BaseEditor';
@@ -89,6 +108,7 @@ import {
type ObjectGroupsOutsideEditorChanges,
type ProjectItemRenamedOutsideEditorChanges,
type WillDeleteSceneChanges,
type WillDeleteGameplayTestChanges,
type WillDeleteObjectChanges,
} from '../EditorFunctions/OutsideEditorChanges';
import { type Exporter } from '../ExportAndShare/ShareDialog';
@@ -291,6 +311,7 @@ const editorKindToRenderer: {
'external layout': renderExternalLayoutEditorContainer,
'events functions extension': renderEventsFunctionsExtensionEditorContainer,
'custom object': renderCustomObjectEditorContainer,
'gameplay-test': renderGameplayTestEditorContainer,
'start page': renderHomePageContainer,
resources: renderResourcesEditorContainer,
'global-search': renderGlobalEventsSearchEditorContainer,
@@ -611,6 +632,29 @@ const MainFrame = (props: Props): React.MixedElement => {
getWorkingAiRequest,
suspendAiRequest: suspendWorkingAiRequest,
} = React.useContext(AiRequestContext);
// Allow gameplay tests to be run from anywhere in the editor. Registered
// ONCE (the only dependency is a stable ref), reading the latest values
// through refs: re-registering on renders would leave the registry
// momentarily null, which the AI function calls processor could hit
// ("no editor registered").
const isGameplayTestRunInProgress = useIsGameplayTestRunInProgress();
const unsavedChangesRef = useStableUpToDateRef(unsavedChanges);
React.useEffect(
() => {
registerGameplayTestRunnerDependencies({
getPreviewLauncher: () => _previewLauncher.current,
onTestsRunFinished: () => {
// The last run summary of tests was updated on the project.
const currentUnsavedChanges = unsavedChangesRef.current;
if (currentUnsavedChanges)
currentUnsavedChanges.triggerUnsavedChanges();
},
});
return () => registerGameplayTestRunnerDependencies(null);
},
[unsavedChangesRef]
);
const {
hasUnsavedChanges,
sealUnsavedChanges,
@@ -783,6 +827,8 @@ const MainFrame = (props: Props): React.MixedElement => {
? parseCustomObjectEditorTabName(name).variantName ||
parseCustomObjectEditorTabName(name).objectName +
` ${i18n._(t`(Object)`)}`
: kind === 'gameplay-test'
? (name.split('::').pop() || name) + ` ${i18n._(t`(Test)`)}`
: name;
const tabOptions =
kind === 'layout'
@@ -797,6 +843,7 @@ const MainFrame = (props: Props): React.MixedElement => {
'external layout',
'events functions extension',
'custom object',
'gameplay-test',
].includes(kind)
? `${kind} ${name}`
: kind;
@@ -835,6 +882,8 @@ const MainFrame = (props: Props): React.MixedElement => {
) : kind === 'events functions extension' ||
kind === 'custom object' ? (
<ExtensionIcon />
) : kind === 'gameplay-test' ? (
<PreviewIcon />
) : kind === 'ask-ai' ? (
<RobotIcon size={16} />
) : null;
@@ -1057,10 +1106,17 @@ const MainFrame = (props: Props): React.MixedElement => {
aiRequestId,
paneIdentifier,
continueProcessingFunctionCallsOnMount,
prefilledUserRequest,
} = options || {};
const newPaneIdentifier =
paneIdentifier || (currentProject ? 'right' : 'center');
if (prefilledUserRequest) {
// Delivered to the Ask AI editor as soon as it's mounted (or
// immediately if it already is).
requestAskAiPrefill(prefilledUserRequest);
}
setState(state => {
let openedEditor = getOpenedAskAiEditor(state.editorTabs);
let newEditorTabs = state.editorTabs;
@@ -1738,6 +1794,136 @@ const MainFrame = (props: Props): React.MixedElement => {
});
};
const deleteGameplayTest = (scope: GameplayTestScope, test: gdTest) => {
const { i18n } = props;
const { currentProject } = state;
if (!currentProject) return;
const answer = Window.showConfirmDialog(
i18n._(
t`Are you sure you want to remove this gameplay test? This can't be undone.`
)
);
if (!answer) return;
const testName = test.getName();
setState(state => ({
...state,
editorTabs: closeGameplayTestTabs(
state.editorTabs,
getGameplayTestProjectItemName(scope, testName)
),
})).then(state => {
if (!state.currentProject) return;
const testsContainer = getTestsContainer(state.currentProject, scope);
if (testsContainer) testsContainer.removeTest(testName);
_onProjectItemModified();
});
};
const renameGameplayTest = (
scope: GameplayTestScope,
oldName: string,
newName: string
) => {
const { currentProject } = state;
const { i18n } = props;
if (!currentProject) return;
const testsContainer = getTestsContainer(currentProject, scope);
if (!testsContainer) return;
if (!testsContainer.hasTestNamed(oldName) || newName === oldName) return;
const uniqueNewName = newNameGenerator(
newName || i18n._(t`Unnamed`),
tentativeNewName => {
return testsContainer.hasTestNamed(tentativeNewName);
}
);
const test = testsContainer.getTest(oldName);
test.setName(uniqueNewName);
setState(state => ({
...state,
editorTabs: getEditorTabsWithRenamedProjectItem(
state.editorTabs,
currentProject,
editorTab =>
getRenamedGameplayTestTabProjectItemName(
editorTab,
getGameplayTestProjectItemName(scope, oldName),
getGameplayTestProjectItemName(scope, uniqueNewName)
)
),
})).then(() => {
_onProjectItemModified();
});
};
const runGameplayTestFromUi = React.useCallback(
async (scope: GameplayTestScope, testName: string) => {
const { currentProject } = state;
if (!currentProject) return;
try {
await runProjectGameplayTests({
project: currentProject,
tests: [{ scope, testName }],
options: {},
});
} catch (error) {
console.error('Error while running the gameplay test:', error);
}
},
[state]
);
const runAllGameplayTestsFromUi = React.useCallback(
async () => {
const { currentProject } = state;
if (!currentProject) return;
// Run the tests of the project, then the tests of every extension.
const tests: Array<GameplayTestToRun> = [];
const projectTests = currentProject.getTests();
for (let i = 0; i < projectTests.getTestsCount(); i++) {
tests.push({
scope: { type: 'project' },
testName: projectTests.getTestAt(i).getName(),
});
}
for (
let extensionIndex = 0;
extensionIndex < currentProject.getEventsFunctionsExtensionsCount();
extensionIndex++
) {
const extension = currentProject.getEventsFunctionsExtensionAt(
extensionIndex
);
const extensionTests = extension.getTests();
for (let i = 0; i < extensionTests.getTestsCount(); i++) {
tests.push({
scope: { type: 'extension', extensionName: extension.getName() },
testName: extensionTests.getTestAt(i).getName(),
});
}
}
if (!tests.length) return;
try {
await runProjectGameplayTests({
project: currentProject,
tests,
options: {},
});
} catch (error) {
console.error('Error while running the gameplay tests:', error);
}
},
[state]
);
const deleteEventsFunctionsExtension = async (
eventsFunctionsExtension: gdEventsFunctionsExtension
) => {
@@ -2537,6 +2723,13 @@ const MainFrame = (props: Props): React.MixedElement => {
}: LaunchPreviewOptions) => {
if (!currentProject) return;
if (currentProject.getLayoutsCount() === 0) return;
if (getIsGameplayTestRunInProgress()) {
// Launching or hot-reloading a preview would interfere with the
// gameplay test being run (the game also ignores these commands,
// as a backstop).
console.info('Preview not launched: a gameplay test is running.');
return;
}
if (
await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'preview')
@@ -2683,6 +2876,7 @@ const MainFrame = (props: Props): React.MixedElement => {
getIsAlwaysOnTopInPreview: preferences.getIsAlwaysOnTopInPreview,
numberOfWindows: numberOfWindows === undefined ? 1 : numberOfWindows,
isForInGameEdition: !!isForInGameEdition,
isForGameplayTest: false,
editorId: isForInGameEdition ? isForInGameEdition.editorId : '',
editorCameraState3D: isForInGameEdition
? isForInGameEdition.editorCameraState3D
@@ -3024,6 +3218,23 @@ const MainFrame = (props: Props): React.MixedElement => {
[setState, getEditorOpeningOptions]
);
const openGameplayTest = React.useCallback(
(scope: GameplayTestScope, testName: string) => {
setState(state => ({
...state,
editorTabs: openEditorTab(
state.editorTabs,
// $FlowFixMe[incompatible-type]
getEditorOpeningOptions({
kind: 'gameplay-test',
name: getGameplayTestProjectItemName(scope, testName),
})
),
}));
},
[setState, getEditorOpeningOptions]
);
const openEventsFunctionsExtension = React.useCallback(
(
name: string,
@@ -3757,6 +3968,21 @@ const MainFrame = (props: Props): React.MixedElement => {
),
};
}
if (kind === 'gameplay-test') {
return {
...state,
editorTabs: getEditorTabsWithRenamedProjectItem(
state.editorTabs,
currentProject,
editorTab =>
getRenamedGameplayTestTabProjectItemName(
editorTab,
oldName,
newName
)
),
};
}
return state;
});
};
@@ -3777,6 +4003,20 @@ const MainFrame = (props: Props): React.MixedElement => {
}));
};
// Called before a gameplay test is actually deleted from the project, so
// any tab bound to it is closed first (mirrors the manual delete flow).
const onWillDeleteGameplayTest = async (
changes: WillDeleteGameplayTestChanges
): Promise<void> => {
await setState(state => ({
...state,
editorTabs: closeGameplayTestTabs(
state.editorTabs,
changes.gameplayTestProjectItemName
),
}));
};
// Called before the object is actually deleted from the project, so any
// open editor can still safely read it (e.g. to close a dialog/panel
// referring to it) without risking a dangling reference.
@@ -5245,10 +5485,15 @@ const MainFrame = (props: Props): React.MixedElement => {
useMainFrameCommands({
i18n,
project: state.currentProject,
// Launching or hot-reloading a preview while a gameplay test runs would
// interfere with it: the commands are disabled meanwhile.
previewEnabled:
!!state.currentProject && state.currentProject.getLayoutsCount() > 0,
!!state.currentProject &&
state.currentProject.getLayoutsCount() > 0 &&
!isGameplayTestRunInProgress,
onOpenProjectManager: toggleProjectManager,
hasPreviewsRunning: hasNonEditionPreviewsRunning,
hasPreviewsRunning:
hasNonEditionPreviewsRunning && !isGameplayTestRunInProgress,
allowNetworkPreview:
!!_previewLauncher.current &&
_previewLauncher.current.canDoNetworkPreview(),
@@ -5289,6 +5534,11 @@ const MainFrame = (props: Props): React.MixedElement => {
onOpenExternalEvents: openExternalEvents,
onOpenExternalLayout: openExternalLayout,
onOpenEventsFunctionsExtension: openEventsFunctionsExtension,
onOpenGameplayTest: (testName: string) =>
openGameplayTest({ type: 'project' }, testName),
onRunGameplayTest: (testName: string) =>
runGameplayTestFromUi({ type: 'project' }, testName),
onRunAllGameplayTests: runAllGameplayTestsFromUi,
onOpenCommandPalette: openCommandPalette,
onOpenProfile: onOpenProfileDialog,
onRestartInGameEditor,
@@ -5452,6 +5702,15 @@ const MainFrame = (props: Props): React.MixedElement => {
!isSavingProject &&
(!currentFileMetadata || !isProjectOwnedBySomeoneElse);
// Not memoized: the handlers close over the current state (like the other
// project item handlers).
const gameplayTestsCallbacks: GameplayTestsCallbacks = {
onOpenGameplayTest: openGameplayTest,
onRenameGameplayTest: renameGameplayTest,
onDeleteGameplayTest: deleteGameplayTest,
onRunGameplayTest: runGameplayTestFromUi,
};
const editorTabsPaneProps: EditorTabsPaneCommonProps = {
gameEditorMode,
setGameEditorMode,
@@ -5488,6 +5747,7 @@ const MainFrame = (props: Props): React.MixedElement => {
onQuitVersionHistory: onQuitVersionHistory,
onOpenAskAi: openAskAi,
onCloseAskAi: closeAskAi,
gameplayTestsCallbacks,
getStorageProvider: getStorageProvider,
// $FlowFixMe[incompatible-type]
setPreviewedLayout: setPreviewedLayout,
@@ -5545,6 +5805,7 @@ const MainFrame = (props: Props): React.MixedElement => {
onObjectGroupsModifiedOutsideEditor: onObjectGroupsModifiedOutsideEditor,
onProjectItemRenamedOutsideEditor: onProjectItemRenamedOutsideEditor,
onWillDeleteScene: onWillDeleteScene,
onWillDeleteGameplayTest: onWillDeleteGameplayTest,
onWillDeleteObject: onWillDeleteObject,
onWillInstallExtension: onWillInstallExtension,
onExtensionInstalled: onExtensionInstalled,
@@ -5596,6 +5857,10 @@ const MainFrame = (props: Props): React.MixedElement => {
previewDebuggerServer={previewDebuggerServer || null}
onLaunchPreviewForInGameEdition={onLaunchPreviewForInGameEdition}
/>
<GameplayTestFrame
previewDebuggerServer={previewDebuggerServer || null}
onStopRequested={stopRunningProjectGameplayTest}
/>
{!!renderMainMenu &&
renderMainMenu(
{ ...buildMainMenuProps, isApplicationTopLevelMenu: true },
@@ -5634,10 +5899,22 @@ const MainFrame = (props: Props): React.MixedElement => {
onDeleteExternalLayout={deleteExternalLayout}
onDeleteEventsFunctionsExtension={deleteEventsFunctionsExtension}
onDeleteExternalEvents={deleteExternalEvents}
onDeleteGameplayTest={(test: gdTest) =>
deleteGameplayTest({ type: 'project' }, test)
}
onRenameLayout={renameLayout}
onRenameExternalLayout={renameExternalLayout}
onRenameEventsFunctionsExtension={renameEventsFunctionsExtension}
onRenameExternalEvents={renameExternalEvents}
onRenameGameplayTest={(oldName: string, newName: string) =>
renameGameplayTest({ type: 'project' }, oldName, newName)
}
onOpenGameplayTest={(testName: string) =>
openGameplayTest({ type: 'project' }, testName)
}
onRunGameplayTest={(testName: string) =>
runGameplayTestFromUi({ type: 'project' }, testName)
}
onOpenResources={openResources}
onReloadEventsFunctionsExtensions={onReloadEventsFunctionsExtensions}
onWillInstallExtension={onWillInstallExtension}
@@ -25,6 +25,11 @@ export const enumerateEventsFunctionsExtensions = (
project.getEventsFunctionsExtensionAt(i)
);
export const enumerateGameplayTests = (project: gdProject): Array<gdTest> =>
mapFor(0, project.getTests().getTestsCount(), i =>
project.getTests().getTestAt(i)
);
export const filterProjectItemsList = <T>(
list: Array<T>,
searchText: string
@@ -0,0 +1,236 @@
// @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,
gameplayTestsRootFolderId,
} from '.';
import { type HTMLDataset } from '../Utils/HTMLDataset';
import IconButton from '../UI/IconButton';
import PlayIcon from '../UI/CustomSvgIcons/Preview';
const GAMEPLAY_TEST_CLIPBOARD_KIND = 'Gameplay test';
export type GameplayTestTreeViewItemCallbacks = {|
onDeleteGameplayTest: gdTest => void,
onRenameGameplayTest: (string, string) => void,
onOpenGameplayTest: string => void,
onRunGameplayTest: string => void | Promise<void>,
|};
export type GameplayTestTreeViewItemCommonProps = {|
...TreeItemProps,
...GameplayTestTreeViewItemCallbacks,
|};
export type GameplayTestTreeViewItemProps = {|
...GameplayTestTreeViewItemCommonProps,
project: gdProject,
|};
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: GameplayTestTreeViewItemProps;
constructor(test: gdTest, props: GameplayTestTreeViewItemProps) {
this.test = test;
this.props = props;
}
isDescendantOf(itemContent: TreeViewItemContent): boolean {
return itemContent.getId() === gameplayTestsRootFolderId;
}
getRootId(): string {
return gameplayTestsRootFolderId;
}
getName(): string | React.Node {
return this.test.getName();
}
getId(): string {
return getGameplayTestTreeViewItemId(this.test);
}
getHtmlId(index: number): ?string {
return `gameplay-test-item-${index}`;
}
getDataSet(): ?HTMLDataset {
return {
'gameplay-test': this.test.getName(),
};
}
getThumbnail(): ?string {
return null;
}
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 (
<IconButton
size="small"
onClick={(e: any) => {
e.stopPropagation();
this.props.onRunGameplayTest(this.test.getName());
}}
tooltip={t`Run the test`}
>
<PlayIcon fontSize="small" />
</IconButton>
);
}
delete(): void {
this.props.onDeleteGameplayTest(this.test);
}
getIndex(): number {
return this.props.project.getTests().getTestPosition(this.test);
}
moveAt(destinationIndex: number): void {
const originIndex = this.getIndex();
if (destinationIndex !== originIndex) {
this.props.project.getTests().moveTest(
originIndex,
// When moving the item down, it must not be counted.
destinationIndex + (destinationIndex <= originIndex ? 0 : -1)
);
this._onProjectItemModified();
}
}
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 project = this.props.project;
const newName = newNameGenerator(name, name =>
project.getTests().hasTestNamed(name)
);
const newTest = project
.getTests()
.insertNewTest(newName, this.getIndex() + 1);
unserializeFromJSObject(newTest, copiedTest, 'unserializeFrom');
// Unserialization has overwritten the name.
newTest.setName(newName);
this._onProjectItemModified();
this.props.editName(getGameplayTestTreeViewItemId(newTest));
}
_duplicate(): void {
this.copy();
this.paste();
}
_onProjectItemModified() {
if (this.props.unsavedChanges)
this.props.unsavedChanges.triggerUnsavedChanges();
this.props.forceUpdate();
}
getRightButton(i18n: I18nType): any {
return null;
}
}
+129 -1
View File
@@ -67,6 +67,14 @@ import {
type ExternalLayoutTreeViewItemProps,
type ExternalLayoutTreeViewItemCallbacks,
} from './ExternalLayoutTreeViewItemContent';
import {
GameplayTestTreeViewItemContent,
getGameplayTestTreeViewItemId,
type GameplayTestTreeViewItemProps,
type GameplayTestTreeViewItemCallbacks,
} from './GameplayTestTreeViewItemContent';
import { DEFAULT_GAMEPLAY_TEST_SOURCE } from '../GameplayTests/DefaultGameplayTestSource';
import { areGameplayTestsEnabled } from '../GameplayTests/AreGameplayTestsEnabled';
import { type MenuItemTemplate } from '../UI/Menu/Menu.flow';
import useAlertDialog from '../UI/Alert/useAlertDialog';
import { type ShowConfirmDeleteDialogOptions } from '../UI/Alert/AlertContext';
@@ -107,11 +115,15 @@ export const externalEventsRootFolderId: string = getProjectManagerItemId(
export const externalLayoutsRootFolderId: string = getProjectManagerItemId(
'external-layout'
);
export const gameplayTestsRootFolderId: string = getProjectManagerItemId(
'gameplay-tests'
);
const scenesEmptyPlaceholderId = 'scenes-placeholder';
const extensionsEmptyPlaceholderId = 'extensions-placeholder';
const externalEventsEmptyPlaceholderId = 'external-events-placeholder';
const externalLayoutEmptyPlaceholderId = 'external-layout-placeholder';
const gameplayTestsEmptyPlaceholderId = 'gameplay-tests-placeholder';
const styles = {
listContainer: {
@@ -421,6 +433,7 @@ type Props = {|
...ExtensionTreeViewItemCallbacks,
...ExternalEventsTreeViewItemCallbacks,
...ExternalLayoutTreeViewItemCallbacks,
...GameplayTestTreeViewItemCallbacks,
onOpenResources: () => void,
onReloadEventsFunctionsExtensions: () => void,
isOpen: boolean,
@@ -456,14 +469,18 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
onDeleteExternalEvents,
onDeleteExternalLayout,
onDeleteEventsFunctionsExtension,
onDeleteGameplayTest,
onRenameLayout,
onRenameExternalEvents,
onRenameExternalLayout,
onRenameEventsFunctionsExtension,
onRenameGameplayTest,
onOpenLayout,
onOpenExternalEvents,
onOpenExternalLayout,
onOpenEventsFunctionsExtension,
onOpenGameplayTest,
onRunGameplayTest,
onOpenResources,
onReloadEventsFunctionsExtensions,
isOpen,
@@ -788,6 +805,35 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
[project, onProjectItemModified, editName, scrollToItem]
);
const addGameplayTest = React.useCallback(
(index: number, i18n: I18nType) => {
if (!project) return;
const newName = newNameGenerator(i18n._(t`Untitled test`), name =>
project.getTests().hasTestNamed(name)
);
const newTest = project.getTests().insertNewTest(newName, index + 1);
newTest.setSource(DEFAULT_GAMEPLAY_TEST_SOURCE);
onProjectItemModified();
const gameplayTestItemId = getGameplayTestTreeViewItemId(newTest);
if (treeViewRef.current) {
treeViewRef.current.openItems([
gameplayTestItemId,
gameplayTestsRootFolderId,
]);
}
// Scroll to the new test (after a new render was done).
setTimeout(() => {
scrollToItem(gameplayTestItemId);
}, 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(gameplayTestItemId);
},
[project, onProjectItemModified, editName, scrollToItem]
);
const addExternalLayout = React.useCallback(
(index: number, i18n: I18nType) => {
if (!project) return;
@@ -1034,13 +1080,50 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
]
);
const gameplayTestTreeViewItemProps = React.useMemo<?GameplayTestTreeViewItemProps>(
() =>
project
? {
project,
unsavedChanges,
preferences,
gdevelopTheme,
forceUpdate,
forceUpdateList,
showDeleteConfirmation,
editName,
scrollToItem,
onDeleteGameplayTest,
onRenameGameplayTest,
onOpenGameplayTest,
onRunGameplayTest,
}
: null,
[
project,
unsavedChanges,
preferences,
gdevelopTheme,
forceUpdate,
forceUpdateList,
showDeleteConfirmation,
editName,
scrollToItem,
onDeleteGameplayTest,
onRenameGameplayTest,
onOpenGameplayTest,
onRunGameplayTest,
]
);
const getTreeViewData = React.useCallback(
(i18n: I18nType): Array<TreeViewItem> => {
return !project ||
!sceneTreeViewItemProps ||
!extensionTreeViewItemProps ||
!externalEventsTreeViewItemProps ||
!externalLayoutTreeViewItemProps
!externalLayoutTreeViewItemProps ||
!gameplayTestTreeViewItemProps
? []
: [
{
@@ -1234,15 +1317,59 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
);
},
},
...(areGameplayTestsEnabled()
? [
{
isRoot: true,
content: new LabelTreeViewItemContent(
gameplayTestsRootFolderId,
i18n._(t`Gameplay tests`),
{
icon: <Add />,
label: i18n._(t`Add a gameplay test`),
click: () => {
const index =
project.getTests().getTestsCount() - 1;
addGameplayTest(index, i18n);
},
id: 'add-new-gameplay-test-button',
}
),
getChildren(i18n: I18nType): ?Array<TreeViewItem> {
if (project.getTests().getTestsCount() === 0) {
return [
new PlaceHolderTreeViewItem(
gameplayTestsEmptyPlaceholderId,
i18n._(t`Start by adding a new gameplay test.`)
),
];
}
return mapFor(
0,
project.getTests().getTestsCount(),
i =>
new LeafTreeViewItem(
new GameplayTestTreeViewItemContent(
project.getTests().getTestAt(i),
gameplayTestTreeViewItemProps
)
)
);
},
},
]
: []),
];
},
[
addExternalEvents,
addExternalLayout,
addGameplayTest,
addNewScene,
extensionTreeViewItemProps,
externalEventsTreeViewItemProps,
externalLayoutTreeViewItemProps,
gameplayTestTreeViewItemProps,
onOpenGamesDashboardDialog,
onOpenResources,
openProjectProperties,
@@ -1309,6 +1436,7 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
extensionsRootFolderId,
externalEventsRootFolderId,
externalLayoutsRootFolderId,
...(areGameplayTestsEnabled() ? [gameplayTestsRootFolderId] : []),
];
const [
@@ -0,0 +1,173 @@
// @flow
import * as React from 'react';
import { Column } from './Grid';
import Text from './Text';
import IconButton from './IconButton';
import Paper from './Paper';
import { Toolbar, ToolbarGroup } from './Toolbar';
import GDevelopThemeContext from './Theme/GDevelopThemeContext';
// Same metrics as the home page mobile menu (`HomePageMenuBar`), so the two
// bottom bars look and feel the same.
const iconSize = 24;
const iconButtonPadding = 4;
/**
* Padding bottom is bigger than padding top to leave space for the Android/iOS
* bottom navigation bar.
*/
const iconButtonMarginBottom = 12;
const iconButtonLabelSize = 20;
const bottomTabsHeight =
iconSize +
iconButtonLabelSize +
2 * iconButtonPadding +
iconButtonMarginBottom;
const styles = {
editorsContainer: {
display: 'flex',
flex: 1,
// Prevent a tall or wide editor (e.g. a code editor) from overflowing
// the tabs or the screen.
minHeight: 0,
minWidth: 0,
overflow: 'hidden',
position: 'relative',
},
editorContainer: {
display: 'flex',
flex: 1,
minWidth: 0,
overflow: 'hidden',
},
hiddenEditorContainer: {
display: 'none',
},
tabsContainer: {
width: '100%',
fontSize: iconSize,
height: bottomTabsHeight,
},
buttonContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
button: {
padding: iconButtonPadding,
marginBottom: iconButtonMarginBottom,
fontSize: 'inherit',
},
icon: {
display: 'flex',
justifyContent: 'center',
marginBottom: 2,
},
};
export type EditorBottomTab<TabName> = {|
value: TabName,
label: React.Node,
getIcon: (options: {| color: string, fontSize: string |}) => React.Node,
renderEditor: () => React.Node,
|};
type Props<TabName> = {|
tabs: Array<EditorBottomTab<TabName>>,
currentTab: TabName,
onChangeTab: TabName => void,
|};
/**
* Display one editor at a time, switched with icon+label tabs shown at the
* bottom (same design as the home page menu on mobile) for small screens,
* where editors can't be shown side by side (in an `EditorMosaic`). All
* editors stay mounted (only hidden), so their internal state (scroll
* position, cursor...) survives tab switches.
*/
const EditorBottomTabsSwitcher = <TabName: string>({
tabs,
currentTab,
onChangeTab,
}: Props<TabName>): React.Node => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<Column expand noMargin noOverflowParent>
<div style={styles.editorsContainer}>
{tabs.map(tab => (
<div
key={tab.value}
style={
tab.value === currentTab
? styles.editorContainer
: styles.hiddenEditorContainer
}
>
{tab.renderEditor()}
</div>
))}
</div>
<Paper
background="medium"
square
style={{
...styles.tabsContainer,
borderTop: `1px solid ${gdevelopTheme.home.separator.color}`,
}}
>
<Toolbar height={bottomTabsHeight}>
<ToolbarGroup spaceOut>
{tabs.map(tab => {
const isActive = tab.value === currentTab;
return (
<div
style={{
...styles.buttonContainer,
borderTop: `3px solid ${
isActive
? gdevelopTheme.iconButton.selectedBackgroundColor
: // Always keep the border so there's no layout shift.
'transparent'
}`,
...(!isActive
? { color: gdevelopTheme.text.color.secondary }
: {}),
}}
key={tab.value}
>
{/* $FlowFixMe[incompatible-type] */}
<IconButton
color="inherit"
disableRipple
disableFocusRipple
disableHover
style={styles.button}
onClick={() => {
onChangeTab(tab.value);
}}
selected={false}
>
<Column noMargin>
<span style={styles.icon}>
{tab.getIcon({
color: 'inherit',
fontSize: 'inherit',
})}
</span>
<Text size="body-small" color="inherit" noMargin>
{tab.label}
</Text>
</Column>
</IconButton>
</div>
);
})}
</ToolbarGroup>
</Toolbar>
</Paper>
</Column>
);
};
export default EditorBottomTabsSwitcher;
+1
View File
@@ -65,6 +65,7 @@ type ErrorBoundaryScope =
| 'extensions-search-dialog'
| 'external-events-editor'
| 'external-layout-editor'
| 'gameplay-test-editor-properties'
| 'variables-list'
| 'new-object-dialog'
| 'object-details'
@@ -52,6 +52,12 @@ export const Default = (): React.Node => (
hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps}
onWillInstallExtension={action('extension will be installed')}
onExtensionInstalled={action('extension installed')}
gameplayTestsCallbacks={{
onOpenGameplayTest: action('open gameplay test'),
onRenameGameplayTest: action('rename gameplay test'),
onDeleteGameplayTest: action('delete gameplay test'),
onRunGameplayTest: action('run gameplay test'),
}}
onEventBasedObjectTypeChanged={action(
'onEventBasedObjectTypeChanged'
)}
@@ -103,6 +109,12 @@ export const WithObjectEditor = (): React.Node => {
hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps}
onWillInstallExtension={action('extension will be installed')}
onExtensionInstalled={action('extension installed')}
gameplayTestsCallbacks={{
onOpenGameplayTest: action('open gameplay test'),
onRenameGameplayTest: action('rename gameplay test'),
onDeleteGameplayTest: action('delete gameplay test'),
onRunGameplayTest: action('run gameplay test'),
}}
onEventBasedObjectTypeChanged={action(
'onEventBasedObjectTypeChanged'
)}
@@ -37,6 +37,11 @@ export const Default = (): React.Node => (
onOpenCustomObjectEditor={action('onOpenCustomObjectEditor')}
onAddEventsBasedObject={cb => cb({ isRenderedIn3D: false })}
onEventBasedObjectTypeChanged={action('onEventBasedObjectTypeChanged')}
// Gameplay tests
onOpenGameplayTest={action('open gameplay test')}
onRenameGameplayTest={action('rename gameplay test')}
onDeleteGameplayTest={action('delete gameplay test')}
onRunGameplayTest={action('run gameplay test')}
// Behaviors
onSelectEventsBasedBehavior={action('behavior selected')}
onDeleteEventsBasedBehavior={action('behavior deleted')}
@@ -0,0 +1,138 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import {
GameplayTestFrameLayout,
type GameplayTestFrameRunStatus,
} from '../../../GameplayTests/GameplayTestFrame';
import Text from '../../../UI/Text';
import { Column } from '../../../UI/Grid';
import { getPaperDecorator } from '../../PaperDecorator';
import { type StoryDecorator } from '@storybook/react';
export default {
title: 'GameplayTests/GameplayTestFrame',
component: GameplayTestFrameLayout,
decorators: [(getPaperDecorator('dark'): StoryDecorator)],
};
const styles = {
storyContainer: { height: 460, position: 'relative' },
fakeGame: {
display: 'flex',
flex: 1,
alignItems: 'flex-end',
background: 'linear-gradient(to bottom, #4f28cd, #95c6ff)',
},
fakeGameGround: {
width: '100%',
height: 32,
backgroundColor: '#16cf89',
position: 'relative',
},
fakeGamePlayer: {
position: 'absolute',
bottom: 32,
left: 60,
width: 20,
height: 28,
borderRadius: 3,
backgroundColor: '#ffbc57',
},
};
/** A stand-in for the game preview iframe, which can't run in Storybook. */
const FakeGameView = () => (
<div style={styles.fakeGame}>
<div style={styles.fakeGameGround}>
<div style={styles.fakeGamePlayer} />
</div>
</div>
);
const makeRunStatus = (
partialRunStatus: Partial<GameplayTestFrameRunStatus>
): GameplayTestFrameRunStatus => ({
testName: 'PlayerCanCollectCoin',
status: 'running',
frame: 247,
durationMs: null,
testIndex: 0,
testsCount: 1,
...partialRunStatus,
});
const FrameStory = ({
runStatus,
initiallyMinimized,
}: {|
runStatus: GameplayTestFrameRunStatus | null,
initiallyMinimized?: boolean,
|}) => {
const [isMinimized, setIsMinimized] = React.useState<boolean>(
!!initiallyMinimized
);
return (
<div style={styles.storyContainer}>
<Column>
<Text>
The frame is displayed over the whole editor: drag its title bar to
move it around, and use the buttons to minimize the game or stop the
test.
</Text>
</Column>
<GameplayTestFrameLayout
runStatus={runStatus}
isMinimized={isMinimized}
onToggleMinimized={() => setIsMinimized(!isMinimized)}
onStopRequested={action('stop requested')}
>
<FakeGameView />
</GameplayTestFrameLayout>
</div>
);
};
export const Launching = (): React.Node => (
<FrameStory runStatus={makeRunStatus({ status: 'launching', frame: null })} />
);
export const Running = (): React.Node => (
<FrameStory runStatus={makeRunStatus({})} />
);
export const RunningABatchOfTests = (): React.Node => (
<FrameStory
runStatus={makeRunStatus({
testName: 'PlayerReachesTheExitOfTheFirstLevel',
frame: 1024,
testIndex: 2,
testsCount: 5,
})}
/>
);
export const Passed = (): React.Node => (
<FrameStory
runStatus={makeRunStatus({
status: 'passed',
frame: 320,
durationMs: 5423,
})}
/>
);
export const Failed = (): React.Node => (
<FrameStory
runStatus={makeRunStatus({
status: 'failed',
frame: 481,
durationMs: 8102,
})}
/>
);
export const Minimized = (): React.Node => (
<FrameStory runStatus={makeRunStatus({})} initiallyMinimized />
);
@@ -0,0 +1,71 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import paperDecorator from '../../PaperDecorator';
import { GameplayTestOutputPanel } from '../../../GameplayTests/GameplayTestOutputPanel';
import { ColumnStackLayout } from '../../../UI/Layout';
import Text from '../../../UI/Text';
import FixedWidthFlexContainer from '../../FixedWidthFlexContainer';
export default {
title: 'GameplayTests/GameplayTestOutputPanel',
component: GameplayTestOutputPanel,
decorators: [paperDecorator],
};
export const Default = (): React.Node => (
<FixedWidthFlexContainer width={290}>
<ColumnStackLayout>
<Text noMargin size="sub-title">
Errors
</Text>
<GameplayTestOutputPanel
canCopy
lines={[
{ level: 'error', message: 'Assertion failed: Score is 1' },
{ level: 'error', message: ' at gameplay test source (line 12)' },
]}
placeholder={<Trans>No error.</Trans>}
/>
<Text noMargin size="sub-title">
Console logs of the game
</Text>
<GameplayTestOutputPanel
canCopy
lines={[
{ level: 'log', message: 'Player spawned', prefix: 'frame 1' },
{
level: 'info',
message: 'Coin collected at 320;480',
prefix: 'frame 128',
},
{
level: 'warn',
message: 'Coin has no "Collectible" behavior',
prefix: 'frame 128',
},
{
level: 'error',
message:
'Uncaught TypeError: Cannot read property "value" of null, in a very long message that has to be wrapped on several lines to be fully readable.',
prefix: 'frame 130',
},
]}
placeholder={<Trans>The game did not log anything.</Trans>}
/>
<Text noMargin size="sub-title">
Empty
</Text>
<GameplayTestOutputPanel
lines={[]}
placeholder={
<Trans>
Everything logged by the game with `console.log` while the test runs
will be shown here.
</Trans>
}
/>
</ColumnStackLayout>
</FixedWidthFlexContainer>
);
@@ -0,0 +1,352 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { GameplayTestProperties } from '../../../GameplayTests/GameplayTestProperties';
import {
type GameplayTestResult,
type GameplayTestScope,
} from '../../../GameplayTests/GameplayTestRunner';
import Background from '../../../UI/Background';
import FixedHeightFlexContainer from '../../FixedHeightFlexContainer';
import FixedWidthFlexContainer from '../../FixedWidthFlexContainer';
export default {
title: 'GameplayTests/GameplayTestProperties',
component: GameplayTestProperties,
};
const screenshotLabels = ['Before the jump', 'After the jump'];
const testSource = `await harness.goToScene('Level1');
harness.setKeyPressed('Right', true);
await harness.stepFrames(60);
harness.assert(harness.getSceneVariable('Score') === 1, 'Score is 1');`;
/**
* A stand-in for a `gd.Test`: the tests of a project are only available in
* libGD.js, which is not what these stories are about (and the panel only
* reads/writes these fields).
*/
const makeFakeGameplayTest = ({
name,
description,
lastRunStatus,
lastRunAt,
lastRunDurationMs,
lastRunFramesExecuted,
}: {|
name: string,
description: string,
lastRunStatus?: string,
lastRunAt?: number,
lastRunDurationMs?: number,
lastRunFramesExecuted?: number,
|}): gdTest => {
let currentDescription = description;
let currentSource = testSource;
// $FlowFixMe[incompatible-cast] - only the methods used by the panel are faked.
return ({
getName: () => name,
getType: () => 'gameplay',
getDescription: () => currentDescription,
setDescription: (newDescription: string) => {
currentDescription = newDescription;
},
getSource: () => currentSource,
setSource: (newSource: string) => {
currentSource = newSource;
},
getLastRunStatus: () => lastRunStatus || '',
getLastRunAt: () =>
lastRunStatus ? lastRunAt || Date.now() - 5 * 60 * 1000 : 0,
getLastRunDurationMs: () => lastRunDurationMs || 0,
getLastRunFramesExecuted: () => lastRunFramesExecuted || 0,
}: any);
};
const makeResult = (
partialResult: Partial<GameplayTestResult>
): GameplayTestResult => ({
testName: 'PlayerCanCollectCoin',
status: 'passed',
framesExecuted: 0,
durationMs: 0,
gameTimeMs: 0,
assertions: [],
errors: [],
consoleLogs: [],
eventLog: [],
finalState: null,
screenshots: [],
profiles: [],
performance: null,
...partialResult,
});
/** Draw fake game screenshots, to show the screenshots section. */
const useFakeScreenshots = (labels: Array<string>) =>
React.useMemo(
() =>
labels.map((label, index) => {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext('2d');
const sky = context.createLinearGradient(0, 0, 0, 180);
sky.addColorStop(0, '#4f28cd');
sky.addColorStop(1, '#95c6ff');
context.fillStyle = sky;
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#16cf89';
context.fillRect(0, 140, 320, 40);
context.fillStyle = '#ffbc57';
context.fillRect(40 + index * 90, 110, 24, 30);
return {
label,
frame: 120 * (index + 1),
jpegBase64: canvas.toDataURL('image/jpeg', 0.7).split(',')[1],
};
}),
[labels]
);
const PropertiesPanelStory = ({
test,
scope,
isRunning,
runningFrame,
lastResult,
}: {|
test: gdTest,
scope?: GameplayTestScope,
isRunning?: boolean,
runningFrame?: number | null,
lastResult?: GameplayTestResult | null,
|}) => (
<FixedHeightFlexContainer height={560}>
<FixedWidthFlexContainer width={310}>
<Background>
<GameplayTestProperties
test={test}
scope={scope || { type: 'project' }}
isRunning={!!isRunning}
runningFrame={runningFrame || null}
lastResult={lastResult || null}
onRunTest={action('run test')}
onStopTest={action('stop test')}
onEditWithAi={action('edit with AI')}
onTestModified={action('test modified')}
/>
</Background>
</FixedWidthFlexContainer>
</FixedHeightFlexContainer>
);
const description =
'The player walks right and collects the first coin: the score is incremented.';
export const NeverRun = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({ name: 'PlayerCanCollectCoin', description: '' }),
[]
);
return <PropertiesPanelStory test={test} />;
};
export const Launching = (): React.Node => {
const test = React.useMemo(
() => makeFakeGameplayTest({ name: 'PlayerCanCollectCoin', description }),
[]
);
return <PropertiesPanelStory test={test} isRunning />;
};
export const Running = (): React.Node => {
const test = React.useMemo(
() => makeFakeGameplayTest({ name: 'PlayerCanCollectCoin', description }),
[]
);
return <PropertiesPanelStory test={test} isRunning runningFrame={247} />;
};
export const Passed = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'PlayerCanCollectCoin',
description,
lastRunStatus: 'passed',
lastRunDurationMs: 5423,
lastRunFramesExecuted: 320,
}),
[]
);
return (
<PropertiesPanelStory
test={test}
lastResult={makeResult({
status: 'passed',
durationMs: 5423,
framesExecuted: 320,
gameTimeMs: 5333,
assertions: [
{ message: 'Level1 is the current scene', passed: true },
{ message: 'The player is on a platform', passed: true },
{ message: 'Score is 1', passed: true },
],
consoleLogs: [
{ level: 'log', message: 'Coin collected!' },
{ level: 'log', message: 'Score is now 1' },
],
})}
/>
);
};
export const Failed = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'PlayerCanCollectCoin',
description,
lastRunStatus: 'failed',
lastRunDurationMs: 8102,
lastRunFramesExecuted: 481,
}),
[]
);
return (
<PropertiesPanelStory
test={test}
lastResult={makeResult({
status: 'failed',
durationMs: 8102,
framesExecuted: 481,
assertions: [
{ message: 'Level1 is the current scene', passed: true },
{ message: 'The player is on a platform', passed: true },
{ message: 'Score is 1', passed: false },
],
errors: [
'Assertion failed: Score is 1',
' at gameplay test source (line 12)',
],
consoleLogs: [
{ level: 'log', message: 'Player spawned at 32;480' },
{ level: 'warn', message: 'Coin has no "Collectible" behavior' },
{ level: 'error', message: 'Cannot read property "value" of null' },
],
})}
/>
);
};
export const FailedWithScreenshots = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'PlayerCanCollectCoin',
description,
lastRunStatus: 'failed',
lastRunDurationMs: 8102,
lastRunFramesExecuted: 481,
}),
[]
);
const screenshots = useFakeScreenshots(screenshotLabels);
return (
<PropertiesPanelStory
test={test}
lastResult={makeResult({
status: 'failed',
durationMs: 8102,
framesExecuted: 481,
assertions: [
{ message: 'Level1 is the current scene', passed: true },
{ message: 'Score is 1', passed: false },
],
errors: ['Assertion failed: Score is 1'],
screenshots,
})}
/>
);
};
export const ScriptError = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'PlayerCanCollectCoin',
description: 'This test has a broken script.',
lastRunStatus: 'error',
lastRunDurationMs: 312,
}),
[]
);
return (
<PropertiesPanelStory
test={test}
lastResult={makeResult({
status: 'error',
durationMs: 312,
framesExecuted: 0,
errors: [
'TypeError: harness.stepFrame is not a function',
' at gameplay test source (line 3)',
],
})}
/>
);
};
export const TimedOut = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'PlayerReachesTheExit',
description: 'The player walks right until the exit of the level.',
lastRunStatus: 'timeout',
lastRunDurationMs: 30000,
lastRunFramesExecuted: 1800,
}),
[]
);
return (
<PropertiesPanelStory
test={test}
lastResult={makeResult({
status: 'timeout',
durationMs: 30000,
framesExecuted: 1800,
assertions: [{ message: 'Level1 is the current scene', passed: true }],
errors: [
'The test did not finish within 30000ms: the game may be stuck.',
],
})}
/>
);
};
export const InExtensionAndRunInAPreviousSession = (): React.Node => {
const test = React.useMemo(
() =>
makeFakeGameplayTest({
name: 'HealthBarIsUpdated',
description: 'The health bar of the extension is updated when hit.',
lastRunStatus: 'passed',
lastRunAt: Date.now() - 3 * 24 * 3600 * 1000,
lastRunDurationMs: 1240,
lastRunFramesExecuted: 74,
}),
[]
);
return (
<PropertiesPanelStory
test={test}
scope={{ type: 'extension', extensionName: 'Health' }}
/>
);
};
@@ -0,0 +1,58 @@
// @flow
import * as React from 'react';
import paperDecorator from '../../PaperDecorator';
import {
GameplayTestStatusChip,
GameplayTestStatusIcon,
type GameplayTestDisplayStatus,
} from '../../../GameplayTests/GameplayTestStatusIndicator';
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
import Text from '../../../UI/Text';
export default {
title: 'GameplayTests/GameplayTestStatusIndicator',
component: GameplayTestStatusChip,
decorators: [paperDecorator],
};
const allStatuses: Array<GameplayTestDisplayStatus> = [
'never-run',
'launching',
'running',
'passed',
'failed',
'error',
'timeout',
'stopped',
];
export const AllStatuses = (): React.Node => (
<ColumnStackLayout>
{allStatuses.map(status => (
<LineStackLayout key={status} noMargin alignItems="center">
<GameplayTestStatusIcon status={status} />
<GameplayTestStatusChip status={status} />
<GameplayTestStatusChip status={status} size="small" />
<Text noMargin color="secondary" size="body-small">
{status}
</Text>
</LineStackLayout>
))}
</ColumnStackLayout>
);
export const WithDetails = (): React.Node => (
<ColumnStackLayout>
<LineStackLayout noMargin alignItems="center">
<GameplayTestStatusChip status="running" details="frame 247" />
</LineStackLayout>
<LineStackLayout noMargin alignItems="center">
<GameplayTestStatusChip
status="passed"
size="small"
details="320 frames in 5.42s"
/>
</LineStackLayout>
</ColumnStackLayout>
);
@@ -44,6 +44,7 @@ const fakeEditorToolbar = (
const defaultProps: MainFrameToolbarProps = {
showProjectButtons: true,
showPreviewAndShareButtons: true,
openShareDialog: () => {},
isSharingEnabled: true,
hidden: false,
@@ -70,6 +70,10 @@ export const NoProjectOpen = (): React.Node => {
'onDeleteEventsFunctionsExtension'
)}
onDeleteExternalEvents={action('onDeleteExternalEvents')}
onDeleteGameplayTest={action('onDeleteGameplayTest')}
onRenameGameplayTest={action('onRenameGameplayTest')}
onOpenGameplayTest={action('onOpenGameplayTest')}
onRunGameplayTest={action('onRunGameplayTest')}
onRenameLayout={action('onRenameLayout')}
onRenameExternalLayout={action('onRenameExternalLayout')}
onRenameEventsFunctionsExtension={action(
@@ -146,6 +150,10 @@ export const ProjectOpen = (): React.Node => {
'onDeleteEventsFunctionsExtension'
)}
onDeleteExternalEvents={action('onDeleteExternalEvents')}
onDeleteGameplayTest={action('onDeleteGameplayTest')}
onRenameGameplayTest={action('onRenameGameplayTest')}
onOpenGameplayTest={action('onOpenGameplayTest')}
onRunGameplayTest={action('onRunGameplayTest')}
onRenameLayout={action('onRenameLayout')}
onRenameExternalLayout={action('onRenameExternalLayout')}
onRenameEventsFunctionsExtension={action(