[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());
+  }
+}