blob: d71337fe8e8bbcfc0df3695fa77b4b906b1a55da [file]
// Copyright (c) 2020, 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:async';
import 'dart:convert';
import 'dart:io' as io;
import 'package:dartdev/dartdev.dart';
import 'package:dartdev/src/unified_analytics.dart';
import 'package:dartdev/src/vm_interop_handler.dart';
import 'package:file/memory.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
// Needed to reset the global HTTP client after a test.
import 'package:pub/src/http.dart' as pub show withHttpClient;
import 'package:test/test.dart';
import 'package:unified_analytics/unified_analytics.dart';
import 'experiment_util.dart';
import 'utils.dart';
List<Map<String, Object?>> extractAnalytics(io.ProcessResult result) {
return LineSplitter.split(
result.stderr,
).where((line) => line.startsWith('[analytics]: ')).map((line) {
return (json.decode(line.substring('[analytics]: '.length)) as Map)
.cast<String, Object?>();
}).toList();
}
void main() {
final experiments = experimentsWithValidation();
group('VM -> CLI flag smoke test:', () {
late DartdevRunner command;
setUp(() {
command = DartdevRunner([], isAnalyticsTest: true);
});
test('--no-analytics', () async {
final result = await command.runCommand(
command.parse(['--no-analytics']),
);
expect(result, 0);
expect(command.unifiedAnalytics.telemetryEnabled, false);
});
test('--suppress-analytics', () async {
final result = await command.runCommand(
command.parse(['--suppress-analytics']),
);
expect(result, 0);
expect(command.unifiedAnalytics.telemetryEnabled, false);
});
test('--suppress-analytics and --disable-analytics', () async {
final result = await command.runCommand(
command.parse(['--suppress-analytics', '--disable-analytics']),
);
// --suppress-analytics and --disable-analytics can't be provided
// together to ensure analytics state properly sticks.
expect(result, 254);
});
test('--suppress-analytics and --enable-analytics', () async {
final result = await command.runCommand(
command.parse(['--suppress-analytics', '--enable-analytics']),
);
// --suppress-analytics and --enable-analytics can't be provided
// together to ensure analytics state properly sticks.
expect(result, 254);
});
});
group('Sending analytics', () {
test('help', () async {
final p = project();
final analytics = await p.runLocalWithFakeAnalytics(['help']);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'help',
enabledExperiments: '',
),
]);
});
test('create', () async {
final p = project();
final analytics = await p.runLocalWithFakeAnalytics([
'create',
'--no-pub',
'-tpackage-simple',
path.join(io.Directory.systemTemp.createTempSync().path, 'name'),
]);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'create',
enabledExperiments: '',
pubspecHasFlutterSdk: false,
pubspecEnvironmentSdk: '^3.0.0',
),
]);
});
group('pub', () {
test(
'get',
() async {
final p = project(
pubspecExtras: {
'dependencies': {'lints': '2.0.1'},
},
);
final analytics = await p.runLocalWithFakeAnalytics(['pub', 'get']);
// Pub no longer sends custom analytics,
// so only the command should be sent.
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'pub/get',
enabledExperiments: '',
pubspecHasFlutterSdk: false,
pubspecDependencies: const {'lints'},
pubspecEnvironmentSdk: '^3.0.0',
),
]);
},
// This test does a pub get, so it might run slow. Consider adding
// retries if necessary.
timeout: Timeout.factor(5),
);
});
test('format', () async {
final p = project();
final analytics = await p.runLocalWithFakeAnalytics([
'format',
'-l80',
'.',
]);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'format',
enabledExperiments: '',
),
]);
});
test('info', () async {
final p = project();
final analytics = await p.runLocalWithFakeAnalytics(['info']);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'info/dump',
enabledExperiments: '',
),
]);
});
test('run', () async {
final p = project(mainSrc: 'void main(List<String> args) => print(args)');
await pub.withHttpClient(client: http.Client(), () async {
final analytics = await p.runLocalWithFakeAnalytics([
'run',
'--no-pause-isolates-on-exit',
'--enable-asserts',
'lib/main.dart',
'--argument',
]);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'run',
enabledExperiments: '',
pubspecHasFlutterSdk: false,
pubspecEnvironmentSdk: '^3.0.0',
),
]);
});
});
group('run --enable-experiments', () {
for (final experiment in experiments) {
test(experiment.name, () async {
{
for (final no in ['', 'no-']) {
final p = project(mainSrc: experiment.validation);
await pub.withHttpClient(client: http.Client(), () async {
final analytics = await p.runLocalWithFakeAnalytics([
'run',
'--enable-experiment=$no${experiment.name}',
'lib/main.dart',
]);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'run',
enabledExperiments: '$no${experiment.name}',
pubspecHasFlutterSdk: false,
pubspecEnvironmentSdk: '^3.0.0',
),
]);
});
}
}
});
}
});
test('compile', () async {
final p = project(
mainSrc: 'void main(List<String> args) => print(args);',
);
final analytics = await p.runLocalWithFakeAnalytics([
'compile',
'kernel',
'lib/main.dart',
'-o',
'main.kernel',
]);
expect(analytics.sentEvents, [
Event.dartCliCommandExecuted(
name: 'compile/kernel',
enabledExperiments: '',
pubspecHasFlutterSdk: false,
pubspecEnvironmentSdk: '^3.0.0',
),
]);
});
});
group('Analytics hang test', () {
test('close hangs but runner finishes quickly', () async {
final fs = MemoryFileSystem.test(style: FileSystemStyle.posix);
final homeDirectory = fs.directory('/');
final fakeAnalytics = Analytics.fake(
tool: DashTool.dartTool,
homeDirectory: homeDirectory,
dartVersion: 'dartVersion',
fs: fs,
);
final hangingAnalytics = HangingAnalytics(fakeAnalytics);
final runner = DartdevRunner(
['--no-analytics'],
analyticsOverride: hangingAnalytics,
);
final stopwatch = Stopwatch()..start();
final result = await runner.runCommand(runner.parse(['--no-analytics']));
stopwatch.stop();
expect(result, 0);
// The timeout is 250ms, so it should definitely finish in less
// than 1 second.
// If it hung, it would take much longer or never finish.
expect(stopwatch.elapsedMilliseconds, lessThan(1000));
});
});
group('Sub-tool suppression on first run:', () {
const configPath = '/.dart-tool/dart-flutter-telemetry.config';
final suppressVar = DashEnvVar.suppressAnalytics.name;
late MemoryFileSystem fs;
setUp(() {
fs = MemoryFileSystem.test(style: FileSystemStyle.posix);
});
FakeAnalytics createFake({bool isExternal = true}) => Analytics.fake(
tool: DashTool.dartTool,
homeDirectory: fs.directory('/'),
dartVersion: 'dartVersion',
fs: fs,
isExternal: isExternal,
);
/// A fake for a user who has already seen the consent message.
FakeAnalytics createConsentedFake() {
createFake().clientShowedMessage();
return createFake();
}
bool dartToolConsentRecorded() {
final config = fs.file(configPath);
return config.existsSync() &&
RegExp(
r'^dart-tool=',
multiLine: true,
).hasMatch(config.readAsStringSync());
}
// NOTE: `VmInteropHandler.environmentOverrides` is static, so values set
// by one run stay visible to later tests in this isolate (including any
// child processes they start through `runProcess`). Each run below unsets
// and then sets `DASH__SUPPRESS_ANALYTICS`, so reading it right after a
// run is reliable.
Future<String?> runAndGetPropagatedValue(
Analytics analytics, {
required bool hasTerminal,
List<String> args = const [],
Map<String, String> environment = const {},
bool isAnalyticsTest = true,
}) async {
final runner = DartdevRunner(
args,
analyticsOverride: analytics,
// When true, keeps `isBot()` from affecting whether the message is
// printed.
isAnalyticsTest: isAnalyticsTest,
stdoutHasTerminal: hasTerminal,
// Don't inherit analytics variables (or bot / agent variables) from
// the test process.
environment: environment,
);
// With no command, `CommandRunner` prints usage; keep test output quiet.
final result = await runZoned(
() => runner.runCommand(runner.parse(args)),
zoneSpecification: ZoneSpecification(print: (_, _, _, _) {}),
);
expect(result, 0);
return VmInteropHandler.environmentOverrides[suppressVar];
}
group('helpers', () {
test('shouldPrintConsentMessage', () {
const rows = [
(pending: true, terminal: true, bot: false, expected: true),
(pending: true, terminal: true, bot: true, expected: false),
(pending: true, terminal: false, bot: false, expected: false),
(pending: true, terminal: false, bot: true, expected: false),
(pending: false, terminal: true, bot: false, expected: false),
(pending: false, terminal: true, bot: true, expected: false),
(pending: false, terminal: false, bot: false, expected: false),
(pending: false, terminal: false, bot: true, expected: false),
];
for (final (:pending, :terminal, :bot, :expected) in rows) {
expect(
shouldPrintConsentMessage(
consentPending: pending,
hasTerminal: terminal,
botSuppressed: bot,
),
expected,
reason: 'pending: $pending, terminal: $terminal, bot: $bot',
);
}
});
test('shouldSuppressSubtools', () {
expect(
shouldSuppressSubtools(
suppressAnalytics: false,
showsConsentMessage: false,
),
isFalse,
);
// Regression case: sub-tools must be suppressed on the run where the
// consent message is shown.
expect(
shouldSuppressSubtools(
suppressAnalytics: false,
showsConsentMessage: true,
),
isTrue,
);
expect(
shouldSuppressSubtools(
suppressAnalytics: true,
showsConsentMessage: false,
),
isTrue,
);
expect(
shouldSuppressSubtools(
suppressAnalytics: true,
showsConsentMessage: true,
),
isTrue,
);
});
});
test('clientShowedMessage clears shouldShowMessage, not okToSend', () {
// dartdev relies on this: it must read `shouldShowMessage` before
// calling `clientShowedMessage()`, and it sends nothing itself on the
// run where the message is shown.
final analytics = createFake();
expect(analytics.shouldShowMessage, isTrue);
expect(analytics.okToSend, isFalse);
analytics.clientShowedMessage();
expect(analytics.shouldShowMessage, isFalse);
expect(analytics.okToSend, isFalse);
expect(dartToolConsentRecorded(), isTrue);
// A sub-tool reporting under the same label, started later in the same
// run, would be allowed to send. This is why dartdev suppresses
// sub-tools on this run.
expect(createFake().okToSend, isTrue);
});
test('first run with a terminal suppresses sub-tools', () async {
final analytics = createFake();
final value = await runAndGetPropagatedValue(
analytics,
hasTerminal: true,
);
expect(value, 'true');
// The message was shown and recorded.
expect(analytics.shouldShowMessage, isFalse);
expect(dartToolConsentRecorded(), isTrue);
expect(analytics.sentEvents, isEmpty);
});
test('first run without a terminal does not suppress sub-tools', () async {
final analytics = createFake();
final value = await runAndGetPropagatedValue(
analytics,
hasTerminal: false,
);
expect(value, 'false');
// The message was not shown, so consent is still pending and nothing
// was recorded; sub-tools using the same label can't send either.
expect(analytics.shouldShowMessage, isTrue);
expect(dartToolConsentRecorded(), isFalse);
expect(createFake().okToSend, isFalse);
});
test('consented user does not suppress sub-tools', () async {
final value = await runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
);
expect(value, 'false');
});
test('--suppress-analytics still suppresses sub-tools', () async {
final value = await runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
args: const ['--suppress-analytics'],
);
expect(value, 'true');
});
test('inherited DASH__SUPPRESS_ANALYTICS=true is propagated', () async {
final value = await runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
environment: {suppressVar: 'true'},
);
expect(value, 'true');
});
for (final inherited in ['false', '1', '']) {
test(
'inherited DASH__SUPPRESS_ANALYTICS="$inherited" is ignored',
() async {
final value = await runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
environment: {suppressVar: inherited},
);
expect(value, 'false');
},
);
}
group('internal builds (isExternal: false)', () {
test('first run with a terminal does not suppress sub-tools', () async {
final analytics = createFake(isExternal: false);
expect(analytics.shouldShowMessage, isFalse);
expect(analytics.okToSend, isTrue);
final value = await runAndGetPropagatedValue(
analytics,
hasTerminal: true,
);
expect(value, 'false');
expect(fs.file(configPath).existsSync(), isFalse);
});
test('clientShowedMessage records nothing', () {
createFake(isExternal: false).clientShowedMessage();
expect(fs.file(configPath).existsSync(), isFalse);
});
test('--suppress-analytics still suppresses sub-tools', () async {
final value = await runAndGetPropagatedValue(
createFake(isExternal: false),
hasTerminal: true,
args: const ['--suppress-analytics'],
);
expect(value, 'true');
});
});
group('bot and AI agent detection', () {
// These runs don't set `isAnalyticsTest`, so bot detection applies. It
// reads the `environment` override rather than the test process's
// environment, so results don't depend on where the tests run.
Future<String?> run(Map<String, String> environment) =>
runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
environment: environment,
isAnalyticsTest: false,
);
test('not a bot, no agent: not suppressed', () async {
expect(await run(const {}), 'false');
});
test('bot, no agent: suppressed', () async {
expect(await run(const {'BOT': 'true'}), 'true');
});
test('BOT=false overrides CI variables: not suppressed', () async {
expect(await run(const {'BOT': 'false', 'CI': 'true'}), 'false');
});
test('bot with an AI agent: not suppressed', () async {
expect(await run(const {'CI': 'true', 'CLAUDECODE': '1'}), 'false');
});
test('bot with a generic AGENT variable: not suppressed', () async {
expect(await run(const {'BOT': 'true', 'AGENT': 'my-agent'}), 'false');
});
test('AI agent, not a bot: not suppressed', () async {
expect(await run(const {'GEMINI_CLI': '1'}), 'false');
});
test('AI agent with --suppress-analytics: suppressed', () async {
final value = await runAndGetPropagatedValue(
createConsentedFake(),
hasTerminal: true,
args: const ['--suppress-analytics'],
environment: const {'BOT': 'true', 'CLAUDECODE': '1'},
isAnalyticsTest: false,
);
expect(value, 'true');
});
});
});
group('unified analytics helpers:', () {
test('sanitizeStacktrace strips file paths and compresses whitespace', () {
const rawStack =
'#0 main (file:///Users/username/project/bin/main.dart:10:5)\n'
'#1 helper (file:///home/user/app/lib/helper.dart:2:1)';
final sanitized = sanitizeStacktrace(rawStack, shorten: true);
expect(sanitized, isNot(contains('/Users/username/project/bin/')));
expect(sanitized, contains('main.dart'));
expect(sanitized, contains('helper.dart'));
});
test('getDartStorageDirectory resolves user .dart directory', () {
final dir = getDartStorageDirectory();
expect(dir, isNotNull);
expect(dir!.path, path.join(homeDir!.path, '.dart'));
expect(path.basename(dir.path), '.dart');
});
});
group('isBot environment detection:', () {
test('isBot function executes without error', () {
// isBot should execute synchronously without throwing
expect(() => isBot(), returnsNormally);
});
test('empty environment is not a bot', () {
expect(isBot(environment: const {}), isFalse);
});
test('CI variables are detected', () {
expect(isBot(environment: const {'BOT': 'true'}), isTrue);
expect(isBot(environment: const {'CI': 'true'}), isTrue);
expect(isBot(environment: const {'GITHUB_ACTIONS': 'true'}), isTrue);
expect(isBot(environment: const {'SWARMING_TASK_ID': 'x'}), isTrue);
});
test('explicit non-bot signals take precedence', () {
expect(isBot(environment: const {'BOT': 'false', 'CI': 'true'}), isFalse);
expect(
isBot(environment: const {'FLUTTER_HOST': 'vscode', 'CI': 'true'}),
isFalse,
);
});
});
}
class HangingAnalytics implements Analytics {
final FakeAnalytics _delegate;
final Completer<void> _closeCompleter = Completer<void>();
HangingAnalytics(this._delegate);
@override
Future<void> close({int delayDuration = 250}) => _closeCompleter.future;
@override
bool get shouldShowMessage => _delegate.shouldShowMessage;
@override
String get getConsentMessage => _delegate.getConsentMessage;
@override
void clientShowedMessage() => _delegate.clientShowedMessage();
@override
Future<void> setTelemetry(bool value) => _delegate.setTelemetry(value);
@override
bool get telemetryEnabled => _delegate.telemetryEnabled;
@override
Future<http.Response>? send(Event event) {
_delegate.send(event);
return null;
}
@override
dynamic noSuchMethod(Invocation invocation) {
super.noSuchMethod(invocation);
}
}