[flutter_tools] rewrite Usage.test to capture objects instead of print logs (#74829)

diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
index 0391511..88d1280 100644
--- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart
+++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
@@ -296,7 +296,7 @@
       fileSystem: MemoryFileSystem.test(),
       platform: platform,
       processManager: processManager,
-      usage: Usage.test(),
+      usage: TestUsage(),
       logger: BufferLogger.test(),
       terminal: Terminal.test(),
       majorVersion: majorVersion,
diff --git a/packages/flutter_tools/lib/src/reporting/usage.dart b/packages/flutter_tools/lib/src/reporting/usage.dart
index 7dfbd7e..a12f8f9 100644
--- a/packages/flutter_tools/lib/src/reporting/usage.dart
+++ b/packages/flutter_tools/lib/src/reporting/usage.dart
@@ -91,8 +91,6 @@
                       runningOnBot: runningOnBot,
                       firstRunMessenger: firstRunMessenger);
 
-  factory Usage.test() => _DefaultUsage.test();
-
   /// Uses the global [Usage] instance to send a 'command' to analytics.
   static void command(String command, {
     Map<CustomDimensions, Object> parameters,
@@ -276,12 +274,6 @@
     _analytics.analyticsOpt = AnalyticsOpt.optOut;
   }
 
-  _DefaultUsage.test() :
-      _suppressAnalytics = false,
-      _analytics = AnalyticsMock(true),
-      firstRunMessenger = null,
-      _clock = SystemClock.fixed(DateTime(2020, 10, 8));
-
   Analytics _analytics;
   final FirstRunMessenger firstRunMessenger;
 
@@ -469,3 +461,163 @@
     _sessionValues[param] = value.toString();
   }
 }
+
+
+/// Create a testing Usage instance.
+///
+/// All sent events, exceptions, timings, and pages are
+/// buffered on the object and can be inspected later.
+@visibleForTesting
+class TestUsage implements Usage {
+  final List<TestUsageCommand> commands = <TestUsageCommand>[];
+  final List<TestUsageEvent> events = <TestUsageEvent>[];
+  final List<dynamic> exceptions = <dynamic>[];
+  final List<TestTimingEvent> timings = <TestTimingEvent>[];
+
+  @override
+  bool enabled = true;
+
+  @override
+  bool suppressAnalytics = false;
+
+  @override
+  String get clientId => 'test-client';
+
+  @override
+  Future<void> ensureAnalyticsSent() {
+    throw UnimplementedError();
+  }
+
+  @override
+  Stream<Map<String, dynamic>> get onSend => throw UnimplementedError();
+
+  @override
+  void printWelcome() { }
+
+  @override
+  void sendCommand(String command, {Map<String, String> parameters}) {
+    commands.add(TestUsageCommand(command, parameters: parameters));
+  }
+
+  @override
+  void sendEvent(String category, String parameter, {String label, int value, Map<String, String> parameters}) {
+    events.add(TestUsageEvent(category, parameter, label: label, value: value, parameters: parameters));
+  }
+
+  @override
+  void sendException(dynamic exception) {
+    exceptions.add(exception);
+  }
+
+  @override
+  void sendTiming(String category, String variableName, Duration duration, {String label}) {
+    timings.add(TestTimingEvent(category, variableName, duration, label: label));
+  }
+}
+
+@visibleForTesting
+@immutable
+class TestUsageCommand {
+  const TestUsageCommand(this.command, {this.parameters});
+
+  final String command;
+  final Map<String, String> parameters;
+
+  @override
+  bool operator ==(Object other) {
+    return other is TestUsageCommand &&
+      other.command == command &&
+      _mapsEqual(other.parameters, parameters);
+  }
+
+  @override
+  int get hashCode => command.hashCode ^ parameters.hashCode;
+
+  @override
+  String toString() => 'TestUsageCommand($command, parameters:$parameters)';
+}
+
+@visibleForTesting
+@immutable
+class TestUsageEvent {
+  const TestUsageEvent(this.category, this.parameter, {this.label, this.value, this.parameters});
+
+  final String category;
+  final String parameter;
+  final String label;
+  final int value;
+  final Map<String, String> parameters;
+
+  @override
+  bool operator ==(Object other) {
+    return other is TestUsageEvent &&
+      other.category == category &&
+      other.parameter == parameter &&
+      other.label == label &&
+      other.value == value &&
+      _mapsEqual(other.parameters, parameters);
+  }
+
+  @override
+  int get hashCode => category.hashCode ^
+    parameter.hashCode ^
+    label.hashCode ^
+    value.hashCode ^
+    parameters.hashCode;
+
+  @override
+  String toString() => 'TestUsageEvent($category, $parameter, label:$label, value:$value, parameters:$parameters)';
+}
+
+@visibleForTesting
+@immutable
+class TestTimingEvent {
+  const TestTimingEvent(this.category, this.variableName, this.duration, {this.label});
+
+  final String category;
+  final String variableName;
+  final Duration duration;
+  final String label;
+
+  @override
+  bool operator ==(Object other) {
+    return other is TestTimingEvent &&
+      other.category == category &&
+      other.variableName == variableName &&
+      other.duration == duration &&
+      other.label == label;
+  }
+
+  @override
+  int get hashCode => category.hashCode ^
+    variableName.hashCode ^
+    duration.hashCode ^
+    label.hashCode;
+
+  @override
+  String toString() => 'TestTimingEvent($category, $variableName, $duration, label:$label)';
+}
+
+bool _mapsEqual(Map<dynamic, dynamic> a, Map<dynamic, dynamic> b) {
+  if (a == b) {
+    return true;
+  }
+  if (a == null || b == null) {
+    return false;
+  }
+  if (a.length != b.length) {
+    return false;
+  }
+
+  for (final dynamic k in a.keys) {
+    final dynamic bValue = b[k];
+    if (bValue == null && !b.containsKey(k)) {
+      return false;
+    }
+    if (bValue != a[k]) {
+      return false;
+    }
+  }
+
+  return true;
+}
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart
index abe3452..1f459c0 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart
@@ -48,7 +48,7 @@
 
 void main() {
   FileSystem fileSystem;
-  Usage usage;
+  TestUsage usage;
   BufferLogger logger;
 
   setUpAll(() {
@@ -57,7 +57,7 @@
 
   setUp(() {
     fileSystem = MemoryFileSystem.test();
-    usage = Usage.test();
+    usage = TestUsage();
     logger = BufferLogger.test();
   });
 
@@ -242,16 +242,15 @@
       ..createSync(recursive: true)
       ..writeAsBytesSync(List<int>.generate(10000, (int index) => 0));
 
-    // Capture Usage.test() events.
-    final StringBuffer buffer = await capturedConsolePrint(() =>
-      createTestCommandRunner(command).run(
-        const <String>['build', 'ipa', '--no-pub', '--analyze-size']
-      )
+    await createTestCommandRunner(command).run(
+      const <String>['build', 'ipa', '--no-pub', '--analyze-size']
     );
 
     expect(testLogger.statusText, contains('A summary of your iOS bundle analysis can be found at'));
     expect(testLogger.statusText, contains('flutter pub global activate devtools; flutter pub global run devtools --appSizeBase='));
-    expect(buffer.toString(), contains('event {category: code-size-analysis, action: ios, label: null, value: null, cd33: '));
+    expect(usage.events, contains(
+      const TestUsageEvent('code-size-analysis', 'ios'),
+    ));
   }, overrides: <Type, Generator>{
     FileSystem: () => fileSystem,
     ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_linux_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_linux_test.dart
index a38c01c..3ef45ed 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/build_linux_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/build_linux_test.dart
@@ -47,12 +47,12 @@
 
   FileSystem fileSystem;
   ProcessManager processManager;
-  Usage usage;
+  TestUsage usage;
 
   setUp(() {
     fileSystem = MemoryFileSystem.test();
     Cache.flutterRoot = _kTestFlutterRoot;
-    usage = Usage.test();
+    usage = TestUsage();
   });
 
   // Creates the mock files necessary to look like a Flutter project.
@@ -457,16 +457,15 @@
       ..createSync(recursive: true)
       ..writeAsBytesSync(List<int>.filled(10000, 0));
 
-    // Capture Usage.test() events.
-    final StringBuffer buffer = await capturedConsolePrint(() =>
-      createTestCommandRunner(command).run(
-        const <String>['build', 'linux', '--no-pub', '--analyze-size']
-      )
+    await createTestCommandRunner(command).run(
+      const <String>['build', 'linux', '--no-pub', '--analyze-size']
     );
 
     expect(testLogger.statusText, contains('A summary of your Linux bundle analysis can be found at'));
     expect(testLogger.statusText, contains('flutter pub global activate devtools; flutter pub global run devtools --appSizeBase='));
-    expect(buffer.toString(), contains('event {category: code-size-analysis, action: linux, label: null, value: null, cd33:'));
+    expect(usage.events, contains(
+      const TestUsageEvent('code-size-analysis', 'linux'),
+    ));
   }, overrides: <Type, Generator>{
     FileSystem: () => fileSystem,
     ProcessManager: () => processManager,
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart
index 52f1ad5..d42e24f 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart
@@ -54,7 +54,7 @@
 
 void main() {
   FileSystem fileSystem;
-  Usage usage;
+  TestUsage usage;
 
   setUpAll(() {
     Cache.disableLocking();
@@ -62,7 +62,7 @@
 
   setUp(() {
     fileSystem = MemoryFileSystem.test();
-    usage = Usage.test();
+    usage = TestUsage();
   });
 
   // Sets up the minimal mock project files necessary to look like a Flutter project.
@@ -338,16 +338,15 @@
       ..createSync(recursive: true)
       ..writeAsBytesSync(List<int>.generate(10000, (int index) => 0));
 
-    // Capture Usage.test() events.
-    final StringBuffer buffer = await capturedConsolePrint(() =>
-      createTestCommandRunner(command).run(
-        const <String>['build', 'macos', '--no-pub', '--analyze-size']
-      )
+    await createTestCommandRunner(command).run(
+      const <String>['build', 'macos', '--no-pub', '--analyze-size']
     );
 
     expect(testLogger.statusText, contains('A summary of your macOS bundle analysis can be found at'));
     expect(testLogger.statusText, contains('flutter pub global activate devtools; flutter pub global run devtools --appSizeBase='));
-    expect(buffer.toString(), contains('event {category: code-size-analysis, action: macos, label: null, value: null, cd33:'));
+    expect(usage.events, contains(
+      const TestUsageEvent('code-size-analysis', 'macos'),
+    ));
   }, overrides: <Type, Generator>{
     FileSystem: () => fileSystem,
     ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart
index 64da20c..b577778 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart
@@ -45,7 +45,7 @@
 
   ProcessManager processManager;
   MockVisualStudio mockVisualStudio;
-  Usage usage;
+  TestUsage usage;
 
   setUpAll(() {
     Cache.disableLocking();
@@ -55,7 +55,7 @@
     fileSystem = MemoryFileSystem.test(style: FileSystemStyle.windows);
     Cache.flutterRoot = flutterRoot;
     mockVisualStudio = MockVisualStudio();
-    usage = Usage.test();
+    usage = TestUsage();
   });
 
   // Creates the mock files necessary to look like a Flutter project.
@@ -401,16 +401,15 @@
       }),
     ]);
 
-    // Capture Usage.test() events.
-    final StringBuffer buffer = await capturedConsolePrint(() =>
-      createTestCommandRunner(command).run(
-        const <String>['windows', '--no-pub', '--analyze-size']
-      )
+    await createTestCommandRunner(command).run(
+      const <String>['windows', '--no-pub', '--analyze-size']
     );
 
     expect(testLogger.statusText, contains('A summary of your Windows bundle analysis can be found at'));
     expect(testLogger.statusText, contains('flutter pub global activate devtools; flutter pub global run devtools --appSizeBase='));
-    expect(buffer.toString(), contains('event {category: code-size-analysis, action: windows, label: null, value: null, cd33:'));
+    expect(usage.events, contains(
+       const TestUsageEvent('code-size-analysis', 'windows'),
+    ));
   }, overrides: <Type, Generator>{
     FeatureFlags: () => TestFeatureFlags(isWindowsEnabled: true),
     FileSystem: () => fileSystem,
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
index 9a597bc..78caba8 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
@@ -141,13 +141,13 @@
       Artifacts artifacts;
       MockCache mockCache;
       MockProcessManager mockProcessManager;
-      Usage usage;
+      TestUsage usage;
       Directory tempDir;
 
       setUp(() {
         artifacts = Artifacts.test();
         mockCache = MockCache();
-        usage = Usage.test();
+        usage = TestUsage();
         fs = MemoryFileSystem.test();
         mockProcessManager = MockProcessManager();
 
@@ -368,19 +368,18 @@
           ..writeAsStringSync('# Hello, World');
         globals.fs.currentDirectory = tempDir;
 
-        // Capture Usage.test() events.
-        final StringBuffer buffer = await capturedConsolePrint(() =>
-          expectToolExitLater(createTestCommandRunner(command).run(<String>[
-            'run',
-            '--no-pub',
-            '--no-hot',
-          ]), isNull)
-        );
-        // Allow any CustomDimensions.localTime (cd33) timestamp.
-        final RegExp usageRegexp = RegExp(
-          'screenView {cd3: false, cd4: ios, cd22: iOS 13, cd23: debug, cd18: false, cd15: swift, cd31: false, cd33: .*, viewName: run'
-        );
-        expect(buffer.toString(), matches(usageRegexp));
+        await expectToolExitLater(createTestCommandRunner(command).run(<String>[
+          'run',
+          '--no-pub',
+          '--no-hot',
+        ]), isNull);
+
+        expect(usage.commands, contains(
+          const TestUsageCommand('run', parameters: <String, String>{
+            'cd3': 'false', 'cd4': 'ios', 'cd22': 'iOS 13',
+            'cd23': 'debug', 'cd18': 'false', 'cd15': 'swift', 'cd31': 'false',
+          }
+        )));
       }, overrides: <Type, Generator>{
         Artifacts: () => artifacts,
         Cache: () => mockCache,
diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart
index 72a4245..cff5cb8 100644
--- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart
@@ -226,7 +226,7 @@
       platform: platform,
       processManager: fakeProcessManager,
       terminal: terminal,
-      usage: Usage.test(),
+      usage: TestUsage(),
     );
     fileSystem.file(xcodebuild).deleteSync();
 
@@ -430,7 +430,7 @@
       platform: platform,
       processManager: fakeProcessManager,
       terminal: terminal,
-      usage: Usage.test(),
+      usage: TestUsage(),
     );
 
     expect(await xcodeProjectInterpreter.getInfo(workingDirectory), isNotNull);
@@ -457,7 +457,7 @@
       platform: platform,
       processManager: fakeProcessManager,
       terminal: terminal,
-      usage: Usage.test(),
+      usage: TestUsage(),
     );
 
     expect(
diff --git a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
index c2ab75b..b8f886b 100644
--- a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
@@ -34,7 +34,7 @@
   CocoaPods cocoaPodsUnderTest;
   InvokeProcess resultOfPodVersion;
   BufferLogger logger;
-  Usage usage;
+  TestUsage usage;
 
   void pretendPodVersionFails() {
     resultOfPodVersion = () async => exitsWithError();
@@ -71,7 +71,7 @@
     projectUnderTest = FlutterProject.fromDirectory(fileSystem.directory('project'));
     projectUnderTest.ios.xcodeProject.createSync(recursive: true);
     projectUnderTest.macos.xcodeProject.createSync(recursive: true);
-    usage = Usage.test();
+    usage = TestUsage();
     cocoaPodsUnderTest = CocoaPods(
       fileSystem: fileSystem,
       processManager: mockProcessManager,
@@ -486,21 +486,18 @@
             'LoadError - dlsym(0x7fbbeb6837d0, Init_ffi_c): symbol not found - /Library/Ruby/Gems/2.6.0/gems/ffi-1.13.1/lib/ffi_c.bundle',
           ));
 
-      // Capture Usage.test() events.
-      final StringBuffer buffer =
-          await capturedConsolePrint(() => expectToolExitLater(
-                cocoaPodsUnderTest.processPods(
-                  xcodeProject: projectUnderTest.ios,
-                  buildMode: BuildMode.debug,
-                ),
-                equals('Error running pod install'),
-              ));
+      await expectToolExitLater(
+        cocoaPodsUnderTest.processPods(
+          xcodeProject: projectUnderTest.ios,
+          buildMode: BuildMode.debug,
+        ),
+        equals('Error running pod install'),
+      );
       expect(
         logger.errorText,
         contains('set up CocoaPods for ARM macOS'),
       );
-      expect(buffer.toString(),
-          contains('event {category: pod-install-failure, action: arm-ffi'));
+      expect(usage.events, contains(const TestUsageEvent('pod-install-failure', 'arm-ffi')));
     });
 
     testWithoutContext('ffi failure on x86 macOS does not prompt gem install', () async {
diff --git a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart
index 433a725..0c49132 100644
--- a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart
+++ b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart
@@ -64,7 +64,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final int port = (await portDiscovery.query())?.port;
       expect(port, isNull);
@@ -76,7 +76,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: logger,
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final Uri uri = await portDiscovery.getObservatoryUri(
         '',
@@ -101,7 +101,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final int port = (await portDiscovery.query())?.port;
       expect(port, 123);
@@ -127,7 +127,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final MDnsObservatoryDiscoveryResult result = await portDiscovery.query();
       expect(result?.port, 123);
@@ -153,7 +153,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       expect(portDiscovery.query, throwsToolExit());
     });
@@ -177,7 +177,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final int port = (await portDiscovery.query(applicationId: 'fiz'))?.port;
       expect(port, 321);
@@ -204,7 +204,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
       expect(port, 1234);
@@ -219,7 +219,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
       expect(port, isNull);
@@ -234,7 +234,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       expect(
         () async => await portDiscovery.query(),
@@ -259,7 +259,7 @@
       final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(
         mdnsClient: client,
         logger: BufferLogger.test(),
-        flutterUsage: Usage.test(),
+        flutterUsage: TestUsage(),
       );
       final Uri uri = await portDiscovery.getObservatoryUri('bar', mockDevice, hostVmservicePort: 0);
       expect(uri.toString(), 'http://127.0.0.1:123/');