From 10fb2c0621562a5c7fead554b976629d243272d6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 5 Dec 2024 15:48:17 +0100 Subject: [PATCH] Ecosystem testing (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow for testing a package upgrade against the ecosystem. See for example https://github.com/mosuem/i18n/pull/2#issuecomment-2506648734 --- - [x] I’ve reviewed the contributor guide and applied the relevant portions to this PR.
Contribution guidelines:
- See our [contributor guide](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md) for general expectations for PRs. - Larger or significant changes should be discussed in an issue before creating a PR. - Contributions to our repos should follow the [Dart style guide](https://dart.dev/guides/language/effective-dart) and use `dart format`. - Most changes should add an entry to the changelog and may need to [rev the pubspec package version](https://github.com/dart-lang/sdk/blob/main/docs/External-Package-Maintenance.md#making-a-change). - Changes to packages require [corresponding tests](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md#Testing). Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.
--- .github/test_repos/repos.json | 12 + .github/workflows/ecosystem_test.yaml | 66 ++++ .../workflows/ecosystem_test_internal.yaml | 13 + .github/workflows/post_summaries.yaml | 7 + .github/workflows/quest.yml | 41 ++ pkgs/quest/.gitignore | 3 + pkgs/quest/LICENSE | 27 ++ pkgs/quest/README.md | 79 ++++ pkgs/quest/analysis_options.yaml | 5 + pkgs/quest/bin/quest.dart | 353 ++++++++++++++++++ pkgs/quest/pubspec.yaml | 18 + pkgs/quest/schema.json | 35 ++ pkgs/quest/test/quest_test.dart | 43 +++ 13 files changed, 702 insertions(+) create mode 100644 .github/test_repos/repos.json create mode 100644 .github/workflows/ecosystem_test.yaml create mode 100644 .github/workflows/ecosystem_test_internal.yaml create mode 100644 .github/workflows/quest.yml create mode 100644 pkgs/quest/.gitignore create mode 100644 pkgs/quest/LICENSE create mode 100644 pkgs/quest/README.md create mode 100644 pkgs/quest/analysis_options.yaml create mode 100644 pkgs/quest/bin/quest.dart create mode 100644 pkgs/quest/pubspec.yaml create mode 100644 pkgs/quest/schema.json create mode 100644 pkgs/quest/test/quest_test.dart diff --git a/.github/test_repos/repos.json b/.github/test_repos/repos.json new file mode 100644 index 00000000..616587ff --- /dev/null +++ b/.github/test_repos/repos.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../pkgs/quest/schema.json", + "https://github.com/mosuem/my_app_old_web": { + "level": "analyze" + }, + "https://github.com/mosuem/my_app_new_web": { + "level": "test", + "packages": { + "exclude": "intl4x" + } + } +} diff --git a/.github/workflows/ecosystem_test.yaml b/.github/workflows/ecosystem_test.yaml new file mode 100644 index 00000000..1574ab57 --- /dev/null +++ b/.github/workflows/ecosystem_test.yaml @@ -0,0 +1,66 @@ +name: Ecosystem test + +on: + workflow_call: + inputs: + repos_file: + description: 'Path to the file containing the list of repository names' + type: string + required: true + local_debug: + description: Whether to use a local version of ecosystem testing - only for debug + default: false + type: boolean + required: false + +jobs: + update_and_test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: subosito/flutter-action@44ac965b96f18d999802d4b807e3256d5a3f9fa1 + with: + channel: main + + - run: echo "${{ toJSON(github.event.pull_request.labels.*.name) }}" + + - name: Install firehose + run: dart pub global activate -s path pkgs/quest + if: ${{ inputs.local_debug }} + + - run: dart pub global activate -s git https://github.com/dart-lang/ecosystem.git --git-ref main --git-path pkgs/quest + if: ${{ !inputs.local_debug }} + + - name: Update package and test + run: | + dart pub global run quest ${{ inputs.repos_file }} ${{ github.repositoryUrl }} ${{ github.head_ref || github.ref_name }} "${{ toJSON(github.event.pull_request.labels.*.name) }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Output issue number + run: | + mkdir -p output + echo ${{ github.event.number }} > output/issueNumber + + - name: Find Comment + uses: peter-evans/find-comment@90e9b82d6319a7ae3f32bc0b434db48d6c376e55 + id: fc + with: + issue-number: ${{ github.event.number }} + comment-author: github-actions[bot] + body-includes: '## Ecosystem testing' + + - name: Write comment id to file + if: ${{ steps.fc.outputs.comment-id != 0 }} + run: echo ${{ steps.fc.outputs.comment-id }} >> output/commentId + + - name: Upload markdown + if: success() || failure() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 + with: + name: output + path: output/ diff --git a/.github/workflows/ecosystem_test_internal.yaml b/.github/workflows/ecosystem_test_internal.yaml new file mode 100644 index 00000000..fb65bf3a --- /dev/null +++ b/.github/workflows/ecosystem_test_internal.yaml @@ -0,0 +1,13 @@ +name: Ecosystem test:Internal + +on: + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + test_ecosystem: + uses: ./.github/workflows/ecosystem_test.yaml + with: + repos_file: .github/test_repos/repos.json + local_debug: true diff --git a/.github/workflows/post_summaries.yaml b/.github/workflows/post_summaries.yaml index 4928c891..7030dd88 100644 --- a/.github/workflows/post_summaries.yaml +++ b/.github/workflows/post_summaries.yaml @@ -8,6 +8,7 @@ on: workflows: - Publish:Internal - Health:Internal + - Ecosystem test:Internal types: - completed @@ -45,6 +46,12 @@ jobs: fs.writeFileSync('${{ github.workspace }}/comment.zip', Buffer.from(download.data)); - run: unzip comment.zip + - name: 'Print the comment' + run: | + if [ -f "./comment.md" ]; then + cat ./comment.md + fi + # Create the comment, or update the existing one, with the markdown # generated in the Health workflow. diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml new file mode 100644 index 00000000..0c63c576 --- /dev/null +++ b/.github/workflows/quest.yml @@ -0,0 +1,41 @@ +name: package:quest + +permissions: read-all + +on: + pull_request: + branches: [ main ] + paths: + - '.github/workflows/quest.yml' + - 'pkgs/quest/**' + push: + branches: [ main ] + paths: + - '.github/workflows/quest.yml' + - 'pkgs/quest/**' + schedule: + - cron: '0 0 * * 0' # weekly + +defaults: + run: + working-directory: pkgs/quest + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: subosito/flutter-action@44ac965b96f18d999802d4b807e3256d5a3f9fa1 + with: + channel: main + + - run: dart pub get + + - run: dart analyze --fatal-infos + + - run: dart format --output=none --set-exit-if-changed . + + - run: dart test diff --git a/pkgs/quest/.gitignore b/pkgs/quest/.gitignore new file mode 100644 index 00000000..3a857904 --- /dev/null +++ b/pkgs/quest/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/pkgs/quest/LICENSE b/pkgs/quest/LICENSE new file mode 100644 index 00000000..b03a7886 --- /dev/null +++ b/pkgs/quest/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/quest/README.md b/pkgs/quest/README.md new file mode 100644 index 00000000..69ade6b6 --- /dev/null +++ b/pkgs/quest/README.md @@ -0,0 +1,79 @@ +# Quest: Ecosystem Testing for Dart Packages +Embark your package on a quest of testing against a suite of applications. This helps identify potential breaking changes introduced by package updates, ensuring seamless integration across the ecosystem. + +## What does it do? +It checks if your package upgrade would result in failures in the ecosystem. This is achieved by running the following pseudocode: +```dart +for (final app in applicationSuite) { + if (app.dependencies.contains(package)) { + pubGet(app); + analyze(app); + test(app); + + upgradePackage(app); + + pubGet(app); + analyze(app); + test(app); + } +} +``` + +## How do I use it? +1. Create a suite of repositories to test against at `.github/test_repos/repos.json`. Follow the schema specified [here](schema.json). +```json +{ + "https://github.com/mosuem/my_app_old_web": { + "level": "analyze" + }, + "https://github.com/mosuem/my_app_new_web": { + "level": "test", + "packages": { + "exclude": "intl4x" + } + } +} +``` + +2. Add a workflow file with the following contents: +```yaml +name: Ecosystem test + +on: + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + test_ecosystem: + uses: dart-lang/ecosystem/.github/workflows/ecosystem_test.yaml@main + with: + repos_file: .github/test_repos/repos.json +``` + +3. To show the markdown result as a comment, also add a workflow file +```yaml +name: Comment on the pull request + +on: + # Trigger this workflow after the Health workflow completes. This workflow will have permissions to + # do things like create comments on the PR, even if the original workflow couldn't. + workflow_run: + workflows: + - Health + - Publish + - Ecosystem test + types: + - completed + +jobs: + upload: + uses: dart-lang/ecosystem/.github/workflows/post_summaries.yaml@main + permissions: + pull-requests: write +``` + +4. Profit! + +# Contributing +Contributions are welcome! Please see the [contribution guidelines](../../CONTRIBUTING.md). diff --git a/pkgs/quest/analysis_options.yaml b/pkgs/quest/analysis_options.yaml new file mode 100644 index 00000000..df4c571b --- /dev/null +++ b/pkgs/quest/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +linter: + rules: + - prefer_final_locals diff --git a/pkgs/quest/bin/quest.dart b/pkgs/quest/bin/quest.dart new file mode 100644 index 00000000..7e8075bf --- /dev/null +++ b/pkgs/quest/bin/quest.dart @@ -0,0 +1,353 @@ +// Copyright (c) 2024, 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 'package:collection/collection.dart'; +import 'package:firehose/firehose.dart' as fire; +import 'package:path/path.dart' as p; + +final checkEmoji = '\u2705'; +final crossEmoji = '\u274C'; + +Future main(List arguments) async { + final repositoriesFile = arguments[0]; + var gitUri = arguments[1].replaceRange(0, 'git'.length, 'https'); + gitUri = gitUri.substring(0, gitUri.length - '.git'.length); + final branch = arguments[2]; + final lines = arguments[3].split('\n'); + final labels = lines + .sublist(1, lines.length - 1) + .map((e) => e.trim()) + .where((line) => line.startsWith('ecosystem-test')); + print('Labels: $labels'); + final packages = fire.Repository().locatePackages(); + //TODO: Possibly run for all packages, not just the first. + final package = packages.firstWhereOrNull((package) => + labels.any((label) => label == 'ecosystem-test-${package.name}')); + if (package != null) { + print('Found $package. Embark on a quest!'); + final version = '${package.name}:${json.encode({ + 'git': { + 'url': gitUri, + 'ref': branch, + 'path': + p.relative(package.directory.path, from: Directory.current.path) + } + })}'; + final chronicles = await Quest( + package.name, + version, + repositoriesFile, + ).embark(); + final comment = createComment(chronicles); + await writeComment(comment); + print(chronicles); + exitCode = chronicles.success ? 0 : 1; + } +} + +enum Level { + solve, + analyze, + test; +} + +/// The result of embarking on a quest. Stores the [package] which was tested +/// with its new [version] as well as the [chapters] of the chronicles, each +/// storing the result of testing a single [Application]. +class Chronicles { + final String package; + final String version; + final Map chapters; + + Chronicles(this.package, this.version, this.chapters); + + bool get success => chapters.values.every((chapter) => chapter.success); + + @override + String toString() { + return ''' +Chronicles(package: $package, version: $version, chapters: $chapters)'''; + } +} + +/// An individual chapter in the [Chronicles]. This stores the result of testing +/// against an individual [Application]. +class Chapter { + final Map before; + final Map after; + + Chapter({required this.before, required this.after}); + + bool get success => failure == null; + + Level? get failure => Level.values.firstWhereOrNull((level) => + before[level]?.success == true && after[level]?.success == false); + + String toRow(Application application) => ''' +| ${application.name} | ${Level.values.map((l) => '${before[l]?.success.toEmoji ?? '-'}/${after[l]?.success.toEmoji ?? '-'}').join(' | ')} |'''; + + @override + String toString() => 'Chapter(before: $before, after: $after)'; +} + +/// A success bool paired with the stdout and stderr for easier debugging. +class CheckResult { + final bool success; + final String stdout; + final String stderr; + + CheckResult({ + required this.success, + required this.stdout, + required this.stderr, + }); + + @override + String toString() => + 'ChapterLevel(success: $success, stdout: $stdout, stderr: $stderr)'; +} + +/// An application to test against, specified by the [url] where it can be +/// cloned from, its [name] for display purposes, and the maximum [level] it +/// should be tested to. +class Application { + final String url; + final String name; + final Level level; + + const Application({ + required this.url, + required this.name, + required this.level, + }); + + static Future> listFromFile(String path) async { + final s = await File(path).readAsString(); + return (jsonDecode(s) as Map) + .entries + .where((e) => e.key != r'$schema') + .map((e) => MapEntry(e.key as String, e.value as Map)) + .map((e) { + return Application( + url: e.key, + name: (e.value['name'] as String?) ?? p.basename(e.key), + level: Level.values.firstWhere((l) => l.name == e.value['level']), + ); + }); + } + + @override + String toString() => 'Repository(url: $url, name: $name, level: $level)'; +} + +/// Contains the logic to fill [Chronicles] with the [Chapter]s of testing the +/// [candidatePackage] at [version] against the [Application]s listed in the +/// [applicationFile]. +class Quest { + final String candidatePackage; + final String version; + final String applicationFile; + + Quest(this.candidatePackage, this.version, this.applicationFile); + + /// For each package under test, this: + /// * Does a pub get (and optionally analyze and test) + /// * Upgrades to the new dep version + /// * Again runs pub get (and optionally analyze and test) + Future embark() async { + final tempDir = await Directory.systemTemp.createTemp(); + final chapters = {}; + for (var application in await Application.listFromFile(applicationFile)) { + final path = await cloneRepo(application.url, tempDir); + print('Cloned $application into $path'); + final depsListResult = + (await runFlutter(['pub', 'deps', '--json'], path)).stdout; + final depsJson = + jsonDecode(depsListResult.substring(depsListResult.indexOf('{'))) + as Map; + final depsPackages = depsJson['packages'] as List; + print(depsPackages); + if (depsPackages.any((p) => (p as Map)['name'] == candidatePackage)) { + print('Run checks for vanilla package'); + final resultBefore = await runChecks(path, application.level); + + print('Clean repo'); + await runFlutter(['clean'], path); + + print('Rev package:$candidatePackage to version $version $application'); + final revSuccess = + await runFlutter(['pub', 'add', version], path, true); + + print('Run checks for modified package'); + final resultAfter = await runChecks(path, application.level); + + // flutter pub add runs an implicit pub get + resultAfter[Level.solve] = revSuccess; + + chapters[application] = Chapter( + before: resultBefore, + after: resultAfter, + ); + } else { + print('No package:$candidatePackage found in $application'); + } + } + await tempDir.delete(recursive: true); + return Chronicles(candidatePackage, version, chapters); + } + + /// Uses `gh` to clone the Github repo at [url]. + Future cloneRepo(String url, Directory tempDir) async { + final name = url.split('/').last; + + var fullPath = p.join(tempDir.path, name); + if (Directory(fullPath).existsSync()) { + fullPath = p.join(tempDir.path, '${name}_${url.hashCode}'); + } + final arguments = ['repo', 'clone', url, '--', fullPath]; + print('Running `gh ${arguments.join(' ')}`'); + final processResult = await Process.run('gh', arguments); + final stdout = processResult.stdout as String; + final stderr = processResult.stderr as String; + print('stdout:'); + print(stdout); + print('stderr:'); + print(stderr); + return fullPath; + } + + Future> runChecks(String path, Level level) async { + final result = {}; + result[Level.solve] = await runFlutter(['pub', 'get'], path); + if (level.index >= Level.analyze.index && + result[Level.solve]?.success == true) { + result[Level.analyze] = await runFlutter(['analyze'], path); + } + if (level.index >= Level.test.index && + result[Level.solve]?.success == true) { + result[Level.test] = await runFlutter(['test'], path); + } + return result; + } + + Future runFlutter( + List arguments, + String path, [ + bool useDart = false, + ]) async { + final executable = useDart ? 'dart' : 'flutter'; + print('Running `$executable ${arguments.join(' ')}` in $path'); + final processResult = await Process.run( + //Due to https://github.com/flutter/flutter/issues/144898, we can't run Flutter on `pub add` + executable, + arguments, + workingDirectory: path, + ); + final stdout = processResult.stdout as String; + final stderr = processResult.stderr as String; + print('stdout:'); + print(stdout); + print('stderr:'); + print(stderr); + return CheckResult( + success: processResult.exitCode == 0, + stdout: stdout, + stderr: stderr, + ); + } +} + +Future writeComment(String content) async { + final commentFile = File('output/comment.md'); + await commentFile.create(recursive: true); + await commentFile.writeAsString(content); +} + +String createComment(Chronicles chronicles) { + final contents = ''' +## Ecosystem testing + +| Package | Solve | Analyze | Test | +| ------- | ----- | ------- | ---- | +${chronicles.chapters.entries.map((chapter) => chapter.value.toRow(chapter.key)).join('\n')} + +
+ +Details per app + +${chronicles.chapters.entries.map((entry) { + final application = entry.key; + final chapter = entry.value; + return ''' +
+ +${application.name} ${chapter.success ? checkEmoji : crossEmoji} + + +${chapter.success ? 'The app tests passed!' : ''' +The failure occured at the "${chapter.failure!.name}" step, this is the error output of that step: +``` +${chapter.after[chapter.failure!]?.stderr} +``` +'''} + +The complete list of logs is: + +${chapter.before.keys.map((level) => ''' +
+ +Logs for step: ${level.name} + + + +### Before: + +StdOut: +``` +${chapter.before[level]?.stdout} +``` + +StdErr: +``` +${chapter.before[level]?.stderr} +``` + +### After: + +StdOut: +``` +${chapter.after[level]?.stdout} +``` + +StdErr: +``` +${chapter.after[level]?.stderr} +``` + +
+''').join('\n')} + +
+ +'''; + }).join('\n')} + +
+ + '''; + return contents; +} + +extension on bool { + String get toEmoji { + if (this) { + return checkEmoji; + } else { + return crossEmoji; + } + } +} diff --git a/pkgs/quest/pubspec.yaml b/pkgs/quest/pubspec.yaml new file mode 100644 index 00000000..07e09a2b --- /dev/null +++ b/pkgs/quest/pubspec.yaml @@ -0,0 +1,18 @@ +name: quest +description: Test package upgrades against the ecosystem. +repository: https://github.com/dart-lang/ecosystem/tree/main/pkgs/quest + +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + collection: ^1.19.1 + firehose: ^0.9.3 + io: ^1.0.4 + path: ^1.9.1 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.24.0 diff --git a/pkgs/quest/schema.json b/pkgs/quest/schema.json new file mode 100644 index 00000000..8ea27b6f --- /dev/null +++ b/pkgs/quest/schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Repository Configuration", + "description": "Schema for repositories for ecosystem testing", + "type": "object", + "patternProperties": { + "^https:\\/\\/github\\.com\\/[^\\/]+\\/[^\\/]+$": { + "type": "object", + "properties": { + "level": { + "type": "string", + "description": "Level of testing depth.", + "enum": [ + "solve", + "analyze", + "test" + ] + }, + "packages": { + "type": "object", + "description": "Packages which are tested using this repository", + "properties": { + "exclude": { + "type": "string", + "description": "Packages to exclude from the analysis" + } + } + } + }, + "required": [ + "level" + ] + } + } +} diff --git a/pkgs/quest/test/quest_test.dart b/pkgs/quest/test/quest_test.dart new file mode 100644 index 00000000..e118ebd9 --- /dev/null +++ b/pkgs/quest/test/quest_test.dart @@ -0,0 +1,43 @@ +// Copyright (c) 2024, 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 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../bin/quest.dart'; + +void main() { + test('test name', () async { + final temp = await Directory.systemTemp.createTemp(); + final repoFile = await File(p.join(temp.path, 'repos.json')).create(); + await repoFile.writeAsString( + jsonEncode({ + 'https://github.com/mosuem/my_app_old_web': {'level': 'analyze'}, + 'https://github.com/mosuem/my_app_new_web': {'level': 'test'}, + }), + ); + + final chronicles = await Quest( + 'intl', + 'intl:{"git":{"url":"https://github.com/mosuem/i18n","ref":"pr","path":"pkgs/intl"}}', + repoFile.path, + ).embark(); + + final comment = createComment(chronicles); + expect(comment, startsWith(goldenComment)); + await temp.delete(recursive: true); + }, timeout: const Timeout(Duration(minutes: 5))); +} + +final goldenComment = ''' +## Ecosystem testing + +| Package | Solve | Analyze | Test | +| ------- | ----- | ------- | ---- | +| my_app_old_web | $checkEmoji/$crossEmoji | $checkEmoji/$checkEmoji | -/- | +| my_app_new_web | $checkEmoji/$checkEmoji | $checkEmoji/$checkEmoji | $checkEmoji/$checkEmoji | +''';