Skip to content

Commit

Permalink
feat: add usage analytics (#71)
Browse files Browse the repository at this point in the history
  • Loading branch information
felangel authored Feb 16, 2021
1 parent 2163d7f commit a936698
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 19 deletions.
53 changes: 46 additions & 7 deletions lib/src/command_runner.dart
Original file line number Diff line number Diff line change
@@ -1,32 +1,65 @@
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:io/ansi.dart';
import 'package:io/io.dart';
import 'package:mason/mason.dart';
import 'package:usage/usage_io.dart';
import 'package:very_good_cli/src/commands/commands.dart';

import 'version.dart';

// The Google Analytics tracking ID.
const _gaTrackingId = 'UA-117465969-4';

// The Google Analytics Application Name.
const _gaAppName = 'very-good-cli';

/// {@template very_good_command_runner}
/// A [CommandRunner] for the Very Good CLI.
/// {@endtemplate}
class VeryGoodCommandRunner extends CommandRunner<int> {
/// {@macro very_good_command_runner}
VeryGoodCommandRunner({Logger logger})
VeryGoodCommandRunner({Analytics analytics, Logger logger})
: _logger = logger ?? Logger(),
_analytics =
analytics ?? AnalyticsIO(_gaTrackingId, _gaAppName, packageVersion),
super('very_good', '🦄 A Very Good Command Line Interface') {
argParser.addFlag(
'version',
negatable: false,
help: 'Print the current version.',
);
addCommand(CreateCommand(logger: logger));
argParser
..addFlag(
'version',
negatable: false,
help: 'Print the current version.',
)
..addOption(
'analytics',
help: 'Opt into or out of anonymous usage statistics.',
);
addCommand(CreateCommand(analytics: _analytics, logger: logger));
}

/// Standard timeout duration for the CLI.
static const timeout = Duration(milliseconds: 500);

final Logger _logger;
final Analytics _analytics;

@override
Future<int> run(Iterable<String> args) async {
try {
if (_analytics.firstRun) {
final response = _logger.prompt(lightGray.wrap(
'''+---------------------------------------------------+
| Welcome to the Very Good CLI! |
+---------------------------------------------------+
| We would like to collect anonymous |
| usage statistics in order to improve the tool. |
| Would you like to opt-into help us improve? [y/n] |
+---------------------------------------------------+\n''',
));
final normalizedResponse = response.toLowerCase().trim();
_analytics.enabled =
normalizedResponse == 'y' || normalizedResponse == 'yes';
}
final _argResults = parse(args);
return await runCommand(_argResults) ?? ExitCode.success.code;
} on FormatException catch (e, stackTrace) {
Expand All @@ -52,6 +85,12 @@ class VeryGoodCommandRunner extends CommandRunner<int> {
_logger.info('very_good version: $packageVersion');
return ExitCode.success.code;
}
if (topLevelResults['analytics'] != null) {
final optIn = topLevelResults['analytics'] == 'true' ? true : false;
_analytics.enabled = optIn;
_logger.info('analytics ${_analytics.enabled ? 'enabled' : 'disabled'}.');
return ExitCode.success.code;
}
return super.runCommand(topLevelResults);
}
}
19 changes: 17 additions & 2 deletions lib/src/commands/create.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import 'package:io/io.dart';
import 'package:mason/mason.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;

import 'package:usage/usage_io.dart';
import 'package:very_good_analysis/very_good_analysis.dart';
import 'package:very_good_cli/src/command_runner.dart';
import 'package:very_good_cli/src/templates/very_good_core_bundle.dart';

// A valid Dart identifier that can be used for a package, i.e. no
Expand All @@ -24,9 +26,12 @@ typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);
class CreateCommand extends Command<int> {
/// {@macro create_command}
CreateCommand({
@required Analytics analytics,
Logger logger,
GeneratorBuilder generator,
}) : _logger = logger ?? Logger(),
}) : assert(analytics != null),
_analytics = analytics,
_logger = logger ?? Logger(),
_generator = generator ?? MasonGenerator.fromBundle {
argParser.addOption(
'project-name',
Expand All @@ -36,6 +41,7 @@ class CreateCommand extends Command<int> {
);
}

final Analytics _analytics;
final Logger _logger;
final Future<MasonGenerator> Function(MasonBundle) _generator;

Expand All @@ -62,8 +68,17 @@ class CreateCommand extends Command<int> {
DirectoryGeneratorTarget(outputDirectory, _logger),
vars: {'project_name': projectName},
);

generateDone('Bootstrapping complete');
_logSummary(fileCount);

unawaited(_analytics.sendEvent(
'create',
generator.id,
label: generator.description,
));
await _analytics.waitForLastPing(timeout: VeryGoodCommandRunner.timeout);

return ExitCode.success.code;
}

Expand Down
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies:
mason: ^0.0.1-dev.26
meta: ^1.2.4
path: ^1.7.0
usage: ^3.4.2
very_good_analysis: ^1.0.4

dev_dependencies:
coverage: ^0.13.4
Expand All @@ -20,7 +22,6 @@ dev_dependencies:
build_version: ^2.0.1
mockito: ^4.0.0
test: ^1.14.3
very_good_analysis: ^1.0.4

executables:
very_good:
68 changes: 62 additions & 6 deletions test/src/command_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import 'package:io/io.dart';
import 'package:mason/mason.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:usage/usage_io.dart';
import 'package:very_good_cli/src/command_runner.dart';
import 'package:very_good_cli/src/version.dart';

class MockAnalytics extends Mock implements Analytics {}

class MockLogger extends Mock implements Logger {}

void main() {
group('VeryGoodCommandRunner', () {
List<String> printLogs;
Analytics analytics;
Logger logger;
VeryGoodCommandRunner commandRunner;

Expand All @@ -28,16 +32,41 @@ void main() {

setUp(() {
printLogs = [];

analytics = MockAnalytics();
when(analytics.firstRun).thenReturn(false);
when(analytics.enabled).thenReturn(false);

logger = MockLogger();
commandRunner = VeryGoodCommandRunner(logger: logger);
commandRunner = VeryGoodCommandRunner(
analytics: analytics,
logger: logger,
);
});

test('can be instantiated without an explicit logger instance', () {
test('can be instantiated without an explicit analytics/logger instance',
() {
final commandRunner = VeryGoodCommandRunner();
expect(commandRunner, isNotNull);
});

group('run', () {
test('prompts for analytics collection on first run (y)', () async {
when(analytics.firstRun).thenReturn(true);
when(logger.prompt(any)).thenReturn('y');
final result = await commandRunner.run(['--version']);
expect(result, equals(ExitCode.success.code));
verify(analytics.enabled = true);
});

test('prompts for analytics collection on first run (n)', () async {
when(analytics.firstRun).thenReturn(true);
when(logger.prompt(any)).thenReturn('n');
final result = await commandRunner.run(['--version']);
expect(result, equals(ExitCode.success.code));
verify(analytics.enabled = false);
});

test('handles FormatException', () async {
const exception = FormatException('oops!');
var isFirstInvocation = true;
Expand Down Expand Up @@ -75,8 +104,9 @@ void main() {
'Usage: very_good <command> [arguments]\n'
'\n'
'Global options:\n'
'-h, --help Print this usage information.\n'
' --version Print the current version.\n'
'-h, --help Print this usage information.\n'
' --version Print the current version.\n'
''' --analytics Opt into or out of anonymous usage statistics.\n'''
'\n'
'Available commands:\n'
''' create Creates a new very good flutter application in seconds.\n'''
Expand All @@ -96,8 +126,9 @@ void main() {
'Usage: very_good <command> [arguments]\n'
'\n'
'Global options:\n'
'-h, --help Print this usage information.\n'
' --version Print the current version.\n'
'-h, --help Print this usage information.\n'
' --version Print the current version.\n'
''' --analytics Opt into or out of anonymous usage statistics.\n'''
'\n'
'Available commands:\n'
''' create Creates a new very good flutter application in seconds.\n'''
Expand All @@ -116,6 +147,31 @@ void main() {
}));
});

group('--analytics', () {
test('sets analytics.enabled to true', () async {
final result = await commandRunner.run(['--analytics', 'true']);
expect(result, equals(ExitCode.success.code));
verify(analytics.enabled = true);
});

test('sets analytics.enabled to false', () async {
final result = await commandRunner.run(['--analytics', 'false']);
expect(result, equals(ExitCode.success.code));
verify(analytics.enabled = false);
});

test('sets analytics.enabled to false (garbage value)', () async {
final result = await commandRunner.run(['--analytics', 'garbage']);
expect(result, equals(ExitCode.success.code));
verify(analytics.enabled = false);
});

test('exits with bad usage when missing value', () async {
final result = await commandRunner.run(['--analytics']);
expect(result, equals(ExitCode.usage.code));
});
});

group('--version', () {
test('outputs current version', () async {
final result = await commandRunner.run(['--version']);
Expand Down
41 changes: 38 additions & 3 deletions test/src/commands/create_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,50 @@ import 'package:io/io.dart';
import 'package:mason/mason.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:usage/usage_io.dart';
import 'package:very_good_cli/src/command_runner.dart';
import 'package:very_good_cli/src/commands/create.dart';

class MockArgResults extends Mock implements ArgResults {}

class MockAnalytics extends Mock implements Analytics {}

class MockLogger extends Mock implements Logger {}

class MockMasonGenerator extends Mock implements MasonGenerator {}

void main() {
group('Create', () {
Analytics analytics;
Logger logger;
VeryGoodCommandRunner commandRunner;

setUp(() {
analytics = MockAnalytics();
when(analytics.firstRun).thenReturn(false);
when(analytics.enabled).thenReturn(false);
when(analytics.sendEvent(any, any, label: anyNamed('label')))
.thenAnswer((_) => Future.value());
when(analytics.waitForLastPing(timeout: anyNamed('timeout')))
.thenAnswer((_) => Future.value());

logger = MockLogger();
when(logger.progress(any)).thenReturn(([_]) {});
commandRunner = VeryGoodCommandRunner(logger: logger);
commandRunner = VeryGoodCommandRunner(
analytics: analytics,
logger: logger,
);
});

test('throws AssertionError when analytics is null', () {
expect(
() => CreateCommand(analytics: null),
throwsA(isA<AssertionError>()),
);
});

test('can be instantiated without any explicit dependencies', () {
final command = CreateCommand();
test('can be instantiated without explicit logger', () {
final command = CreateCommand(analytics: analytics);
expect(command, isNotNull);
});

Expand Down Expand Up @@ -69,11 +91,14 @@ void main() {
final argResults = MockArgResults();
final generator = MockMasonGenerator();
final command = CreateCommand(
analytics: analytics,
logger: logger,
generator: (_) async => generator,
)..argResultOverrides = argResults;
when(argResults['project-name']).thenReturn('my_app');
when(argResults.rest).thenReturn(['.tmp']);
when(generator.id).thenReturn('generator_id');
when(generator.description).thenReturn('generator description');
when(generator.generate(any, vars: anyNamed('vars')))
.thenAnswer((_) async => 62);
final result = await command.run();
Expand All @@ -96,6 +121,16 @@ void main() {
vars: {'project_name': 'my_app'},
),
).called(1);
verify(
analytics.sendEvent(
'create',
'generator_id',
label: 'generator description',
),
).called(1);
verify(
analytics.waitForLastPing(timeout: VeryGoodCommandRunner.timeout),
).called(1);
});
});
}

0 comments on commit a936698

Please sign in to comment.