diff --git a/pkg/pub_integration/pubspec.yaml b/pkg/pub_integration/pubspec.yaml index 93ee7e3514..90f9e57c7e 100644 --- a/pkg/pub_integration/pubspec.yaml +++ b/pkg/pub_integration/pubspec.yaml @@ -17,5 +17,6 @@ dependencies: dev_dependencies: coverage: any # test already depends on it + markdown: ^7.3.0 shelf: ^1.4.0 test: ^1.16.5 diff --git a/pkg/pub_integration/tool/compare_screenshots.dart b/pkg/pub_integration/tool/compare_screenshots.dart new file mode 100644 index 0000000000..0cb917ebc7 --- /dev/null +++ b/pkg/pub_integration/tool/compare_screenshots.dart @@ -0,0 +1,125 @@ +// Copyright (c) 2025, 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:io'; + +import 'package:markdown/markdown.dart'; +import 'package:path/path.dart' as p; + +/// Compares the screenshots from the previous and current test runs. +/// Uses imagemagick for image processing. +/// +/// `dart ` +Future main(List args) async { + final beforeFiles = await _list(args[0]); + final afterFiles = await _list(args[1]); + + final reportDir = Directory(args[2]); + await reportDir.create(recursive: true); + await _CompareTool(beforeFiles, afterFiles, reportDir)._compare(); +} + +class _CompareTool { + final Directory _reportDir; + final Map _beforeFiles; + final Map _afterFiles; + final _report = StringBuffer(); + + _CompareTool( + this._beforeFiles, + this._afterFiles, + this._reportDir, + ); + + Future _compare() async { + _report.writeln( + 'Screenshot comparison report generated at ${DateTime.now().toIso8601String()}.'); + + final newFiles = _afterFiles.keys + .where((key) => !_beforeFiles.containsKey(key)) + .toList(); + if (newFiles.isNotEmpty) { + _report.writeln([ + '', + '# New files', + newFiles.map((e) => '- `$e`').join('\n'), + ].join('\n\n')); + } + + final missingFiles = _beforeFiles.keys + .where((key) => !_afterFiles.containsKey(key)) + .toList(); + if (missingFiles.isNotEmpty) { + _report.writeln([ + '', + '# Missing files', + missingFiles.map((e) => '- `$e`').join('\n'), + ].join('\n\n')); + } + + for (final path in _afterFiles.keys) { + final after = _afterFiles[path]!; + if (!_beforeFiles.containsKey(path)) continue; + final before = _beforeFiles[path]!; + + // quick byte-content check + final afterBytes = await after.readAsBytes(); + final beforeBytes = await before.readAsBytes(); + if (afterBytes.length == beforeBytes.length && + afterBytes.indexed.every((e) => beforeBytes[e.$1] == e.$2)) { + continue; + } + + final relativeDir = p.dirname(path); + final basename = p.basenameWithoutExtension(path); + final diffPath = + p.join(_reportDir.path, relativeDir, '$basename-diff.png'); + await File(diffPath).parent.create(recursive: true); + + final pr = await Process.run('compare', [ + before.path, + after.path, + diffPath, + ]); + if (pr.exitCode == 0) continue; + + final beforeFile = + File(p.join(_reportDir.path, relativeDir, '$basename-before.png')); + await beforeFile.writeAsBytes(beforeBytes); + final afterFile = + File(p.join(_reportDir.path, relativeDir, '$basename-after.png')); + await afterFile.writeAsBytes(afterBytes); + + _report.writeln('`$path`\n'); + _report.writeln( + '![before](${p.join(relativeDir, '$basename-before.png')})\n'); + _report + .writeln('![after](${p.join(relativeDir, '$basename-after.png')})\n'); + _report + .writeln('![diff](${p.join(relativeDir, '$basename-diff.png')})\n'); + _report.writeln(); + } + + await _writeIndexHtml(); + } + + Future _writeIndexHtml() async { + await File(p.join(_reportDir.path, 'index.html')).writeAsString([ + '', + markdownToHtml(_report.toString()), + '', + ].join('\n')); + } +} + +Future> _list(String path) async { + final map = {}; + await for (final file in Directory(path).list(recursive: true)) { + if (file is! File) continue; + final rp = p.relative(file.path, from: path); + map[rp] = file; + } + return Map.fromEntries( + map.entries.toList()..sort((a, b) => a.key.compareTo(b.key))); +}