blob: 50f7f3f4144094471264208909d165cd1b3fbb43 [file] [log] [blame] [edit]
// 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);
}