// Copyright (c) 2023, 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 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:test/fake.dart'; import 'package:test/test.dart'; import 'package:unified_analytics/src/constants.dart'; import 'package:unified_analytics/src/enums.dart'; import 'package:unified_analytics/src/log_handler.dart'; import 'package:unified_analytics/src/utils.dart'; import 'package:unified_analytics/unified_analytics.dart'; void main() { late FakeAnalytics analytics; late Directory homeDirectory; late MemoryFileSystem fs; late File logFile; final testEvent = Event.hotReloadTime(timeMs: 10); final propertyEditorEvent = Event.devtoolsEvent( label: devtoolsEventLabel, screen: propertyEditorId, eventCategory: 'test', value: 1); final devToolsEvent = Event.devtoolsEvent( label: devtoolsEventLabel, screen: 'inspector', eventCategory: 'test', value: 1); setUp(() { fs = MemoryFileSystem.test(style: FileSystemStyle.posix); homeDirectory = fs.directory('home'); logFile = homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kLogFileName); // Create the initialization analytics instance to onboard the tool final initializationAnalytics = Analytics.fake( tool: DashTool.flutterTool, homeDirectory: homeDirectory, dartVersion: 'dartVersion', fs: fs, platform: DevicePlatform.macos, ); initializationAnalytics.clientShowedMessage(); // This instance is free to send events since the instance above // has confirmed that the client has shown the message analytics = Analytics.fake( tool: DashTool.flutterTool, homeDirectory: homeDirectory, dartVersion: 'dartVersion', fs: fs, platform: DevicePlatform.macos, ); }); test('Ensure that log file is created', () { expect(logFile.existsSync(), true); }); test('LogFileStats is null before events are sent', () { expect(analytics.logFileStats(), isNull); }); test('LogFileStats returns valid response after sent events', () async { final countOfEventsToSend = 10; for (var i = 0; i < countOfEventsToSend; i++) { analytics.send(testEvent); } expect(analytics.logFileStats(), isNotNull); expect(logFile.readAsLinesSync().length, countOfEventsToSend); expect(analytics.logFileStats()!.recordCount, countOfEventsToSend); }); test('LogFileStats handles granular events', () async { final countOfPropertyEditorEventsToSend = 5; final countOfDevToolsEventsToSend = 10; for (var i = 0; i < countOfPropertyEditorEventsToSend; i++) { analytics.send(propertyEditorEvent); } for (var i = 0; i < countOfDevToolsEventsToSend; i++) { analytics.send(devToolsEvent); } expect(analytics.logFileStats(), isNotNull); expect(logFile.readAsLinesSync().length, countOfPropertyEditorEventsToSend + countOfDevToolsEventsToSend); expect(analytics.logFileStats()!.eventCount['property_editor'], countOfPropertyEditorEventsToSend); expect(analytics.logFileStats()!.eventCount['devtools_event'], countOfDevToolsEventsToSend); }); test('The only record in the log file is malformed', () async { // Write invalid json for the only log record logFile.writeAsStringSync('{{\n'); expect(logFile.readAsLinesSync().length, 1); final logFileStats = analytics.logFileStats(); expect(logFileStats, isNull, reason: 'Null should be returned since only ' 'one record is in there and it is malformed'); analytics.sendPendingErrorEvents(); expect( analytics.sentEvents, contains( Event.analyticsException( workflow: 'LogFileStats.logFileStats', error: 'FormatException', description: 'message: Unexpected character\nsource: {{', ), )); }); test('The first record is malformed, but rest are valid', () async { // Write invalid json for the only log record logFile.writeAsStringSync('{{\n'); final countOfEventsToSend = 10; for (var i = 0; i < countOfEventsToSend; i++) { analytics.send(testEvent); } expect(logFile.readAsLinesSync().length, countOfEventsToSend + 1); final logFileStats = analytics.logFileStats(); expect(logFileStats, isNotNull); expect(logFileStats!.recordCount, countOfEventsToSend); }); test('Several records are malformed', () async { final countOfMalformedRecords = 4; for (var i = 0; i < countOfMalformedRecords; i++) { final currentContents = logFile.readAsStringSync(); logFile.writeAsStringSync('$currentContents{{\n'); } final countOfEventsToSend = 10; for (var i = 0; i < countOfEventsToSend; i++) { analytics.send(testEvent); } expect(logFile.readAsLinesSync().length, countOfEventsToSend + countOfMalformedRecords); final logFileStats = analytics.logFileStats(); analytics.sendPendingErrorEvents(); expect(logFile.readAsLinesSync().length, countOfEventsToSend + countOfMalformedRecords + 1, reason: 'There should have been on error event sent when getting stats'); expect(logFileStats, isNotNull); expect(logFileStats!.recordCount, countOfEventsToSend); }); test('Valid json but invalid keys', () { // The second line here is missing the "events" top level // key which should cause an error for that record only // // Important to note that this won't actually cause a FormatException // like the other malformed records, instead the LogItem.fromRecord // constructor will return null if all the keys are not available final contents = ''' {"client_id":"ffcea97b-db5e-4c66-98c2-3942de4fac40","events":[{"name":"hot_reload_time","params":{"timeMs":136}}],"user_properties":{"session_id":{"value":1699385899950},"flutter_channel":{"value":"ey-test-channel"},"host":{"value":"macOS"},"flutter_version":{"value":"Flutter 3.6.0-7.0.pre.47"},"dart_version":{"value":"Dart 2.19.0"},"analytics_pkg_version":{"value":"5.2.0"},"tool":{"value":"flutter-tool"},"local_time":{"value":"2023-11-07 15:37:26.685761 -0500"},"host_os_version":{"value":"Version 14.1 (Build 23B74)"},"locale":{"value":"en"},"client_ide":{"value":"VSCode"}}} {"client_id":"ffcea97b-db5e-4c66-98c2-3942de4fac40","WRONG_EVENT_KEY":[{"name":"hot_reload_time","params":{"timeMs":136}}],"user_properties":{"session_id":{"value":1699385899950},"flutter_channel":{"value":"ey-test-channel"},"host":{"value":"macOS"},"flutter_version":{"value":"Flutter 3.6.0-7.0.pre.47"},"dart_version":{"value":"Dart 2.19.0"},"analytics_pkg_version":{"value":"5.2.0"},"tool":{"value":"flutter-tool"},"local_time":{"value":"2023-11-07 15:37:26.685761 -0500"},"host_os_version":{"value":"Version 14.1 (Build 23B74)"},"locale":{"value":"en"},"client_ide":{"value":"VSCode"}}} {"client_id":"ffcea97b-db5e-4c66-98c2-3942de4fac40","events":[{"name":"hot_reload_time","params":{"timeMs":136}}],"user_properties":{"session_id":{"value":1699385899950},"flutter_channel":{"value":"ey-test-channel"},"host":{"value":"macOS"},"flutter_version":{"value":"Flutter 3.6.0-7.0.pre.47"},"dart_version":{"value":"Dart 2.19.0"},"analytics_pkg_version":{"value":"5.2.0"},"tool":{"value":"flutter-tool"},"local_time":{"value":"2023-11-07 15:37:26.685761 -0500"},"host_os_version":{"value":"Version 14.1 (Build 23B74)"},"locale":{"value":"en"},"client_ide":{"value":"VSCode"}}} '''; logFile.writeAsStringSync(contents); final logFileStats = analytics.logFileStats(); expect(logFile.readAsLinesSync().length, 3); expect(logFileStats, isNotNull); expect(logFileStats!.recordCount, 2); }); test('Malformed record gets phased out after several events', () async { // Write invalid json for the only log record logFile.writeAsStringSync('{{\n'); // Send the max number of events minus two so that we have // one malformed record on top of the logs and the rest // are valid log records // // We need to account for the event that is sent when // calling [logFileStats()] fails and sends an instance // of [Event.analyticsException] final recordsToSendInitially = kLogFileLength - 2; for (var i = 0; i < recordsToSendInitially; i++) { analytics.send(testEvent); } final logFileStats = analytics.logFileStats(); analytics.sendPendingErrorEvents(); expect(analytics.sentEvents.last.eventName, DashEvent.analyticsException, reason: 'Calling for the stats should have caused an error'); expect(logFile.readAsLinesSync().length, kLogFileLength); expect(logFileStats, isNotNull); expect(logFileStats!.recordCount, recordsToSendInitially, reason: 'The first record should be malformed'); expect(logFile.readAsLinesSync()[0].trim(), '{{'); // Sending one more event should flush out the malformed record analytics.send(testEvent); final secondLogFileStats = analytics.logFileStats(); expect(analytics.sentEvents.last, testEvent); expect(secondLogFileStats, isNotNull); expect(secondLogFileStats!.recordCount, kLogFileLength); expect(logFile.readAsLinesSync()[0].trim(), isNot('{{')); }); test( 'Catches and discards any FileSystemException raised from attempting ' 'to write to the log file', () async { final logFilePath = 'log.txt'; final fs = MemoryFileSystem.test(opHandle: (context, operation) { if (context == logFilePath && operation == FileSystemOp.write) { throw FileSystemException( 'writeFrom failed', logFilePath, const OSError('No space left on device', 28), ); } }); final logFile = fs.file(logFilePath); logFile.createSync(); final logHandler = LogHandler(logFile: logFile); logHandler.save(data: {}); }); test('deletes log file larger than kMaxLogFileSize', () async { var deletedLargeLogFile = false; var wroteDataToLogFile = false; const data = {}; final logFile = _FakeFile('log.txt') .._deleteSyncImpl = (() => deletedLargeLogFile = true) .._createSyncImpl = () {} .._statSyncImpl = (() => _FakeFileStat(kMaxLogFileSize + 1)) .._writeAsStringSync = (contents, {mode = FileMode.append}) { expect(contents.trim(), data.toString()); expect(mode, FileMode.writeOnlyAppend); wroteDataToLogFile = true; }; final logHandler = LogHandler(logFile: logFile); logHandler.save(data: data); expect(deletedLargeLogFile, isTrue); expect(wroteDataToLogFile, isTrue); }); test('does not delete log file if smaller than kMaxLogFileSize', () async { var wroteDataToLogFile = false; const data = {}; final logFile = _FakeFile('log.txt') .._deleteSyncImpl = (() => fail('called logFile.deleteSync() when file was less than ' 'kMaxLogFileSize')) .._createSyncImpl = () {} .._readAsLinesSyncImpl = (() => ['three', 'previous', 'lines']) .._statSyncImpl = (() => _FakeFileStat(kMaxLogFileSize - 1)) .._writeAsStringSync = (contents, {mode = FileMode.append}) { expect(contents.trim(), data.toString()); expect(mode, FileMode.writeOnlyAppend); wroteDataToLogFile = true; }; final logHandler = LogHandler(logFile: logFile); logHandler.save(data: data); expect(wroteDataToLogFile, isTrue); }); test('Catching cast errors for each log record silently', () async { // Write a json array to the log file which will cause // a cast error when parsing each line logFile.writeAsStringSync('[{}, 1, 2, 3]\n'); final logFileStats = analytics.logFileStats(); expect(logFileStats, isNull); // Ensure it will work as expected after writing correct logs final countOfEventsToSend = 10; for (var i = 0; i < countOfEventsToSend; i++) { analytics.send(testEvent); } analytics.sendPendingErrorEvents(); final secondLogFileStats = analytics.logFileStats(); expect(secondLogFileStats, isNotNull); expect(secondLogFileStats!.recordCount, countOfEventsToSend + 1, reason: 'Plus one for the error event that is sent ' 'from the first logFileStats call'); }); test( 'truncateStringToLength returns same string when ' 'max length greater than string length', () { final testString = 'Version 14.1 (Build 23B74)'; final maxLength = 100; expect(testString.length < maxLength, true); String runTruncateString() => truncateStringToLength(testString, maxLength); expect(runTruncateString, returnsNormally); final newString = runTruncateString(); expect(newString, testString); }); test( 'truncateStringToLength returns truncated string when ' 'max length less than string length', () { final testString = 'Version 14.1 (Build 23B74)'; final maxLength = 10; expect(testString.length > maxLength, true); String runTruncateString() => truncateStringToLength(testString, maxLength); expect(runTruncateString, returnsNormally); final newString = runTruncateString(); expect(newString.length, maxLength); expect(newString, 'Version 14'); }); test('truncateStringToLength handle errors for invalid max length', () { final testString = 'Version 14.1 (Build 23B74)'; var maxLength = 0; String runTruncateString() => truncateStringToLength(testString, maxLength); expect(runTruncateString, throwsArgumentError); maxLength = -1; expect(runTruncateString, throwsArgumentError); }); test('truncateStringToLength same string when max length is the same', () { final testString = 'Version 14.1 (Build 23B74)'; final maxLength = testString.length; String runTruncateString() => truncateStringToLength(testString, maxLength); expect(runTruncateString, returnsNormally); final newString = runTruncateString(); expect(newString.length, maxLength); expect(newString, testString); }); } class _FakeFileStat extends Fake implements FileStat { _FakeFileStat(this.size); @override final int size; } class _FakeFile extends Fake implements File { _FakeFile(this.path); List Function()? _readAsLinesSyncImpl; @override List readAsLinesSync({Encoding encoding = utf8}) => _readAsLinesSyncImpl!(); @override final String path; FileStat Function()? _statSyncImpl; @override FileStat statSync() => _statSyncImpl!(); void Function()? _deleteSyncImpl; @override void deleteSync({bool recursive = false}) => _deleteSyncImpl!(); void Function()? _createSyncImpl; @override void createSync({bool recursive = false, bool exclusive = false}) { return _createSyncImpl!(); } void Function(String contents, {FileMode mode})? _writeAsStringSync; @override void writeAsStringSync( String contents, { FileMode mode = FileMode.write, Encoding encoding = utf8, bool flush = false, }) => _writeAsStringSync!(contents, mode: mode); }