diff --git a/doc/api/cli.md b/doc/api/cli.md index c0daa2c60f9fc4..71e1f9ddb6b417 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -933,11 +933,19 @@ in the `$schema` must be replaced with the version of Node.js you are using. ], "watch-path": "src", "watch-preserve-output": true + }, + "testRunner": { + "test-isolation": "process" } } ``` -In the `nodeOptions` field, only flags that are allowed in [`NODE_OPTIONS`][] are supported. +The configuration file supports namespace-specific options: + +* The `nodeOptions` field contains CLI flags that are allowed in [`NODE_OPTIONS`][]. + +* Namespace fields like `testRunner` contain configuration specific to that subsystem. + No-op flags are not supported. Not all V8 flags are currently supported. @@ -951,7 +959,7 @@ For example, the configuration file above is equivalent to the following command-line arguments: ```bash -node --import amaro/strip --watch-path=src --watch-preserve-output +node --import amaro/strip --watch-path=src --watch-preserve-output --test-isolation=process ``` The priority in configuration is as follows: @@ -964,11 +972,10 @@ Values in the configuration file will not override the values in the environment variables and command-line options, but will override the values in the `NODE_OPTIONS` env file parsed by the `--env-file` flag. -If duplicate keys are present in the configuration file, only -the first key will be used. +Keys cannot be duplicated within the same or different namespaces. The configuration parser will throw an error if the configuration file contains -unknown keys or keys that cannot used in `NODE_OPTIONS`. +unknown keys or keys that cannot be used in a namespace. Node.js will not sanitize or perform validation on the user-provided configuration, so **NEVER** use untrusted configuration files. diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json index 1648957888096d..75902338c806cb 100644 --- a/doc/node-config-schema.json +++ b/doc/node-config-schema.json @@ -584,6 +584,135 @@ } }, "type": "object" + }, + "testRunner": { + "type": "object", + "additionalProperties": false, + "properties": { + "experimental-test-coverage": { + "type": "boolean" + }, + "experimental-test-module-mocks": { + "type": "boolean" + }, + "test-concurrency": { + "type": "number" + }, + "test-coverage-branches": { + "type": "number" + }, + "test-coverage-exclude": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-coverage-functions": { + "type": "number" + }, + "test-coverage-include": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-coverage-lines": { + "type": "number" + }, + "test-force-exit": { + "type": "boolean" + }, + "test-global-setup": { + "type": "string" + }, + "test-isolation": { + "type": "string" + }, + "test-name-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-only": { + "type": "boolean" + }, + "test-reporter": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-reporter-destination": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-shard": { + "type": "string" + }, + "test-skip-pattern": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string", + "minItems": 1 + }, + "type": "array" + } + ] + }, + "test-timeout": { + "type": "number" + }, + "test-update-snapshots": { + "type": "boolean" + } + } } }, "type": "object" diff --git a/lib/internal/options.js b/lib/internal/options.js index 12548ac49a2aff..fef0d61d143335 100644 --- a/lib/internal/options.js +++ b/lib/internal/options.js @@ -13,6 +13,7 @@ const { getCLIOptionsInfo, getEmbedderOptions: getEmbedderOptionsFromBinding, getEnvOptionsInputType, + getNamespaceOptionsInputType, } = internalBinding('options'); let warnOnAllowUnauthorized = true; @@ -38,7 +39,22 @@ function getEmbedderOptions() { } function generateConfigJsonSchema() { - const map = getEnvOptionsInputType(); + const envOptionsMap = getEnvOptionsInputType(); + const namespaceOptionsMap = getNamespaceOptionsInputType(); + + function createPropertyForType(type) { + if (type === 'array') { + return { + __proto__: null, + oneOf: [ + { __proto__: null, type: 'string' }, + { __proto__: null, items: { __proto__: null, type: 'string', minItems: 1 }, type: 'array' }, + ], + }; + } + + return { __proto__: null, type }; + } const schema = { __proto__: null, @@ -60,24 +76,43 @@ function generateConfigJsonSchema() { type: 'object', }; - const nodeOptions = schema.properties.nodeOptions.properties; + // Get the root properties object for adding namespaces + const rootProperties = schema.properties; + const nodeOptions = rootProperties.nodeOptions.properties; - for (const { 0: key, 1: type } of map) { + // Add env options to nodeOptions (backward compatibility) + for (const { 0: key, 1: type } of envOptionsMap) { const keyWithoutPrefix = StringPrototypeReplace(key, '--', ''); - if (type === 'array') { - nodeOptions[keyWithoutPrefix] = { - __proto__: null, - oneOf: [ - { __proto__: null, type: 'string' }, - { __proto__: null, items: { __proto__: null, type: 'string', minItems: 1 }, type: 'array' }, - ], - }; - } else { - nodeOptions[keyWithoutPrefix] = { __proto__: null, type }; + nodeOptions[keyWithoutPrefix] = createPropertyForType(type); + } + + // Add namespace properties at the root level + for (const { 0: namespace, 1: optionsMap } of namespaceOptionsMap) { + // Create namespace object at the root level + rootProperties[namespace] = { + __proto__: null, + type: 'object', + additionalProperties: false, + properties: { __proto__: null }, + }; + + const namespaceProperties = rootProperties[namespace].properties; + + // Add all options for this namespace + for (const { 0: optionName, 1: optionType } of optionsMap) { + const keyWithoutPrefix = StringPrototypeReplace(optionName, '--', ''); + namespaceProperties[keyWithoutPrefix] = createPropertyForType(optionType); } + + // Sort the namespace properties alphabetically + const sortedNamespaceKeys = ArrayPrototypeSort(ObjectKeys(namespaceProperties)); + const sortedNamespaceProperties = ObjectFromEntries( + ArrayPrototypeMap(sortedNamespaceKeys, (key) => [key, namespaceProperties[key]]), + ); + rootProperties[namespace].properties = sortedNamespaceProperties; } - // Sort the proerties by key alphabetically. + // Sort the top-level properties by key alphabetically const sortedKeys = ArrayPrototypeSort(ObjectKeys(nodeOptions)); const sortedProperties = ObjectFromEntries( ArrayPrototypeMap(sortedKeys, (key) => [key, nodeOptions[key]]), @@ -85,6 +120,14 @@ function generateConfigJsonSchema() { schema.properties.nodeOptions.properties = sortedProperties; + // Also sort the root level properties + const sortedRootKeys = ArrayPrototypeSort(ObjectKeys(rootProperties)); + const sortedRootProperties = ObjectFromEntries( + ArrayPrototypeMap(sortedRootKeys, (key) => [key, rootProperties[key]]), + ); + + schema.properties = sortedRootProperties; + return schema; } diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 4c2949f4158ab1..0e82014798811d 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -98,7 +98,13 @@ let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => { }); const kIsolatedProcessName = Symbol('kIsolatedProcessName'); -const kFilterArgs = ['--test', '--experimental-test-coverage', '--watch']; +const kFilterArgs = [ + '--test', + '--experimental-test-coverage', + '--watch', + '--experimental-default-config-file', + '--experimental-config-file', +]; const kFilterArgValues = ['--test-reporter', '--test-reporter-destination']; const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms']; diff --git a/src/node.cc b/src/node.cc index f921303b639dbc..e67238f54d8686 100644 --- a/src/node.cc +++ b/src/node.cc @@ -910,7 +910,7 @@ static ExitCode InitializeNodeWithArgsInternal( default: UNREACHABLE(); } - node_options_from_config = per_process::config_reader.AssignNodeOptions(); + node_options_from_config = per_process::config_reader.GetNodeOptions(); // (@marco-ippolito) Avoid reparsing the env options again std::vector env_argv_from_config = ParseNodeOptionsEnvVar(node_options_from_config, errors); @@ -956,7 +956,19 @@ static ExitCode InitializeNodeWithArgsInternal( #endif if (!(flags & ProcessInitializationFlags::kDisableCLIOptions)) { - const ExitCode exit_code = + // Parse the options coming from the config file. + // This is done before parsing the command line options + // as the cli flags are expected to override the config file ones. + std::vector extra_argv = + per_process::config_reader.GetNamespaceFlags(); + // [0] is expected to be the program name, fill it in from the real argv. + extra_argv.insert(extra_argv.begin(), argv->at(0)); + // Parse the extra argv coming from the config file + ExitCode exit_code = ProcessGlobalArgsInternal( + &extra_argv, nullptr, errors, kDisallowedInEnvvar); + if (exit_code != ExitCode::kNoFailure) return exit_code; + // Parse options coming from the command line. + exit_code = ProcessGlobalArgsInternal(argv, exec_argv, errors, kDisallowedInEnvvar); if (exit_code != ExitCode::kNoFailure) return exit_code; } diff --git a/src/node_config_file.cc b/src/node_config_file.cc index e697002ae1a2e0..12607cd0d5d06f 100644 --- a/src/node_config_file.cc +++ b/src/node_config_file.cc @@ -37,121 +37,171 @@ std::optional ConfigReader::GetDataFromArgs( return std::nullopt; } -ParseResult ConfigReader::ParseNodeOptions( - simdjson::ondemand::object* node_options_object) { - auto env_options_map = options_parser::MapEnvOptionsFlagInputType(); - simdjson::ondemand::value ondemand_value; - std::string_view key; - - for (auto field : *node_options_object) { - if (field.unescaped_key().get(key) || field.value().get(ondemand_value)) { - return ParseResult::InvalidContent; - } +ParseResult ConfigReader::ProcessOptionValue( + const std::pair& option_info, + simdjson::ondemand::value* option_value, + std::vector* output) { + const std::string& option_name = option_info.first; + const options_parser::OptionType option_type = option_info.second; - // The key needs to match the CLI option - std::string prefix = "--"; - auto it = env_options_map.find(prefix.append(key)); - if (it != env_options_map.end()) { - switch (it->second) { - case options_parser::OptionType::kBoolean: { - bool result; - if (ondemand_value.get_bool().get(result)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } + switch (option_type) { + case options_parser::OptionType::kBoolean: { + bool result; + if (option_value->get_bool().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); + return ParseResult::InvalidContent; + } - if (result) { - // If the value is true, we need to set the flag - node_options_.push_back(it->first); - } + if (result) { + // If the value is true, we need to set the flag + output->push_back(option_name); + } - break; - } - // String array can allow both string and array types - case options_parser::OptionType::kStringList: { - simdjson::ondemand::json_type field_type; - if (ondemand_value.type().get(field_type)) { + break; + } + // String array can allow both string and array types + case options_parser::OptionType::kStringList: { + simdjson::ondemand::json_type field_type; + if (option_value->type().get(field_type)) { + return ParseResult::InvalidContent; + } + switch (field_type) { + case simdjson::ondemand::json_type::array: { + std::vector result; + simdjson::ondemand::array raw_imports; + if (option_value->get_array().get(raw_imports)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); return ParseResult::InvalidContent; } - switch (field_type) { - case simdjson::ondemand::json_type::array: { - std::vector result; - simdjson::ondemand::array raw_imports; - if (ondemand_value.get_array().get(raw_imports)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } - for (auto raw_import : raw_imports) { - std::string_view import; - if (raw_import.get_string(import)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } - node_options_.push_back(it->first + "=" + std::string(import)); - } - break; - } - case simdjson::ondemand::json_type::string: { - std::string result; - if (ondemand_value.get_string(result)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } - node_options_.push_back(it->first + "=" + result); - break; - } - default: - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + for (auto raw_import : raw_imports) { + std::string_view import; + if (raw_import.get_string(import)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); return ParseResult::InvalidContent; + } + output->push_back(option_name + "=" + std::string(import)); } break; } - case options_parser::OptionType::kString: { + case simdjson::ondemand::json_type::string: { std::string result; - if (ondemand_value.get_string(result)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } - node_options_.push_back(it->first + "=" + result); - break; - } - case options_parser::OptionType::kInteger: { - int64_t result; - if (ondemand_value.get_int64().get(result)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); - return ParseResult::InvalidContent; - } - node_options_.push_back(it->first + "=" + std::to_string(result)); - break; - } - case options_parser::OptionType::kHostPort: - case options_parser::OptionType::kUInteger: { - uint64_t result; - if (ondemand_value.get_uint64().get(result)) { - FPrintF(stderr, "Invalid value for %s\n", it->first.c_str()); + if (option_value->get_string(result)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); return ParseResult::InvalidContent; } - node_options_.push_back(it->first + "=" + std::to_string(result)); + output->push_back(option_name + "=" + result); break; } - case options_parser::OptionType::kNoOp: { - FPrintF(stderr, - "No-op flag %s is currently not supported\n", - it->first.c_str()); - return ParseResult::InvalidContent; - break; - } - case options_parser::OptionType::kV8Option: { - FPrintF(stderr, - "V8 flag %s is currently not supported\n", - it->first.c_str()); - return ParseResult::InvalidContent; - } default: - UNREACHABLE(); + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); + return ParseResult::InvalidContent; + } + break; + } + case options_parser::OptionType::kString: { + std::string result; + if (option_value->get_string(result)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); + return ParseResult::InvalidContent; + } + output->push_back(option_name + "=" + result); + break; + } + case options_parser::OptionType::kInteger: { + int64_t result; + if (option_value->get_int64().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); + return ParseResult::InvalidContent; + } + output->push_back(option_name + "=" + std::to_string(result)); + break; + } + case options_parser::OptionType::kHostPort: + case options_parser::OptionType::kUInteger: { + uint64_t result; + if (option_value->get_uint64().get(result)) { + FPrintF(stderr, "Invalid value for %s\n", option_name.c_str()); + return ParseResult::InvalidContent; + } + output->push_back(option_name + "=" + std::to_string(result)); + break; + } + case options_parser::OptionType::kNoOp: { + FPrintF(stderr, + "No-op flag %s is currently not supported\n", + option_name.c_str()); + return ParseResult::InvalidContent; + break; + } + case options_parser::OptionType::kV8Option: { + FPrintF(stderr, + "V8 flag %s is currently not supported\n", + option_name.c_str()); + return ParseResult::InvalidContent; + } + default: + UNREACHABLE(); + } + return ParseResult::Valid; +} + +ParseResult ConfigReader::ParseOptions( + simdjson::ondemand::object* options_object, + std::unordered_set* unique_options, + const std::string& namespace_name) { + // Determine which options map to use and output vector + std::unordered_map options_map; + std::vector* output_vector; + + if (namespace_name == "nodeOptions") { + // Special case for backward compatibility: handle nodeOptions with env + // options map + options_map = options_parser::MapEnvOptionsFlagInputType(); + output_vector = &node_options_; + } else { + // Handle other namespaces + options_map = options_parser::MapOptionsByNamespace(namespace_name); + output_vector = &namespace_options_; + + if (!env_options_initialized_) { + env_options_map_ = options_parser::MapEnvOptionsFlagInputType(); + env_options_initialized_ = true; + } + } + + simdjson::ondemand::value option_value; + std::string_view option_key; + + for (auto field : *options_object) { + if (field.unescaped_key().get(option_key) || + field.value().get(option_value)) { + return ParseResult::InvalidContent; + } + + // The key needs to match the CLI option + std::string prefix = "--"; + auto option = options_map.find(prefix.append(option_key)); + if (option != options_map.end()) { + // If the option has already been set, return an error + if (unique_options->contains(option->first)) { + FPrintF( + stderr, "Option %s is already defined\n", option->first.c_str()); + return ParseResult::InvalidContent; + } + // Add the option to the unique set to prevent duplicates + // on future iterations + unique_options->insert(option->first); + // Process the option value based on its type + ParseResult result = + ProcessOptionValue(*option, &option_value, output_vector); + if (result != ParseResult::Valid) { + return result; } } else { - FPrintF(stderr, "Unknown or not allowed option %s\n", key.data()); + FPrintF(stderr, + "Unknown or not allowed option %s for namespace %s\n", + option_key.data(), + namespace_name.c_str()); return ParseResult::InvalidContent; } } @@ -177,9 +227,10 @@ ParseResult ConfigReader::ParseConfig(const std::string_view& config_path) { return ParseResult::InvalidContent; } + // Validate config is an object simdjson::ondemand::object main_object; - // If document is not an object, throw an error. - if (auto root_error = document.get_object().get(main_object)) { + auto root_error = document.get_object().get(main_object); + if (root_error) { if (root_error == simdjson::error_code::INCORRECT_TYPE) { FPrintF(stderr, "Root value unexpected not an object for %s\n\n", @@ -190,36 +241,67 @@ ParseResult ConfigReader::ParseConfig(const std::string_view& config_path) { return ParseResult::InvalidContent; } - simdjson::ondemand::object node_options_object; - // If "nodeOptions" is an object, parse it - if (auto node_options_error = - main_object["nodeOptions"].get_object().get(node_options_object)) { - if (node_options_error != simdjson::error_code::NO_SUCH_FIELD) { + // Get all available namespaces for validation + std::vector available_namespaces = + options_parser::MapAvailableNamespaces(); + // Add "nodeOptions" as a special case for backward compatibility + available_namespaces.emplace_back("nodeOptions"); + + // Create a set for faster lookup of valid namespaces + std::unordered_set valid_namespaces(available_namespaces.begin(), + available_namespaces.end()); + // Create a set to track unique options + std::unordered_set unique_options; + // Iterate through the main object to find all namespaces + for (auto field : main_object) { + std::string_view field_name; + if (field.unescaped_key().get(field_name)) { + return ParseResult::InvalidContent; + } + + // Check if this field is a valid namespace + std::string namespace_name(field_name); + if (!valid_namespaces.contains(namespace_name)) { + // If not, skip it + continue; + } + + // Get the namespace object + simdjson::ondemand::object namespace_object; + auto field_error = field.value().get_object().get(namespace_object); + + // If namespace value is not an object + if (field_error) { FPrintF(stderr, - "\"nodeOptions\" value unexpected for %s\n\n", + "\"%s\" value unexpected for %s (should be an object)\n", + namespace_name.c_str(), config_path.data()); return ParseResult::InvalidContent; } - } else { - return ParseNodeOptions(&node_options_object); + + // Process options for this namespace using the unified method + ParseResult result = + ParseOptions(&namespace_object, &unique_options, namespace_name); + if (result != ParseResult::Valid) { + return result; + } } return ParseResult::Valid; } -std::string ConfigReader::AssignNodeOptions() { - if (node_options_.empty()) { - return ""; - } else { - DCHECK_GT(node_options_.size(), 0); - std::string acc; - acc.reserve(node_options_.size() * 2); - for (size_t i = 0; i < node_options_.size(); ++i) { - // The space is necessary at the beginning of the string - acc += " " + node_options_[i]; - } - return acc; +std::string ConfigReader::GetNodeOptions() { + std::string acc = ""; + const size_t total_options = node_options_.size(); + acc.reserve(total_options * 2); + for (auto& opt : node_options_) { + acc += " " + opt; } + return acc; +} + +const std::vector& ConfigReader::GetNamespaceFlags() const { + return namespace_options_; } size_t ConfigReader::GetFlagsSize() { diff --git a/src/node_config_file.h b/src/node_config_file.h index 5419590a9e05fb..b369dca97b0062 100644 --- a/src/node_config_file.h +++ b/src/node_config_file.h @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include #include "node_internals.h" #include "simdjson.h" #include "util-inl.h" @@ -29,14 +32,30 @@ class ConfigReader { std::optional GetDataFromArgs( const std::vector& args); - std::string AssignNodeOptions(); + std::string GetNodeOptions(); + const std::vector& GetNamespaceFlags() const; size_t GetFlagsSize(); private: - ParseResult ParseNodeOptions(simdjson::ondemand::object* node_options_object); + // Parse options for a specific namespace (including nodeOptions for backward + // compatibility) + ParseResult ParseOptions(simdjson::ondemand::object* options_object, + std::unordered_set* unique_options, + const std::string& namespace_name); + + // Process a single option value based on its type + ParseResult ProcessOptionValue( + const std::pair& option_info, + simdjson::ondemand::value* option_value, + std::vector* output); std::vector node_options_; + std::vector namespace_options_; + + // Cache for fast lookup of environment options + std::unordered_map env_options_map_; + bool env_options_initialized_ = false; }; } // namespace node diff --git a/src/node_options-inl.h b/src/node_options-inl.h index 55078af457fc7c..877e8ce4ded92b 100644 --- a/src/node_options-inl.h +++ b/src/node_options-inl.h @@ -31,98 +31,128 @@ namespace options_parser { template void OptionsParser::AddOption(const char* name, const char* help_text, - bool Options::* field, + bool Options::*field, OptionEnvvarSettings env_setting, - bool default_is_true) { + bool default_is_true, + OptionNamespaces namespace_id) { options_.emplace(name, OptionInfo{kBoolean, std::make_shared>(field), env_setting, help_text, - default_is_true}); + default_is_true, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, - uint64_t Options::* field, - OptionEnvvarSettings env_setting) { + uint64_t Options::*field, + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { options_.emplace( name, OptionInfo{kUInteger, std::make_shared>(field), env_setting, - help_text}); + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, - int64_t Options::* field, - OptionEnvvarSettings env_setting) { + int64_t Options::*field, + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { options_.emplace( name, OptionInfo{kInteger, std::make_shared>(field), env_setting, - help_text}); + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, - std::string Options::* field, - OptionEnvvarSettings env_setting) { + std::string Options::*field, + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { options_.emplace( name, OptionInfo{kString, std::make_shared>(field), env_setting, - help_text}); + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template -void OptionsParser::AddOption( - const char* name, - const char* help_text, - std::vector Options::* field, - OptionEnvvarSettings env_setting) { - options_.emplace(name, OptionInfo { - kStringList, - std::make_shared>>(field), - env_setting, - help_text - }); +void OptionsParser::AddOption(const char* name, + const char* help_text, + std::vector Options::*field, + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { + options_.emplace( + name, + OptionInfo{ + kStringList, + std::make_shared>>(field), + env_setting, + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, - HostPort Options::* field, - OptionEnvvarSettings env_setting) { + HostPort Options::*field, + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { options_.emplace( name, OptionInfo{kHostPort, std::make_shared>(field), env_setting, - help_text}); + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, NoOp no_op_tag, - OptionEnvvarSettings env_setting) { - options_.emplace(name, OptionInfo{kNoOp, nullptr, env_setting, help_text}); + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { + options_.emplace(name, + OptionInfo{kNoOp, + nullptr, + env_setting, + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template void OptionsParser::AddOption(const char* name, const char* help_text, V8Option v8_option_tag, - OptionEnvvarSettings env_setting) { + OptionEnvvarSettings env_setting, + OptionNamespaces namespace_id) { options_.emplace(name, - OptionInfo{kV8Option, nullptr, env_setting, help_text}); + OptionInfo{kV8Option, + nullptr, + env_setting, + help_text, + false, + NamespaceEnumToString(namespace_id)}); } template @@ -198,7 +228,8 @@ auto OptionsParser::Convert( Convert(original.field, get_child), original.env_setting, original.help_text, - original.default_is_true}; + original.default_is_true, + original.namespace_id}; } template diff --git a/src/node_options.cc b/src/node_options.cc index cd2b8e4995e9c2..e8de10cd238645 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -17,6 +17,7 @@ #include #include #include +#include using v8::Boolean; using v8::Context; @@ -241,6 +242,64 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, namespace options_parser { +// Helper function to convert option types to their string representation +// and add them to a V8 Map +static bool AddOptionTypeToMap(Isolate* isolate, + Local context, + Local map, + const std::string& option_name, + const OptionType& option_type) { + std::string type; + switch (static_cast(option_type)) { + case 0: // No-op + case 1: // V8 flags + break; // V8 and NoOp flags are not supported + + case 2: + type = "boolean"; + break; + case 3: // integer + case 4: // unsigned integer + case 6: // host port + type = "number"; + break; + case 5: // string + type = "string"; + break; + case 7: // string array + type = "array"; + break; + default: + UNREACHABLE(); + } + + if (type.empty()) { + return true; // Skip this entry but continue processing + } + + Local option_key; + if (!String::NewFromUtf8(isolate, + option_name.data(), + v8::NewStringType::kNormal, + option_name.size()) + .ToLocal(&option_key)) { + return true; // Skip this entry but continue processing + } + + Local type_value; + if (!String::NewFromUtf8( + isolate, type.data(), v8::NewStringType::kNormal, type.size()) + .ToLocal(&type_value)) { + return true; // Skip this entry but continue processing + } + + if (map->Set(context, option_key, type_value).IsEmpty()) { + return false; // Error occurred, stop processing + } + + return true; +} + class DebugOptionsParser : public OptionsParser { public: DebugOptionsParser(); @@ -694,82 +753,119 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { &EnvironmentOptions::experimental_default_config_file); AddOption("--test", "launch test runner on startup", - &EnvironmentOptions::test_runner); + &EnvironmentOptions::test_runner, + kDisallowedInEnvvar); AddOption("--test-concurrency", "specify test runner concurrency", - &EnvironmentOptions::test_runner_concurrency); + &EnvironmentOptions::test_runner_concurrency, + kDisallowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-force-exit", "force test runner to exit upon completion", - &EnvironmentOptions::test_runner_force_exit); + &EnvironmentOptions::test_runner_force_exit, + kDisallowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-timeout", "specify test runner timeout", - &EnvironmentOptions::test_runner_timeout); + &EnvironmentOptions::test_runner_timeout, + kDisallowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-update-snapshots", "regenerate test snapshots", - &EnvironmentOptions::test_runner_update_snapshots); + &EnvironmentOptions::test_runner_update_snapshots, + kDisallowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--experimental-test-coverage", "enable code coverage in the test runner", - &EnvironmentOptions::test_runner_coverage); + &EnvironmentOptions::test_runner_coverage, + kDisallowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-branches", "the branch coverage minimum threshold", &EnvironmentOptions::test_coverage_branches, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-functions", "the function coverage minimum threshold", &EnvironmentOptions::test_coverage_functions, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-lines", "the line coverage minimum threshold", &EnvironmentOptions::test_coverage_lines, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-isolation", "configures the type of test isolation used in the test runner", &EnvironmentOptions::test_isolation, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); // TODO(cjihrig): Remove this alias in a semver major. AddAlias("--experimental-test-isolation", "--test-isolation"); AddOption("--experimental-test-module-mocks", "enable module mocking in the test runner", - &EnvironmentOptions::test_runner_module_mocks); - AddOption("--experimental-test-snapshots", "", NoOp{}); + &EnvironmentOptions::test_runner_module_mocks, + kDisallowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); + AddOption("--experimental-test-snapshots", + "", + NoOp{}, + kDisallowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-name-pattern", "run tests whose name matches this regular expression", &EnvironmentOptions::test_name_pattern, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-reporter", "report test output using the given reporter", &EnvironmentOptions::test_reporter, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-reporter-destination", "report given reporter to the given destination", &EnvironmentOptions::test_reporter_destination, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-only", "run tests with 'only' option set", &EnvironmentOptions::test_only, - kAllowedInEnvvar); + kAllowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-shard", "run test at specific shard", &EnvironmentOptions::test_shard, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-skip-pattern", "run tests whose name do not match this regular expression", &EnvironmentOptions::test_skip_pattern, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-include", "include files in coverage report that match this glob pattern", &EnvironmentOptions::coverage_include_pattern, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-exclude", "exclude files from coverage report that match this glob pattern", &EnvironmentOptions::coverage_exclude_pattern, - kAllowedInEnvvar); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-global-setup", "specifies the path to the global setup file", &EnvironmentOptions::test_global_setup_path, - kAllowedInEnvvar); - AddOption("--test-udp-no-try-send", "", // For testing only. - &EnvironmentOptions::test_udp_no_try_send); + kAllowedInEnvvar, + OptionNamespaces::kTestRunnerNamespace); + AddOption("--test-udp-no-try-send", + "", // For testing only. + &EnvironmentOptions::test_udp_no_try_send, + kDisallowedInEnvvar); AddOption("--throw-deprecation", "throw an exception on deprecations", &EnvironmentOptions::throw_deprecation, @@ -1326,6 +1422,49 @@ MapEnvOptionsFlagInputType() { return type_map; } +std::vector MapAvailableNamespaces() { + std::vector namespaceNames; + auto availableNamespaces = AllNamespaces(); + for (size_t i = 1; i < availableNamespaces.size(); i++) { + OptionNamespaces ns = availableNamespaces[i]; + std::string ns_string = NamespaceEnumToString(ns); + if (!ns_string.empty()) { + namespaceNames.push_back(ns_string); + } + } + + return namespaceNames; +} + +std::unordered_map +MapOptionsByNamespace(std::string namespace_name) { + std::unordered_map type_map; + const auto& parser = _ppop_instance; + for (const auto& item : parser.options_) { + if (!item.first.empty() && !item.first.starts_with('[') && + item.second.namespace_id == namespace_name) { + type_map[item.first] = item.second.type; + } + } + return type_map; +} + +std::unordered_map> +MapNamespaceOptionsAssociations() { + std::vector available_namespaces = + options_parser::MapAvailableNamespaces(); + std::unordered_map< + std::string, + std::unordered_map> + namespace_option_mapping; + for (const std::string& available_namespace : available_namespaces) { + namespace_option_mapping[available_namespace] = + options_parser::MapOptionsByNamespace(available_namespace); + } + return namespace_option_mapping; +} + struct IterateCLIOptionsScope { explicit IterateCLIOptionsScope(Environment* env) { // Temporarily act as if the current Environment's/IsolateData's options @@ -1593,56 +1732,71 @@ void GetEnvOptionsInputType(const FunctionCallbackInfo& args) { for (const auto& item : _ppop_instance.options_) { if (!item.first.empty() && !item.first.starts_with('[') && item.second.env_setting == kAllowedInEnvvar) { - std::string type; - switch (static_cast(item.second.type)) { - case 0: // No-op - case 1: // V8 flags - break; // V8 and NoOp flags are not supported - - case 2: - type = "boolean"; - break; - case 3: // integer - case 4: // unsigned integer - case 6: // host port - type = "number"; - break; - case 5: // string - type = "string"; - break; - case 7: // string array - type = "array"; - break; - default: - UNREACHABLE(); + if (!AddOptionTypeToMap( + isolate, context, flags_map, item.first, item.second.type)) { + return; } + } + } + args.GetReturnValue().Set(flags_map); +} - if (type.empty()) { - continue; - } +// This function returns a two-level nested map containing all the available +// options grouped by their namespaces along with their input types. This is +// used for config file JSON schema generation +void GetNamespaceOptionsInputType(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Environment* env = Environment::GetCurrent(context); - Local value; - if (!String::NewFromUtf8( - isolate, type.data(), v8::NewStringType::kNormal, type.size()) - .ToLocal(&value)) { - continue; + if (!env->has_run_bootstrapping_code()) { + // No code because this is an assertion. + THROW_ERR_OPTIONS_BEFORE_BOOTSTRAPPING( + isolate, "Should not query options before bootstrapping is done"); + } + + Mutex::ScopedLock lock(per_process::cli_options_mutex); + + Local namespaces_map = Map::New(isolate); + + // Get the mapping of namespaces to their options and types + auto namespace_options = options_parser::MapNamespaceOptionsAssociations(); + + for (const auto& ns_entry : namespace_options) { + const std::string& namespace_name = ns_entry.first; + const auto& options_map = ns_entry.second; + + Local options_type_map = Map::New(isolate); + + for (const auto& opt_entry : options_map) { + const std::string& option_name = opt_entry.first; + const options_parser::OptionType& option_type = opt_entry.second; + + if (!AddOptionTypeToMap( + isolate, context, options_type_map, option_name, option_type)) { + return; } + } - Local field; + // Only add namespaces that have options + if (options_type_map->Size() > 0) { + Local namespace_key; if (!String::NewFromUtf8(isolate, - item.first.data(), + namespace_name.data(), v8::NewStringType::kNormal, - item.first.size()) - .ToLocal(&field)) { + namespace_name.size()) + .ToLocal(&namespace_key)) { continue; } - if (flags_map->Set(context, field, value).IsEmpty()) { + if (namespaces_map->Set(context, namespace_key, options_type_map) + .IsEmpty()) { return; } } } - args.GetReturnValue().Set(flags_map); + + args.GetReturnValue().Set(namespaces_map); } void Initialize(Local target, @@ -1659,6 +1813,10 @@ void Initialize(Local target, context, target, "getEmbedderOptions", GetEmbedderOptions); SetMethodNoSideEffect( context, target, "getEnvOptionsInputType", GetEnvOptionsInputType); + SetMethodNoSideEffect(context, + target, + "getNamespaceOptionsInputType", + GetNamespaceOptionsInputType); Local env_settings = Object::New(isolate); NODE_DEFINE_CONSTANT(env_settings, kAllowedInEnvvar); NODE_DEFINE_CONSTANT(env_settings, kDisallowedInEnvvar); @@ -1685,6 +1843,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(GetCLIOptionsInfo); registry->Register(GetEmbedderOptions); registry->Register(GetEnvOptionsInputType); + registry->Register(GetNamespaceOptionsInputType); } } // namespace options_parser diff --git a/src/node_options.h b/src/node_options.h index d0c73722c9a68a..b616249d18e1c4 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -380,7 +380,7 @@ class PerProcessOptions : public Options { namespace options_parser { HostPort SplitHostPort(const std::string& arg, - std::vector* errors); + std::vector* errors); void GetOptions(const v8::FunctionCallbackInfo& args); std::string GetBashCompletion(); @@ -395,6 +395,43 @@ enum OptionType { kStringList, }; std::unordered_map MapEnvOptionsFlagInputType(); +std::unordered_map MapOptionsByNamespace( + std::string namespace_name); +std::unordered_map> +MapNamespaceOptionsAssociations(); +std::vector MapAvailableNamespaces(); + +// Define all namespace entries +#define OPTION_NAMESPACE_LIST(V) \ + V(kNoNamespace, "") \ + V(kTestRunnerNamespace, "testRunner") + +enum class OptionNamespaces { +#define V(name, _) name, + OPTION_NAMESPACE_LIST(V) +#undef V +}; + +inline const std::string NamespaceEnumToString(OptionNamespaces ns) { + switch (ns) { +#define V(name, string_value) \ + case OptionNamespaces::name: \ + return string_value; + OPTION_NAMESPACE_LIST(V) +#undef V + default: + return ""; + } +} + +inline constexpr auto AllNamespaces() { + return std::array{ +#define V(name, _) OptionNamespaces::name, + OPTION_NAMESPACE_LIST(V) +#undef V + }; +} template class OptionsParser { @@ -413,39 +450,55 @@ class OptionsParser { // default_is_true is only a hint in printing help text, it does not // affect the default value of the option. Set the default value in the // Options struct instead. - void AddOption(const char* name, - const char* help_text, - bool Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar, - bool default_is_true = false); - void AddOption(const char* name, - const char* help_text, - uint64_t Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - int64_t Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - std::string Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - std::vector Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - HostPort Options::*field, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - NoOp no_op_tag, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); - void AddOption(const char* name, - const char* help_text, - V8Option v8_option_tag, - OptionEnvvarSettings env_setting = kDisallowedInEnvvar); + void AddOption( + const char* name, + const char* help_text, + bool Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + bool default_is_true = false, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + uint64_t Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + int64_t Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + std::string Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + std::vector Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + HostPort Options::*field, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + NoOp no_op_tag, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); + void AddOption( + const char* name, + const char* help_text, + V8Option v8_option_tag, + OptionEnvvarSettings env_setting = kDisallowedInEnvvar, + OptionNamespaces namespace_id = OptionNamespaces::kNoNamespace); // Adds aliases. An alias can be of the form "--option-a" -> "--option-b", // or have a more complex group expansion, like @@ -535,12 +588,15 @@ class OptionsParser { // - A type. // - A way to store/access the property value. // - The information of whether it may occur in an env var or not. + // - A default value (if applicable). + // - A namespace ID (optional) to allow for namespacing of options. struct OptionInfo { OptionType type; std::shared_ptr field; OptionEnvvarSettings env_setting; std::string help_text; bool default_is_true = false; + std::string namespace_id; }; // An implied option is composed of the information on where to store a @@ -581,6 +637,9 @@ class OptionsParser { friend std::string GetBashCompletion(); friend std::unordered_map MapEnvOptionsFlagInputType(); + friend std::unordered_map MapOptionsByNamespace( + std::string namespace_name); + friend std::vector MapAvailableNamespaces(); friend void GetEnvOptionsInputType( const v8::FunctionCallbackInfo& args); }; diff --git a/test/fixtures/rc/duplicate-namespace-option/node.config.json b/test/fixtures/rc/duplicate-namespace-option/node.config.json new file mode 100644 index 00000000000000..4d948fbd33961d --- /dev/null +++ b/test/fixtures/rc/duplicate-namespace-option/node.config.json @@ -0,0 +1,6 @@ +{ + "testRunner": { + "test-name-pattern": "first-pattern", + "test-name-pattern": "second-pattern" + } +} diff --git a/test/fixtures/rc/empty-valid-namespace.json b/test/fixtures/rc/empty-valid-namespace.json new file mode 100644 index 00000000000000..dbeb33d7aa8b59 --- /dev/null +++ b/test/fixtures/rc/empty-valid-namespace.json @@ -0,0 +1,3 @@ +{ + "testRunner": {} +} diff --git a/test/fixtures/rc/namespace-with-array.json b/test/fixtures/rc/namespace-with-array.json new file mode 100644 index 00000000000000..056a4291e9b666 --- /dev/null +++ b/test/fixtures/rc/namespace-with-array.json @@ -0,0 +1,5 @@ +{ + "testRunner": { + "test-coverage-exclude": ["config-pattern1", "config-pattern2"] + } +} diff --git a/test/fixtures/rc/namespace-with-disallowed-envvar.json b/test/fixtures/rc/namespace-with-disallowed-envvar.json new file mode 100644 index 00000000000000..6152684e0583f4 --- /dev/null +++ b/test/fixtures/rc/namespace-with-disallowed-envvar.json @@ -0,0 +1,6 @@ +{ + "testRunner": { + "test-concurrency": 1, + "experimental-test-coverage": true + } +} diff --git a/test/fixtures/rc/namespaced/node.config.json b/test/fixtures/rc/namespaced/node.config.json new file mode 100644 index 00000000000000..df929d25c10b52 --- /dev/null +++ b/test/fixtures/rc/namespaced/node.config.json @@ -0,0 +1,5 @@ +{ + "testRunner": { + "test-isolation": "none" + } +} diff --git a/test/fixtures/rc/override-namespace.json b/test/fixtures/rc/override-namespace.json new file mode 100644 index 00000000000000..acb37b2eec485c --- /dev/null +++ b/test/fixtures/rc/override-namespace.json @@ -0,0 +1,8 @@ +{ + "testRunner": { + "test-isolation": "process" + }, + "nodeOptions": { + "test-isolation": "none" + } +} diff --git a/test/fixtures/rc/override-node-option-with-namespace.json b/test/fixtures/rc/override-node-option-with-namespace.json new file mode 100644 index 00000000000000..2db9e1a47f07ea --- /dev/null +++ b/test/fixtures/rc/override-node-option-with-namespace.json @@ -0,0 +1,8 @@ +{ + "nodeOptions": { + "test-isolation": "none" + }, + "testRunner": { + "test-isolation": "process" + } +} diff --git a/test/fixtures/rc/unknown-flag-namespace.json b/test/fixtures/rc/unknown-flag-namespace.json new file mode 100644 index 00000000000000..b5d87ad8dd3acd --- /dev/null +++ b/test/fixtures/rc/unknown-flag-namespace.json @@ -0,0 +1,5 @@ +{ + "testRunner": { + "unknown-flag": true + } +} diff --git a/test/fixtures/rc/unknown-namespace.json b/test/fixtures/rc/unknown-namespace.json new file mode 100644 index 00000000000000..14730d83efff73 --- /dev/null +++ b/test/fixtures/rc/unknown-namespace.json @@ -0,0 +1,5 @@ +{ + "an-invalid-namespace": { + "a-key": "a-value" + } +} diff --git a/test/parallel/test-config-file.js b/test/parallel/test-config-file.js index ac7f9edf3b4082..0e67d12f09a02b 100644 --- a/test/parallel/test-config-file.js +++ b/test/parallel/test-config-file.js @@ -3,8 +3,8 @@ const { spawnPromisified, skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); const fixtures = require('../common/fixtures'); -const { match, strictEqual } = require('node:assert'); -const { test } = require('node:test'); +const { match, strictEqual, deepStrictEqual } = require('node:assert'); +const { test, it, describe } = require('node:test'); const { chmodSync, constants } = require('node:fs'); const common = require('../common'); @@ -55,18 +55,19 @@ test('should parse boolean flag', async () => { strictEqual(result.code, 0); }); -test('should not override a flag declared twice', async () => { +test('should throw an error when a flag is declared twice', async () => { const result = await spawnPromisified(process.execPath, [ '--no-warnings', '--experimental-config-file', fixtures.path('rc/override-property.json'), fixtures.path('typescript/ts/transformation/test-enum.ts'), ]); - strictEqual(result.stderr, ''); - strictEqual(result.stdout, 'Hello, TypeScript!\n'); - strictEqual(result.code, 0); + match(result.stderr, /Option --experimental-transform-types is already defined/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); }); + test('should override env-file', async () => { const result = await spawnPromisified(process.execPath, [ '--no-warnings', @@ -97,7 +98,7 @@ test('should not override NODE_OPTIONS', async () => { strictEqual(result.code, 1); }); -test('should not ovverride CLI flags', async () => { +test('should not override CLI flags', async () => { const result = await spawnPromisified(process.execPath, [ '--no-warnings', '--no-experimental-transform-types', @@ -375,3 +376,147 @@ test('should throw an error when the file is non readable', { skip: common.isWin chmodSync(fixtures.path('rc/non-readable/node.config.json'), constants.S_IRWXU | constants.S_IRWXG | constants.S_IRWXO); }); + +describe('namespace-scoped options', () => { + it('should parse a namespace-scoped option correctly', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--experimental-config-file', + fixtures.path('rc/namespaced/node.config.json'), + '-p', 'require("internal/options").getOptionValue("--test-isolation")', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'none\n'); + strictEqual(result.code, 0); + }); + + it('should throw an error when a namespace-scoped option is not recognised', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/unknown-flag-namespace.json'), + '-p', '"Hello, World!"', + ]); + match(result.stderr, /Unknown or not allowed option unknown-flag/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); + }); + + it('should not throw an error when a namespace is not recognised', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/unknown-namespace.json'), + '-p', '"Hello, World!"', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'Hello, World!\n'); + strictEqual(result.code, 0); + }); + + it('should handle an empty namespace valid namespace', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-config-file', + fixtures.path('rc/empty-valid-namespace.json'), + '-p', '"Hello, World!"', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'Hello, World!\n'); + strictEqual(result.code, 0); + }); + + it('should throw an error if a namespace-scoped option has already been set in node options', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--experimental-config-file', + fixtures.path('rc/override-node-option-with-namespace.json'), + '-p', 'require("internal/options").getOptionValue("--test-isolation")', + ]); + match(result.stderr, /Option --test-isolation is already defined/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); + }); + + it('should throw an error if a node option has already been set in a namespace-scoped option', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--experimental-config-file', + fixtures.path('rc/override-namespace.json'), + '-p', 'require("internal/options").getOptionValue("--test-isolation")', + ]); + match(result.stderr, /Option --test-isolation is already defined/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 9); + }); + + it('should prioritise CLI namespace-scoped options over config file options', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--test-isolation', 'process', + '--experimental-config-file', + fixtures.path('rc/namespaced/node.config.json'), + '-p', 'require("internal/options").getOptionValue("--test-isolation")', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, 'process\n'); + strictEqual(result.code, 0); + }); + + it('should append namespace-scoped config file options with CLI options in case of array', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--test-coverage-exclude', 'cli-pattern1', + '--test-coverage-exclude', 'cli-pattern2', + '--experimental-config-file', + fixtures.path('rc/namespace-with-array.json'), + '-p', 'JSON.stringify(require("internal/options").getOptionValue("--test-coverage-exclude"))', + ]); + strictEqual(result.stderr, ''); + const excludePatterns = JSON.parse(result.stdout); + const expected = [ + 'config-pattern1', + 'config-pattern2', + 'cli-pattern1', + 'cli-pattern2', + ]; + deepStrictEqual(excludePatterns, expected); + strictEqual(result.code, 0); + }); + + it('should allow setting kDisallowedInEnvvar in the config file if part of a namespace', async () => { + // This test assumes that the --test-concurrency flag is configured as kDisallowedInEnvVar + // and that it is part of at least one namespace. + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--experimental-config-file', + fixtures.path('rc/namespace-with-disallowed-envvar.json'), + '-p', 'require("internal/options").getOptionValue("--test-concurrency")', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '1\n'); + strictEqual(result.code, 0); + }); + + it('should override namespace-scoped config file options with CLI options', async () => { + // This test assumes that the --test-concurrency flag is configured as kDisallowedInEnvVar + // and that it is part of at least one namespace. + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--expose-internals', + '--test-concurrency', '2', + '--experimental-config-file', + fixtures.path('rc/namespace-with-disallowed-envvar.json'), + '-p', 'require("internal/options").getOptionValue("--test-concurrency")', + ]); + strictEqual(result.stderr, ''); + strictEqual(result.stdout, '2\n'); + strictEqual(result.code, 0); + }); +});