blob: a6f427d209f9f834b69ee44115d9afe2b3a4225a [file] [log] [blame]
// Copyright (c) 2022, 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:isolate';
import 'package:http/http.dart' as http;
import 'package:http/retry.dart' as http;
import 'package:markdown/markdown.dart';
import 'package:pool/pool.dart';
import 'package:tar/tar.dart';
import 'package:test/test.dart';
// ignore_for_file: avoid_dynamic_calls
const extensions = [
'.md',
'.mkd',
'.mdwn',
'.mdown',
'.mdtxt',
'.mdtext',
'.markdown',
'README',
'CHANGELOG',
];
void main() async {
// This test is a really dumb and very slow crash-test.
// It downloads the latest package version for each package on pub.dev
// and tries to parse all `*.md` files in the package, counting the number
// of times where the parser throws.
//
// Needless to say, this test is very slow and running it eats a lot of CPU.
// But it's a fairly good way to try a lot of real-world markdown text to see
// if any of the poorly formatted markdown causes the parser to crash.
test(
'crash test',
() async {
final started = DateTime.now();
var lastStatus = DateTime(0);
void status(String Function() message) {
if (DateTime.now().difference(lastStatus) >
const Duration(seconds: 30)) {
lastStatus = DateTime.now();
print(message());
}
}
final c = http.RetryClient(http.Client());
Future<dynamic> getJson(String url) async {
final u = Uri.tryParse(url);
if (u == null) {
return null;
}
try {
final data = await c.read(u);
try {
return jsonDecode(data);
} on FormatException {
return null;
}
} on http.ClientException {
return null;
} on IOException {
return null;
}
}
final packages =
((await getJson('https://pub.dev/api/package-names'))['packages']
as List)
.cast<String>();
//.take(3).toList(); // useful when testing
print('## Found ${packages.length} packages to scan');
var count = 0;
final pool = Pool(50);
final packageVersions = <PackageVersion>[];
await Future.wait(packages.map((package) async {
await pool.withResource(() async {
final response = await getJson(
'https://pub.dev/api/packages/$package',
);
final entry = response['latest'] as Map?;
if (entry != null) {
packageVersions.add(PackageVersion(
package: package,
version: entry['version'] as String,
archiveUrl: entry['archive_url'] as String,
));
}
count++;
status(
() => 'Listed versions for $count / ${packages.length} packages',
);
});
}));
print('## Found ${packageVersions.length} package versions to scan');
count = 0;
final errors = <String>[];
var skipped = 0;
await Future.wait(packageVersions.map((pv) async {
await pool.withResource(() async {
final archiveUrl = Uri.tryParse(pv.archiveUrl);
if (archiveUrl == null) {
skipped++;
return;
}
late List<int> archive;
try {
archive = await c.readBytes(archiveUrl);
} on http.ClientException {
skipped++;
return;
} on IOException {
skipped++;
return;
}
final result = await _findMarkdownIssues(
pv.package,
pv.version,
archive,
);
// If tar decoding fails.
if (result == null) {
skipped++;
return;
}
errors.addAll(result);
result.forEach(print);
});
count++;
status(() =>
'Scanned $count / ${packageVersions.length} (skipped $skipped),'
' found ${errors.length} issues');
}));
await pool.close();
c.close();
print('## Finished scanning');
print('Scanned ${packageVersions.length} package versions in '
'${DateTime.now().difference(started)}');
if (errors.isNotEmpty) {
print('Found issues:');
errors.forEach(print);
fail('Found ${errors.length} cases where markdownToHtml threw!');
}
},
timeout: const Timeout(Duration(hours: 5)),
tags: 'crash_test', // skipped by default, see: dart_test.yaml
);
}
class PackageVersion {
final String package;
final String version;
final String archiveUrl;
PackageVersion({
required this.package,
required this.version,
required this.archiveUrl,
});
}
/// Scans [gzippedArchive] for markdown files and tries to parse them all.
///
/// Creates a list of issues that arose when parsing markdown files. The
/// [package] and [version] strings are used to construct nice issues.
/// An issue string may be multi-line, but should be printable.
///
/// Returns a list of issues, or `null` if decoding and parsing [gzippedArchive]
/// failed.
Future<List<String>?> _findMarkdownIssues(
String package,
String version,
List<int> gzippedArchive,
) async {
return Isolate.run<List<String>?>(() async {
try {
final archive = gzip.decode(gzippedArchive);
final issues = <String>[];
await TarReader.forEach(Stream.value(archive), (entry) async {
if (extensions.any((ext) => entry.name.endsWith(ext))) {
late String contents;
try {
final bytes = await http.ByteStream(entry.contents).toBytes();
contents = utf8.decode(bytes);
} on FormatException {
return; // ignore invalid utf8
}
final start = DateTime.now();
try {
markdownToHtml(
contents,
extensionSet: ExtensionSet.gitHubWeb,
);
} catch (err, st) {
issues.add(
'package:$package-$version/${entry.name}, throws: $err\n$st');
}
final time = DateTime.now().difference(start);
if (time.inSeconds > 30) {
issues.add(
'package:$package-$version/${entry.name} took $time to process');
}
}
});
return issues;
} on FormatException {
return null;
}
}).timeout(const Duration(minutes: 2), onTimeout: () {
return ['package:$package-$version failed to be processed in 2 minutes'];
});
}