diff --git a/.github/workflows/shorebird_ci.yml b/.github/workflows/shorebird_ci.yml index 9597d6a599344..690db437da585 100644 --- a/.github/workflows/shorebird_ci.yml +++ b/.github/workflows/shorebird_ci.yml @@ -38,6 +38,17 @@ jobs: distribution: "zulu" java-version: "17" + - name: 📦 Cache Gradle + if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }} + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: 🐦 Run Flutter Tools Tests # TODO(eseidel): Find a nice way to run this on windows. if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }} diff --git a/packages/shorebird_tests/test/android_test.dart b/packages/shorebird_tests/test/android_test.dart index b24cce0ee9bf6..1e0a6da4f3824 100644 --- a/packages/shorebird_tests/test/android_test.dart +++ b/packages/shorebird_tests/test/android_test.dart @@ -3,6 +3,8 @@ import 'package:test/test.dart'; import 'shorebird_tests.dart'; void main() { + setUpAll(warmUpTemplateProject); + group('shorebird android projects', () { testWithShorebirdProject('can build an apk', (projectDirectory) async { await projectDirectory.runFlutterBuildApk(); diff --git a/packages/shorebird_tests/test/base_test.dart b/packages/shorebird_tests/test/base_test.dart index 14dba7ca6cc85..ce9b0564b1396 100644 --- a/packages/shorebird_tests/test/base_test.dart +++ b/packages/shorebird_tests/test/base_test.dart @@ -3,6 +3,8 @@ import 'package:test/test.dart'; import 'shorebird_tests.dart'; void main() { + setUpAll(warmUpTemplateProject); + group('shorebird helpers', () { testWithShorebirdProject('can build a base project', (projectDirectory) async { diff --git a/packages/shorebird_tests/test/ios_test.dart b/packages/shorebird_tests/test/ios_test.dart index 3fe6e1652c3e5..bb694405ae946 100644 --- a/packages/shorebird_tests/test/ios_test.dart +++ b/packages/shorebird_tests/test/ios_test.dart @@ -3,6 +3,8 @@ import 'package:test/test.dart'; import 'shorebird_tests.dart'; void main() { + setUpAll(warmUpTemplateProject); + group( 'shorebird ios projects', () { diff --git a/packages/shorebird_tests/test/shorebird_tests.dart b/packages/shorebird_tests/test/shorebird_tests.dart index e332d8714d34a..4100c6c5aca50 100644 --- a/packages/shorebird_tests/test/shorebird_tests.dart +++ b/packages/shorebird_tests/test/shorebird_tests.dart @@ -91,46 +91,104 @@ Future _createFlutterProject(Directory projectDirectory) async { } } -@isTest -Future testWithShorebirdProject(String name, - FutureOr Function(Directory projectDirectory) testFn) async { - test( - name, - () async { - final parentDirectory = Directory.systemTemp.createTempSync(); - final projectDirectory = Directory( - path.join( - parentDirectory.path, - 'shorebird_test', - ), - )..createSync(); +/// Cached template project directory, created once and reused across tests. +/// +/// This avoids running `flutter create` for every test, which saves +/// significant time (especially the first Gradle/SDK download). +Directory? _templateProject; - try { - await _createFlutterProject(projectDirectory); +/// Creates (or returns the cached) template Flutter project with +/// shorebird.yaml configured. The first call runs `flutter create` and +/// `flutter build apk` to warm up Gradle caches. +/// +/// Call this from `setUpAll` so the expensive setup runs outside per-test +/// timeouts. +Future warmUpTemplateProject() => _getTemplateProject(); + +Future _getTemplateProject() async { + if (_templateProject != null) { + return _templateProject!; + } + + final Directory templateDir = Directory( + path.join(Directory.systemTemp.createTempSync().path, 'shorebird_template'), + )..createSync(); - projectDirectory.pubspecFile.writeAsStringSync(''' -${projectDirectory.pubspecFile.readAsStringSync()} + await _createFlutterProject(templateDir); + + templateDir.pubspecFile.writeAsStringSync(''' +${templateDir.pubspecFile.readAsStringSync()} assets: - shorebird.yaml '''); - File( - path.join( - projectDirectory.path, - 'shorebird.yaml', - ), - ).writeAsStringSync(''' + File( + path.join(templateDir.path, 'shorebird.yaml'), + ).writeAsStringSync(''' app_id: "123" '''); + // Warm up the Gradle cache with a throwaway build so subsequent + // per-test builds are fast and don't hit the per-test timeout. + // Skip if Gradle cache is already populated (e.g., from GHA cache restore). + final Directory gradleCache = Directory( + path.join(Platform.environment['HOME'] ?? '', '.gradle', 'caches'), + ); + final bool hasGradleCache = + gradleCache.existsSync() && gradleCache.listSync().isNotEmpty; + if (hasGradleCache) { + print('[warmup] Gradle cache exists, skipping warm-up build'); + } else { + await _runFlutterCommand( + ['build', 'apk'], + workingDirectory: templateDir, + ); + } + + _templateProject = templateDir; + return templateDir; +} + +/// Copies the template project to a fresh directory for test isolation. +Future _copyTemplateProject() async { + final Directory template = await _getTemplateProject(); + final Directory testDir = Directory( + path.join(Directory.systemTemp.createTempSync().path, 'shorebird_test'), + ); + + // Use platform copy to preserve the full directory tree efficiently. + if (Platform.isWindows) { + await Process.run('xcopy', [ + template.path, + testDir.path, + '/E', + '/I', + '/Q', + ]); + } else { + await Process.run('cp', ['-R', template.path, testDir.path]); + } + + return testDir; +} + +@isTest +Future testWithShorebirdProject(String name, + FutureOr Function(Directory projectDirectory) testFn) async { + test( + name, + () async { + final Directory projectDirectory = await _copyTemplateProject(); + + try { await testFn(projectDirectory); } finally { projectDirectory.deleteSync(recursive: true); } }, timeout: Timeout( - // These tests usually run flutter create, flutter build, etc, which can take a while, - // specially in CI, so setting from the default of 30 seconds to 6 minutes. + // Per-test timeout can be shorter now since the template project + // creation and Gradle warm-up happen outside the test timeout. Duration(minutes: 6), ), );