| // 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:path/path.dart' as p; |
| 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/error_handler.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 FileSystem fs; |
| late File logFile; |
| |
| final testEvent = Event.hotReloadTime(timeMs: 10); |
| |
| setUp(() { |
| fs = MemoryFileSystem.test(style: FileSystemStyle.posix); |
| homeDirectory = fs.directory('home'); |
| logFile = fs.file(p.join( |
| homeDirectory.path, |
| kDartToolDirectoryName, |
| kLogFileName, |
| )); |
| |
| // Create the initialization analytics instance to onboard the tool |
| final initializationAnalytics = Analytics.test( |
| tool: DashTool.flutterTool, |
| homeDirectory: homeDirectory, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| 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.test( |
| tool: DashTool.flutterTool, |
| homeDirectory: homeDirectory, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| dartVersion: 'dartVersion', |
| fs: fs, |
| platform: DevicePlatform.macos, |
| ) as FakeAnalytics; |
| }); |
| |
| 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('The only record in the log file is malformed', () { |
| // 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'); |
| 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(); |
| |
| // Removing this test case because we are no longer writing error events |
| // to the locally persisted log file |
| // 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(); |
| |
| // Removing this test case because we are no longer writing error events |
| // to the locally persisted log file |
| // 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 - 1); |
| |
| // Removing this test case because we are no longer writing error events |
| // to the locally persisted log file |
| // expect(logFile.readAsLinesSync()[0].trim(), isNot('{{')); |
| }); |
| |
| test( |
| 'Catches and discards any FileSystemException raised from attempting ' |
| 'to write to the log file', () async { |
| final fs = MemoryFileSystem.test(opHandle: (context, operation) { |
| if (context.endsWith(kLogFileName) && operation == FileSystemOp.write) { |
| throw FileSystemException( |
| 'writeFrom failed', |
| context, |
| const OSError('No space left on device', 28), |
| ); |
| } |
| }); |
| final logHandler = LogHandler( |
| fs: fs, |
| homeDirectory: fs.currentDirectory, |
| errorHandler: ErrorHandler(sendFunction: (_) {}), |
| ); |
| |
| logHandler.save(data: {}); |
| }); |
| |
| test('deletes log file larger than kMaxLogFileSize', () async { |
| var deletedLargeLogFile = false; |
| var wroteDataToLogFile = false; |
| const data = <String, Object?>{}; |
| |
| final logHandler = LogHandler( |
| fs: fs, |
| homeDirectory: fs.currentDirectory, |
| errorHandler: ErrorHandler(sendFunction: (_) {}), |
| ); |
| logHandler.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; |
| }; |
| |
| 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 = <String, Object?>{}; |
| final fakeLogFile = _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( |
| fs: fs, |
| homeDirectory: fs.currentDirectory, |
| errorHandler: ErrorHandler(sendFunction: (_) {}), |
| ); |
| logHandler.logFile = fakeLogFile; |
| |
| 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); |
| } |
| final secondLogFileStats = analytics.logFileStats(); |
| |
| expect(secondLogFileStats, isNotNull); |
| |
| // Removing this test case because we are no longer writing error events |
| // to the locally persisted log file |
| // 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<String> Function()? _readAsLinesSyncImpl; |
| |
| @override |
| List<String> 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); |
| } |