Path: blob/master/modules/gdscript/tests/gdscript_test_runner.cpp
20836 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(ResourceUID::ensure_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}147148#ifdef DEBUG_ENABLED149// Set all warning levels to "Warn" in order to test them properly, even the ones that default to error.150ProjectSettings::get_singleton()->set_setting("debug/gdscript/warnings/enable", true);151for (int i = 0; i < (int)GDScriptWarning::WARNING_MAX; i++) {152if (i == GDScriptWarning::UNTYPED_DECLARATION || i == GDScriptWarning::INFERRED_DECLARATION) {153// TODO: Add ability for test scripts to specify which warnings to enable/disable for testing.154continue;155}156const String setting_path = GDScriptWarning::get_setting_path_from_code((GDScriptWarning::Code)i);157ProjectSettings::get_singleton()->set_setting(setting_path, (int)GDScriptWarning::WARN);158}159160// Force the call, since the language is initialized **before** applying project settings161// and the `settings_changed` signal is emitted with `call_deferred()`.162GDScriptParser::update_project_settings();163#endif // DEBUG_ENABLED164165// Enable printing to show results.166CoreGlobals::print_line_enabled = true;167CoreGlobals::print_error_enabled = true;168}169170GDScriptTestRunner::~GDScriptTestRunner() {171test_function_name = StringName();172if (do_init_languages) {173finish_language();174}175}176177#ifndef DEBUG_ENABLED178static String strip_warnings(const String &p_expected) {179// On release builds we don't have warnings. Here we remove them from the output before comparison180// so it doesn't fail just because of difference in warnings.181String expected_no_warnings;182for (String line : p_expected.split("\n")) {183if (line.begins_with("~~ ")) {184continue;185}186expected_no_warnings += line + "\n";187}188return expected_no_warnings.strip_edges() + "\n";189}190#endif191192int GDScriptTestRunner::run_tests() {193if (!make_tests()) {194FAIL("An error occurred while making the tests.");195return -1;196}197198if (!generate_class_index()) {199FAIL("An error occurred while generating class index.");200return -1;201}202203int failed = 0;204for (int i = 0; i < tests.size(); i++) {205GDScriptTest test = tests[i];206if (print_filenames) {207print_line(test.get_source_relative_filepath());208}209GDScriptTest::TestResult result = test.run_test();210211String expected = FileAccess::get_file_as_string(test.get_output_file());212#ifndef DEBUG_ENABLED213expected = strip_warnings(expected);214#endif215INFO(test.get_source_file());216if (!result.passed) {217INFO(expected);218failed++;219}220221CHECK_MESSAGE(result.passed, (result.passed ? String() : result.output));222}223224return failed;225}226227bool GDScriptTestRunner::generate_outputs() {228is_generating = true;229230if (!make_tests()) {231print_line("Failed to generate a test output.");232return false;233}234235if (!generate_class_index()) {236return false;237}238239for (int i = 0; i < tests.size(); i++) {240GDScriptTest test = tests[i];241if (print_filenames) {242print_line(test.get_source_relative_filepath());243} else {244OS::get_singleton()->print(".");245}246247bool result = test.generate_output();248249if (!result) {250print_line("\nCould not generate output for " + test.get_source_file());251return false;252}253}254print_line("\nGenerated output files for " + itos(tests.size()) + " tests successfully.");255256return true;257}258259bool GDScriptTestRunner::make_tests_for_dir(const String &p_dir) {260Error err = OK;261Ref<DirAccess> dir(DirAccess::open(p_dir, &err));262263if (err != OK) {264return false;265}266267String current_dir = dir->get_current_dir();268269dir->list_dir_begin();270String next = dir->get_next();271272while (!next.is_empty()) {273if (dir->current_is_dir()) {274if (next == "." || next == ".." || next == "completion" || next == "lsp") {275next = dir->get_next();276continue;277}278if (!make_tests_for_dir(current_dir.path_join(next))) {279return false;280}281} else {282// `*.notest.gd` files are skipped.283if (next.ends_with(".notest.gd")) {284next = dir->get_next();285continue;286} else if (binary_tokens && next.ends_with(".textonly.gd")) {287next = dir->get_next();288continue;289} else if (next.has_extension("gd")) {290#ifndef DEBUG_ENABLED291// On release builds, skip tests marked as debug only.292Error open_err = OK;293Ref<FileAccess> script_file(FileAccess::open(current_dir.path_join(next), FileAccess::READ, &open_err));294if (open_err != OK) {295ERR_PRINT(vformat(R"(Couldn't open test file "%s".)", next));296next = dir->get_next();297continue;298} else {299if (script_file->get_line() == "#debug-only") {300next = dir->get_next();301continue;302}303}304#endif305306String out_file = next.get_basename() + ".out";307ERR_FAIL_COND_V_MSG(!is_generating && !dir->file_exists(out_file), false, "Could not find output file for " + next);308309if (next.ends_with(".bin.gd")) {310// Test text mode first.311GDScriptTest text_test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);312tests.push_back(text_test);313// Test binary mode even without `--use-binary-tokens`.314GDScriptTest bin_test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);315bin_test.set_tokenizer_mode(GDScriptTest::TOKENIZER_BUFFER);316tests.push_back(bin_test);317} else {318GDScriptTest test(current_dir.path_join(next), current_dir.path_join(out_file), source_dir);319if (binary_tokens) {320test.set_tokenizer_mode(GDScriptTest::TOKENIZER_BUFFER);321}322tests.push_back(test);323}324}325}326327next = dir->get_next();328}329330dir->list_dir_end();331332return true;333}334335bool GDScriptTestRunner::make_tests() {336Error err = OK;337Ref<DirAccess> dir(DirAccess::open(source_dir, &err));338339ERR_FAIL_COND_V_MSG(err != OK, false, "Could not open specified test directory.");340341source_dir = dir->get_current_dir() + "/"; // Make it absolute path.342return make_tests_for_dir(dir->get_current_dir());343}344345static bool generate_class_index_recursive(const String &p_dir) {346Error err = OK;347Ref<DirAccess> dir(DirAccess::open(p_dir, &err));348349if (err != OK) {350return false;351}352353String current_dir = dir->get_current_dir();354355dir->list_dir_begin();356String next = dir->get_next();357358StringName gdscript_name = GDScriptLanguage::get_singleton()->get_name();359while (!next.is_empty()) {360if (dir->current_is_dir()) {361if (next == "." || next == ".." || next == "completion" || next == "lsp") {362next = dir->get_next();363continue;364}365if (!generate_class_index_recursive(current_dir.path_join(next))) {366return false;367}368} else {369if (!next.ends_with(".gd")) {370next = dir->get_next();371continue;372}373String base_type;374String source_file = current_dir.path_join(next);375bool is_abstract = false;376bool is_tool = false;377String class_name = GDScriptLanguage::get_singleton()->get_global_class_name(source_file, &base_type, nullptr, &is_abstract, &is_tool);378if (class_name.is_empty()) {379next = dir->get_next();380continue;381}382ERR_FAIL_COND_V_MSG(ScriptServer::is_global_class(class_name), false,383"Class name '" + class_name + "' from " + source_file + " is already used in " + ScriptServer::get_global_class_path(class_name));384385ScriptServer::add_global_class(class_name, base_type, gdscript_name, source_file, is_abstract, is_tool);386}387388next = dir->get_next();389}390391dir->list_dir_end();392393return true;394}395396bool GDScriptTestRunner::generate_class_index() {397Error err = OK;398Ref<DirAccess> dir(DirAccess::open(source_dir, &err));399400ERR_FAIL_COND_V_MSG(err != OK, false, "Could not open specified test directory.");401402source_dir = dir->get_current_dir() + "/"; // Make it absolute path.403return generate_class_index_recursive(dir->get_current_dir());404}405406GDScriptTest::GDScriptTest(const String &p_source_path, const String &p_output_path, const String &p_base_dir) {407source_file = p_source_path;408output_file = p_output_path;409base_dir = p_base_dir;410_print_handler.printfunc = print_handler;411_error_handler.errfunc = error_handler;412}413414void GDScriptTestRunner::handle_cmdline() {415List<String> cmdline_args = OS::get_singleton()->get_cmdline_args();416417for (List<String>::Element *E = cmdline_args.front(); E; E = E->next()) {418String &cmd = E->get();419if (cmd == "--gdscript-generate-tests") {420String path;421if (E->next()) {422path = E->next()->get();423} else {424path = "modules/gdscript/tests/scripts";425}426427GDScriptTestRunner runner(path, false, cmdline_args.find("--print-filenames") != nullptr);428429bool completed = runner.generate_outputs();430int failed = completed ? 0 : -1;431exit(failed);432}433}434}435436void GDScriptTest::enable_stdout() {437// TODO: this could likely be handled by doctest or `tests/test_macros.h`.438OS::get_singleton()->set_stdout_enabled(true);439OS::get_singleton()->set_stderr_enabled(true);440}441442void GDScriptTest::disable_stdout() {443// TODO: this could likely be handled by doctest or `tests/test_macros.h`.444OS::get_singleton()->set_stdout_enabled(false);445OS::get_singleton()->set_stderr_enabled(false);446}447448void GDScriptTest::print_handler(void *p_this, const String &p_message, bool p_error, bool p_rich) {449TestResult *result = (TestResult *)p_this;450result->output += p_message + "\n";451}452453void 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) {454ErrorHandlerData *data = (ErrorHandlerData *)p_this;455GDScriptTest *self = data->self;456TestResult *result = data->result;457458result->status = GDTEST_RUNTIME_ERROR;459460String header = _error_handler_type_string(p_type);461462// Only include the file, line, and function for script errors,463// otherwise the test outputs changes based on the platform/compiler.464if (p_type == ERR_HANDLER_SCRIPT) {465header += vformat(" at %s:%d on %s()",466String::utf8(p_file).trim_prefix(self->base_dir).replace_char('\\', '/'),467p_line,468String::utf8(p_function));469}470471StringBuilder error_string;472error_string.append(vformat(">> %s: %s\n", header, String::utf8(p_error)));473if (strlen(p_explanation) > 0) {474error_string.append(vformat(">> %s\n", String::utf8(p_explanation)));475}476477result->output += error_string.as_string();478}479480bool GDScriptTest::check_output(const String &p_output) const {481Error err = OK;482String expected = FileAccess::get_file_as_string(output_file, &err);483484ERR_FAIL_COND_V_MSG(err != OK, false, "Error when opening the output file.");485486String got = p_output.strip_edges(); // TODO: may be hacky.487got += "\n"; // Make sure to insert newline for CI static checks.488489#ifndef DEBUG_ENABLED490expected = strip_warnings(expected);491#endif492493return got == expected;494}495496String GDScriptTest::get_text_for_status(GDScriptTest::TestStatus p_status) const {497switch (p_status) {498case GDTEST_OK:499return "GDTEST_OK";500case GDTEST_LOAD_ERROR:501return "GDTEST_LOAD_ERROR";502case GDTEST_PARSER_ERROR:503return "GDTEST_PARSER_ERROR";504case GDTEST_ANALYZER_ERROR:505return "GDTEST_ANALYZER_ERROR";506case GDTEST_COMPILER_ERROR:507return "GDTEST_COMPILER_ERROR";508case GDTEST_RUNTIME_ERROR:509return "GDTEST_RUNTIME_ERROR";510}511return "";512}513514GDScriptTest::TestResult GDScriptTest::execute_test_code(bool p_is_generating) {515disable_stdout();516517TestResult result;518result.status = GDTEST_OK;519result.output = String();520result.passed = false;521522Error err = OK;523524// Create script.525Ref<GDScript> script;526script.instantiate();527script->set_path(source_file);528if (tokenizer_mode == TOKENIZER_TEXT) {529err = script->load_source_code(source_file);530} else {531String code = FileAccess::get_file_as_string(source_file, &err);532if (!err) {533Vector<uint8_t> buffer = GDScriptTokenizerBuffer::parse_code_string(code, GDScriptTokenizerBuffer::COMPRESS_ZSTD);534script->set_binary_tokens_source(buffer);535}536}537if (err != OK) {538enable_stdout();539result.status = GDTEST_LOAD_ERROR;540result.passed = false;541ERR_FAIL_V_MSG(result, "\nCould not load source code for: '" + source_file + "'");542}543544// Test parsing.545GDScriptParser parser;546if (tokenizer_mode == TOKENIZER_TEXT) {547err = parser.parse(script->get_source_code(), source_file, false);548} else {549err = parser.parse_binary(script->get_binary_tokens_source(), source_file);550}551if (err != OK) {552enable_stdout();553result.status = GDTEST_PARSER_ERROR;554result.output = get_text_for_status(result.status) + "\n";555556const List<GDScriptParser::ParserError> &errors = parser.get_errors();557if (!errors.is_empty()) {558// Only the first error since the following might be cascading.559result.output += errors.front()->get().message + "\n"; // TODO: line, column?560}561if (!p_is_generating) {562result.passed = check_output(result.output);563}564return result;565}566567// Test type-checking.568GDScriptAnalyzer analyzer(&parser);569err = analyzer.analyze();570if (err != OK) {571enable_stdout();572result.status = GDTEST_ANALYZER_ERROR;573result.output = get_text_for_status(result.status) + "\n";574575StringBuilder error_string;576for (const GDScriptParser::ParserError &error : parser.get_errors()) {577error_string.append(vformat(">> ERROR at line %d: %s\n", error.start_line, error.message));578}579result.output += error_string.as_string();580if (!p_is_generating) {581result.passed = check_output(result.output);582}583return result;584}585586#ifdef DEBUG_ENABLED587StringBuilder warning_string;588for (const GDScriptWarning &warning : parser.get_warnings()) {589warning_string.append(vformat("~~ WARNING at line %d: (%s) %s\n", warning.start_line, warning.get_name(), warning.get_message()));590}591result.output += warning_string.as_string();592#endif593594// Test compiling.595GDScriptCompiler compiler;596err = compiler.compile(&parser, script.ptr(), false);597if (err != OK) {598enable_stdout();599result.status = GDTEST_COMPILER_ERROR;600result.output = get_text_for_status(result.status) + "\n";601result.output += compiler.get_error() + "\n";602if (!p_is_generating) {603result.passed = check_output(result.output);604}605return result;606}607608// `*.norun.gd` files are allowed to not contain a `test()` function (no runtime testing).609if (source_file.ends_with(".norun.gd")) {610enable_stdout();611result.status = GDTEST_OK;612result.output = get_text_for_status(result.status) + "\n" + result.output;613if (!p_is_generating) {614result.passed = check_output(result.output);615}616return result;617}618619// Test running.620const HashMap<StringName, GDScriptFunction *>::ConstIterator test_function_element = script->get_member_functions().find(GDScriptTestRunner::test_function_name);621if (!test_function_element) {622enable_stdout();623result.status = GDTEST_LOAD_ERROR;624result.output = "";625result.passed = false;626ERR_FAIL_V_MSG(result, "\nCould not find test function on: '" + source_file + "'");627}628629// Setup output handlers.630ErrorHandlerData error_data(&result, this);631632_print_handler.userdata = &result;633_error_handler.userdata = &error_data;634add_print_handler(&_print_handler);635add_error_handler(&_error_handler);636637err = script->reload();638if (err) {639enable_stdout();640result.status = GDTEST_LOAD_ERROR;641result.output = "";642result.passed = false;643remove_print_handler(&_print_handler);644remove_error_handler(&_error_handler);645ERR_FAIL_V_MSG(result, "\nCould not reload script: '" + source_file + "'");646}647648// Create object instance for test.649Object *obj = ClassDB::instantiate(script->get_native()->get_name());650Ref<RefCounted> obj_ref;651if (obj->is_ref_counted()) {652obj_ref = Ref<RefCounted>(Object::cast_to<RefCounted>(obj));653}654obj->set_script(script);655GDScriptInstance *instance = static_cast<GDScriptInstance *>(obj->get_script_instance());656657// Call test function.658Callable::CallError call_err;659instance->callp(GDScriptTestRunner::test_function_name, nullptr, 0, call_err);660661// Tear down output handlers.662remove_print_handler(&_print_handler);663remove_error_handler(&_error_handler);664665// Check results.666if (call_err.error != Callable::CallError::CALL_OK) {667enable_stdout();668result.status = GDTEST_LOAD_ERROR;669result.passed = false;670ERR_FAIL_V_MSG(result, "\nCould not call test function on: '" + source_file + "'");671}672673result.output = get_text_for_status(result.status) + "\n" + result.output;674if (!p_is_generating) {675result.passed = check_output(result.output);676}677678if (obj_ref.is_null()) {679memdelete(obj);680}681682enable_stdout();683684GDScriptCache::remove_script(script->get_path());685686return result;687}688689GDScriptTest::TestResult GDScriptTest::run_test() {690return execute_test_code(false);691}692693bool GDScriptTest::generate_output() {694TestResult result = execute_test_code(true);695if (result.status == GDTEST_LOAD_ERROR) {696return false;697}698699Error err = OK;700Ref<FileAccess> out_file = FileAccess::open(output_file, FileAccess::WRITE, &err);701if (err != OK) {702return false;703}704705String output = result.output.strip_edges(); // TODO: may be hacky.706output += "\n"; // Make sure to insert newline for CI static checks.707708out_file->store_string(output);709710return true;711}712713} // namespace GDScriptTests714715716