blob: 5ff63218e854bc66e5b151765b6034f3193e961d [file] [log] [blame]
// Copyright (c) 2019, 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:convert';
import 'dart:io';
import 'dart:mirrors';
import 'package:html/dom.dart' show Element;
import 'package:html/parser.dart' show parseFragment;
import 'package:markdown/markdown.dart'
show
AutolinkExtensionSyntax,
BlockSyntax,
InlineSyntax,
StrikethroughSyntax,
TableSyntax,
markdownToHtml;
import 'package:path/path.dart' as p;
import '../test/util.dart';
// Locate the "tool" directory. Use mirrors so that this works with the test
// package, which loads this suite into an isolate.
String get toolDir {
final path = (reflect(loadCommonMarkSections) as ClosureMirror)
.function
.location!
.sourceUri
.path;
return p.dirname(path);
}
File getStatsFile(String prefix) =>
File(p.join(toolDir, '${prefix}_stats.json'));
Map<String, List<CommonMarkTestCase>> loadCommonMarkSections(
String testPrefix) {
final testFile = File(p.join(toolDir, '${testPrefix}_tests.json'));
final testsJson = testFile.readAsStringSync();
final testArray = jsonDecode(testsJson) as List;
final sections = <String, List<CommonMarkTestCase>>{};
for (final exampleMap in testArray) {
final exampleTest =
CommonMarkTestCase.fromJson(exampleMap as Map<String, dynamic>);
final sectionList =
sections.putIfAbsent(exampleTest.section, () => <CommonMarkTestCase>[]);
sectionList.add(exampleTest);
}
return sections;
}
class Config {
static final Config commonMarkConfig = Config._(
'common_mark',
'https://spec.commonmark.org/0.30/',
);
static final Config gfmConfig = Config._(
'gfm',
'https://github.github.com/gfm/',
);
final String prefix;
final String baseUrl;
Config._(this.prefix, this.baseUrl);
}
class CommonMarkTestCase {
final String markdown;
final String section;
final int example;
final String html;
final int startLine;
final int endLine;
final Set<String> extensions;
CommonMarkTestCase(
this.example,
this.section,
this.startLine,
this.endLine,
this.markdown,
this.html,
this.extensions,
);
factory CommonMarkTestCase.fromJson(Map<String, dynamic> json) {
return CommonMarkTestCase(
json['example'] as int,
json['section'] as String /*!*/,
json['start_line'] as int,
json['end_line'] as int,
json['markdown'] as String /*!*/,
json['html'] as String,
json['extensions'] == null
? const {}
: Set.from(json['extensions'] as List),
);
}
@override
String toString() => '$section - $example';
}
enum CompareLevel { strict, loose, fail, error }
class CompareResult {
final CompareLevel compareLevel;
final CommonMarkTestCase testCase;
final String? result;
CompareResult(this.testCase, this.result, this.compareLevel);
}
CompareResult compareResult(
Config config,
CommonMarkTestCase testCase, {
bool throwOnError = false,
bool verboseFail = false,
bool verboseLooseMatch = false,
Set<String> extensions = const {},
}) {
var enabletagfilter = false;
String output;
final inlineSyntaxes = <InlineSyntax>[];
final blockSyntaxes = <BlockSyntax>[];
for (final extension in extensions) {
switch (extension) {
case 'autolink':
inlineSyntaxes.add(AutolinkExtensionSyntax());
break;
case 'strikethrough':
inlineSyntaxes.add(StrikethroughSyntax());
break;
case 'table':
blockSyntaxes.add(const TableSyntax());
break;
case 'tagfilter':
enabletagfilter = true;
break;
default:
throw UnimplementedError('Unimplemented extension "$extension"');
}
}
try {
output = markdownToHtml(
testCase.markdown,
inlineSyntaxes: inlineSyntaxes,
blockSyntaxes: blockSyntaxes,
enableTagfilter: enabletagfilter,
);
} catch (err, stackTrace) {
if (throwOnError) {
rethrow;
}
if (verboseFail) {
_printVerboseFailure(
config.baseUrl,
'ERROR',
testCase,
'Thrown: $err\n$stackTrace',
);
}
return CompareResult(testCase, null, CompareLevel.error);
}
if (testCase.html == output) {
return CompareResult(testCase, output, CompareLevel.strict);
}
final expectedParsed = parseFragment(testCase.html);
final actual = parseFragment(output);
final looseMatch = _compareHtml(expectedParsed.children, actual.children);
if (!looseMatch && verboseFail) {
_printVerboseFailure(config.baseUrl, 'FAIL', testCase, output);
}
if (looseMatch && verboseLooseMatch) {
_printVerboseFailure(config.baseUrl, 'LOOSE', testCase, output);
}
return CompareResult(
testCase,
output,
looseMatch ? CompareLevel.loose : CompareLevel.fail,
);
}
String _indent(String s) =>
s.splitMapJoin('\n', onNonMatch: (n) => ' ${whitespaceColor(n)}');
void _printVerboseFailure(
String baseUrl,
String message,
CommonMarkTestCase testCase,
String actual,
) {
print('$message: $baseUrl#example-${testCase.example} '
'@ ${testCase.section}');
print('input:');
print(_indent(testCase.markdown));
print('expected:');
print(_indent(testCase.html));
print('actual:');
print(_indent(actual));
print('-----------------------');
}
/// Compare two DOM trees for equality.
bool _compareHtml(
List<Element> expectedElements,
List<Element> actualElements,
) {
if (expectedElements.length != actualElements.length) {
return false;
}
for (var childNum = 0; childNum < expectedElements.length; childNum++) {
final expected = expectedElements[childNum];
final actual = actualElements[childNum];
if (expected.runtimeType != actual.runtimeType) {
return false;
}
if (expected.localName != actual.localName) {
return false;
}
if (expected.attributes.length != actual.attributes.length) {
return false;
}
final expectedAttrKeys = expected.attributes.keys.toList();
expectedAttrKeys.sort();
final actualAttrKeys = actual.attributes.keys.toList();
actualAttrKeys.sort();
for (var attrNum = 0; attrNum < actualAttrKeys.length; attrNum++) {
final expectedAttrKey = expectedAttrKeys[attrNum];
final actualAttrKey = actualAttrKeys[attrNum];
if (expectedAttrKey != actualAttrKey) {
return false;
}
if (expected.attributes[expectedAttrKey] !=
actual.attributes[actualAttrKey]) {
return false;
}
}
final childrenEqual = _compareHtml(expected.children, actual.children);
if (!childrenEqual) {
return false;
}
}
return true;
}