[ffigen] Run Flutter FFI plugin template ffigen on CI (#433)
diff --git a/pkgs/ffigen/.github/workflows/test-package.yml b/pkgs/ffigen/.github/workflows/test-package.yml index 6d6c470..fdc7a01 100644 --- a/pkgs/ffigen/.github/workflows/test-package.yml +++ b/pkgs/ffigen/.github/workflows/test-package.yml
@@ -75,7 +75,7 @@ with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: lcov.info - + test-windows: needs: analyze runs-on: windows-latest @@ -90,3 +90,28 @@ run: dart test/setup.dart - name: Run VM tests run: dart test --platform vm + + # Sanity check the latest `flutter create --template plugin_ffi`. + # This will break if we change the Flutter template or the generated code. + # But, getting libclang on the LUCI infrastructure has proven to be + # non-trivial. See discussion on + # https://github.com/flutter/flutter/issues/105513. + # If we need to change the generated code, we should temporarily disable this + # test, or temporarily disable the requirement for all bots to be green to + # merge PRs. + # Running this sanity check on one OS should be sufficient. Chosing Windows + # because it is the most likely to break. + test-windows-flutter: + needs: analyze + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: 'master' + - name: Install dependencies + run: flutter pub get + - name: Build test dylib and bindings + run: dart test/setup.dart + - name: Run VM tests + run: flutter pub run test test_flutter/ --platform vm
diff --git a/pkgs/ffigen/test_flutter/flutter_template_tests/flutter_plugin_ffi_test.dart b/pkgs/ffigen/test_flutter/flutter_template_tests/flutter_plugin_ffi_test.dart new file mode 100644 index 0000000..9f74c81 --- /dev/null +++ b/pkgs/ffigen/test_flutter/flutter_template_tests/flutter_plugin_ffi_test.dart
@@ -0,0 +1,164 @@ +// 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 'package:test/test.dart'; + +void main() { + Uri? tempDirUri; + final projectName = 'test_project'; + + setUp(() async { + tempDirUri = (await Directory.current.createTemp('.temp_test_')).uri; + }); + + tearDown(() async { + final dir = Directory(tempDirUri!.toFilePath()); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + }); + + test('Run Flutter', () async { + final projectDirUri = tempDirUri!.resolve('$projectName/'); + final libDirUri = projectDirUri.resolve('lib/'); + final bindingsGeneratedUri = + libDirUri.resolve('${projectName}_bindings_generated.dart'); + final bindingsGeneratedCopyUri = + libDirUri.resolve('${projectName}_bindings_generated_copy.dart'); + + await Task.serial([ + RunProcess( + executable: 'flutter', + arguments: [ + 'create', + '--template=plugin_ffi', + projectName, + ], + workingDirectory: tempDirUri, + ), + Copy( + source: bindingsGeneratedUri, + target: bindingsGeneratedCopyUri, + ), + RunProcess( + executable: 'flutter', + arguments: [ + 'pub', + 'run', + 'ffigen', + '--config', + 'ffigen.yaml', + ], + workingDirectory: projectDirUri, + ), + ]).run(); + + final originalBindings = await readFileAsString(bindingsGeneratedCopyUri); + final regeneratedBindings = await readFileAsString(bindingsGeneratedUri); + + expect(originalBindings, regeneratedBindings); + }); +} + +Future<String> readFileAsString(Uri uri) async { + final contents = await File(uri.toFilePath()).readAsString(); + return contents.replaceAll('\r', ''); +} + +abstract class Task { + Future<void> run(); + + factory Task.serial(Iterable<Task> tasks) => _SerialTask(tasks); +} + +class _SerialTask implements Task { + final Iterable<Task> tasks; + + _SerialTask(this.tasks); + + @override + Future<void> run() async { + for (final task in tasks) { + await task.run(); + } + } +} + +class RunProcess implements Task { + final List<String> arguments; + final String executable; + final Uri? workingDirectory; + Map<String, String>? environment; + final bool throwOnFailure; + + RunProcess({ + required this.arguments, + required this.executable, + this.workingDirectory, + this.environment, + this.throwOnFailure = true, + }); + + /// Excluding [workingDirectory]. + String get commandString => [ + if (workingDirectory != null) '(cd ${workingDirectory!.path};', + ...?environment?.entries.map((entry) => '${entry.key}=${entry.value}'), + executable, + ...arguments.map((a) => a.contains(' ') ? "'$a'" : a), + if (workingDirectory != null) ')', + ].join(' '); + + @override + Future<void> run() async { + final workingDirectoryString = workingDirectory?.toFilePath(); + + print('Running `$commandString`.'); + final process = await Process.start(executable, arguments, + runInShell: true, + includeParentEnvironment: true, + workingDirectory: workingDirectoryString, + environment: environment) + .then((process) { + process.stdout.transform(utf8.decoder).forEach((s) => print(' $s')); + process.stderr.transform(utf8.decoder).forEach((s) => print(' $s')); + return process; + }); + final exitCode = await process.exitCode; + if (exitCode != 0) { + final message = + 'Command `$commandString` failed with exit code $exitCode.'; + print(message); + if (throwOnFailure) { + throw Exception(message); + } + } + print('Command `$commandString` done.'); + } +} + +class Copy implements Task { + final Uri source; + final Uri target; + + Copy({ + required this.source, + required this.target, + }); + + @override + Future<void> run() async { + final file = File.fromUri(source); + if (!await file.exists()) { + final message = + "File not in expected location: '${source.toFilePath()}'."; + print(message); + throw Exception(message); + } + print('Copying ${source.toFilePath()} to ${target.toFilePath()}.'); + await file.copy(target.toFilePath()); + } +}