| // Copyright (c) 2015, 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 'package:boolean_selector/boolean_selector.dart'; | |
| import 'package:collection/collection.dart'; | |
| import 'configuration/skip.dart'; | |
| import 'configuration/timeout.dart'; | |
| import 'platform_selector.dart'; | |
| import 'suite_platform.dart'; | |
| import 'util/identifier_regex.dart'; | |
| import 'util/pretty_print.dart'; | |
| /// Metadata for a test or test suite. | |
| /// | |
| /// This metadata comes from declarations on the test itself; it doesn't include | |
| /// configuration from the user. | |
| final class Metadata { | |
| /// Empty metadata with only default values. | |
| /// | |
| /// Using this is slightly more efficient than manually constructing a new | |
| /// metadata with no arguments. | |
| static final empty = Metadata._(); | |
| /// The selector indicating which platforms the suite supports. | |
| final PlatformSelector testOn; | |
| /// The modification to the timeout for the test or suite. | |
| final Timeout timeout; | |
| /// Whether the test or suite should be skipped. | |
| bool get skip => _skip ?? false; | |
| final bool? _skip; | |
| /// The reason the test or suite should be skipped, if given. | |
| final String? skipReason; | |
| /// Whether to use verbose stack traces. | |
| bool get verboseTrace => _verboseTrace ?? false; | |
| final bool? _verboseTrace; | |
| /// Whether to chain stack traces. | |
| bool get chainStackTraces => _chainStackTraces ?? _verboseTrace ?? false; | |
| final bool? _chainStackTraces; | |
| /// The user-defined tags attached to the test or suite. | |
| final Set<String> tags; | |
| /// The number of times to re-run a test before being marked as a failure. | |
| int get retry => _retry ?? 0; | |
| final int? _retry; | |
| /// Platform-specific metadata. | |
| /// | |
| /// Each key identifies a platform, and its value identifies the specific | |
| /// metadata for that platform. These can be applied by calling [forPlatform]. | |
| final Map<PlatformSelector, Metadata> onPlatform; | |
| /// Metadata that applies only when specific tags are applied. | |
| /// | |
| /// Tag-specific metadata is applied when merging this with other metadata. | |
| /// Note that unlike [onPlatform], the base metadata takes precedence over any | |
| /// tag-specific metadata. | |
| /// | |
| /// This is guaranteed not to have any keys that match [tags]; those are | |
| /// resolved when the metadata is constructed. | |
| final Map<BooleanSelector, Metadata> forTag; | |
| /// The language version comment, if one is present. | |
| /// | |
| /// Only available for test suites and not individual tests. | |
| final String? languageVersionComment; | |
| /// Parses a user-provided map into the value for [onPlatform]. | |
| static Map<PlatformSelector, Metadata> _parseOnPlatform( | |
| Map<String, dynamic>? onPlatform, | |
| ) { | |
| if (onPlatform == null) return {}; | |
| var result = <PlatformSelector, Metadata>{}; | |
| onPlatform.forEach((platform, metadata) { | |
| var selector = PlatformSelector.parse(platform); | |
| if (metadata is Timeout || metadata is Skip) { | |
| result[selector] = _parsePlatformOptions(platform, [metadata]); | |
| } else if (metadata is List) { | |
| result[selector] = _parsePlatformOptions(platform, metadata); | |
| } else { | |
| throw ArgumentError( | |
| 'Metadata for platform "$platform" must be a ' | |
| 'Timeout, Skip, or List of those; was "$metadata".', | |
| ); | |
| } | |
| }); | |
| return result; | |
| } | |
| static Metadata _parsePlatformOptions( | |
| String platform, | |
| List<dynamic> metadata, | |
| ) { | |
| Timeout? timeout; | |
| dynamic skip; | |
| for (var metadatum in metadata) { | |
| if (metadatum is Timeout) { | |
| if (timeout != null) { | |
| throw ArgumentError( | |
| 'Only a single Timeout may be declared for ' | |
| '"$platform".', | |
| ); | |
| } | |
| timeout = metadatum; | |
| } else if (metadatum is Skip) { | |
| if (skip != null) { | |
| throw ArgumentError( | |
| 'Only a single Skip may be declared for ' | |
| '"$platform".', | |
| ); | |
| } | |
| skip = metadatum.reason ?? true; | |
| } else { | |
| throw ArgumentError( | |
| 'Metadata for platform "$platform" must be a ' | |
| 'Timeout, Skip, or List of those; was "$metadata".', | |
| ); | |
| } | |
| } | |
| return Metadata.parse(timeout: timeout, skip: skip); | |
| } | |
| /// Parses a user-provided [String] or [Iterable] into the value for [tags]. | |
| /// | |
| /// Throws an [ArgumentError] if [tags] is not a [String] or an [Iterable]. | |
| static Set<String> _parseTags(Object? tags) { | |
| if (tags == null) return {}; | |
| if (tags is String) return {tags}; | |
| if (tags is! Iterable) { | |
| throw ArgumentError.value( | |
| tags, | |
| 'tags', | |
| 'must be either a String or an Iterable.', | |
| ); | |
| } | |
| if (tags.any((tag) => tag is! String)) { | |
| throw ArgumentError.value(tags, 'tags', 'must contain only Strings.'); | |
| } | |
| return Set.from(tags); | |
| } | |
| /// Creates new Metadata. | |
| /// | |
| /// [testOn] defaults to [PlatformSelector.all]. | |
| /// | |
| /// If [forTag] contains metadata that applies to [tags], that metadata is | |
| /// included inline in the returned value. The values directly passed to the | |
| /// constructor take precedence over tag-specific metadata. | |
| factory Metadata({ | |
| PlatformSelector? testOn, | |
| Timeout? timeout, | |
| bool? skip, | |
| bool? verboseTrace, | |
| bool? chainStackTraces, | |
| int? retry, | |
| String? skipReason, | |
| Iterable<String>? tags, | |
| Map<PlatformSelector, Metadata>? onPlatform, | |
| Map<BooleanSelector, Metadata>? forTag, | |
| String? languageVersionComment, | |
| }) { | |
| // Returns metadata without forTag resolved at all. | |
| Metadata unresolved() => Metadata._( | |
| testOn: testOn, | |
| timeout: timeout, | |
| skip: skip, | |
| verboseTrace: verboseTrace, | |
| chainStackTraces: chainStackTraces, | |
| retry: retry, | |
| skipReason: skipReason, | |
| tags: tags, | |
| onPlatform: onPlatform, | |
| forTag: forTag, | |
| languageVersionComment: languageVersionComment, | |
| ); | |
| // If there's no tag-specific metadata, or if none of it applies, just | |
| // return the metadata as-is. | |
| if (forTag == null || tags == null) return unresolved(); | |
| tags = Set.from(tags); | |
| forTag = Map.from(forTag); | |
| // Otherwise, resolve the tag-specific components. Doing this eagerly means | |
| // we only have to resolve suite- or group-level tags once, rather than | |
| // doing it for every test individually. | |
| var empty = Metadata._(); | |
| var merged = forTag.keys.toList().fold(empty, (Metadata merged, selector) { | |
| if (!selector.evaluate(tags!.contains)) return merged; | |
| return merged.merge(forTag!.remove(selector)!); | |
| }); | |
| if (merged == empty) return unresolved(); | |
| return merged.merge(unresolved()); | |
| } | |
| /// Creates new Metadata. | |
| /// | |
| /// Unlike [Metadata], this assumes [forTag] is already resolved. | |
| Metadata._({ | |
| PlatformSelector? testOn, | |
| Timeout? timeout, | |
| bool? skip, | |
| this.skipReason, | |
| bool? verboseTrace, | |
| bool? chainStackTraces, | |
| int? retry, | |
| Iterable<String>? tags, | |
| Map<PlatformSelector, Metadata>? onPlatform, | |
| Map<BooleanSelector, Metadata>? forTag, | |
| this.languageVersionComment, | |
| }) : testOn = testOn ?? PlatformSelector.all, | |
| timeout = timeout ?? const Timeout.factor(1), | |
| _skip = skip, | |
| _verboseTrace = verboseTrace, | |
| _chainStackTraces = chainStackTraces, | |
| _retry = retry, | |
| tags = UnmodifiableSetView(tags == null ? {} : tags.toSet()), | |
| onPlatform = onPlatform == null | |
| ? const {} | |
| : UnmodifiableMapView(onPlatform), | |
| forTag = forTag == null ? const {} : UnmodifiableMapView(forTag) { | |
| if (retry != null) RangeError.checkNotNegative(retry, 'retry'); | |
| _validateTags(); | |
| } | |
| /// Creates a new Metadata, but with fields parsed from caller-friendly values | |
| /// where applicable. | |
| /// | |
| /// Throws a [FormatException] if any field is invalid. | |
| Metadata.parse({ | |
| String? testOn, | |
| Timeout? timeout, | |
| dynamic skip, | |
| bool? verboseTrace, | |
| bool? chainStackTraces, | |
| int? retry, | |
| Map<String, dynamic>? onPlatform, | |
| Object? /* String|Iterable<String> */ tags, | |
| this.languageVersionComment, | |
| }) : testOn = testOn == null | |
| ? PlatformSelector.all | |
| : PlatformSelector.parse(testOn), | |
| timeout = timeout ?? const Timeout.factor(1), | |
| _skip = skip == null ? null : skip != false, | |
| _verboseTrace = verboseTrace, | |
| _chainStackTraces = chainStackTraces, | |
| _retry = retry, | |
| skipReason = skip is String ? skip : null, | |
| onPlatform = _parseOnPlatform(onPlatform), | |
| tags = _parseTags(tags), | |
| forTag = const {} { | |
| if (skip != null && skip is! String && skip is! bool) { | |
| throw ArgumentError('"skip" must be a String or a bool, was "$skip".'); | |
| } | |
| if (retry != null) RangeError.checkNotNegative(retry, 'retry'); | |
| _validateTags(); | |
| } | |
| /// Deserializes the result of [Metadata.serialize] into a new [Metadata]. | |
| Metadata.deserialize(Map serialized) | |
| : testOn = serialized['testOn'] == null | |
| ? PlatformSelector.all | |
| : PlatformSelector.parse(serialized['testOn'] as String), | |
| timeout = _deserializeTimeout(serialized['timeout']), | |
| _skip = serialized['skip'] as bool?, | |
| skipReason = serialized['skipReason'] as String?, | |
| _verboseTrace = serialized['verboseTrace'] as bool?, | |
| _chainStackTraces = serialized['chainStackTraces'] as bool?, | |
| _retry = (serialized['retry'] as num?)?.toInt(), | |
| tags = Set.from(serialized['tags'] as Iterable), | |
| onPlatform = { | |
| for (var pair in serialized['onPlatform'] as List) | |
| PlatformSelector.parse(pair.first as String): Metadata.deserialize( | |
| pair.last as Map, | |
| ), | |
| }, | |
| forTag = (serialized['forTag'] as Map).map( | |
| (key, nested) => MapEntry( | |
| BooleanSelector.parse(key as String), | |
| Metadata.deserialize(nested as Map), | |
| ), | |
| ), | |
| languageVersionComment = serialized['languageVersionComment'] as String?; | |
| /// Deserializes timeout from the format returned by [_serializeTimeout]. | |
| static Timeout _deserializeTimeout(Object? serialized) { | |
| if (serialized == 'none') return Timeout.none; | |
| var scaleFactor = (serialized as Map)['scaleFactor']; | |
| if (scaleFactor != null) return Timeout.factor(scaleFactor as num); | |
| return Timeout( | |
| Duration(microseconds: (serialized['duration'] as num).toInt()), | |
| ); | |
| } | |
| /// Throws an [ArgumentError] if any tags in [tags] aren't hyphenated | |
| /// identifiers. | |
| void _validateTags() { | |
| var invalidTags = tags | |
| .where((tag) => !tag.contains(anchoredHyphenatedIdentifier)) | |
| .map((tag) => '"$tag"') | |
| .toList(); | |
| if (invalidTags.isEmpty) return; | |
| throw ArgumentError( | |
| "Invalid ${pluralize('tag', invalidTags.length)} " | |
| '${toSentence(invalidTags)}. Tags must be (optionally hyphenated) ' | |
| 'Dart identifiers.', | |
| ); | |
| } | |
| /// Throws a [FormatException] if any [PlatformSelector]s use any variables | |
| /// that don't appear either in [validVariables] or in the set of variables | |
| /// that are known to be valid for all selectors. | |
| void validatePlatformSelectors(Set<String> validVariables) { | |
| testOn.validate(validVariables); | |
| onPlatform.forEach((selector, metadata) { | |
| selector.validate(validVariables); | |
| metadata.validatePlatformSelectors(validVariables); | |
| }); | |
| } | |
| /// Return a new [Metadata] that merges [this] with [other]. | |
| /// | |
| /// If the two [Metadata]s have conflicting properties, [other] wins. If | |
| /// either has a [forTag] metadata for one of the other's tags, that metadata | |
| /// is merged as well. | |
| Metadata merge(Metadata other) => Metadata( | |
| testOn: testOn.intersection(other.testOn), | |
| timeout: timeout.merge(other.timeout), | |
| skip: other._skip ?? _skip, | |
| skipReason: other.skipReason ?? skipReason, | |
| verboseTrace: other._verboseTrace ?? _verboseTrace, | |
| chainStackTraces: other._chainStackTraces ?? _chainStackTraces, | |
| retry: other._retry ?? _retry, | |
| tags: tags.union(other.tags), | |
| onPlatform: mergeMaps( | |
| onPlatform, | |
| other.onPlatform, | |
| value: (metadata1, metadata2) => metadata1.merge(metadata2), | |
| ), | |
| forTag: mergeMaps( | |
| forTag, | |
| other.forTag, | |
| value: (metadata1, metadata2) => metadata1.merge(metadata2), | |
| ), | |
| languageVersionComment: | |
| other.languageVersionComment ?? languageVersionComment, | |
| ); | |
| /// Returns a copy of [this] with the given fields changed. | |
| Metadata change({ | |
| PlatformSelector? testOn, | |
| Timeout? timeout, | |
| bool? skip, | |
| bool? verboseTrace, | |
| bool? chainStackTraces, | |
| int? retry, | |
| String? skipReason, | |
| Map<PlatformSelector, Metadata>? onPlatform, | |
| Set<String>? tags, | |
| Map<BooleanSelector, Metadata>? forTag, | |
| String? languageVersionComment, | |
| }) { | |
| testOn ??= this.testOn; | |
| timeout ??= this.timeout; | |
| skip ??= _skip; | |
| verboseTrace ??= _verboseTrace; | |
| chainStackTraces ??= _chainStackTraces; | |
| retry ??= _retry; | |
| skipReason ??= this.skipReason; | |
| onPlatform ??= this.onPlatform; | |
| tags ??= this.tags; | |
| forTag ??= this.forTag; | |
| languageVersionComment ??= this.languageVersionComment; | |
| return Metadata( | |
| testOn: testOn, | |
| timeout: timeout, | |
| skip: skip, | |
| verboseTrace: verboseTrace, | |
| chainStackTraces: chainStackTraces, | |
| skipReason: skipReason, | |
| onPlatform: onPlatform, | |
| tags: tags, | |
| forTag: forTag, | |
| retry: retry, | |
| languageVersionComment: languageVersionComment, | |
| ); | |
| } | |
| /// Returns a copy of [this] with all platform-specific metadata from | |
| /// [onPlatform] resolved. | |
| Metadata forPlatform(SuitePlatform platform) { | |
| if (onPlatform.isEmpty) return this; | |
| var metadata = this; | |
| onPlatform.forEach((platformSelector, platformMetadata) { | |
| if (!platformSelector.evaluate(platform)) return; | |
| metadata = metadata.merge(platformMetadata); | |
| }); | |
| return metadata.change(onPlatform: {}); | |
| } | |
| /// Serializes [this] into a JSON-safe object that can be deserialized using | |
| /// [Metadata.deserialize]. | |
| Map<String, dynamic> serialize() { | |
| // Make this a list to guarantee that the order is preserved. | |
| var serializedOnPlatform = <List<Object>>[]; | |
| onPlatform.forEach((key, value) { | |
| serializedOnPlatform.add([key.toString(), value.serialize()]); | |
| }); | |
| return { | |
| 'testOn': testOn == PlatformSelector.all ? null : testOn.toString(), | |
| 'timeout': _serializeTimeout(timeout), | |
| 'skip': _skip, | |
| 'skipReason': skipReason, | |
| 'verboseTrace': _verboseTrace, | |
| 'chainStackTraces': _chainStackTraces, | |
| 'retry': _retry, | |
| 'tags': tags.toList(), | |
| 'onPlatform': serializedOnPlatform, | |
| 'forTag': forTag.map( | |
| (selector, metadata) => | |
| MapEntry(selector.toString(), metadata.serialize()), | |
| ), | |
| 'languageVersionComment': languageVersionComment, | |
| }; | |
| } | |
| /// Serializes timeout into a JSON-safe object. | |
| dynamic _serializeTimeout(Timeout timeout) { | |
| if (timeout == Timeout.none) return 'none'; | |
| return { | |
| 'duration': timeout.duration?.inMicroseconds, | |
| 'scaleFactor': timeout.scaleFactor, | |
| }; | |
| } | |
| } |