Path: blob/master/modules/gdscript/tests/gdscript_test_runner.cpp
10278 views
/**************************************************************************/1/* gdscript_test_runner.cpp */2/**************************************************************************/3/* This file is part of: */4/* GODOT ENGINE */5/* https://godotengine.org */6/**************************************************************************/7/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */8/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */9/* */10/* Permission is hereby granted, free of charge, to any person obtaining */11/* a copy of this software and associated documentation files (the */12/* "Software"), to deal in the Software without restriction, including */13/* without limitation the rights to use, copy, modify, merge, publish, */14/* distribute, sublicense, and/or sell copies of the Software, and to */15/* permit persons to whom the Software is furnished to do so, subject to */16/* the following conditions: */17/* */18/* The above copyright notice and this permission notice shall be */19/* included in all copies or substantial portions of the Software. */20/* */21/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */22/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */23/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */24/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */25/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */26/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */27/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */28/**************************************************************************/2930#include "gdscript_test_runner.h"3132#include "../gdscript.h"33#include "../gdscript_analyzer.h"34#include "../gdscript_compiler.h"35#include "../gdscript_parser.h"36#include "../gdscript_tokenizer_buffer.h"3738#include "core/config/project_settings.h"39#include "core/core_globals.h"40#include "core/io/dir_access.h"41#include "core/io/file_access_pack.h"42#include "core/os/os.h"43#include "core/string/string_builder.h"44#include "scene/resources/packed_scene.h"4546#include "tests/test_macros.h"4748namespace GDScriptTests {4950void init_autoloads() {51HashMap<StringName, ProjectSettings::AutoloadInfo> autoloads = ProjectSettings::get_singleton()->get_autoload_list();5253// First pass, add the constants so they exist before any script is loaded.54for (const KeyValue<StringName, ProjectSettings::AutoloadInfo> &E : ProjectSettings::get_singleton()->get_autoload_list()) {55const ProjectSettings::AutoloadInfo &info = E.value;5657if (info.is_singleton) {58for (int i = 0; i < ScriptServer::get_language_count(); i++) {59ScriptServer::get_language(i)->add_global_constant(info.name, Variant());60}61}62}6364// Second pass, load into global constants.65for (const KeyValue<StringName, ProjectSettings::AutoloadInfo> &E : ProjectSettings::get_singleton()->get_autoload_list()) {66const ProjectSettings::AutoloadInfo &info = E.value;6768if (!info.is_singleton) {69// Skip non-singletons since we don't have a scene tree here anyway.70continue;71}7273Node *n = nullptr;74if (ResourceLoader::get_resource_type(info.path) == "PackedScene") {75// Cache the scene reference before loading it (for cyclic references)76Ref<PackedScene> scn;77scn.instantiate();78scn->set_path(info.path);79scn->reload_from_file();80ERR_CONTINUE_MSG(scn.is_null(), vformat("Failed to instantiate an autoload, can't load from path: %s.", info.path));8182if (scn.is_valid()) {83n = scn->instantiate();84}85} else {86Ref<Resource> res = ResourceLoader::load(info.path);87ERR_CONTINUE_MSG(res.is_null(), vformat("Failed to instantiate an autoload, can't load from path: %s.", info.path));8889Ref<Script> scr = res;90if (scr.is_valid()) {91StringName ibt = scr->get_instance_base_type();92bool valid_type = ClassDB::is_parent_class(ibt, "Node");93ERR_CONTINUE_MSG(!valid_type, vformat("Failed to instantiate an autoload, script '%s' does not inherit from 'Node'.", info.path));9495Object *obj = ClassDB::instantiate(ibt);96ERR_CONTINUE_MSG(!obj, vformat("Failed to instantiate an autoload, cannot instantiate '%s'.", ibt));9798n = Object::cast_to<Node>(obj);99n->set_script(scr);100}101}102103ERR_CONTINUE_MSG(!n, vformat("Failed to instantiate an autoload, path is not pointing to a scene or a script: %s.", info.path));104n->set_name(info.name);105106for (int i = 0; i < ScriptServer::get_language_count(); i++) {107ScriptServer::get_language(i)->add_global_constant(info.name, n);108}109}110}111112void init_language(const String &p_base_path) {113// Setup project settings since it's needed by the languages to get the global scripts.114// This also sets up the base resource path.115Error err = ProjectSettings::get_singleton()->setup(p_base_path, String(), true);116if (err) {117print_line("Could not load project settings.");118// Keep going since some scripts still work without this.119}120121// Initialize the language for the test routine.122GDScriptLanguage::get_singleton()->init();123init_autoloads();124}125126void finish_language() {127GDScriptLanguage::get_singleton()->finish();128ScriptServer::global_classes_clear();129}130131StringName GDScriptTestRunner::test_function_name;132133GDScriptTestRunner::GDScriptTestRunner(const String &p_source_dir, bool p_init_language, bool p_print_filenames, bool p_use_binary_tokens) {134test_function_name = StringName("test");135do_init_languages = p_init_language;136print_filenames = p_print_filenames;137binary_tokens = p_use_binary_tokens;138139source_dir = p_source_dir;140if (!source_dir.ends_with("/")) {141source_dir += "/";142}143144if (do_init_languages) {145init_language(p_source_dir);146}147#ifdef DEBUG_ENABLED148// Set all warning levels to "Warn" in order to test them properly, even the ones that default to error.149ProjectSettings::get_singleton()->set_setting("debug/gdscript/warnings/enable", true);150for (int i = 0; i < (int)GDScriptWarning::WARNING_MAX; i++) {151if (i == GDScriptWarning::UNTYPED_DECLARATION || i == GDScriptWarning::INFERRED_DECLARATION) {152// TODO: Add ability for test scripts to specify which warnings to enable/disable for testing.153continue;154}155String warning_setting = GDScriptWarning::get_settings_path_from_code((GDScriptWarning::Code)i);156ProjectSettings::get_singleton()->set_setting(warning_setting, (int)GDScriptWarning::WARN);157}158#endif159160// Enable printing to show results161CoreGlobals::print_line_enabled = true;162CoreGlobals::print_error_enabled = true;163}164165GDScriptTestRunner::~GDScriptTestRunner() {166test_function_name = StringName();167if (do_init_languages) {168finish_language();169}170}171172#ifndef DEBUG_ENABLED173static String strip_warnings(const String &p_expected) {174// On release builds we don't have warnings. Here we remove them from the output before comparison175// so it doesn't fail just because of difference in warnings.176String expected_no_warnings;177for (String line : p_expected.split("\n")) {178if (line.begins_with("~~ ")) {179continue;180}181expected_no_warnings += line + "\n";182}183return expected_no_warnings.strip_edges() + "\n";184}185#endif186187int GDScriptTestRunner::run_tests() {188if (!make_tests()) {189FAIL("An error occurred while making the tests.");190return -1;191}192193if (!generate_class_index()) {194FAIL("An error occurred while generating class index.");195return -1;196}197198int failed = 0;199for (int i = 0; i < tests.size(); i++) {200GDScriptTest test = tests[i];201if (print_filenames) {202print_line(test.get_source_relative_filepath());203}204GDScriptTest::TestResult result = test.run_test();205206String expected = FileAccess::get_file_as_string(test.get_output_file());207#ifndef DEBUG_ENABLED208expected = strip_warnings(expected);209#endif210INFO(test.get_source_file());211if (!result.passed) {212INFO(expected);213failed++;214}215216CHECK_MESSAGE(result.passed, (result.passed ? String() : result.output));217}218219return failed;220}221222bool GDScriptTestRunner::generate_outputs() {223is_generating = true;224225if (!make_tests()) {226print_line("Failed to generate a test output.");227return false;228}229230if (!generate_class_index()) {231return false;232}233234for (int i = 0; i < tests.size(); i++) {235GDScriptTest test = tests[i];236if (print_filenames) {237print_line(test.get_source_relative_filepath());238} else {239OS::get_singleton()->print(".");240}241242bool result = test.generate_output();243244if (!result) {245print_line("\nCould not generate output for " + test.get_source_file());246return false;247}248}249print_line("\nGenerated output files for " + itos(tests.size()) + " tests successfully.");250251return true;252}253254bool GDScriptTestRunner::make_tests_for_dir(const String &p_dir) {255Error err = OK;256Ref<DirAccess> dir(DirAccess::open(p_dir, &err));257258if (err != OK) {259return false;260}261262String current_dir = dir->get_current_dir();263264dir->list_dir_begin();265String next = dir->get_next();266267while (!next.is_empty()) {268if (dir->current_is_dir()) {269if (next == "." || next == ".." || next == "completion" || next == "lsp") {270next = dir->get_next();271continue;272}273if (!make_tests_for_dir(current_dir.path_join(next))) {274return false;275}276} else {277// `*.notest.gd` files are skipped.278if (next.ends_with(".notest.gd")) {279next = dir->get_next();280continue;281} else if (binary_tokens && next.ends_with(".textonly.gd")) {282next = dir->get_next();283continue;284} else if (next.get_extension().to_lower() == "gd") {285#ifndef DEBUG_ENABLED286// On release builds, skip tests marked as debug only.287Error open_err = OK;288Ref<FileAccess> script_file(FileAccess::open(current_dir.path_join(next), FileAccess::READ, &open_err));289if (open_err != OK) {290ERR_PRINT(vformat(R"(Couldn't open test file "%s".)", next));291next = dir->get_next();292continue;293} else {294if (script_file->get_line() == "#debug-only") {295next = dir->get_next();296continue;297}298}299#endif300301String out_file = next.get_basename() + ".out";302ERR_FAIL_COND_V_MSG(!is_generating && !dir->file_exists(out_file), false, "Could not find output file for " + next);303304if (next.ends_with(".bin.gd")) {305// Test text mode first.306GDScriptTest text_test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);307tests.push_back(text_test);308// Test binary mode even without `--use-binary-tokens`.309GDScriptTest bin_test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);310bin_test.set_tokenizer_mode(GDScriptTest::TOKENIZER_BUFFER);311tests.push_back(bin_test);312} else {313GDScriptTest test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);314if (binary_tokens) {315test.set_tokenizer_mode(GDScriptTest::TOKENIZER_BUFFER);316}317tests.push_back(test);318}319}320}321322next = dir->get_next();323}324325dir->list_dir_end();326327return true;328}329330bool GDScriptTestRunner::make_tests() {331Error err = OK;332Ref<DirAccess> dir(DirAccess::open(source_dir, &err));333334ERR_FAIL_COND_V_MSG(err != OK, false, "Could not open specified test directory.");335336source_dir = dir->get_current_dir() + "/"; // Make it absolute path.337return make_tests_for_dir(dir->get_current_dir());338}339340static bool generate_class_index_recursive(const String &p_dir) {341Error err = OK;342Ref<DirAccess> dir(DirAccess::open(p_dir, &err));343344if (err != OK) {345return false;346}347348String current_dir = dir->get_current_dir();349350dir->list_dir_begin();351String next = dir->get_next();352353StringName gdscript_name = GDScriptLanguage::get_singleton()->get_name();354while (!next.is_empty()) {355if (dir->current_is_dir()) {356if (next == "." || next == ".." || next == "completion" || next == "lsp") {357next = dir->get_next();358continue;359}360if (!generate_class_index_recursive(current_dir.path_join(next))) {361return false;362}363} else {364if (!next.ends_with(".gd")) {365next = dir->get_next();366continue;367}368String base_type;369String source_file = current_dir.path_join(next);370bool is_abstract = false;371bool is_tool = false;372String class_name = GDScriptLanguage::get_singleton()->get_global_class_name(source_file, &base_type, nullptr, &is_abstract, &is_tool);373if (class_name.is_empty()) {374next = dir->get_next();375continue;376}377ERR_FAIL_COND_V_MSG(ScriptServer::is_global_class(class_name), false,378"Class name '" + class_name + "' from " + source_file + " is already used in " + ScriptServer::get_global_class_path(class_name));379380ScriptServer::add_global_class(class_name, base_type, gdscript_name, source_file, is_abstract, is_tool);381}382383next = dir->get_next();384}385386dir->list_dir_end();387388return true;389}390391bool GDScriptTestRunner::generate_class_index() {392Error err = OK;393Ref<DirAccess> dir(DirAccess::open(source_dir, &err));394395ERR_FAIL_COND_V_MSG(err != OK, false, "Could not open specified test directory.");396397source_dir = dir->get_current_dir() + "/"; // Make it absolute path.398return generate_class_index_recursive(dir->get_current_dir());399}400401GDScriptTest::GDScriptTest(const String &p_source_path, const String &p_output_path, const String &p_base_dir) {402source_file = p_source_path;403output_file = p_output_path;404base_dir = p_base_dir;405_print_handler.printfunc = print_handler;406_error_handler.errfunc = error_handler;407}408409void GDScriptTestRunner::handle_cmdline() {410List<String> cmdline_args = OS::get_singleton()->get_cmdline_args();411412for (List<String>::Element *E = cmdline_args.front(); E; E = E->next()) {413String &cmd = E->get();414if (cmd == "--gdscript-generate-tests") {415String path;416if (E->next()) {417path = E->next()->get();418} else {419path = "modules/gdscript/tests/scripts";420}421422GDScriptTestRunner runner(path, false, cmdline_args.find("--print-filenames") != nullptr);423424bool completed = runner.generate_outputs();425int failed = completed ? 0 : -1;426exit(failed);427}428}429}430431void GDScriptTest::enable_stdout() {432// TODO: this could likely be handled by doctest or `tests/test_macros.h`.433OS::get_singleton()->set_stdout_enabled(true);434OS::get_singleton()->set_stderr_enabled(true);435}436437void GDScriptTest::disable_stdout() {438// TODO: this could likely be handled by doctest or `tests/test_macros.h`.439OS::get_singleton()->set_stdout_enabled(false);440OS::get_singleton()->set_stderr_enabled(false);441}442443void GDScriptTest::print_handler(void *p_this, const String &p_message, bool p_error, bool p_rich) {444TestResult *result = (TestResult *)p_this;445result->output += p_message + "\n";446}447448void GDScriptTest::error_handler(void *p_this, const char *p_function, const char *p_file, int p_line, const char *p_error, const char *p_explanation, bool p_editor_notify, ErrorHandlerType p_type) {449ErrorHandlerData *data = (ErrorHandlerData *)p_this;450GDScriptTest *self = data->self;451TestResult *result = data->result;452453result->status = GDTEST_RUNTIME_ERROR;454455String header = _error_handler_type_string(p_type);456457// Only include the file, line, and function for script errors,458// otherwise the test outputs changes based on the platform/compiler.459if (p_type == ERR_HANDLER_SCRIPT) {460header += vformat(" at %s:%d on %s()",461String::utf8(p_file).trim_prefix(self->base_dir).replace_char('\\', '/'),462p_line,463String::utf8(p_function));464}465466StringBuilder error_string;467error_string.append(vformat(">> %s: %s\n", header, String::utf8(p_error)));468if (strlen(p_explanation) > 0) {469error_string.append(vformat(">> %s\n", String::utf8(p_explanation)));470}471472result->output += error_string.as_string();473}474475bool GDScriptTest::check_output(const String &p_output) const {476Error err = OK;477String expected = FileAccess::get_file_as_string(output_file, &err);478479ERR_FAIL_COND_V_MSG(err != OK, false, "Error when opening the output file.");480481String got = p_output.strip_edges(); // TODO: may be hacky.482got += "\n"; // Make sure to insert newline for CI static checks.483484#ifndef DEBUG_ENABLED485expected = strip_warnings(expected);486#endif487488return got == expected;489}490491String GDScriptTest::get_text_for_status(GDScriptTest::TestStatus p_status) const {492switch (p_status) {493case GDTEST_OK:494return "GDTEST_OK";495case GDTEST_LOAD_ERROR:496return "GDTEST_LOAD_ERROR";497case GDTEST_PARSER_ERROR:498return "GDTEST_PARSER_ERROR";499case GDTEST_ANALYZER_ERROR:500return "GDTEST_ANALYZER_ERROR";501case GDTEST_COMPILER_ERROR:502return "GDTEST_COMPILER_ERROR";503case GDTEST_RUNTIME_ERROR:504return "GDTEST_RUNTIME_ERROR";505}506return "";507}508509GDScriptTest::TestResult GDScriptTest::execute_test_code(bool p_is_generating) {510disable_stdout();511512TestResult result;513result.status = GDTEST_OK;514result.output = String();515result.passed = false;516517Error err = OK;518519// Create script.520Ref<GDScript> script;521script.instantiate();522script->set_path(source_file);523if (tokenizer_mode == TOKENIZER_TEXT) {524err = script->load_source_code(source_file);525} else {526String code = FileAccess::get_file_as_string(source_file, &err);527if (!err) {528Vector<uint8_t> buffer = GDScriptTokenizerBuffer::parse_code_string(code, GDScriptTokenizerBuffer::COMPRESS_ZSTD);529script->set_binary_tokens_source(buffer);530}531}532if (err != OK) {533enable_stdout();534result.status = GDTEST_LOAD_ERROR;535result.passed = false;536ERR_FAIL_V_MSG(result, "\nCould not load source code for: '" + source_file + "'");537}538539// Test parsing.540GDScriptParser parser;541if (tokenizer_mode == TOKENIZER_TEXT) {542err = parser.parse(script->get_source_code(), source_file, false);543} else {544err = parser.parse_binary(script->get_binary_tokens_source(), source_file);545}546if (err != OK) {547enable_stdout();548result.status = GDTEST_PARSER_ERROR;549result.output = get_text_for_status(result.status) + "\n";550551const List<GDScriptParser::ParserError> &errors = parser.get_errors();552if (!errors.is_empty()) {553// Only the first error since the following might be cascading.554result.output += errors.front()->get().message + "\n"; // TODO: line, column?555}556if (!p_is_generating) {557result.passed = check_output(result.output);558}559return result;560}561562// Test type-checking.563GDScriptAnalyzer analyzer(&parser);564err = analyzer.analyze();565if (err != OK) {566enable_stdout();567result.status = GDTEST_ANALYZER_ERROR;568result.output = get_text_for_status(result.status) + "\n";569570StringBuilder error_string;571for (const GDScriptParser::ParserError &error : parser.get_errors()) {572error_string.append(vformat(">> ERROR at line %d: %s\n", error.line, error.message));573}574result.output += error_string.as_string();575if (!p_is_generating) {576result.passed = check_output(result.output);577}578return result;579}580581#ifdef DEBUG_ENABLED582StringBuilder warning_string;583for (const GDScriptWarning &warning : parser.get_warnings()) {584warning_string.append(vformat("~~ WARNING at line %d: (%s) %s\n", warning.start_line, warning.get_name(), warning.get_message()));585}586result.output += warning_string.as_string();587#endif588589// Test compiling.590GDScriptCompiler compiler;591err = compiler.compile(&parser, script.ptr(), false);592if (err != OK) {593enable_stdout();594result.status = GDTEST_COMPILER_ERROR;595result.output = get_text_for_status(result.status) + "\n";596result.output += compiler.get_error() + "\n";597if (!p_is_generating) {598result.passed = check_output(result.output);599}600return result;601}602603// `*.norun.gd` files are allowed to not contain a `test()` function (no runtime testing).604if (source_file.ends_with(".norun.gd")) {605enable_stdout();606result.status = GDTEST_OK;607result.output = get_text_for_status(result.status) + "\n" + result.output;608if (!p_is_generating) {609result.passed = check_output(result.output);610}611return result;612}613614// Test running.615const HashMap<StringName, GDScriptFunction *>::ConstIterator test_function_element = script->get_member_functions().find(GDScriptTestRunner::test_function_name);616if (!test_function_element) {617enable_stdout();618result.status = GDTEST_LOAD_ERROR;619result.output = "";620result.passed = false;621ERR_FAIL_V_MSG(result, "\nCould not find test function on: '" + source_file + "'");622}623624// Setup output handlers.625ErrorHandlerData error_data(&result, this);626627_print_handler.userdata = &result;628_error_handler.userdata = &error_data;629add_print_handler(&_print_handler);630add_error_handler(&_error_handler);631632err = script->reload();633if (err) {634enable_stdout();635result.status = GDTEST_LOAD_ERROR;636result.output = "";637result.passed = false;638remove_print_handler(&_print_handler);639remove_error_handler(&_error_handler);640ERR_FAIL_V_MSG(result, "\nCould not reload script: '" + source_file + "'");641}642643// Create object instance for test.644Object *obj = ClassDB::instantiate(script->get_native()->get_name());645Ref<RefCounted> obj_ref;646if (obj->is_ref_counted()) {647obj_ref = Ref<RefCounted>(Object::cast_to<RefCounted>(obj));648}649obj->set_script(script);650GDScriptInstance *instance = static_cast<GDScriptInstance *>(obj->get_script_instance());651652// Call test function.653Callable::CallError call_err;654instance->callp(GDScriptTestRunner::test_function_name, nullptr, 0, call_err);655656// Tear down output handlers.657remove_print_handler(&_print_handler);658remove_error_handler(&_error_handler);659660// Check results.661if (call_err.error != Callable::CallError::CALL_OK) {662enable_stdout();663result.status = GDTEST_LOAD_ERROR;664result.passed = false;665ERR_FAIL_V_MSG(result, "\nCould not call test function on: '" + source_file + "'");666}667668result.output = get_text_for_status(result.status) + "\n" + result.output;669if (!p_is_generating) {670result.passed = check_output(result.output);671}672673if (obj_ref.is_null()) {674memdelete(obj);675}676677enable_stdout();678679GDScriptCache::remove_script(script->get_path());680681return result;682}683684GDScriptTest::TestResult GDScriptTest::run_test() {685return execute_test_code(false);686}687688bool GDScriptTest::generate_output() {689TestResult result = execute_test_code(true);690if (result.status == GDTEST_LOAD_ERROR) {691return false;692}693694Error err = OK;695Ref<FileAccess> out_file = FileAccess::open(output_file, FileAccess::WRITE, &err);696if (err != OK) {697return false;698}699700String output = result.output.strip_edges(); // TODO: may be hacky.701output += "\n"; // Make sure to insert newline for CI static checks.702703out_file->store_string(output);704705return true;706}707708} // namespace GDScriptTests709710711