blob: aee48699a7e78f2d846e87784abea75ece73a6de [file] [log] [blame]
// Copyright (c) 2020, 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:analyzer/file_system/file_system.dart';
import 'package:analyzer/file_system/memory_file_system.dart';
import 'package:analyzer/src/dart/sdk/sdk.dart';
import 'package:analyzer/src/test_utilities/mock_sdk.dart';
import 'package:dartdoc/src/dartdoc_options.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/package_config_provider.dart';
import 'package:dartdoc/src/package_meta.dart';
import 'package:dartdoc/src/warnings.dart';
import 'package:test/test.dart';
import 'src/utils.dart' as utils;
void main() {
Matcher hasInvalidParameterWarning(String message) =>
_HasWarning(PackageWarning.invalidParameter, message);
Matcher hasMissingExampleWarning(String message) =>
_HasWarning(PackageWarning.missingExampleFile, message);
group('documentation_comment tests', () {
late MemoryResourceProvider resourceProvider;
late PackageMetaProvider packageMetaProvider;
late FakePackageConfigProvider packageConfigProvider;
late Folder projectRoot;
final packageName = 'my_package';
late PackageGraph packageGraph;
late ModelElement libraryModel;
void expectNoWarnings() {
expect(packageGraph.packageWarningCounter.hasWarnings, isFalse);
expect(packageGraph.packageWarningCounter.countedWarnings, isEmpty);
}
setUp(() async {
resourceProvider = MemoryResourceProvider();
final sdkRoot = resourceProvider.getFolder(
resourceProvider.convertPath('/sdk'),
);
createMockSdk(
resourceProvider: resourceProvider,
root: sdkRoot,
);
utils.writeMockSdkFiles(sdkRoot);
packageMetaProvider = PackageMetaProvider(
PubPackageMeta.fromElement,
PubPackageMeta.fromFilename,
PubPackageMeta.fromDir,
resourceProvider,
sdkRoot,
defaultSdk: FolderBasedDartSdk(resourceProvider, sdkRoot),
messageForMissingPackageMeta:
PubPackageMeta.messageForMissingPackageMeta,
);
packageConfigProvider = FakePackageConfigProvider();
// To build the package graph, we always ask package_config for a
// [PackageConfig] for the SDK directory. Put a dummy entry in.
packageConfigProvider.addPackageToConfigFor(
sdkRoot.path, 'analyzer', Uri.file('/sdk/pkg/analyzer/'));
projectRoot = utils.writePackage(
packageName, resourceProvider, packageConfigProvider);
projectRoot
.getChildAssumingFile('dartdoc_options.yaml')
.writeAsStringSync('''
dartdoc:
warnings:
- missing-code-block-language
''');
projectRoot
.getChildAssumingFolder('lib')
.getChildAssumingFile('a.dart')
.writeAsStringSync('''
/// Documentation comment.
int x;
''');
var optionSet = await DartdocOptionRoot.fromOptionGenerators(
'dartdoc', [createDartdocOptions], packageMetaProvider);
optionSet.parseArguments([]);
packageGraph = await utils.bootBasicPackage(
projectRoot.path, packageMetaProvider, packageConfigProvider,
additionalArguments: []);
libraryModel = packageGraph.defaultPackage.libraries.first;
});
test('removes triple slashes', () async {
var doc = await libraryModel.processComment('''
/// Text.
/// More text.
''');
expect(doc, equals('''
Text.
More text.'''));
});
test('removes space after triple slashes', () async {
var doc = await libraryModel.processComment('''
/// Text.
/// More text.
''');
// TODO(srawlins): Actually, the three spaces before 'More' is perhaps not
// the best fit. Should it only be two, to match the indent from the first
// line's "Text"?
expect(doc, equals('''
Text.
More text.'''));
});
test('leaves blank lines', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// More text.
''');
expect(doc, equals('''
Text.
More text.'''));
});
test('warns when an unknown directive is parsed', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@marco name}
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel, PackageWarning.unknownDirective, "'marco'"),
isTrue);
});
test('warns when a directive with wrong case is parsed', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@youTube url}
''');
expect(
packageGraph.packageWarningCounter.hasWarning(libraryModel,
PackageWarning.unknownDirective, "'youTube' (use lowercase)"),
isTrue);
});
test('processes @animation', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expectNoWarnings();
var rendered = libraryModel.modelElementRenderer.renderAnimation(
'barHerderAnimation',
100,
200,
Uri.parse('http://host/path/to/video.mp4'),
'barHerderAnimation_play_button_');
expect(doc, equals('''
Text.
$rendered
End text.'''));
});
test('renders an unnamed @animation', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4}
''');
expectNoWarnings();
expect(doc, contains('<video id="animation_1"'));
});
test('renders a named @animation', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id=namedAnimation}
''');
expectNoWarnings();
expect(doc, contains('<video id="namedAnimation"'));
});
test('renders a named @animation, out-of-order', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 id=namedAnimation http://host/path/to/video.mp4}
''');
expectNoWarnings();
expect(doc, contains('<video id="namedAnimation"'));
});
test('renders a named @animation with double quotes', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id="namedAnimation"}
''');
expectNoWarnings();
expect(doc, contains('<video id="namedAnimation"'));
});
test('renders a named @animation with single quotes', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id='namedAnimation'}
''');
expectNoWarnings();
expect(doc, contains('<video id="namedAnimation"'));
});
test('renders multiple @animation using unique IDs', () async {
var doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4}
/// {@animation 100 200 http://host/path/to/video2.mp4}
''');
expectNoWarnings();
expect(doc, contains('<video id="animation_1"'));
expect(doc, contains('<video id="animation_2"'));
// A second element with unnamed animations requires unique IDs as well.
doc = await libraryModel.processComment('''
/// First line.
///
/// {@animation 100 200 http://host/path/to/video.mp4}
/// {@animation 100 200 http://host/path/to/video2.mp4}
''');
expectNoWarnings();
expect(doc, contains('<video id="animation_3"'));
expect(doc, contains('<video id="animation_4"'));
});
test('warns when @animation has fewer than 3 arguments', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'Invalid @animation directive, "{@animation 100 '
'http://host/path/to/video.mp4 id=barHerderAnimation}"\n'
'Animation directives must be of the form "{@animation WIDTH '
'HEIGHT URL [id=ID]}"'));
});
test('warns when @animation has more than 4 arguments', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 300 400 http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'Invalid @animation directive, "{@animation 100 200 300 400 '
'http://host/path/to/video.mp4 id=barHerderAnimation}"\n'
'Animation directives must be of the form "{@animation WIDTH '
'HEIGHT URL [id=ID]}"'));
});
test('warns when @animation has more than 4 arguments', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 300 400 http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'Invalid @animation directive, "{@animation 100 200 300 400 '
'http://host/path/to/video.mp4 id=barHerderAnimation}"\n'
'Animation directives must be of the form "{@animation WIDTH '
'HEIGHT URL [id=ID]}"'));
});
test('warns when @animation has a non-unique identifier', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id=barHerderAnimation}
/// {@animation 100 200 http://host/path/to/video.mpg id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'An animation has a non-unique identifier, '
'"barHerderAnimation". Animation identifiers must be unique.'));
});
test('warns when @animation has an invalid identifier', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 http://host/path/to/video.mp4 id=not-valid}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'An animation has an invalid identifier, "not-valid". The '
'identifier can only contain letters, numbers and underscores, and '
'must not begin with a number.'));
});
test('warns when @animation has a malformed width', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100px 200 http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'An animation has an invalid width (barHerderAnimation), '
'"100px". The width must be an integer.'));
});
test('warns when @animation has a malformed height', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200px http://host/path/to/video.mp4 id=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'An animation has an invalid height (barHerderAnimation), '
'"200px". The height must be an integer.'));
});
test('warns when @animation has an unknown parameter', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation 100 200 http://host/path/to/video.mp4 name=barHerderAnimation}
///
/// End text.
''');
expect(
libraryModel,
hasInvalidParameterWarning(
'The {@animation ...} directive was called with invalid '
'parameters. FormatException: Could not find an option named '
'"name".'));
});
test('warns when @animation uses the deprecated syntax', () async {
await libraryModel.processComment('''
/// Text.
///
/// {@animation barHerderAnimation 100 200 http://host/path/to/video.mp4}
///
/// End text.
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel,
PackageWarning.deprecated,
'Deprecated form of @animation directive, "{@animation '
'barHerderAnimation 100 200 http://host/path/to/video.mp4}"\n'
'Animation directives are now of the form "{@animation WIDTH '
'HEIGHT URL [id=ID]}" (id is an optional parameter)'),
isTrue);
});
test('processes @template', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@template abc}
/// Template text.
/// {@endtemplate}
///
/// End text.
''');
expectNoWarnings();
expect(doc, equals('''
Text.
{@macro abc}
End text.'''));
});
test('processes leading @template', () async {
var doc = await libraryModel.processComment('''
/// {@template abc}
/// Template text.
/// {@endtemplate}
///
/// End text.
''');
expectNoWarnings();
expect(doc, equals('''
{@macro abc}
End text.'''));
});
test('processes trailing @template', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@template abc}
/// Template text.
/// {@endtemplate}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
{@macro abc}'''));
});
test('processes @template w/o blank line following', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@template abc}
/// Template text.
/// {@endtemplate}
/// End text.
''');
expectNoWarnings();
expect(doc, equals('''
Text.
{@macro abc}
End text.'''));
});
test('allows whitespace around @template name', () async {
var doc = await libraryModel.processComment('''
/// {@template abc }
/// Template text.
/// {@endtemplate}
''');
expectNoWarnings();
expect(doc, equals('{@macro abc}'));
});
test('processes @example with file', () async {
projectRoot.getChildAssumingFile('abc.md').writeAsStringSync('''
```plaintext
Code snippet
```
''');
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@example abc}
///
/// End text.
''');
expectNoWarnings();
expect(doc, equals('''
Text.
```plaintext
Code snippet
```
End text.'''));
});
test('processes @example with a region', () async {
projectRoot
.getChildAssumingFile('abc-r.md')
.writeAsStringSync('Markdown text.');
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@example region=r abc}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
Markdown text.'''));
});
test('adds language to processed @example with an extension and no lang',
() async {
projectRoot.getChildAssumingFile('abc.html.md').writeAsStringSync('''
```
Code snippet
```
''');
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@example abc.html}
///
/// End text.
''');
expectNoWarnings();
expect(doc, equals('''
Text.
```html
Code snippet
```
End text.'''));
});
test('adds language to processed @example with a lang and an extension',
() async {
projectRoot.getChildAssumingFile('abc.html.md').writeAsStringSync('''
```
Code snippet
```
''');
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@example abc.html lang=html}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
```html
Code snippet
```
'''));
});
test('adds language to processed @example with a lang and no extension',
() async {
projectRoot.getChildAssumingFile('abc.md').writeAsStringSync('''
```
Code snippet
```
''');
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@example abc lang=html}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
```html
Code snippet
```
'''));
});
test('processes @example with file, not found', () async {
var doc = await libraryModel.processComment('''
/// {@example abc}
''');
var abcPath = resourceProvider.pathContext.canonicalize(
resourceProvider.pathContext.join(projectRoot.path, 'abc.md'));
var libPathInWarning = resourceProvider.pathContext.join('lib', 'a.dart');
expect(
libraryModel,
hasMissingExampleWarning(
'$abcPath; path listed at $libPathInWarning'));
// When the example path is invalid, the directive should be left in-place.
expect(doc, equals('{@example abc}'));
});
test('processes @example with directories, not found', () async {
var doc = await libraryModel.processComment('''
/// {@example abc/def/ghi}
''');
var abcPath = resourceProvider.pathContext.canonicalize(resourceProvider
.pathContext
.join(projectRoot.path, 'abc', 'def', 'ghi.md'));
var libPathInWarning = resourceProvider.pathContext.join('lib', 'a.dart');
expect(
libraryModel,
hasMissingExampleWarning(
'$abcPath; path listed at $libPathInWarning'));
// When the example path is invalid, the directive should be left in-place.
expect(doc, equals('{@example abc/def/ghi}'));
});
test('processes @example with a region, not found', () async {
var doc = await libraryModel.processComment('''
/// {@example region=r abc}
''');
var abcPath = resourceProvider.pathContext.canonicalize(
resourceProvider.pathContext.join(projectRoot.path, 'abc-r.md'));
var libPathInWarning = resourceProvider.pathContext.join('lib', 'a.dart');
expect(
libraryModel,
hasMissingExampleWarning(
'$abcPath; path listed at $libPathInWarning'));
// When the example path is invalid, the directive should be left in-place.
expect(doc, equals('{@example region=r abc}'));
});
test('leaves @inject-html unprocessed when disabled', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@inject-html}<script></script>{@end-inject-html}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
{@inject-html}<script></script>{@end-inject-html}'''));
});
test('leaves @tool unprocessed when disabled', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@tool date}{@end-tool}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
{@tool date}{@end-tool}'''));
});
test('processes @inject-html when enabled', () async {
packageGraph = await utils.bootBasicPackage(
projectRoot.path, packageMetaProvider, packageConfigProvider,
additionalArguments: ['--inject-html']);
libraryModel = packageGraph.defaultPackage.libraries.first;
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@inject-html}<script></script>{@end-inject-html}
''');
expectNoWarnings();
expect(doc, equals('''
Text.
<dartdoc-html>6829def5ec06d211fa90fe69a58213ae901f3ee4</dartdoc-html>
'''));
});
test('processes @youtube', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@youtube 100 200 https://www.youtube.com/watch?v=oHg5SJYRHA0}
///
/// End text.
''');
expectNoWarnings();
expect(
doc,
matches(RegExp(
'^Text.\n\n+'
r'<p style="position: relative;\s+padding-top: 200.00%;">\s*'
r'<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0\?rel=0".*</iframe>\s*'
'</p>\n\n+'
r'End text.$',
multiLine: true,
dotAll: true)));
});
test('processes leading @youtube', () async {
var doc = await libraryModel.processComment('''
/// {@youtube 100 200 https://www.youtube.com/watch?v=oHg5SJYRHA0}
///
/// End text.
''');
expectNoWarnings();
expect(
doc,
matches(RegExp(
r'^<p style="position: relative;\s+padding-top: 200.00%;">\s*'
r'<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0\?rel=0".*</iframe>\s*'
'</p>\n\n+'
r'End text.$',
multiLine: true,
dotAll: true)));
});
test('processes trailing @youtube', () async {
var doc = await libraryModel.processComment('''
/// Text.
///
/// {@youtube 100 200 https://www.youtube.com/watch?v=oHg5SJYRHA0}
''');
expectNoWarnings();
expect(
doc,
matches(RegExp(
'^Text.\n\n+'
r'<p style="position: relative;\s+padding-top: 200.00%;">\s*'
r'<iframe src="https://www.youtube.com/embed/oHg5SJYRHA0\?rel=0".*</iframe>\s*'
r'</p>$',
multiLine: true,
dotAll: true)));
});
test('warns when @youtube has less than 3 arguments', () async {
await libraryModel.processComment(
'/// {@youtube 100 https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning('Invalid @youtube directive, '
'"{@youtube 100 https://www.youtube.com/watch?v=oHg5SJYRHA0}"\n'
'YouTube directives must be of the form '
'"{@youtube WIDTH HEIGHT URL}"'));
});
test('warns when @youtube has more than 3 arguments', () async {
await libraryModel.processComment(
'/// {@youtube 100 200 300 https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning('Invalid @youtube directive, '
'"{@youtube 100 200 300 https://www.youtube.com/watch?v=oHg5SJYRHA0}"\n'
'YouTube directives must be of the form '
'"{@youtube WIDTH HEIGHT URL}"'));
});
test('warns when @youtube has a malformed width', () async {
await libraryModel.processComment(
'/// {@youtube 100px 200 https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning(
'A @youtube directive has an invalid width, "100px". '
'The width must be a positive integer.'));
});
test('warns when @youtube has a negative width', () async {
await libraryModel.processComment(
'/// {@youtube -100 200 https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning(
'The {@youtube ...} directive was called with invalid '
'parameters. FormatException: Could not find an option with '
'short name "-1".'));
});
test('warns when @youtube has a malformed height', () async {
await libraryModel.processComment(
'/// {@youtube 100 200px https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning(
'A @youtube directive has an invalid height, "200px". The height '
'must be a positive integer.'));
});
test('warns when @youtube has a negative height', () async {
await libraryModel.processComment(
'/// {@youtube 100 -200 https://www.youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning(
'The {@youtube ...} directive was called with invalid '
'parameters. FormatException: Could not find an option with '
'short name "-2".'));
});
test('warns when @youtube has an invalid URL', () async {
await libraryModel.processComment(
'/// {@youtube 100 200 https://www.not-youtube.com/watch?v=oHg5SJYRHA0}');
expect(
libraryModel,
hasInvalidParameterWarning('A @youtube directive has an invalid URL: '
'"https://www.not-youtube.com/watch?v=oHg5SJYRHA0". Supported '
'YouTube URLs have the following format: '
'https://www.youtube.com/watch?v=oHg5SJYRHA0.'));
});
test('warns when @youtube has a URL with extra query parameters', () async {
await libraryModel.processComment(
'/// {@youtube 100 200 https://www.not-youtube.com/watch?v=oHg5SJYRHA0&a=1}');
expect(
libraryModel,
hasInvalidParameterWarning('A @youtube directive has an invalid URL: '
'"https://www.not-youtube.com/watch?v=oHg5SJYRHA0&a=1". '
'Supported YouTube URLs have the following format: '
'https://www.youtube.com/watch?v=oHg5SJYRHA0.'));
});
test('warns when fenced code block does not specify language', () async {
await libraryModel.processComment('''
/// ```
/// void main() {}
/// ```
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel,
PackageWarning.missingCodeBlockLanguage,
'A fenced code block in Markdown should have a language specified'),
isTrue);
});
test('warns when squiggly fenced code block does not specify language',
() async {
await libraryModel.processComment('''
/// ~~~
/// void main() {}
/// ~~~
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel,
PackageWarning.missingCodeBlockLanguage,
'A fenced code block in Markdown should have a language specified'),
isTrue);
});
test('does not warn when fenced code block does specify language',
() async {
await libraryModel.processComment('''
/// ```dart
/// void main() {}
/// ```
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel,
PackageWarning.missingCodeBlockLanguage,
'A fenced code block in Markdown should have a language specified'),
isFalse);
});
test('does not warn when fenced block is not closed', () async {
await libraryModel.processComment('''
/// ```
/// A not closed fenced code block
''');
expect(
packageGraph.packageWarningCounter.hasWarning(
libraryModel,
PackageWarning.missingCodeBlockLanguage,
'A fenced code block in Markdown should have a language specified'),
isFalse);
});
}, onPlatform: {
'windows': Skip('These tests do not work on Windows (#2446)')
});
// TODO(srawlins): More unit tests: @example with `config.examplePathPrefix`,
// @tool.
}
class _HasWarning extends Matcher {
final PackageWarning kind;
final String message;
_HasWarning(this.kind, this.message);
@override
bool matches(Object? actual, Map<Object?, Object?> matchState) {
if (actual is ModelElement) {
return actual.packageGraph.packageWarningCounter
.hasWarning(actual, kind, message);
} else {
return false;
}
}
@override
Description describe(Description description) =>
description.add('Library to be warned with $kind and message:\n$message');
@override
Description describeMismatch(Object? actual, Description mismatchDescription,
Map<Object?, Object?> matchState, bool verbose) {
if (actual is ModelElement) {
var warnings = actual
.packageGraph.packageWarningCounter.countedWarnings[actual.element];
return mismatchDescription.add('has warnings: $warnings');
} else {
return mismatchDescription.add('is a ${actual.runtimeType}');
}
}
}