-
Notifications
You must be signed in to change notification settings - Fork 3.1k
/
Copy pathfirebase_test_lab_command.dart
375 lines (340 loc) · 12.7 KB
/
firebase_test_lab_command.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
// Copyright 2013 The Flutter Authors. 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:io' as io;
import 'package:file/file.dart';
import 'package:uuid/uuid.dart';
import 'common/core.dart';
import 'common/gradle.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/plugin_utils.dart';
import 'common/repository_package.dart';
const int _exitGcloudAuthFailed = 3;
/// A command to run tests via Firebase test lab.
class FirebaseTestLabCommand extends PackageLoopingCommand {
/// Creates an instance of the test runner command.
FirebaseTestLabCommand(
super.packagesDir, {
super.processRunner,
super.platform,
}) {
argParser.addOption(
_gCloudProjectArg,
help: 'The Firebase project name.',
);
argParser.addOption(_gCloudServiceKeyArg,
help: 'The path to the service key for gcloud authentication.\n'
'If not provided, setup will be skipped, so testing will fail '
'unless gcloud is already configured.');
argParser.addOption('test-run-id',
defaultsTo: const Uuid().v4(),
help:
'Optional string to append to the results path, to avoid conflicts. '
'Randomly chosen on each invocation if none is provided. '
'The default shown here is just an example.');
argParser.addOption('build-id',
defaultsTo:
io.Platform.environment['CIRRUS_BUILD_ID'] ?? 'unknown_build',
help:
'Optional string to append to the results path, to avoid conflicts. '
r'Defaults to $CIRRUS_BUILD_ID if that is set.');
argParser.addMultiOption('device',
splitCommas: false,
defaultsTo: <String>[
'model=walleye,version=26',
'model=redfin,version=30'
],
help:
'Device model(s) to test. See https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run for more info');
argParser.addOption(_gCloudResultsBucketArg, mandatory: true);
argParser.addOption(
kEnableExperiment,
defaultsTo: '',
help: 'Enables the given Dart SDK experiments.',
);
}
static const String _gCloudServiceKeyArg = 'service-key';
static const String _gCloudProjectArg = 'project';
static const String _gCloudResultsBucketArg = 'results-bucket';
@override
final String name = 'firebase-test-lab';
@override
final String description = 'Runs the instrumentation tests of the example '
'apps on Firebase Test Lab.\n\n'
'Runs tests in test_instrumentation folder using the '
'instrumentation_test package.';
bool _firebaseProjectConfigured = false;
Future<void> _configureFirebaseProject() async {
if (_firebaseProjectConfigured) {
return;
}
final String serviceKey = getStringArg(_gCloudServiceKeyArg);
if (serviceKey.isEmpty) {
print(
'No --$_gCloudServiceKeyArg provided; skipping gcloud authorization');
} else {
final io.ProcessResult result = await processRunner.run(
'gcloud',
<String>[
'auth',
'activate-service-account',
'--key-file=$serviceKey',
],
logOnError: true,
);
if (result.exitCode != 0) {
printError('Unable to activate gcloud account.');
throw ToolExit(_exitGcloudAuthFailed);
}
}
final String project = getStringArg(_gCloudProjectArg);
if (project.isEmpty) {
print('No --$_gCloudProjectArg provided; skipping gcloud config');
} else {
final int exitCode = await processRunner.runAndStream('gcloud', <String>[
'config',
'set',
'project',
project,
]);
print('');
if (exitCode == 0) {
print('Firebase project configured.');
} else {
logWarning(
'Warning: gcloud config set returned a non-zero exit code. Continuing anyway.');
}
}
_firebaseProjectConfigured = true;
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final List<PackageResult> results = <PackageResult>[];
for (final RepositoryPackage example in package.getExamples()) {
results.add(await _runForExample(example, package: package));
}
// If all results skipped, report skip overall.
if (results
.every((PackageResult result) => result.state == RunState.skipped)) {
return PackageResult.skip('No examples support Android.');
}
// Otherwise, report failure if there were any failures.
final List<String> allErrors = results
.map((PackageResult result) =>
result.state == RunState.failed ? result.details : <String>[])
.expand((List<String> list) => list)
.toList();
return allErrors.isEmpty
? PackageResult.success()
: PackageResult.fail(allErrors);
}
/// Runs the test for the given example of [package].
Future<PackageResult> _runForExample(
RepositoryPackage example, {
required RepositoryPackage package,
}) async {
final Directory androidDirectory =
example.platformDirectory(FlutterPlatform.android);
if (!androidDirectory.existsSync()) {
return PackageResult.skip(
'${example.displayName} does not support Android.');
}
final Directory uiTestDirectory = androidDirectory
.childDirectory('app')
.childDirectory('src')
.childDirectory('androidTest');
if (!uiTestDirectory.existsSync()) {
printError('No androidTest directory found.');
if (isFlutterPlugin(package)) {
return PackageResult.fail(
<String>['No tests ran (use --exclude if this is intentional).']);
} else {
return PackageResult.skip(
'${example.displayName} has no native Android tests.');
}
}
// Ensure that the Dart integration tests will be run, not just native UI
// tests.
if (!await _testsContainDartIntegrationTestRunner(uiTestDirectory)) {
printError('No integration_test runner found. '
'See the integration_test package README for setup instructions.');
return PackageResult.fail(<String>['No integration_test runner.']);
}
// Ensures that gradle wrapper exists
final GradleProject project = GradleProject(example,
processRunner: processRunner, platform: platform);
if (!await _ensureGradleWrapperExists(project)) {
return PackageResult.fail(<String>['Unable to build example apk']);
}
await _configureFirebaseProject();
if (!await _runGradle(project, 'app:assembleAndroidTest')) {
return PackageResult.fail(<String>['Unable to assemble androidTest']);
}
final List<String> errors = <String>[];
// Used within the loop to ensure a unique GCS output location for each
// test file's run.
int resultsCounter = 0;
for (final File test in _findIntegrationTestFiles(example)) {
final String testName =
getRelativePosixPath(test, from: package.directory);
print('Testing $testName...');
if (!await _runGradle(project, 'app:assembleDebug', testFile: test)) {
printError('Could not build $testName');
errors.add('$testName failed to build');
continue;
}
final String buildId = getStringArg('build-id');
final String testRunId = getStringArg('test-run-id');
final String resultsDir =
'plugins_android_test/${package.displayName}/$buildId/$testRunId/'
'${example.directory.basename}/${resultsCounter++}/';
// Automatically retry failures; there is significant flake with these
// tests whose cause isn't yet understood, and having to re-run the
// entire shard for a flake in any one test is extremely slow. This should
// be removed once the root cause of the flake is understood.
// See https://github.com/flutter/flutter/issues/95063
const int maxRetries = 2;
bool passing = false;
for (int i = 1; i <= maxRetries && !passing; ++i) {
if (i > 1) {
logWarning('$testName failed on attempt ${i - 1}. Retrying...');
}
passing = await _runFirebaseTest(example, test, resultsDir: resultsDir);
}
if (!passing) {
printError('Test failure for $testName after $maxRetries attempts');
errors.add('$testName failed tests');
}
}
if (errors.isEmpty && resultsCounter == 0) {
printError('No integration tests were run.');
errors.add('No tests ran (use --exclude if this is intentional).');
}
return errors.isEmpty
? PackageResult.success()
: PackageResult.fail(errors);
}
/// Checks that Gradle has been configured for [project], and if not runs a
/// Flutter build to generate it.
///
/// Returns true if either gradlew was already present, or the build succeeds.
Future<bool> _ensureGradleWrapperExists(GradleProject project) async {
if (!project.isConfigured()) {
print('Running flutter build apk...');
final String experiment = getStringArg(kEnableExperiment);
final int exitCode = await processRunner.runAndStream(
flutterCommand,
<String>[
'build',
'apk',
'--config-only',
if (experiment.isNotEmpty) '--enable-experiment=$experiment',
],
workingDir: project.androidDirectory);
if (exitCode != 0) {
return false;
}
}
return true;
}
/// Runs [test] from [example] as a Firebase Test Lab test, returning true if
/// the test passed.
///
/// [resultsDir] should be a unique-to-the-test-run directory to store the
/// results on the server.
Future<bool> _runFirebaseTest(
RepositoryPackage example,
File test, {
required String resultsDir,
}) async {
final List<String> args = <String>[
'firebase',
'test',
'android',
'run',
'--type',
'instrumentation',
'--app',
'build/app/outputs/apk/debug/app-debug.apk',
'--test',
'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
'--timeout',
'7m',
'--results-bucket=gs://${getStringArg(_gCloudResultsBucketArg)}',
'--results-dir=$resultsDir',
for (final String device in getStringListArg('device')) ...<String>[
'--device',
device
],
];
final int exitCode = await processRunner.runAndStream('gcloud', args,
workingDir: example.directory);
return exitCode == 0;
}
/// Builds [target] using Gradle in the given [project]. Assumes Gradle is
/// already configured.
///
/// [testFile] optionally does the Flutter build with the given test file as
/// the build target.
///
/// Returns true if the command succeeds.
Future<bool> _runGradle(
GradleProject project,
String target, {
File? testFile,
}) async {
final String experiment = getStringArg(kEnableExperiment);
final String? extraOptions = experiment.isNotEmpty
? Uri.encodeComponent('--enable-experiment=$experiment')
: null;
final int exitCode = await project.runCommand(
target,
arguments: <String>[
'-Pverbose=true',
if (testFile != null) '-Ptarget=${testFile.path}',
if (extraOptions != null) '-Pextra-front-end-options=$extraOptions',
if (extraOptions != null) '-Pextra-gen-snapshot-options=$extraOptions',
],
);
if (exitCode != 0) {
return false;
}
return true;
}
/// Finds and returns all integration test files for [example].
Iterable<File> _findIntegrationTestFiles(RepositoryPackage example) sync* {
final Directory integrationTestDir =
example.directory.childDirectory('integration_test');
if (!integrationTestDir.existsSync()) {
return;
}
yield* integrationTestDir
.listSync(recursive: true)
.where((FileSystemEntity file) =>
file is File && file.basename.endsWith('_test.dart'))
.cast<File>();
}
/// Returns true if any of the test files in [uiTestDirectory] contain the
/// annotation that means that the test will reports the results of running
/// the Dart integration tests.
Future<bool> _testsContainDartIntegrationTestRunner(
Directory uiTestDirectory) async {
return uiTestDirectory
.list(recursive: true, followLinks: false)
.where((FileSystemEntity entity) => entity is File)
.cast<File>()
.any((File file) {
if (file.basename.endsWith('.java')) {
return file
.readAsStringSync()
.contains('@RunWith(FlutterTestRunner.class)');
} else if (file.basename.endsWith('.kt')) {
return file
.readAsStringSync()
.contains('@RunWith(FlutterTestRunner::class)');
}
return false;
});
}
}