Run a periodic crash-test (dart-lang/markdown#590)
* Run a periodic crash-test
* Better documentation
diff --git a/pkgs/markdown/.github/workflows/crash_test.yaml b/pkgs/markdown/.github/workflows/crash_test.yaml
new file mode 100644
index 0000000..fcd1afa
--- /dev/null
+++ b/pkgs/markdown/.github/workflows/crash_test.yaml
@@ -0,0 +1,20 @@
+# Run against all markdown files in latest version of packages on pub.dev to
+# see if any can provoke a crash
+
+name: Crash Tests
+
+on:
+ schedule:
+ # “At 00:00 (UTC) on Sunday.”
+ - cron: '0 0 * * 0'
+
+jobs:
+ crash-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
+ - uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3
+ - name: Install dependencies
+ run: dart pub get
+ - name: Run crash_test.dart
+ run: dart test -P crash_test test/crash_test.dart
diff --git a/pkgs/markdown/test/crash_test.dart b/pkgs/markdown/test/crash_test.dart
index 3d06683..a6f427d 100644
--- a/pkgs/markdown/test/crash_test.dart
+++ b/pkgs/markdown/test/crash_test.dart
@@ -4,6 +4,7 @@
import 'dart:convert';
import 'dart:io';
+import 'dart:isolate';
import 'package:http/http.dart' as http;
import 'package:http/retry.dart' as http;
@@ -14,6 +15,18 @@
// 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
@@ -26,6 +39,16 @@
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);
@@ -50,27 +73,47 @@
((await getJson('https://pub.dev/api/package-names'))['packages']
as List)
.cast<String>();
- print('Found ${packages.length} packages to scan');
+ //.take(3).toList(); // useful when testing
+ print('## Found ${packages.length} packages to scan');
- final errors = <String>[];
- final pool = Pool(50);
var count = 0;
- var skipped = 0;
- var lastStatus = DateTime.now();
+ final pool = Pool(50);
+ final packageVersions = <PackageVersion>[];
await Future.wait(packages.map((package) async {
await pool.withResource(() async {
- final versionsResponse =
- await getJson('https://pub.dev/api/packages/$package');
- final archiveUrl = Uri.tryParse(
- versionsResponse['latest']?['archive_url'] as String? ?? '',
+ 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 = gzip.decode(await c.readBytes(archiveUrl));
+ archive = await c.readBytes(archiveUrl);
} on http.ClientException {
skipped++;
return;
@@ -78,51 +121,106 @@
skipped++;
return;
}
- try {
- await TarReader.forEach(Stream.value(archive), (entry) async {
- if (entry.name.endsWith('.md')) {
- late String contents;
- try {
- final bytes = await http.ByteStream(entry.contents).toBytes();
- contents = utf8.decode(bytes);
- } on FormatException {
- return; // ignore invalid utf8
- }
- try {
- markdownToHtml(
- contents,
- extensionSet: ExtensionSet.gitHubWeb,
- );
- } catch (err, st) {
- errors
- .add('package:$package/${entry.name}, throws: $err\n$st');
- }
- }
- });
- } on FormatException {
+
+ 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++;
- if (DateTime.now().difference(lastStatus) >
- const Duration(seconds: 30)) {
- lastStatus = DateTime.now();
- print('Scanned $count / ${packages.length} (skipped $skipped),'
- ' found ${errors.length} issues');
- }
+ 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: 1)),
+ 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'];
+ });
+}