blob: ef9315335c29b0cb81e003d7cdd8f98855316e00 [file] [log] [blame]
// 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 '../scaffolding/skip.dart';
import '../scaffolding/timeout.dart';
import '../util/pretty_print.dart';
import '../utils.dart';
import 'platform_selector.dart';
import 'suite_platform.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.
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) {
if (metadata is Timeout || metadata is Skip) {
metadata = [metadata];
} else if (metadata is! List) {
throw ArgumentError('Metadata for platform "$platform" must be a '
'Timeout, Skip, or List of those; was "$metadata".');
}
var selector = PlatformSelector.parse(platform);
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".');
}
}
result[selector] = Metadata.parse(timeout: timeout, skip: skip);
});
return result;
}
/// 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(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 [new 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,
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(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 int?,
tags = Set.from(serialized['tags'] as Iterable),
onPlatform = {
for (var pair in serialized['onPlatform'])
PlatformSelector.parse(pair.first as String):
Metadata.deserialize(pair.last)
},
forTag = (serialized['forTag'] as Map).map((key, nested) => MapEntry(
BooleanSelector.parse(key as String),
Metadata.deserialize(nested))),
languageVersionComment =
serialized['languageVersionComment'] as String?;
/// Deserializes timeout from the format returned by [_serializeTimeout].
static Timeout _deserializeTimeout(serialized) {
if (serialized == 'none') return Timeout.none;
var scaleFactor = serialized['scaleFactor'];
if (scaleFactor != null) return Timeout.factor(scaleFactor as num);
return Timeout(Duration(microseconds: serialized['duration'] as int));
}
/// 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 = [];
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
};
}
}