blob: 9a2391581908d3a8ed0a5d2730268a03f75aa907 [file] [log] [blame]
// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:boolean_selector/boolean_selector.dart';
import 'package:glob/glob.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:source_span/source_span.dart';
import 'package:test_api/scaffolding.dart' // ignore: deprecated_member_use
show
Timeout;
import 'package:test_api/src/backend/operating_system.dart'; // ignore: implementation_imports
import 'package:test_api/src/backend/platform_selector.dart'; // ignore: implementation_imports
import 'package:test_api/src/utils.dart'; // ignore: implementation_imports
import 'package:yaml/yaml.dart';
import '../../util/errors.dart';
import '../../util/io.dart';
import '../../util/pretty_print.dart';
import '../configuration.dart';
import '../runtime_selection.dart';
import '../suite.dart';
import 'custom_runtime.dart';
import 'load.dart' as self;
import 'reporters.dart';
import 'runtime_settings.dart';
/// A regular expression matching a Dart identifier.
///
/// This also matches a package name, since they must be Dart identifiers.
final _identifierRegExp = RegExp(r'[a-zA-Z_]\w*');
/// A regular expression matching allowed package names.
///
/// This allows dot-separated valid Dart identifiers. The dots are there for
/// compatibility with Google's internal Dart packages, but they may not be used
/// when publishing a package to pub.dev.
final _packageName =
RegExp('^${_identifierRegExp.pattern}(\\.${_identifierRegExp.pattern})*\$');
/// Loads configuration information from a YAML file at [path].
///
/// See [loadFromString] for further documentation.
Configuration load(String path, {bool global = false}) {
var source = File(path).readAsStringSync();
return loadFromString(source, global: global, sourceUrl: p.toUri(path));
}
/// Loads configuration information from YAML formatted [source].
///
/// If [global] is `true`, this restricts the configuration file to only rules
/// that are supported globally.
///
/// If [sourceUrl] is provided then that will be set as the source url for
/// the yaml document.
///
/// Throws a [FormatException] if the configuration is invalid.
Configuration loadFromString(String source,
{bool global = false, Uri? sourceUrl}) {
var document = loadYamlNode(source, sourceUrl: sourceUrl);
if (document.value == null) return Configuration.empty;
if (document is! Map) {
throw SourceSpanFormatException(
'The configuration must be a YAML map.', document.span, source);
}
var loader =
_ConfigurationLoader(document as YamlMap, source, global: global);
return loader.load();
}
/// A helper for [load] that tracks the YAML document.
class _ConfigurationLoader {
/// The parsed configuration document.
final YamlMap _document;
/// The source string for [_document].
///
/// Used for error reporting.
final String _source;
/// Whether this is parsing the global configuration file.
final bool _global;
/// Whether runner configuration is allowed at this level.
final bool _runnerConfig;
_ConfigurationLoader(this._document, this._source,
{bool global = false, bool runnerConfig = true})
: _global = global,
_runnerConfig = runnerConfig;
/// Loads the configuration in [_document].
Configuration load() => _loadIncludeConfig()
.merge(_loadGlobalTestConfig())
.merge(_loadLocalTestConfig())
.merge(_loadGlobalRunnerConfig())
.merge(_loadLocalRunnerConfig());
/// If an `include` node is contained in [node], merges and returns [config].
Configuration _loadIncludeConfig() {
if (!_runnerConfig) {
_disallow('include');
return Configuration.empty;
}
var includeNode = _document.nodes['include'];
if (includeNode == null) return Configuration.empty;
var includePath = _parseNode(includeNode, 'include path', p.fromUri);
var basePath =
p.join(p.dirname(p.fromUri(_document.span.sourceUrl)), includePath);
try {
return self.load(basePath);
} on FileSystemException catch (error) {
throw SourceSpanFormatException(
getErrorMessage(error), includeNode.span, _source);
}
}
/// Loads test configuration that's allowed in the global configuration file.
Configuration _loadGlobalTestConfig() {
var verboseTrace = _getBool('verbose_trace');
var chainStackTraces = _getBool('chain_stack_traces');
var foldStackFrames = _loadFoldedStackFrames();
var jsTrace = _getBool('js_trace');
var timeout = _parseValue('timeout', (value) => Timeout.parse(value));
var onPlatform = _getMap('on_platform',
key: (keyNode) => _parseNode(keyNode, 'on_platform key',
(value) => PlatformSelector.parse(value, keyNode.span)),
value: (valueNode) =>
_nestedConfig(valueNode, 'on_platform value', runnerConfig: false));
var onOS = _getMap('on_os',
key: (keyNode) {
_validate(keyNode, 'on_os key must be a string.',
(value) => value is String);
var os = OperatingSystem.find(keyNode.value as String);
if (os != OperatingSystem.none) return os;
throw SourceSpanFormatException(
'Invalid on_os key: No such operating system.',
keyNode.span,
_source);
},
value: (valueNode) => _nestedConfig(valueNode, 'on_os value'));
var presets = _getMap('presets',
key: (keyNode) => _parseIdentifierLike(keyNode, 'presets key'),
value: (valueNode) => _nestedConfig(valueNode, 'presets value'));
var config = Configuration(
verboseTrace: verboseTrace,
jsTrace: jsTrace,
timeout: timeout,
presets: presets,
chainStackTraces: chainStackTraces,
foldTraceExcept: foldStackFrames['except'],
foldTraceOnly: foldStackFrames['only'])
.merge(_extractPresets<PlatformSelector>(
onPlatform, (map) => Configuration(onPlatform: map)));
var osConfig = onOS[currentOS];
return osConfig == null ? config : config.merge(osConfig);
}
/// Loads test configuration that's not allowed in the global configuration
/// file.
///
/// If [_global] is `true`, this will error if there are any local test-level
/// configuration fields.
Configuration _loadLocalTestConfig() {
if (_global) {
_disallow('skip');
_disallow('retry');
_disallow('test_on');
_disallow('add_tags');
_disallow('tags');
return Configuration.empty;
}
var skipRaw = _getValue('skip', 'boolean or string',
(value) => (value is bool?) || value is String?);
String? skipReason;
bool? skip;
if (skipRaw is String) {
skipReason = skipRaw;
skip = true;
} else {
skip = skipRaw as bool?;
}
var testOn = _parsePlatformSelector('test_on');
var addTags = _getList(
'add_tags', (tagNode) => _parseIdentifierLike(tagNode, 'Tag name'));
var tags = _getMap('tags',
key: (keyNode) => _parseNode(
keyNode, 'tags key', (value) => BooleanSelector.parse(value)),
value: (valueNode) =>
_nestedConfig(valueNode, 'tag value', runnerConfig: false));
var retry = _getNonNegativeInt('retry');
return Configuration(
skip: skip,
retry: retry,
skipReason: skipReason,
testOn: testOn,
addTags: addTags)
.merge(_extractPresets<BooleanSelector>(
tags, (map) => Configuration(tags: map)));
}
/// Loads runner configuration that's allowed in the global configuration
/// file.
///
/// If [_runnerConfig] is `false`, this will error if there are any
/// runner-level configuration fields.
Configuration _loadGlobalRunnerConfig() {
if (!_runnerConfig) {
_disallow('pause_after_load');
_disallow('reporter');
_disallow('file_reporters');
_disallow('concurrency');
_disallow('names');
_disallow('plain_names');
_disallow('platforms');
_disallow('add_presets');
_disallow('override_platforms');
_disallow('include');
return Configuration.empty;
}
var pauseAfterLoad = _getBool('pause_after_load');
var runSkipped = _getBool('run_skipped');
var reporter = _getString('reporter');
if (reporter != null && !allReporters.keys.contains(reporter)) {
_error('Unknown reporter "$reporter".', 'reporter');
}
var fileReporters = _getMap('file_reporters', key: (keyNode) {
_validate(keyNode, 'file_reporters key must be a string',
(value) => value is String);
final reporter = keyNode.value as String;
if (!allReporters.keys.contains(reporter)) {
_error('Unknown reporter "$reporter".', 'file_reporters');
}
return reporter;
}, value: (valueNode) {
_validate(valueNode, 'file_reporters value must be a string',
(value) => value is String);
return valueNode.value as String;
});
var concurrency = _getInt('concurrency');
// The UI term "platform" corresponds with the implementation term
// "runtime". The [Runtime] class used to be called [TestPlatform], but it
// was changed to avoid conflicting with [SuitePlatform]. We decided not to
// also change the UI to avoid a painful migration.
var runtimes = _getList(
'platforms',
(runtimeNode) => RuntimeSelection(
_parseIdentifierLike(runtimeNode, 'Platform name'),
runtimeNode.span));
var chosenPresets = _getList('add_presets',
(presetNode) => _parseIdentifierLike(presetNode, 'Preset name'));
var overrideRuntimes = _loadOverrideRuntimes();
var customHtmlTemplatePath = _getString('custom_html_template_path');
return Configuration(
pauseAfterLoad: pauseAfterLoad,
customHtmlTemplatePath: customHtmlTemplatePath,
runSkipped: runSkipped,
reporter: reporter,
fileReporters: fileReporters,
concurrency: concurrency,
runtimes: runtimes,
chosenPresets: chosenPresets,
overrideRuntimes: overrideRuntimes);
}
/// Loads the `override_platforms` field.
Map<String, RuntimeSettings> _loadOverrideRuntimes() {
var runtimesNode =
_getNode('override_platforms', 'map', (value) => value is Map)
as YamlMap?;
if (runtimesNode == null) return const {};
var runtimes = <String, RuntimeSettings>{};
runtimesNode.nodes.forEach((identifierNode, valueNode) {
var yamlNode = identifierNode as YamlNode;
var identifier = _parseIdentifierLike(yamlNode, 'Platform identifier');
_validate(valueNode, 'Platform definition must be a map.',
(value) => value is Map);
var map = valueNode as YamlMap;
var settings = _expect(map, 'settings');
_validate(settings, 'Must be a map.', (value) => value is Map);
runtimes[identifier] =
RuntimeSettings(identifier, yamlNode.span, [settings as YamlMap]);
});
return runtimes;
}
/// Loads runner configuration that's not allowed in the global configuration
/// file.
///
/// If [_runnerConfig] is `false` or if [_global] is `true`, this will error
/// if there are any local test-level configuration fields.
Configuration _loadLocalRunnerConfig() {
if (!_runnerConfig || _global) {
_disallow('pub_serve');
_disallow('names');
_disallow('plain_names');
_disallow('paths');
_disallow('filename');
_disallow('include_tags');
_disallow('exclude_tags');
_disallow('define_platforms');
return Configuration.empty;
}
var pubServePort = _getInt('pub_serve');
var patterns = _getList('names', (nameNode) {
_validate(nameNode, 'Names must be strings.', (value) => value is String);
return _parseNode(nameNode, 'name', (value) => RegExp(value));
})
..addAll(_getList('plain_names', (nameNode) {
_validate(
nameNode, 'Names must be strings.', (value) => value is String);
return _parseNode(nameNode, 'name', (value) => RegExp(value));
}));
var paths = _getList('paths', (pathNode) {
_validate(pathNode, 'Paths must be strings.', (value) => value is String);
_validate(pathNode, 'Paths must be relative.',
(value) => p.url.isRelative(value as String));
return _parseNode(pathNode, 'path', p.fromUri);
});
var filename = _parseValue('filename', (value) => Glob(value));
var includeTags = _parseBooleanSelector('include_tags');
var excludeTags = _parseBooleanSelector('exclude_tags');
var defineRuntimes = _loadDefineRuntimes();
return Configuration(
pubServePort: pubServePort,
patterns: patterns,
paths: paths,
filename: filename,
includeTags: includeTags,
excludeTags: excludeTags,
defineRuntimes: defineRuntimes);
}
/// Returns a map representation of the `fold_stack_frames` configuration.
///
/// The key `except` will correspond to the list of packages to fold.
/// The key `only` will correspond to the list of packages to keep in a
/// test [Chain].
Map<String, List<String>> _loadFoldedStackFrames() {
var foldOptionSet = false;
return _getMap('fold_stack_frames', key: (keyNode) {
_validate(keyNode, 'Must be a string', (value) => value is String);
_validate(keyNode, 'Must be "only" or "except".',
(value) => value == 'only' || value == 'except');
if (foldOptionSet) {
throw SourceSpanFormatException(
'Can only contain one of "only" or "except".',
keyNode.span,
_source);
}
foldOptionSet = true;
return keyNode.value as String;
}, value: (valueNode) {
_validate(
valueNode,
'Folded packages must be strings.',
(valueList) =>
valueList is YamlList &&
valueList.every((value) => value is String));
_validate(
valueNode,
'Invalid package name.',
(valueList) => (valueList as Iterable)
.every((value) => _packageName.hasMatch(value as String)));
return List<String>.from(valueNode.value as Iterable);
});
}
/// Loads the `define_platforms` field.
Map<String, CustomRuntime> _loadDefineRuntimes() {
var runtimesNode =
_getNode('define_platforms', 'map', (value) => value is Map)
as YamlMap?;
if (runtimesNode == null) return const {};
var runtimes = <String, CustomRuntime>{};
runtimesNode.nodes.forEach((identifierNode, valueNode) {
var yamlNode = identifierNode as YamlNode;
var identifier = _parseIdentifierLike(yamlNode, 'Platform identifier');
_validate(valueNode, 'Platform definition must be a map.',
(value) => value is Map);
var map = valueNode as YamlMap;
var nameNode = _expect(map, 'name');
_validate(nameNode, 'Must be a string.', (value) => value is String);
var name = nameNode.value as String;
var parentNode = _expect(map, 'extends');
var parent = _parseIdentifierLike(parentNode, 'Platform parent');
var settings = _expect(map, 'settings');
_validate(settings, 'Must be a map.', (value) => value is Map);
runtimes[identifier] = CustomRuntime(name, nameNode.span, identifier,
yamlNode.span, parent, parentNode.span, settings as YamlMap);
});
return runtimes;
}
/// Throws an exception with [message] if [test] returns `false` when passed
/// [node]'s value.
void _validate(YamlNode node, String message, bool Function(dynamic) test) {
if (test(node.value)) return;
throw SourceSpanFormatException(message, node.span, _source);
}
/// Returns the value of the node at [field].
///
/// If [typeTest] returns `false` for that value, instead throws an error
/// complaining that the field is not a [typeName].
Object? _getValue(
String field, String typeName, bool Function(dynamic) typeTest) {
var value = _document[field];
if (value == null || typeTest(value)) return value;
_error('$field must be ${a(typeName)}.', field);
}
/// Returns the YAML node at [field].
///
/// If [typeTest] returns `false` for that node's value, instead throws an
/// error complaining that the field is not a [typeName].
///
/// Returns `null` if [field] does not have a node in [_document].
YamlNode? _getNode(
String field, String typeName, bool Function(dynamic) typeTest) {
var node = _document.nodes[field];
if (node == null) return null;
_validate(node, '$field must be ${a(typeName)}.', typeTest);
return node;
}
/// Asserts that [field] is an int and returns its value.
int? _getInt(String field) =>
_getValue(field, 'int', (value) => value is int?) as int?;
/// Asserts that [field] is a non-negative int and returns its value.
int? _getNonNegativeInt(String field) =>
_getValue(field, 'non-negative int', (value) {
if (value == null) return true;
return value is int && value >= 0;
}) as int?;
/// Asserts that [field] is a boolean and returns its value.
bool? _getBool(String field) =>
_getValue(field, 'boolean', (value) => value is bool?) as bool?;
/// Asserts that [field] is a string and returns its value.
String? _getString(String field) =>
_getValue(field, 'string', (value) => value is String?) as String?;
/// Asserts that [field] is a list and runs [forElement] for each element it
/// contains.
///
/// Returns a list of values returned by [forElement].
List<T> _getList<T>(String field, T Function(YamlNode) forElement) {
var node = _getNode(field, 'list', (value) => value is List) as YamlList?;
if (node == null) return [];
return node.nodes.map(forElement).toList();
}
/// Asserts that [field] is a map and runs [key] and [value] for each pair.
///
/// Returns a map with the keys and values returned by [key] and [value]. Each
/// of these defaults to asserting that the value is a string.
Map<K, V> _getMap<K, V>(String field,
{K Function(YamlNode)? key, V Function(YamlNode)? value}) {
var node = _getNode(field, 'map', (value) => value is Map) as YamlMap?;
if (node == null) return {};
key ??= (keyNode) {
_validate(
keyNode, '$field keys must be strings.', (value) => value is String);
return keyNode.value as K;
};
value ??= (valueNode) {
_validate(valueNode, '$field values must be strings.',
(value) => value is String);
return valueNode.value as V;
};
return node.nodes.map((keyNode, valueNode) =>
MapEntry(key!(keyNode as YamlNode), value!(valueNode)));
}
/// Verifies that [node]'s value is an optionally hyphenated Dart identifier,
/// and returns it
String _parseIdentifierLike(YamlNode node, String name) {
_validate(node, '$name must be a string.', (value) => value is String);
_validate(node, '$name must be an (optionally hyphenated) Dart identifier.',
(value) => (value as String).contains(anchoredHyphenatedIdentifier));
return node.value as String;
}
/// Parses [node]'s value as a boolean selector.
BooleanSelector? _parseBooleanSelector(String name) =>
_parseValue(name, (value) => BooleanSelector.parse(value));
/// Parses [node]'s value as a platform selector.
PlatformSelector? _parsePlatformSelector(String field) {
var node = _document.nodes[field];
if (node == null) return null;
return _parseNode(
node, field, (value) => PlatformSelector.parse(value, node.span));
}
/// Asserts that [node] is a string, passes its value to [parse], and returns
/// the result.
///
/// If [parse] throws a [FormatException], it's wrapped to include [node]'s
/// span.
T _parseNode<T>(YamlNode node, String name, T Function(String) parse) {
_validate(node, '$name must be a string.', (value) => value is String);
try {
return parse(node.value as String);
} on FormatException catch (error) {
throw SourceSpanFormatException(
'Invalid $name: ${error.message}', node.span, _source);
}
}
/// Asserts that [field] is a string, passes it to [parse], and returns the
/// result.
///
/// If [parse] throws a [FormatException], it's wrapped to include [field]'s
/// span.
T? _parseValue<T>(String field, T Function(String) parse) {
var node = _document.nodes[field];
if (node == null) return null;
return _parseNode(node, field, parse);
}
/// Parses a nested configuration document.
///
/// [name] is the name of the field, which is used for error-handling.
/// [runnerConfig] controls whether runner configuration is allowed in the
/// nested configuration. It defaults to [_runnerConfig].
Configuration _nestedConfig(YamlNode? node, String name,
{bool? runnerConfig}) {
if (node == null || node.value == null) return Configuration.empty;
_validate(node, '$name must be a map.', (value) => value is Map);
var loader = _ConfigurationLoader(node as YamlMap, _source,
global: _global, runnerConfig: runnerConfig ?? _runnerConfig);
return loader.load();
}
/// Takes a map that contains [Configuration]s and extracts any
/// preset-specific configuration into a parent [Configuration].
///
/// This is needed because parameters to [new Configuration] such as
/// `onPlatform` take maps to [SuiteConfiguration]s. [SuiteConfiguration]
/// doesn't support preset-specific configuration, so this extracts the preset
/// logic into a parent [Configuration], leaving only maps to
/// [SuiteConfiguration]s. The [create] function is used to construct
/// [Configuration]s from the resolved maps.
Configuration _extractPresets<T>(Map<T, Configuration> map,
Configuration Function(Map<T, SuiteConfiguration>) create) {
if (map.isEmpty) return Configuration.empty;
var base = <T, SuiteConfiguration>{};
var presets = <String, Map<T, SuiteConfiguration>>{};
map.forEach((key, config) {
base[key] = config.suiteDefaults;
config.presets.forEach((preset, presetConfig) {
presets.putIfAbsent(preset, () => {})[key] = presetConfig.suiteDefaults;
});
});
if (presets.isEmpty) {
return base.isEmpty ? Configuration.empty : create(base);
} else {
var newPresets = presets.map((key, map) => MapEntry(key, create(map)));
return create(base).change(presets: newPresets);
}
}
/// Asserts that [map] has a field named [field] and returns it.
YamlNode _expect(YamlMap map, String field) {
var node = map.nodes[field];
if (node != null) return node;
throw SourceSpanFormatException(
'Missing required field "$field".', map.span, _source);
}
/// Throws an error if a field named [field] exists at this level.
void _disallow(String field) {
if (!_document.containsKey(field)) return;
throw SourceSpanFormatException(
"$field isn't supported here.",
// We need the key as a [YamlNode] to get its span.
(_document.nodes.keys.firstWhere((key) => key.value == field)
as YamlNode)
.span,
_source);
}
/// Throws a [SourceSpanFormatException] with [message] about [field].
@alwaysThrows
void _error(String message, String field) {
throw SourceSpanFormatException(
message, _document.nodes[field]!.span, _source);
}
}