diff --git a/bricks/test_optimizer/brick.yaml b/bricks/test_optimizer/brick.yaml index 07aa58c3d..3c2b80b62 100644 --- a/bricks/test_optimizer/brick.yaml +++ b/bricks/test_optimizer/brick.yaml @@ -12,3 +12,11 @@ vars: default: "." description: The path to the package root. prompt: Please enter the path to the package root. + shard-index: + type: number + description: The 1-based index of the shard to generate tests for. + prompt: Please enter the shard index. + total-shards: + type: number + description: The total number of shards the test suite is split into. + prompt: Please enter the total number of shards. diff --git a/bricks/test_optimizer/hooks/lib/pre_gen.dart b/bricks/test_optimizer/hooks/lib/pre_gen.dart index c0348ef02..5562b7f66 100644 --- a/bricks/test_optimizer/hooks/lib/pre_gen.dart +++ b/bricks/test_optimizer/hooks/lib/pre_gen.dart @@ -33,35 +33,78 @@ Future run(HookContext context) async { final flutterSdkRegExp = RegExp(r'sdk:\s*flutter$', multiLine: true); final isFlutter = flutterSdkRegExp.hasMatch(pubspecContents); - final identifierGenerator = DartIdentifierGenerator(); - final testIdentifierTable = >[]; + final shardIndex = context.vars['shard-index'] as int?; + final totalShards = context.vars['total-shards'] as int?; + final tests = testDir .listSync(recursive: true) .where((entity) => entity.isTest); final notOptimizedTests = await getNotOptimizedTests(tests, testDir.path); - for (final entity in tests) { - final relativePath = path - .relative(entity.path, from: testDir.path) - .replaceAll(r'\', '/'); - testIdentifierTable.add({ - 'path': relativePath, - 'identifier': identifierGenerator.next(), - }); - } + // Sorting guarantees a deterministic order across machines, which is what + // makes sharding reproducible: `Directory.listSync` order is filesystem + // dependent, so without this two runners could disagree on the partition + // and either skip or duplicate tests. + final testPaths = + tests + .map( + (entity) => path + .relative(entity.path, from: testDir.path) + .replaceAll(r'\', '/'), + ) + .toList() + ..sort(); + + final optimizedTestPaths = shardOf( + testPaths.where((p) => !notOptimizedTests.contains(p)).toList(), + shardIndex: shardIndex, + totalShards: totalShards, + ); + + // Non optimized tests run as standalone files alongside the optimizer + // entrypoint, so they must be sharded too. Otherwise every runner would + // re-run all of them, defeating the purpose of sharding. + final shardedNotOptimizedTests = shardOf( + notOptimizedTests..sort(), + shardIndex: shardIndex, + totalShards: totalShards, + ); - final optimizedTestsIdentifierTable = testIdentifierTable - .where((e) => !notOptimizedTests.contains(e['path'])) - .toList(); + final identifierGenerator = DartIdentifierGenerator(); + final optimizedTestsIdentifierTable = [ + for (final relativePath in optimizedTestPaths) + {'path': relativePath, 'identifier': identifierGenerator.next()}, + ]; context.vars = { 'tests': optimizedTestsIdentifierTable, 'isFlutter': isFlutter, - 'notOptimizedTests': notOptimizedTests, + 'notOptimizedTests': shardedNotOptimizedTests, }; } +/// Returns the subset of [paths] that belongs to the shard [shardIndex] out of +/// [totalShards]. +/// +/// Returns [paths] unchanged when sharding is not enabled (either value is +/// `null`). +/// +/// Files are dealt out round-robin (index modulo [totalShards]) over the +/// already sorted [paths], which keeps shards balanced in file count and makes +/// the partition stable for a given test suite. +List shardOf( + List paths, { + required int? shardIndex, + required int? totalShards, +}) { + if (shardIndex == null || totalShards == null) return paths; + + return [ + for (var i = shardIndex - 1; i < paths.length; i += totalShards) paths[i], + ]; +} + extension on FileSystemEntity { bool get isTest { return this is File && path.basename(this.path).endsWith('_test.dart'); @@ -85,9 +128,10 @@ Future> getNotOptimizedTests( } } - /// Format to relative path + /// Format to relative path, normalizing separators so the paths compare + /// equal to the ones built in [run] on Windows too. final relativePaths = testWithVeryGoodTest - .map((e) => path.relative(e, from: testDir)) + .map((e) => path.relative(e, from: testDir).replaceAll(r'\', '/')) .toList(); return relativePaths; diff --git a/bricks/test_optimizer/hooks/test/pre_gen_test.dart b/bricks/test_optimizer/hooks/test/pre_gen_test.dart index 2e96591cf..989f28e03 100644 --- a/bricks/test_optimizer/hooks/test/pre_gen_test.dart +++ b/bricks/test_optimizer/hooks/test/pre_gen_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:hooks/pre_gen.dart' as pre_gen; import 'package:mason/mason.dart'; @@ -286,5 +287,173 @@ dependencies: }, ); }); + group('Sharding', () { + /// Creates a package with [count] optimizable test files, plus any + /// [notOptimized] files carrying the skip optimization tag. + Directory createPackage(int count, {int notOptimized = 0}) { + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + final testDir = Directory(path.join(tempDirectory.path, 'test')) + ..createSync(); + for (var i = 0; i < count; i++) { + File(path.join(testDir.path, 'test${i}_test.dart')).createSync(); + } + for (var i = 0; i < notOptimized; i++) { + File( + path.join(testDir.path, 'skip${i}_test.dart'), + ).writeAsStringSync(notOptimizedTestContent); + } + return testDir; + } + + List pathsOf(HookContext context) { + final tests = context.vars['tests'] as List>; + return tests.map((e) => e['path']!).toList(); + } + + Future> runShard(int index, int total) async { + final context = _FakeContext() + ..vars['package-root'] = tempDirectory.absolute.path + ..vars['shard-index'] = index + ..vars['total-shards'] = total; + await pre_gen.run(context); + return [ + ...pathsOf(context), + ...(context.vars['notOptimizedTests']! as List).cast(), + ]; + } + + test('runs every test exactly once across all shards', () async { + createPackage(7, notOptimized: 2); + + final shards = [ + for (var i = 1; i <= 3; i++) await runShard(i, 3), + ]; + final union = shards.expand((shard) => shard).toList(); + + expect( + union..sort(), + [ + for (var i = 0; i < 7; i++) 'test${i}_test.dart', + for (var i = 0; i < 2; i++) 'skip${i}_test.dart', + ]..sort(), + reason: 'Shards must be a complete and disjoint partition', + ); + }); + + test('shards non optimized tests as well', () async { + createPackage(0, notOptimized: 4); + + final first = await runShard(1, 2); + final second = await runShard(2, 2); + + expect(first, ['skip0_test.dart', 'skip2_test.dart']); + expect(second, ['skip1_test.dart', 'skip3_test.dart']); + }); + + test('is deterministic across runs', () async { + createPackage(9); + + expect(await runShard(2, 4), await runShard(2, 4)); + }); + + test('balances shards within one file of each other', () async { + createPackage(10); + + final sizes = [ + for (var i = 1; i <= 4; i++) (await runShard(i, 4)).length, + ]; + + expect(sizes.reduce(max) - sizes.reduce(min), lessThanOrEqualTo(1)); + }); + + test( + 'yields an empty shard when there are more shards than tests', + () async { + createPackage(2); + + expect(await runShard(3, 3), isEmpty); + }, + ); + + test( + 'excludes nested non optimized tests from the optimized set', + () async { + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + final testDir = Directory(path.join(tempDirectory.path, 'test')) + ..createSync(); + final nested = Directory(path.join(testDir.path, 'sub')) + ..createSync(); + File( + path.join(nested.path, 'skip_test.dart'), + ).writeAsStringSync(notOptimizedTestContent); + + final context = _FakeContext() + ..vars['package-root'] = tempDirectory.absolute.path; + await pre_gen.run(context); + + expect( + pathsOf(context), + isEmpty, + reason: + 'A tagged test in a subdirectory must not be optimized, ' + 'otherwise it runs both inlined and standalone', + ); + expect( + context.vars['notOptimizedTests'], + ['sub/skip_test.dart'], + ); + }, + ); + + test('includes every test when sharding is not requested', () async { + createPackage(3); + + final context = _FakeContext() + ..vars['package-root'] = tempDirectory.absolute.path; + await pre_gen.run(context); + + expect(pathsOf(context), hasLength(3)); + }); + }); + + group('shardOf', () { + const paths = ['a.dart', 'b.dart', 'c.dart', 'd.dart', 'e.dart']; + + test('returns paths untouched when shard index is null', () { + expect( + pre_gen.shardOf(paths, shardIndex: null, totalShards: 3), + paths, + ); + }); + + test('returns paths untouched when total shards is null', () { + expect( + pre_gen.shardOf(paths, shardIndex: 1, totalShards: null), + paths, + ); + }); + + test('deals paths out round robin', () { + expect( + pre_gen.shardOf(paths, shardIndex: 1, totalShards: 2), + ['a.dart', 'c.dart', 'e.dart'], + ); + expect( + pre_gen.shardOf(paths, shardIndex: 2, totalShards: 2), + ['b.dart', 'd.dart'], + ); + }); + + test('returns everything for a single shard', () { + expect(pre_gen.shardOf(paths, shardIndex: 1, totalShards: 1), paths); + }); + + test('returns empty for an out of range shard', () { + expect( + pre_gen.shardOf(paths, shardIndex: 9, totalShards: 9), + isEmpty, + ); + }); + }); }); } diff --git a/lib/src/cli/dart_cli.dart b/lib/src/cli/dart_cli.dart index b6ee3baa7..78fe52e76 100644 --- a/lib/src/cli/dart_cli.dart +++ b/lib/src/cli/dart_cli.dart @@ -136,6 +136,8 @@ class Dart { void Function(String)? stderr, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, List? reportOn, + int? shardIndex, + int? totalShards, }) async { return TestCLIRunner.test( logger: logger, @@ -157,6 +159,8 @@ class Dart { stderr: stderr, reportOn: reportOn, buildGenerator: buildGenerator, + shardIndex: shardIndex, + totalShards: totalShards, ); } } diff --git a/lib/src/cli/flutter_cli.dart b/lib/src/cli/flutter_cli.dart index 494d15015..1935f1f77 100644 --- a/lib/src/cli/flutter_cli.dart +++ b/lib/src/cli/flutter_cli.dart @@ -218,6 +218,8 @@ class Flutter { void Function(String)? stderr, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, List? reportOn, + int? shardIndex, + int? totalShards, }) async { return TestCLIRunner.test( logger: logger, @@ -238,6 +240,8 @@ class Flutter { stderr: stderr, buildGenerator: buildGenerator, reportOn: reportOn, + shardIndex: shardIndex, + totalShards: totalShards, ); } } diff --git a/lib/src/cli/templates/test_optimizer_bundle.dart b/lib/src/cli/templates/test_optimizer_bundle.dart index af7015c76..e1bf653d0 100644 --- a/lib/src/cli/templates/test_optimizer_bundle.dart +++ b/lib/src/cli/templates/test_optimizer_bundle.dart @@ -20,7 +20,7 @@ final testOptimizerBundle = MasonBundle.fromJson({ }, { "path": "lib/pre_gen.dart", - "data": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9kYXJ0X2lkZW50aWZpZXJfZ2VuZXJhdG9yLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6bWFzb24vbWFzb24uZGFydCc7CmltcG9ydCAncGFja2FnZTpwYXRoL3BhdGguZGFydCcgYXMgcGF0aDsKCnR5cGVkZWYgRXhpdEZuID0gTmV2ZXIgRnVuY3Rpb24oaW50IGNvZGUpOwoKRXhpdEZuIGV4aXRGbiA9IGV4aXQ7CgpTdHJpbmcgc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnID0gJ3NraXBfdmVyeV9nb29kX29wdGltaXphdGlvbic7ClJlZ0V4cCBza2lwVmVyeUdvb2RPcHRpbWl6YXRpb25SZWdFeHAgPSBSZWdFeHAoCiAgIkBUYWdzXFxzKlxcKFxccypcXFtbXFxzXFxTXSo/W1wiJ10kc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnW1wiJ11bXFxzXFxTXSo/XFxdXFxzKlxcKSIsCiAgbXVsdGlMaW5lOiB0cnVlLAopOwoKRnV0dXJlPHZvaWQ+IHJ1bihIb29rQ29udGV4dCBjb250ZXh0KSBhc3luYyB7CiAgZmluYWwgcGFja2FnZVJvb3QgPSBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddIGFzIFN0cmluZzsKICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbihwYWNrYWdlUm9vdCwgJ3Rlc3QnKSk7CgogIGlmICghdGVzdERpci5leGlzdHNTeW5jKCkpIHsKICAgIGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgZGlyZWN0b3J5ICR7dGVzdERpci5wYXRofScpOwogICAgZXhpdEZuKDEpOwogIH0KCiAgZmluYWwgcHVic3BlYyA9IEZpbGUocGF0aC5qb2luKHBhY2thZ2VSb290LCAncHVic3BlYy55YW1sJykpOwogIGlmICghcHVic3BlYy5leGlzdHNTeW5jKCkpIHsKICAgIGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgcHVic3BlYy55YW1sIGF0ICR7dGVzdERpci5wYXRofScpOwogICAgZXhpdEZuKDEpOwogIH0KCiAgZmluYWwgcHVic3BlY0NvbnRlbnRzID0gYXdhaXQgcHVic3BlYy5yZWFkQXNTdHJpbmcoKTsKICBmaW5hbCBmbHV0dGVyU2RrUmVnRXhwID0gUmVnRXhwKHInc2RrOlxzKmZsdXR0ZXIkJywgbXVsdGlMaW5lOiB0cnVlKTsKICBmaW5hbCBpc0ZsdXR0ZXIgPSBmbHV0dGVyU2RrUmVnRXhwLmhhc01hdGNoKHB1YnNwZWNDb250ZW50cyk7CgogIGZpbmFsIGlkZW50aWZpZXJHZW5lcmF0b3IgPSBEYXJ0SWRlbnRpZmllckdlbmVyYXRvcigpOwogIGZpbmFsIHRlc3RJZGVudGlmaWVyVGFibGUgPSA8TWFwPFN0cmluZywgU3RyaW5nPj5bXTsKICBmaW5hbCB0ZXN0cyA9IHRlc3REaXIKICAgICAgLmxpc3RTeW5jKHJlY3Vyc2l2ZTogdHJ1ZSkKICAgICAgLndoZXJlKChlbnRpdHkpID0+IGVudGl0eS5pc1Rlc3QpOwoKICBmaW5hbCBub3RPcHRpbWl6ZWRUZXN0cyA9IGF3YWl0IGdldE5vdE9wdGltaXplZFRlc3RzKHRlc3RzLCB0ZXN0RGlyLnBhdGgpOwoKICBmb3IgKGZpbmFsIGVudGl0eSBpbiB0ZXN0cykgewogICAgZmluYWwgcmVsYXRpdmVQYXRoID0gcGF0aAogICAgICAgIC5yZWxhdGl2ZShlbnRpdHkucGF0aCwgZnJvbTogdGVzdERpci5wYXRoKQogICAgICAgIC5yZXBsYWNlQWxsKHInXCcsICcvJyk7CiAgICB0ZXN0SWRlbnRpZmllclRhYmxlLmFkZCh7CiAgICAgICdwYXRoJzogcmVsYXRpdmVQYXRoLAogICAgICAnaWRlbnRpZmllcic6IGlkZW50aWZpZXJHZW5lcmF0b3IubmV4dCgpLAogICAgfSk7CiAgfQoKICBmaW5hbCBvcHRpbWl6ZWRUZXN0c0lkZW50aWZpZXJUYWJsZSA9IHRlc3RJZGVudGlmaWVyVGFibGUKICAgICAgLndoZXJlKChlKSA9PiAhbm90T3B0aW1pemVkVGVzdHMuY29udGFpbnMoZVsncGF0aCddKSkKICAgICAgLnRvTGlzdCgpOwoKICBjb250ZXh0LnZhcnMgPSB7CiAgICAndGVzdHMnOiBvcHRpbWl6ZWRUZXN0c0lkZW50aWZpZXJUYWJsZSwKICAgICdpc0ZsdXR0ZXInOiBpc0ZsdXR0ZXIsCiAgICAnbm90T3B0aW1pemVkVGVzdHMnOiBub3RPcHRpbWl6ZWRUZXN0cywKICB9Owp9CgpleHRlbnNpb24gb24gRmlsZVN5c3RlbUVudGl0eSB7CiAgYm9vbCBnZXQgaXNUZXN0IHsKICAgIHJldHVybiB0aGlzIGlzIEZpbGUgJiYgcGF0aC5iYXNlbmFtZSh0aGlzLnBhdGgpLmVuZHNXaXRoKCdfdGVzdC5kYXJ0Jyk7CiAgfQp9CgpGdXR1cmU8TGlzdDxTdHJpbmc+PiBnZXROb3RPcHRpbWl6ZWRUZXN0cygKICBJdGVyYWJsZTxGaWxlU3lzdGVtRW50aXR5PiB0ZXN0cywKICBTdHJpbmcgdGVzdERpciwKKSBhc3luYyB7CiAgZmluYWwgcGF0aHMgPSB0ZXN0cy5tYXAoKGUpID0+IGUucGF0aCkudG9MaXN0KCk7CiAgZmluYWwgZm9ybWF0dGVkUGF0aHMgPSBwYXRocy5tYXAoKGUpID0+IGUucmVwbGFjZUFsbCgnLy4vJywgJy8nKSkudG9MaXN0KCk7CgogIGZpbmFsIGZpbGVGdXR1cmVzID0gZm9ybWF0dGVkUGF0aHMubWFwKF9jaGVja0ZpbGVGb3JTa2lwVmVyeUdvb2RPcHRpbWl6YXRpb24pOwogIGZpbmFsIGZpbGVSZXN1bHRzID0gYXdhaXQgRnV0dXJlLndhaXQoZmlsZUZ1dHVyZXMpOwoKICBmaW5hbCB0ZXN0V2l0aFZlcnlHb29kVGVzdCA9IDxTdHJpbmc+W107CiAgZm9yICh2YXIgaSA9IDA7IGkgPCBmb3JtYXR0ZWRQYXRocy5sZW5ndGg7IGkrKykgewogICAgaWYgKGZpbGVSZXN1bHRzW2ldKSB7CiAgICAgIHRlc3RXaXRoVmVyeUdvb2RUZXN0LmFkZChmb3JtYXR0ZWRQYXRoc1tpXSk7CiAgICB9CiAgfQoKICAvLy8gRm9ybWF0IHRvIHJlbGF0aXZlIHBhdGgKICBmaW5hbCByZWxhdGl2ZVBhdGhzID0gdGVzdFdpdGhWZXJ5R29vZFRlc3QKICAgICAgLm1hcCgoZSkgPT4gcGF0aC5yZWxhdGl2ZShlLCBmcm9tOiB0ZXN0RGlyKSkKICAgICAgLnRvTGlzdCgpOwoKICByZXR1cm4gcmVsYXRpdmVQYXRoczsKfQoKLy8vIENoZWNrIGlmIGEgc2luZ2xlIGZpbGUgY29udGFpbnMgc2tpcF92ZXJ5X2dvb2Rfb3B0aW1pemF0aW9uIHRhZwpGdXR1cmU8Ym9vbD4gX2NoZWNrRmlsZUZvclNraXBWZXJ5R29vZE9wdGltaXphdGlvbihTdHJpbmcgcGF0aCkgYXN5bmMgewogIGZpbmFsIGZpbGUgPSBGaWxlKHBhdGgpOwogIGlmICghZmlsZS5leGlzdHNTeW5jKCkpIHJldHVybiBmYWxzZTsKICBmaW5hbCBjb250ZW50ID0gYXdhaXQgZmlsZS5yZWFkQXNTdHJpbmcoKTsKICByZXR1cm4gc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uUmVnRXhwLmhhc01hdGNoKGNvbnRlbnQpOwp9Cg==", + "data": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9kYXJ0X2lkZW50aWZpZXJfZ2VuZXJhdG9yLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6bWFzb24vbWFzb24uZGFydCc7CmltcG9ydCAncGFja2FnZTpwYXRoL3BhdGguZGFydCcgYXMgcGF0aDsKCnR5cGVkZWYgRXhpdEZuID0gTmV2ZXIgRnVuY3Rpb24oaW50IGNvZGUpOwoKRXhpdEZuIGV4aXRGbiA9IGV4aXQ7CgpTdHJpbmcgc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnID0gJ3NraXBfdmVyeV9nb29kX29wdGltaXphdGlvbic7ClJlZ0V4cCBza2lwVmVyeUdvb2RPcHRpbWl6YXRpb25SZWdFeHAgPSBSZWdFeHAoCiAgIkBUYWdzXFxzKlxcKFxccypcXFtbXFxzXFxTXSo/W1wiJ10kc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnW1wiJ11bXFxzXFxTXSo/XFxdXFxzKlxcKSIsCiAgbXVsdGlMaW5lOiB0cnVlLAopOwoKRnV0dXJlPHZvaWQ+IHJ1bihIb29rQ29udGV4dCBjb250ZXh0KSBhc3luYyB7CiAgZmluYWwgcGFja2FnZVJvb3QgPSBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddIGFzIFN0cmluZzsKICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbihwYWNrYWdlUm9vdCwgJ3Rlc3QnKSk7CgogIGlmICghdGVzdERpci5leGlzdHNTeW5jKCkpIHsKICAgIGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgZGlyZWN0b3J5ICR7dGVzdERpci5wYXRofScpOwogICAgZXhpdEZuKDEpOwogIH0KCiAgZmluYWwgcHVic3BlYyA9IEZpbGUocGF0aC5qb2luKHBhY2thZ2VSb290LCAncHVic3BlYy55YW1sJykpOwogIGlmICghcHVic3BlYy5leGlzdHNTeW5jKCkpIHsKICAgIGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgcHVic3BlYy55YW1sIGF0ICR7dGVzdERpci5wYXRofScpOwogICAgZXhpdEZuKDEpOwogIH0KCiAgZmluYWwgcHVic3BlY0NvbnRlbnRzID0gYXdhaXQgcHVic3BlYy5yZWFkQXNTdHJpbmcoKTsKICBmaW5hbCBmbHV0dGVyU2RrUmVnRXhwID0gUmVnRXhwKHInc2RrOlxzKmZsdXR0ZXIkJywgbXVsdGlMaW5lOiB0cnVlKTsKICBmaW5hbCBpc0ZsdXR0ZXIgPSBmbHV0dGVyU2RrUmVnRXhwLmhhc01hdGNoKHB1YnNwZWNDb250ZW50cyk7CgogIGZpbmFsIHNoYXJkSW5kZXggPSBjb250ZXh0LnZhcnNbJ3NoYXJkLWluZGV4J10gYXMgaW50PzsKICBmaW5hbCB0b3RhbFNoYXJkcyA9IGNvbnRleHQudmFyc1sndG90YWwtc2hhcmRzJ10gYXMgaW50PzsKCiAgZmluYWwgdGVzdHMgPSB0ZXN0RGlyCiAgICAgIC5saXN0U3luYyhyZWN1cnNpdmU6IHRydWUpCiAgICAgIC53aGVyZSgoZW50aXR5KSA9PiBlbnRpdHkuaXNUZXN0KTsKCiAgZmluYWwgbm90T3B0aW1pemVkVGVzdHMgPSBhd2FpdCBnZXROb3RPcHRpbWl6ZWRUZXN0cyh0ZXN0cywgdGVzdERpci5wYXRoKTsKCiAgLy8gU29ydGluZyBndWFyYW50ZWVzIGEgZGV0ZXJtaW5pc3RpYyBvcmRlciBhY3Jvc3MgbWFjaGluZXMsIHdoaWNoIGlzIHdoYXQKICAvLyBtYWtlcyBzaGFyZGluZyByZXByb2R1Y2libGU6IGBEaXJlY3RvcnkubGlzdFN5bmNgIG9yZGVyIGlzIGZpbGVzeXN0ZW0KICAvLyBkZXBlbmRlbnQsIHNvIHdpdGhvdXQgdGhpcyB0d28gcnVubmVycyBjb3VsZCBkaXNhZ3JlZSBvbiB0aGUgcGFydGl0aW9uCiAgLy8gYW5kIGVpdGhlciBza2lwIG9yIGR1cGxpY2F0ZSB0ZXN0cy4KICBmaW5hbCB0ZXN0UGF0aHMgPQogICAgICB0ZXN0cwogICAgICAgICAgLm1hcCgKICAgICAgICAgICAgKGVudGl0eSkgPT4gcGF0aAogICAgICAgICAgICAgICAgLnJlbGF0aXZlKGVudGl0eS5wYXRoLCBmcm9tOiB0ZXN0RGlyLnBhdGgpCiAgICAgICAgICAgICAgICAucmVwbGFjZUFsbChyJ1wnLCAnLycpLAogICAgICAgICAgKQogICAgICAgICAgLnRvTGlzdCgpCiAgICAgICAgLi5zb3J0KCk7CgogIGZpbmFsIG9wdGltaXplZFRlc3RQYXRocyA9IHNoYXJkT2YoCiAgICB0ZXN0UGF0aHMud2hlcmUoKHApID0+ICFub3RPcHRpbWl6ZWRUZXN0cy5jb250YWlucyhwKSkudG9MaXN0KCksCiAgICBzaGFyZEluZGV4OiBzaGFyZEluZGV4LAogICAgdG90YWxTaGFyZHM6IHRvdGFsU2hhcmRzLAogICk7CgogIC8vIE5vbiBvcHRpbWl6ZWQgdGVzdHMgcnVuIGFzIHN0YW5kYWxvbmUgZmlsZXMgYWxvbmdzaWRlIHRoZSBvcHRpbWl6ZXIKICAvLyBlbnRyeXBvaW50LCBzbyB0aGV5IG11c3QgYmUgc2hhcmRlZCB0b28uIE90aGVyd2lzZSBldmVyeSBydW5uZXIgd291bGQKICAvLyByZS1ydW4gYWxsIG9mIHRoZW0sIGRlZmVhdGluZyB0aGUgcHVycG9zZSBvZiBzaGFyZGluZy4KICBmaW5hbCBzaGFyZGVkTm90T3B0aW1pemVkVGVzdHMgPSBzaGFyZE9mKAogICAgbm90T3B0aW1pemVkVGVzdHMuLnNvcnQoKSwKICAgIHNoYXJkSW5kZXg6IHNoYXJkSW5kZXgsCiAgICB0b3RhbFNoYXJkczogdG90YWxTaGFyZHMsCiAgKTsKCiAgZmluYWwgaWRlbnRpZmllckdlbmVyYXRvciA9IERhcnRJZGVudGlmaWVyR2VuZXJhdG9yKCk7CiAgZmluYWwgb3B0aW1pemVkVGVzdHNJZGVudGlmaWVyVGFibGUgPSBbCiAgICBmb3IgKGZpbmFsIHJlbGF0aXZlUGF0aCBpbiBvcHRpbWl6ZWRUZXN0UGF0aHMpCiAgICAgIHsncGF0aCc6IHJlbGF0aXZlUGF0aCwgJ2lkZW50aWZpZXInOiBpZGVudGlmaWVyR2VuZXJhdG9yLm5leHQoKX0sCiAgXTsKCiAgY29udGV4dC52YXJzID0gewogICAgJ3Rlc3RzJzogb3B0aW1pemVkVGVzdHNJZGVudGlmaWVyVGFibGUsCiAgICAnaXNGbHV0dGVyJzogaXNGbHV0dGVyLAogICAgJ25vdE9wdGltaXplZFRlc3RzJzogc2hhcmRlZE5vdE9wdGltaXplZFRlc3RzLAogIH07Cn0KCi8vLyBSZXR1cm5zIHRoZSBzdWJzZXQgb2YgW3BhdGhzXSB0aGF0IGJlbG9uZ3MgdG8gdGhlIHNoYXJkIFtzaGFyZEluZGV4XSBvdXQgb2YKLy8vIFt0b3RhbFNoYXJkc10uCi8vLwovLy8gUmV0dXJucyBbcGF0aHNdIHVuY2hhbmdlZCB3aGVuIHNoYXJkaW5nIGlzIG5vdCBlbmFibGVkIChlaXRoZXIgdmFsdWUgaXMKLy8vIGBudWxsYCkuCi8vLwovLy8gRmlsZXMgYXJlIGRlYWx0IG91dCByb3VuZC1yb2JpbiAoaW5kZXggbW9kdWxvIFt0b3RhbFNoYXJkc10pIG92ZXIgdGhlCi8vLyBhbHJlYWR5IHNvcnRlZCBbcGF0aHNdLCB3aGljaCBrZWVwcyBzaGFyZHMgYmFsYW5jZWQgaW4gZmlsZSBjb3VudCBhbmQgbWFrZXMKLy8vIHRoZSBwYXJ0aXRpb24gc3RhYmxlIGZvciBhIGdpdmVuIHRlc3Qgc3VpdGUuCkxpc3Q8U3RyaW5nPiBzaGFyZE9mKAogIExpc3Q8U3RyaW5nPiBwYXRocywgewogIHJlcXVpcmVkIGludD8gc2hhcmRJbmRleCwKICByZXF1aXJlZCBpbnQ/IHRvdGFsU2hhcmRzLAp9KSB7CiAgaWYgKHNoYXJkSW5kZXggPT0gbnVsbCB8fCB0b3RhbFNoYXJkcyA9PSBudWxsKSByZXR1cm4gcGF0aHM7CgogIHJldHVybiBbCiAgICBmb3IgKHZhciBpID0gc2hhcmRJbmRleCAtIDE7IGkgPCBwYXRocy5sZW5ndGg7IGkgKz0gdG90YWxTaGFyZHMpIHBhdGhzW2ldLAogIF07Cn0KCmV4dGVuc2lvbiBvbiBGaWxlU3lzdGVtRW50aXR5IHsKICBib29sIGdldCBpc1Rlc3QgewogICAgcmV0dXJuIHRoaXMgaXMgRmlsZSAmJiBwYXRoLmJhc2VuYW1lKHRoaXMucGF0aCkuZW5kc1dpdGgoJ190ZXN0LmRhcnQnKTsKICB9Cn0KCkZ1dHVyZTxMaXN0PFN0cmluZz4+IGdldE5vdE9wdGltaXplZFRlc3RzKAogIEl0ZXJhYmxlPEZpbGVTeXN0ZW1FbnRpdHk+IHRlc3RzLAogIFN0cmluZyB0ZXN0RGlyLAopIGFzeW5jIHsKICBmaW5hbCBwYXRocyA9IHRlc3RzLm1hcCgoZSkgPT4gZS5wYXRoKS50b0xpc3QoKTsKICBmaW5hbCBmb3JtYXR0ZWRQYXRocyA9IHBhdGhzLm1hcCgoZSkgPT4gZS5yZXBsYWNlQWxsKCcvLi8nLCAnLycpKS50b0xpc3QoKTsKCiAgZmluYWwgZmlsZUZ1dHVyZXMgPSBmb3JtYXR0ZWRQYXRocy5tYXAoX2NoZWNrRmlsZUZvclNraXBWZXJ5R29vZE9wdGltaXphdGlvbik7CiAgZmluYWwgZmlsZVJlc3VsdHMgPSBhd2FpdCBGdXR1cmUud2FpdChmaWxlRnV0dXJlcyk7CgogIGZpbmFsIHRlc3RXaXRoVmVyeUdvb2RUZXN0ID0gPFN0cmluZz5bXTsKICBmb3IgKHZhciBpID0gMDsgaSA8IGZvcm1hdHRlZFBhdGhzLmxlbmd0aDsgaSsrKSB7CiAgICBpZiAoZmlsZVJlc3VsdHNbaV0pIHsKICAgICAgdGVzdFdpdGhWZXJ5R29vZFRlc3QuYWRkKGZvcm1hdHRlZFBhdGhzW2ldKTsKICAgIH0KICB9CgogIC8vLyBGb3JtYXQgdG8gcmVsYXRpdmUgcGF0aCwgbm9ybWFsaXppbmcgc2VwYXJhdG9ycyBzbyB0aGUgcGF0aHMgY29tcGFyZQogIC8vLyBlcXVhbCB0byB0aGUgb25lcyBidWlsdCBpbiBbcnVuXSBvbiBXaW5kb3dzIHRvby4KICBmaW5hbCByZWxhdGl2ZVBhdGhzID0gdGVzdFdpdGhWZXJ5R29vZFRlc3QKICAgICAgLm1hcCgoZSkgPT4gcGF0aC5yZWxhdGl2ZShlLCBmcm9tOiB0ZXN0RGlyKS5yZXBsYWNlQWxsKHInXCcsICcvJykpCiAgICAgIC50b0xpc3QoKTsKCiAgcmV0dXJuIHJlbGF0aXZlUGF0aHM7Cn0KCi8vLyBDaGVjayBpZiBhIHNpbmdsZSBmaWxlIGNvbnRhaW5zIHNraXBfdmVyeV9nb29kX29wdGltaXphdGlvbiB0YWcKRnV0dXJlPGJvb2w+IF9jaGVja0ZpbGVGb3JTa2lwVmVyeUdvb2RPcHRpbWl6YXRpb24oU3RyaW5nIHBhdGgpIGFzeW5jIHsKICBmaW5hbCBmaWxlID0gRmlsZShwYXRoKTsKICBpZiAoIWZpbGUuZXhpc3RzU3luYygpKSByZXR1cm4gZmFsc2U7CiAgZmluYWwgY29udGVudCA9IGF3YWl0IGZpbGUucmVhZEFzU3RyaW5nKCk7CiAgcmV0dXJuIHNraXBWZXJ5R29vZE9wdGltaXphdGlvblJlZ0V4cC5oYXNNYXRjaChjb250ZW50KTsKfQo=", "type": "text", }, { @@ -40,7 +40,7 @@ final testOptimizerBundle = MasonBundle.fromJson({ }, { "path": "test/pre_gen_test.dart", - "data": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9wcmVfZ2VuLmRhcnQnIGFzIHByZV9nZW47CmltcG9ydCAncGFja2FnZTptYXNvbi9tYXNvbi5kYXJ0JzsKaW1wb3J0ICdwYWNrYWdlOm1vY2t0YWlsL21vY2t0YWlsLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6cGF0aC9wYXRoLmRhcnQnIGFzIHBhdGg7CmltcG9ydCAncGFja2FnZTp0ZXN0L3Rlc3QuZGFydCc7CgpjbGFzcyBfTW9ja0xvZ2dlciBleHRlbmRzIE1vY2sgaW1wbGVtZW50cyBMb2dnZXIge30KCmNsYXNzIF9GYWtlQ29udGV4dCBleHRlbmRzIEZha2UgaW1wbGVtZW50cyBIb29rQ29udGV4dCB7CiAgQG92ZXJyaWRlCiAgZmluYWwgbG9nZ2VyID0gX01vY2tMb2dnZXIoKTsKCiAgQG92ZXJyaWRlCiAgTWFwPFN0cmluZywgT2JqZWN0Pz4gdmFycyA9IHt9Owp9CgpmaW5hbCBub3RPcHRpbWl6ZWRUZXN0Q29udGVudCA9CiAgICAnJycKQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSddKQp2b2lkIG1haW4oKSB7CiAgdGVzdCgndGVzdCcsICgpIHsKICAgIGV4cGVjdCgxLCAxKTsKICB9KTsKfQonJyc7CgpmaW5hbCBhbm90aGVyTm90T3B0aW1pemVkVGVzdENvbnRlbnQgPQogICAgJycnCkBUYWdzKFsnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nLCAnYW5vdGhlcl90YWcnXSkKdm9pZCBtYWluKCkgewogIHRlc3QoJ2Fub3RoZXIgdGVzdCcsICgpIHsKICAgIGV4cGVjdCgxLCAxKTsKICB9KTsKfQonJyc7Cgp2b2lkIG1haW4oKSB7CiAgbGF0ZSBEaXJlY3RvcnkgdGVtcERpcmVjdG9yeTsKCiAgc2V0VXAoKCkgewogICAgdGVtcERpcmVjdG9yeSA9IERpcmVjdG9yeS5zeXN0ZW1UZW1wLmNyZWF0ZVRlbXBTeW5jKCd0ZXN0X29wdGltaXplcicpOwogIH0pOwoKICB0ZWFyRG93bigoKSB7CiAgICB0ZW1wRGlyZWN0b3J5LmRlbGV0ZVN5bmMocmVjdXJzaXZlOiB0cnVlKTsKICB9KTsKCiAgZ3JvdXAoJ1ByZSBnZW4gaG9vaycsICgpIHsKICAgIGxhdGUgSG9va0NvbnRleHQgY29udGV4dDsKCiAgICBzZXRVcCgoKSB7CiAgICAgIGNvbnRleHQgPSBfRmFrZUNvbnRleHQoKTsKICAgIH0pOwoKICAgIGdyb3VwKCdDb21wbGV0ZXMnLCAoKSB7CiAgICAgIHRlc3QoJ3dpdGggdGVzdCBmaWxlcyBsaXN0JywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgIGZpbmFsIHRlc3RzTWFwID0gPFN0cmluZywgU3RyaW5nPnt9OwogICAgICAgIGZvciAoZmluYWwgdGVzdCBpbiB0ZXN0cykgewogICAgICAgICAgZmluYWwgcGF0aCA9IHRlc3RbJ3BhdGgnXSE7CiAgICAgICAgICBmaW5hbCBpZGVudGlmaWVyID0gdGVzdFsnaWRlbnRpZmllciddITsKICAgICAgICAgIHRlc3RzTWFwW3BhdGhdID0gaWRlbnRpZmllcjsKICAgICAgICB9CgogICAgICAgIGZpbmFsIHBhdGhzID0gdGVzdHNNYXAua2V5czsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0MV90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDJfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ25vX3Rlc3RfaGVyZS5kYXJ0JykpKTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgdGVzdHNNYXAudmFsdWVzLnRvU2V0KCkubGVuZ3RoLAogICAgICAgICAgZXF1YWxzKHRlc3RzLmxlbmd0aCksCiAgICAgICAgICByZWFzb246ICdBbGwgdGVzdHMgZmlsZXMgc2hvdWxkIGhhdmUgdW5pcXVlIGlkZW50aWZpZXJzJywKICAgICAgICApOwoKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWydpc0ZsdXR0ZXInXSwgZmFsc2UpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3dpdGggcHJvcGVyIGlzRmx1dHRlciBpZGVudGlmaWNhdGlvbicsICgpIGFzeW5jIHsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpCiAgICAgICAgICAuLndyaXRlQXNTdHJpbmdTeW5jKCcnJwpkZXBlbmRlbmNpZXM6CiAgZmx1dHRlcjoKICAgIHNkazogZmx1dHRlcicnJyk7CgogICAgICAgIERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWydpc0ZsdXR0ZXInXSwgdHJ1ZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2l0aCBwcm9wZXIgbm90IG9wdGltaXplZCB0ZXN0cyBpZGVudGlmaWNhdGlvbicsICgpIGFzeW5jIHsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0MV90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QyX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnbm9fdGVzdF9oZXJlLmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUoCiAgICAgICAgICBwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSwKICAgICAgICApLndyaXRlQXNTdHJpbmdTeW5jKG5vdE9wdGltaXplZFRlc3RDb250ZW50KTsKICAgICAgICBGaWxlKAogICAgICAgICAgcGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ2Fub3RoZXJfbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSwKICAgICAgICApLndyaXRlQXNTdHJpbmdTeW5jKGFub3RoZXJOb3RPcHRpbWl6ZWRUZXN0Q29udGVudCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgIGZpbmFsIHRlc3RzTWFwID0gPFN0cmluZywgU3RyaW5nPnt9OwogICAgICAgIGZvciAoZmluYWwgdGVzdCBpbiB0ZXN0cykgewogICAgICAgICAgZmluYWwgcGF0aCA9IHRlc3RbJ3BhdGgnXSE7CiAgICAgICAgICBmaW5hbCBpZGVudGlmaWVyID0gdGVzdFsnaWRlbnRpZmllciddITsKICAgICAgICAgIHRlc3RzTWFwW3BhdGhdID0gaWRlbnRpZmllcjsKICAgICAgICB9CgogICAgICAgIGZpbmFsIHBhdGhzID0gdGVzdHNNYXAua2V5czsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0MV90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDJfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ25vX3Rlc3RfaGVyZS5kYXJ0JykpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGlzTm90KGNvbnRhaW5zKCdub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBpc05vdChjb250YWlucygnYW5vdGhlcl9ub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKSk7CgogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHRlc3RzTWFwLnZhbHVlcy50b1NldCgpLmxlbmd0aCwKICAgICAgICAgIGVxdWFscyh0ZXN0cy5sZW5ndGgpLAogICAgICAgICAgcmVhc29uOiAnQWxsIHRlc3RzIGZpbGVzIHNob3VsZCBoYXZlIHVuaXF1ZSBpZGVudGlmaWVycycsCiAgICAgICAgKTsKICAgICAgICBmaW5hbCBub3RPcHRpbWl6ZWRUZXN0cyA9CiAgICAgICAgICAgIGNvbnRleHQudmFyc1snbm90T3B0aW1pemVkVGVzdHMnXSBhcyBMaXN0PFN0cmluZz47CiAgICAgICAgZXhwZWN0KAogICAgICAgICAgbm90T3B0aW1pemVkVGVzdHMsCiAgICAgICAgICBjb250YWlucygnbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSwKICAgICAgICApOwogICAgICAgIGV4cGVjdCgKICAgICAgICAgIG5vdE9wdGltaXplZFRlc3RzLAogICAgICAgICAgY29udGFpbnMoJ2Fub3RoZXJfbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSwKICAgICAgICApOwogICAgICB9KTsKICAgIH0pOwoKICAgIGdyb3VwKCdGYWlscycsICgpIHsKICAgICAgc2V0VXAoKCkgewogICAgICAgIHByZV9nZW4uZXhpdEZuID0gKGNvZGUpIHsKICAgICAgICAgIHRocm93IFByb2Nlc3NFeGNlcHRpb24oJ2V4aXQnLCBbY29kZS50b1N0cmluZygpXSk7CiAgICAgICAgfTsKICAgICAgfSk7CgogICAgICB0ZWFyRG93bigoKSB7CiAgICAgICAgcHJlX2dlbi5leGl0Rm4gPSBleGl0OwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3doZW4gdGFyZ2V0IHRlc3QgZGlyIGRvZXMgbm90IGV4aXN0JywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IGV4cGVjdExhdGVyKAogICAgICAgICAgKCkgPT4gcHJlX2dlbi5ydW4oY29udGV4dCksCiAgICAgICAgICB0aHJvd3NBKAogICAgICAgICAgICBpc0E8UHJvY2Vzc0V4Y2VwdGlvbj4oKS5oYXZpbmcoCiAgICAgICAgICAgICAgKGV4KSA9PiBleC5hcmd1bWVudHMuZmlyc3QsCiAgICAgICAgICAgICAgJ2Vycm9yIGNvZGUnLAogICAgICAgICAgICAgIGVxdWFscygnMScpLAogICAgICAgICAgICApLAogICAgICAgICAgKSwKICAgICAgICApOwoKICAgICAgICB2ZXJpZnkoCiAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci5lcnIoJ0NvdWxkIG5vdCBmaW5kIGRpcmVjdG9yeSAke3Rlc3REaXIucGF0aH0nKSwKICAgICAgICApLmNhbGxlZCgxKTsKCiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1sndGVzdHMnXSwgaXNOdWxsKTsKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWydpc0ZsdXR0ZXInXSwgaXNOdWxsKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCd3aGVuIHRhcmdldCBkaXIgZG9lcyBub3QgY29udGFpbiBhIHB1YnNwZWMueWFtbCcsICgpIGFzeW5jIHsKICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0MV90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QyX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnbm9fdGVzdF9oZXJlLmRhcnQnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICBhd2FpdCBleHBlY3RMYXRlcigKICAgICAgICAgICgpID0+IHByZV9nZW4ucnVuKGNvbnRleHQpLAogICAgICAgICAgdGhyb3dzQSgKICAgICAgICAgICAgaXNBPFByb2Nlc3NFeGNlcHRpb24+KCkuaGF2aW5nKAogICAgICAgICAgICAgIChleCkgPT4gZXguYXJndW1lbnRzLmZpcnN0LAogICAgICAgICAgICAgICdlcnJvciBjb2RlJywKICAgICAgICAgICAgICBlcXVhbHMoJzEnKSwKICAgICAgICAgICAgKSwKICAgICAgICAgICksCiAgICAgICAgKTsKCiAgICAgICAgdmVyaWZ5KAogICAgICAgICAgKCkgPT4gY29udGV4dC5sb2dnZXIuZXJyKAogICAgICAgICAgICAnQ291bGQgbm90IGZpbmQgcHVic3BlYy55YW1sIGF0ICR7dGVzdERpci5wYXRofScsCiAgICAgICAgICApLAogICAgICAgICkuY2FsbGVkKDEpOwoKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWyd0ZXN0cyddLCBpc051bGwpOwogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCBpc051bGwpOwogICAgICB9KTsKICAgIH0pOwoKICAgIGdyb3VwKCdza2lwVmVyeUdvb2RPcHRpbWl6YXRpb25SZWdFeHAgcmVnZXgnLCAoKSB7CiAgICAgIGZpbmFsIHJlZ2V4ID0gcHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25SZWdFeHA7CiAgICAgIHRlc3QoJ21hdGNoZXMgc2luZ2xlLWxpbmUgdGFnJywgKCkgewogICAgICAgIGZpbmFsIGNvbnRlbnQgPSAiQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSddKSI7CiAgICAgICAgZXhwZWN0KHJlZ2V4Lmhhc01hdGNoKGNvbnRlbnQpLCBpc1RydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ21hdGNoZXMgc2luZ2xlLWxpbmUgd2l0aCBtdWx0aXBsZSB0YWdzJywgKCkgewogICAgICAgIGZpbmFsIGNvbnRlbnQgPQogICAgICAgICAgICAiQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfScsICdjaHJvbWUnXSkiOwogICAgICAgIGV4cGVjdChyZWdleC5oYXNNYXRjaChjb250ZW50KSwgaXNUcnVlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdtYXRjaGVzIG11bHRpLWxpbmUgdGFnIGxpc3QnLCAoKSB7CiAgICAgICAgZmluYWwgY29udGVudCA9CiAgICAgICAgICAgICcnJwogICAgICBAVGFncyhbCiAgICAgICAgJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9JywKICAgICAgICAnY2hyb21lJywKICAgICAgICAndGVzdCcsCiAgICAgIF0pCiAgICAgICcnJzsKICAgICAgICBleHBlY3QocmVnZXguaGFzTWF0Y2goY29udGVudCksIGlzVHJ1ZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnbWF0Y2hlcyBtdWx0aS1saW5lIHdoZXJlIHRhZyBpcyBub3QgdGhlIGZpcnN0JywgKCkgewogICAgICAgIGZpbmFsIGNvbnRlbnQgPQogICAgICAgICAgICAnJycKICAgICAgQFRhZ3MoWwogICAgICAgICdjaHJvbWUnLAogICAgICAgICcke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfScsCiAgICAgICAgJ3Rlc3QnLAogICAgICBdKQogICAgICAnJyc7CiAgICAgICAgZXhwZWN0KHJlZ2V4Lmhhc01hdGNoKGNvbnRlbnQpLCBpc1RydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2RvZXMgbm90IG1hdGNoIHdoZW4gdGFnIG1pc3NpbmcnLCAoKSB7CiAgICAgICAgY29uc3QgY29udGVudCA9ICJAVGFncyhbJ2Nocm9tZScsICd0ZXN0J10pIjsKICAgICAgICBleHBlY3QocmVnZXguaGFzTWF0Y2goY29udGVudCksIGlzRmFsc2UpOwogICAgICB9KTsKCiAgICAgIHRlc3QoCiAgICAgICAgJ2RvZXMgbm90IG1hdGNoIHN1YnN0cmluZyBvbmx5IChlLmcuIHNraXBfdmVyeV9nb29kX29wdGltaXphdGlvbix0ZXN0KScsCiAgICAgICAgKCkgewogICAgICAgICAgZmluYWwgY29udGVudCA9CiAgICAgICAgICAgICAgJycnCiAgICAgIEBUYWdzKFsKICAgICAgICAnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30sdGVzdCcsCiAgICAgICAgJ2Nocm9tZScsCiAgICAgIF0pCiAgICAgICcnJzsKICAgICAgICAgIGV4cGVjdCgKICAgICAgICAgICAgcmVnZXguaGFzTWF0Y2goY29udGVudCksCiAgICAgICAgICAgIGlzRmFsc2UsCiAgICAgICAgICApOyAvLyBvbmx5IGV4YWN0IHRhZyBzaG91bGQgbWF0Y2gKICAgICAgICB9LAogICAgICApOwogICAgfSk7CiAgfSk7Cn0K", + "data": "aW1wb3J0ICdkYXJ0OmlvJzsKaW1wb3J0ICdkYXJ0Om1hdGgnOwoKaW1wb3J0ICdwYWNrYWdlOmhvb2tzL3ByZV9nZW4uZGFydCcgYXMgcHJlX2dlbjsKaW1wb3J0ICdwYWNrYWdlOm1hc29uL21hc29uLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6bW9ja3RhaWwvbW9ja3RhaWwuZGFydCc7CmltcG9ydCAncGFja2FnZTpwYXRoL3BhdGguZGFydCcgYXMgcGF0aDsKaW1wb3J0ICdwYWNrYWdlOnRlc3QvdGVzdC5kYXJ0JzsKCmNsYXNzIF9Nb2NrTG9nZ2VyIGV4dGVuZHMgTW9jayBpbXBsZW1lbnRzIExvZ2dlciB7fQoKY2xhc3MgX0Zha2VDb250ZXh0IGV4dGVuZHMgRmFrZSBpbXBsZW1lbnRzIEhvb2tDb250ZXh0IHsKICBAb3ZlcnJpZGUKICBmaW5hbCBsb2dnZXIgPSBfTW9ja0xvZ2dlcigpOwoKICBAb3ZlcnJpZGUKICBNYXA8U3RyaW5nLCBPYmplY3Q/PiB2YXJzID0ge307Cn0KCmZpbmFsIG5vdE9wdGltaXplZFRlc3RDb250ZW50ID0KICAgICcnJwpAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9J10pCnZvaWQgbWFpbigpIHsKICB0ZXN0KCd0ZXN0JywgKCkgewogICAgZXhwZWN0KDEsIDEpOwogIH0pOwp9CicnJzsKCmZpbmFsIGFub3RoZXJOb3RPcHRpbWl6ZWRUZXN0Q29udGVudCA9CiAgICAnJycKQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfScsICdhbm90aGVyX3RhZyddKQp2b2lkIG1haW4oKSB7CiAgdGVzdCgnYW5vdGhlciB0ZXN0JywgKCkgewogICAgZXhwZWN0KDEsIDEpOwogIH0pOwp9CicnJzsKCnZvaWQgbWFpbigpIHsKICBsYXRlIERpcmVjdG9yeSB0ZW1wRGlyZWN0b3J5OwoKICBzZXRVcCgoKSB7CiAgICB0ZW1wRGlyZWN0b3J5ID0gRGlyZWN0b3J5LnN5c3RlbVRlbXAuY3JlYXRlVGVtcFN5bmMoJ3Rlc3Rfb3B0aW1pemVyJyk7CiAgfSk7CgogIHRlYXJEb3duKCgpIHsKICAgIHRlbXBEaXJlY3RvcnkuZGVsZXRlU3luYyhyZWN1cnNpdmU6IHRydWUpOwogIH0pOwoKICBncm91cCgnUHJlIGdlbiBob29rJywgKCkgewogICAgbGF0ZSBIb29rQ29udGV4dCBjb250ZXh0OwoKICAgIHNldFVwKCgpIHsKICAgICAgY29udGV4dCA9IF9GYWtlQ29udGV4dCgpOwogICAgfSk7CgogICAgZ3JvdXAoJ0NvbXBsZXRlcycsICgpIHsKICAgICAgdGVzdCgnd2l0aCB0ZXN0IGZpbGVzIGxpc3QnLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDFfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0Ml90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ25vX3Rlc3RfaGVyZS5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgcHJlX2dlbi5ydW4oY29udGV4dCk7CgogICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgZmluYWwgdGVzdHNNYXAgPSA8U3RyaW5nLCBTdHJpbmc+e307CiAgICAgICAgZm9yIChmaW5hbCB0ZXN0IGluIHRlc3RzKSB7CiAgICAgICAgICBmaW5hbCBwYXRoID0gdGVzdFsncGF0aCddITsKICAgICAgICAgIGZpbmFsIGlkZW50aWZpZXIgPSB0ZXN0WydpZGVudGlmaWVyJ10hOwogICAgICAgICAgdGVzdHNNYXBbcGF0aF0gPSBpZGVudGlmaWVyOwogICAgICAgIH0KCiAgICAgICAgZmluYWwgcGF0aHMgPSB0ZXN0c01hcC5rZXlzOwogICAgICAgIGV4cGVjdChwYXRocywgY29udGFpbnMoJ3Rlc3QxX3Rlc3QuZGFydCcpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0Ml90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBpc05vdChjb250YWlucygnbm9fdGVzdF9oZXJlLmRhcnQnKSkpOwoKICAgICAgICBleHBlY3QoCiAgICAgICAgICB0ZXN0c01hcC52YWx1ZXMudG9TZXQoKS5sZW5ndGgsCiAgICAgICAgICBlcXVhbHModGVzdHMubGVuZ3RoKSwKICAgICAgICAgIHJlYXNvbjogJ0FsbCB0ZXN0cyBmaWxlcyBzaG91bGQgaGF2ZSB1bmlxdWUgaWRlbnRpZmllcnMnLAogICAgICAgICk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCBmYWxzZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2l0aCBwcm9wZXIgaXNGbHV0dGVyIGlkZW50aWZpY2F0aW9uJywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKQogICAgICAgICAgLi5jcmVhdGVTeW5jKCkKICAgICAgICAgIC4ud3JpdGVBc1N0cmluZ1N5bmMoJycnCmRlcGVuZGVuY2llczoKICBmbHV0dGVyOgogICAgc2RrOiBmbHV0dGVyJycnKTsKCiAgICAgICAgRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgcHJlX2dlbi5ydW4oY29udGV4dCk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCB0cnVlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCd3aXRoIHByb3BlciBub3Qgb3B0aW1pemVkIHRlc3RzIGlkZW50aWZpY2F0aW9uJywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZSgKICAgICAgICAgIHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpLAogICAgICAgICkud3JpdGVBc1N0cmluZ1N5bmMobm90T3B0aW1pemVkVGVzdENvbnRlbnQpOwogICAgICAgIEZpbGUoCiAgICAgICAgICBwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnYW5vdGhlcl9ub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpLAogICAgICAgICkud3JpdGVBc1N0cmluZ1N5bmMoYW5vdGhlck5vdE9wdGltaXplZFRlc3RDb250ZW50KTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgcHJlX2dlbi5ydW4oY29udGV4dCk7CgogICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgZmluYWwgdGVzdHNNYXAgPSA8U3RyaW5nLCBTdHJpbmc+e307CiAgICAgICAgZm9yIChmaW5hbCB0ZXN0IGluIHRlc3RzKSB7CiAgICAgICAgICBmaW5hbCBwYXRoID0gdGVzdFsncGF0aCddITsKICAgICAgICAgIGZpbmFsIGlkZW50aWZpZXIgPSB0ZXN0WydpZGVudGlmaWVyJ10hOwogICAgICAgICAgdGVzdHNNYXBbcGF0aF0gPSBpZGVudGlmaWVyOwogICAgICAgIH0KCiAgICAgICAgZmluYWwgcGF0aHMgPSB0ZXN0c01hcC5rZXlzOwogICAgICAgIGV4cGVjdChwYXRocywgY29udGFpbnMoJ3Rlc3QxX3Rlc3QuZGFydCcpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0Ml90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBpc05vdChjb250YWlucygnbm9fdGVzdF9oZXJlLmRhcnQnKSkpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ25vdF9vcHRpbWl6ZWRfdGVzdC5kYXJ0JykpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGlzTm90KGNvbnRhaW5zKCdhbm90aGVyX25vdF9vcHRpbWl6ZWRfdGVzdC5kYXJ0JykpKTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgdGVzdHNNYXAudmFsdWVzLnRvU2V0KCkubGVuZ3RoLAogICAgICAgICAgZXF1YWxzKHRlc3RzLmxlbmd0aCksCiAgICAgICAgICByZWFzb246ICdBbGwgdGVzdHMgZmlsZXMgc2hvdWxkIGhhdmUgdW5pcXVlIGlkZW50aWZpZXJzJywKICAgICAgICApOwogICAgICAgIGZpbmFsIG5vdE9wdGltaXplZFRlc3RzID0KICAgICAgICAgICAgY29udGV4dC52YXJzWydub3RPcHRpbWl6ZWRUZXN0cyddIGFzIExpc3Q8U3RyaW5nPjsKICAgICAgICBleHBlY3QoCiAgICAgICAgICBub3RPcHRpbWl6ZWRUZXN0cywKICAgICAgICAgIGNvbnRhaW5zKCdub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpLAogICAgICAgICk7CiAgICAgICAgZXhwZWN0KAogICAgICAgICAgbm90T3B0aW1pemVkVGVzdHMsCiAgICAgICAgICBjb250YWlucygnYW5vdGhlcl9ub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpLAogICAgICAgICk7CiAgICAgIH0pOwogICAgfSk7CgogICAgZ3JvdXAoJ0ZhaWxzJywgKCkgewogICAgICBzZXRVcCgoKSB7CiAgICAgICAgcHJlX2dlbi5leGl0Rm4gPSAoY29kZSkgewogICAgICAgICAgdGhyb3cgUHJvY2Vzc0V4Y2VwdGlvbignZXhpdCcsIFtjb2RlLnRvU3RyaW5nKCldKTsKICAgICAgICB9OwogICAgICB9KTsKCiAgICAgIHRlYXJEb3duKCgpIHsKICAgICAgICBwcmVfZ2VuLmV4aXRGbiA9IGV4aXQ7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2hlbiB0YXJnZXQgdGVzdCBkaXIgZG9lcyBub3QgZXhpc3QnLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgZXhwZWN0TGF0ZXIoCiAgICAgICAgICAoKSA9PiBwcmVfZ2VuLnJ1bihjb250ZXh0KSwKICAgICAgICAgIHRocm93c0EoCiAgICAgICAgICAgIGlzQTxQcm9jZXNzRXhjZXB0aW9uPigpLmhhdmluZygKICAgICAgICAgICAgICAoZXgpID0+IGV4LmFyZ3VtZW50cy5maXJzdCwKICAgICAgICAgICAgICAnZXJyb3IgY29kZScsCiAgICAgICAgICAgICAgZXF1YWxzKCcxJyksCiAgICAgICAgICAgICksCiAgICAgICAgICApLAogICAgICAgICk7CgogICAgICAgIHZlcmlmeSgKICAgICAgICAgICgpID0+IGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgZGlyZWN0b3J5ICR7dGVzdERpci5wYXRofScpLAogICAgICAgICkuY2FsbGVkKDEpOwoKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWyd0ZXN0cyddLCBpc051bGwpOwogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCBpc051bGwpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3doZW4gdGFyZ2V0IGRpciBkb2VzIG5vdCBjb250YWluIGEgcHVic3BlYy55YW1sJywgKCkgYXN5bmMgewogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IGV4cGVjdExhdGVyKAogICAgICAgICAgKCkgPT4gcHJlX2dlbi5ydW4oY29udGV4dCksCiAgICAgICAgICB0aHJvd3NBKAogICAgICAgICAgICBpc0E8UHJvY2Vzc0V4Y2VwdGlvbj4oKS5oYXZpbmcoCiAgICAgICAgICAgICAgKGV4KSA9PiBleC5hcmd1bWVudHMuZmlyc3QsCiAgICAgICAgICAgICAgJ2Vycm9yIGNvZGUnLAogICAgICAgICAgICAgIGVxdWFscygnMScpLAogICAgICAgICAgICApLAogICAgICAgICAgKSwKICAgICAgICApOwoKICAgICAgICB2ZXJpZnkoCiAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci5lcnIoCiAgICAgICAgICAgICdDb3VsZCBub3QgZmluZCBwdWJzcGVjLnlhbWwgYXQgJHt0ZXN0RGlyLnBhdGh9JywKICAgICAgICAgICksCiAgICAgICAgKS5jYWxsZWQoMSk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ3Rlc3RzJ10sIGlzTnVsbCk7CiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snaXNGbHV0dGVyJ10sIGlzTnVsbCk7CiAgICAgIH0pOwogICAgfSk7CgogICAgZ3JvdXAoJ3NraXBWZXJ5R29vZE9wdGltaXphdGlvblJlZ0V4cCByZWdleCcsICgpIHsKICAgICAgZmluYWwgcmVnZXggPSBwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblJlZ0V4cDsKICAgICAgdGVzdCgnbWF0Y2hlcyBzaW5nbGUtbGluZSB0YWcnLCAoKSB7CiAgICAgICAgZmluYWwgY29udGVudCA9ICJAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9J10pIjsKICAgICAgICBleHBlY3QocmVnZXguaGFzTWF0Y2goY29udGVudCksIGlzVHJ1ZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnbWF0Y2hlcyBzaW5nbGUtbGluZSB3aXRoIG11bHRpcGxlIHRhZ3MnLCAoKSB7CiAgICAgICAgZmluYWwgY29udGVudCA9CiAgICAgICAgICAgICJAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9JywgJ2Nocm9tZSddKSI7CiAgICAgICAgZXhwZWN0KHJlZ2V4Lmhhc01hdGNoKGNvbnRlbnQpLCBpc1RydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ21hdGNoZXMgbXVsdGktbGluZSB0YWcgbGlzdCcsICgpIHsKICAgICAgICBmaW5hbCBjb250ZW50ID0KICAgICAgICAgICAgJycnCiAgICAgIEBUYWdzKFsKICAgICAgICAnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nLAogICAgICAgICdjaHJvbWUnLAogICAgICAgICd0ZXN0JywKICAgICAgXSkKICAgICAgJycnOwogICAgICAgIGV4cGVjdChyZWdleC5oYXNNYXRjaChjb250ZW50KSwgaXNUcnVlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdtYXRjaGVzIG11bHRpLWxpbmUgd2hlcmUgdGFnIGlzIG5vdCB0aGUgZmlyc3QnLCAoKSB7CiAgICAgICAgZmluYWwgY29udGVudCA9CiAgICAgICAgICAgICcnJwogICAgICBAVGFncyhbCiAgICAgICAgJ2Nocm9tZScsCiAgICAgICAgJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9JywKICAgICAgICAndGVzdCcsCiAgICAgIF0pCiAgICAgICcnJzsKICAgICAgICBleHBlY3QocmVnZXguaGFzTWF0Y2goY29udGVudCksIGlzVHJ1ZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnZG9lcyBub3QgbWF0Y2ggd2hlbiB0YWcgbWlzc2luZycsICgpIHsKICAgICAgICBjb25zdCBjb250ZW50ID0gIkBUYWdzKFsnY2hyb21lJywgJ3Rlc3QnXSkiOwogICAgICAgIGV4cGVjdChyZWdleC5oYXNNYXRjaChjb250ZW50KSwgaXNGYWxzZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgKICAgICAgICAnZG9lcyBub3QgbWF0Y2ggc3Vic3RyaW5nIG9ubHkgKGUuZy4gc2tpcF92ZXJ5X2dvb2Rfb3B0aW1pemF0aW9uLHRlc3QpJywKICAgICAgICAoKSB7CiAgICAgICAgICBmaW5hbCBjb250ZW50ID0KICAgICAgICAgICAgICAnJycKICAgICAgQFRhZ3MoWwogICAgICAgICcke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSx0ZXN0JywKICAgICAgICAnY2hyb21lJywKICAgICAgXSkKICAgICAgJycnOwogICAgICAgICAgZXhwZWN0KAogICAgICAgICAgICByZWdleC5oYXNNYXRjaChjb250ZW50KSwKICAgICAgICAgICAgaXNGYWxzZSwKICAgICAgICAgICk7IC8vIG9ubHkgZXhhY3QgdGFnIHNob3VsZCBtYXRjaAogICAgICAgIH0sCiAgICAgICk7CiAgICB9KTsKICAgIGdyb3VwKCdTaGFyZGluZycsICgpIHsKICAgICAgLy8vIENyZWF0ZXMgYSBwYWNrYWdlIHdpdGggW2NvdW50XSBvcHRpbWl6YWJsZSB0ZXN0IGZpbGVzLCBwbHVzIGFueQogICAgICAvLy8gW25vdE9wdGltaXplZF0gZmlsZXMgY2FycnlpbmcgdGhlIHNraXAgb3B0aW1pemF0aW9uIHRhZy4KICAgICAgRGlyZWN0b3J5IGNyZWF0ZVBhY2thZ2UoaW50IGNvdW50LCB7aW50IG5vdE9wdGltaXplZCA9IDB9KSB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGNvdW50OyBpKyspIHsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3Qke2l9X3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgfQogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwgbm90T3B0aW1pemVkOyBpKyspIHsKICAgICAgICAgIEZpbGUoCiAgICAgICAgICAgIHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdza2lwJHtpfV90ZXN0LmRhcnQnKSwKICAgICAgICAgICkud3JpdGVBc1N0cmluZ1N5bmMobm90T3B0aW1pemVkVGVzdENvbnRlbnQpOwogICAgICAgIH0KICAgICAgICByZXR1cm4gdGVzdERpcjsKICAgICAgfQoKICAgICAgTGlzdDxTdHJpbmc+IHBhdGhzT2YoSG9va0NvbnRleHQgY29udGV4dCkgewogICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgcmV0dXJuIHRlc3RzLm1hcCgoZSkgPT4gZVsncGF0aCddISkudG9MaXN0KCk7CiAgICAgIH0KCiAgICAgIEZ1dHVyZTxMaXN0PFN0cmluZz4+IHJ1blNoYXJkKGludCBpbmRleCwgaW50IHRvdGFsKSBhc3luYyB7CiAgICAgICAgZmluYWwgY29udGV4dCA9IF9GYWtlQ29udGV4dCgpCiAgICAgICAgICAuLnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoCiAgICAgICAgICAuLnZhcnNbJ3NoYXJkLWluZGV4J10gPSBpbmRleAogICAgICAgICAgLi52YXJzWyd0b3RhbC1zaGFyZHMnXSA9IHRvdGFsOwogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwogICAgICAgIHJldHVybiBbCiAgICAgICAgICAuLi5wYXRoc09mKGNvbnRleHQpLAogICAgICAgICAgLi4uKGNvbnRleHQudmFyc1snbm90T3B0aW1pemVkVGVzdHMnXSEgYXMgTGlzdCkuY2FzdDxTdHJpbmc+KCksCiAgICAgICAgXTsKICAgICAgfQoKICAgICAgdGVzdCgncnVucyBldmVyeSB0ZXN0IGV4YWN0bHkgb25jZSBhY3Jvc3MgYWxsIHNoYXJkcycsICgpIGFzeW5jIHsKICAgICAgICBjcmVhdGVQYWNrYWdlKDcsIG5vdE9wdGltaXplZDogMik7CgogICAgICAgIGZpbmFsIHNoYXJkcyA9IFsKICAgICAgICAgIGZvciAodmFyIGkgPSAxOyBpIDw9IDM7IGkrKykgYXdhaXQgcnVuU2hhcmQoaSwgMyksCiAgICAgICAgXTsKICAgICAgICBmaW5hbCB1bmlvbiA9IHNoYXJkcy5leHBhbmQoKHNoYXJkKSA9PiBzaGFyZCkudG9MaXN0KCk7CgogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHVuaW9uLi5zb3J0KCksCiAgICAgICAgICBbCiAgICAgICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwgNzsgaSsrKSAndGVzdCR7aX1fdGVzdC5kYXJ0JywKICAgICAgICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCAyOyBpKyspICdza2lwJHtpfV90ZXN0LmRhcnQnLAogICAgICAgICAgXS4uc29ydCgpLAogICAgICAgICAgcmVhc29uOiAnU2hhcmRzIG11c3QgYmUgYSBjb21wbGV0ZSBhbmQgZGlzam9pbnQgcGFydGl0aW9uJywKICAgICAgICApOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3NoYXJkcyBub24gb3B0aW1pemVkIHRlc3RzIGFzIHdlbGwnLCAoKSBhc3luYyB7CiAgICAgICAgY3JlYXRlUGFja2FnZSgwLCBub3RPcHRpbWl6ZWQ6IDQpOwoKICAgICAgICBmaW5hbCBmaXJzdCA9IGF3YWl0IHJ1blNoYXJkKDEsIDIpOwogICAgICAgIGZpbmFsIHNlY29uZCA9IGF3YWl0IHJ1blNoYXJkKDIsIDIpOwoKICAgICAgICBleHBlY3QoZmlyc3QsIFsnc2tpcDBfdGVzdC5kYXJ0JywgJ3NraXAyX3Rlc3QuZGFydCddKTsKICAgICAgICBleHBlY3Qoc2Vjb25kLCBbJ3NraXAxX3Rlc3QuZGFydCcsICdza2lwM190ZXN0LmRhcnQnXSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnaXMgZGV0ZXJtaW5pc3RpYyBhY3Jvc3MgcnVucycsICgpIGFzeW5jIHsKICAgICAgICBjcmVhdGVQYWNrYWdlKDkpOwoKICAgICAgICBleHBlY3QoYXdhaXQgcnVuU2hhcmQoMiwgNCksIGF3YWl0IHJ1blNoYXJkKDIsIDQpKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdiYWxhbmNlcyBzaGFyZHMgd2l0aGluIG9uZSBmaWxlIG9mIGVhY2ggb3RoZXInLCAoKSBhc3luYyB7CiAgICAgICAgY3JlYXRlUGFja2FnZSgxMCk7CgogICAgICAgIGZpbmFsIHNpemVzID0gWwogICAgICAgICAgZm9yICh2YXIgaSA9IDE7IGkgPD0gNDsgaSsrKSAoYXdhaXQgcnVuU2hhcmQoaSwgNCkpLmxlbmd0aCwKICAgICAgICBdOwoKICAgICAgICBleHBlY3Qoc2l6ZXMucmVkdWNlKG1heCkgLSBzaXplcy5yZWR1Y2UobWluKSwgbGVzc1RoYW5PckVxdWFsVG8oMSkpOwogICAgICB9KTsKCiAgICAgIHRlc3QoCiAgICAgICAgJ3lpZWxkcyBhbiBlbXB0eSBzaGFyZCB3aGVuIHRoZXJlIGFyZSBtb3JlIHNoYXJkcyB0aGFuIHRlc3RzJywKICAgICAgICAoKSBhc3luYyB7CiAgICAgICAgICBjcmVhdGVQYWNrYWdlKDIpOwoKICAgICAgICAgIGV4cGVjdChhd2FpdCBydW5TaGFyZCgzLCAzKSwgaXNFbXB0eSk7CiAgICAgICAgfSwKICAgICAgKTsKCiAgICAgIHRlc3QoCiAgICAgICAgJ2V4Y2x1ZGVzIG5lc3RlZCBub24gb3B0aW1pemVkIHRlc3RzIGZyb20gdGhlIG9wdGltaXplZCBzZXQnLAogICAgICAgICgpIGFzeW5jIHsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgICAgZmluYWwgbmVzdGVkID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdzdWInKSkKICAgICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgICBGaWxlKAogICAgICAgICAgICBwYXRoLmpvaW4obmVzdGVkLnBhdGgsICdza2lwX3Rlc3QuZGFydCcpLAogICAgICAgICAgKS53cml0ZUFzU3RyaW5nU3luYyhub3RPcHRpbWl6ZWRUZXN0Q29udGVudCk7CgogICAgICAgICAgZmluYWwgY29udGV4dCA9IF9GYWtlQ29udGV4dCgpCiAgICAgICAgICAgIC4udmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CiAgICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgICBleHBlY3QoCiAgICAgICAgICAgIHBhdGhzT2YoY29udGV4dCksCiAgICAgICAgICAgIGlzRW1wdHksCiAgICAgICAgICAgIHJlYXNvbjoKICAgICAgICAgICAgICAgICdBIHRhZ2dlZCB0ZXN0IGluIGEgc3ViZGlyZWN0b3J5IG11c3Qgbm90IGJlIG9wdGltaXplZCwgJwogICAgICAgICAgICAgICAgJ290aGVyd2lzZSBpdCBydW5zIGJvdGggaW5saW5lZCBhbmQgc3RhbmRhbG9uZScsCiAgICAgICAgICApOwogICAgICAgICAgZXhwZWN0KAogICAgICAgICAgICBjb250ZXh0LnZhcnNbJ25vdE9wdGltaXplZFRlc3RzJ10sCiAgICAgICAgICAgIFsnc3ViL3NraXBfdGVzdC5kYXJ0J10sCiAgICAgICAgICApOwogICAgICAgIH0sCiAgICAgICk7CgogICAgICB0ZXN0KCdpbmNsdWRlcyBldmVyeSB0ZXN0IHdoZW4gc2hhcmRpbmcgaXMgbm90IHJlcXVlc3RlZCcsICgpIGFzeW5jIHsKICAgICAgICBjcmVhdGVQYWNrYWdlKDMpOwoKICAgICAgICBmaW5hbCBjb250ZXh0ID0gX0Zha2VDb250ZXh0KCkKICAgICAgICAgIC4udmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CiAgICAgICAgYXdhaXQgcHJlX2dlbi5ydW4oY29udGV4dCk7CgogICAgICAgIGV4cGVjdChwYXRoc09mKGNvbnRleHQpLCBoYXNMZW5ndGgoMykpOwogICAgICB9KTsKICAgIH0pOwoKICAgIGdyb3VwKCdzaGFyZE9mJywgKCkgewogICAgICBjb25zdCBwYXRocyA9IFsnYS5kYXJ0JywgJ2IuZGFydCcsICdjLmRhcnQnLCAnZC5kYXJ0JywgJ2UuZGFydCddOwoKICAgICAgdGVzdCgncmV0dXJucyBwYXRocyB1bnRvdWNoZWQgd2hlbiBzaGFyZCBpbmRleCBpcyBudWxsJywgKCkgewogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHByZV9nZW4uc2hhcmRPZihwYXRocywgc2hhcmRJbmRleDogbnVsbCwgdG90YWxTaGFyZHM6IDMpLAogICAgICAgICAgcGF0aHMsCiAgICAgICAgKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdyZXR1cm5zIHBhdGhzIHVudG91Y2hlZCB3aGVuIHRvdGFsIHNoYXJkcyBpcyBudWxsJywgKCkgewogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHByZV9nZW4uc2hhcmRPZihwYXRocywgc2hhcmRJbmRleDogMSwgdG90YWxTaGFyZHM6IG51bGwpLAogICAgICAgICAgcGF0aHMsCiAgICAgICAgKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdkZWFscyBwYXRocyBvdXQgcm91bmQgcm9iaW4nLCAoKSB7CiAgICAgICAgZXhwZWN0KAogICAgICAgICAgcHJlX2dlbi5zaGFyZE9mKHBhdGhzLCBzaGFyZEluZGV4OiAxLCB0b3RhbFNoYXJkczogMiksCiAgICAgICAgICBbJ2EuZGFydCcsICdjLmRhcnQnLCAnZS5kYXJ0J10sCiAgICAgICAgKTsKICAgICAgICBleHBlY3QoCiAgICAgICAgICBwcmVfZ2VuLnNoYXJkT2YocGF0aHMsIHNoYXJkSW5kZXg6IDIsIHRvdGFsU2hhcmRzOiAyKSwKICAgICAgICAgIFsnYi5kYXJ0JywgJ2QuZGFydCddLAogICAgICAgICk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgncmV0dXJucyBldmVyeXRoaW5nIGZvciBhIHNpbmdsZSBzaGFyZCcsICgpIHsKICAgICAgICBleHBlY3QocHJlX2dlbi5zaGFyZE9mKHBhdGhzLCBzaGFyZEluZGV4OiAxLCB0b3RhbFNoYXJkczogMSksIHBhdGhzKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdyZXR1cm5zIGVtcHR5IGZvciBhbiBvdXQgb2YgcmFuZ2Ugc2hhcmQnLCAoKSB7CiAgICAgICAgZXhwZWN0KAogICAgICAgICAgcHJlX2dlbi5zaGFyZE9mKHBhdGhzLCBzaGFyZEluZGV4OiA5LCB0b3RhbFNoYXJkczogOSksCiAgICAgICAgICBpc0VtcHR5LAogICAgICAgICk7CiAgICAgIH0pOwogICAgfSk7CiAgfSk7Cn0K", "type": "text", }, ], @@ -61,5 +61,15 @@ final testOptimizerBundle = MasonBundle.fromJson({ "default": ".", "prompt": "Please enter the path to the package root.", }, + "shard-index": { + "type": "number", + "description": "The 1-based index of the shard to generate tests for.", + "prompt": "Please enter the shard index.", + }, + "total-shards": { + "type": "number", + "description": "The total number of shards the test suite is split into.", + "prompt": "Please enter the total number of shards.", + }, }, }); diff --git a/lib/src/cli/test_cli_runner.dart b/lib/src/cli/test_cli_runner.dart index 3d6c42c06..d734f88d3 100644 --- a/lib/src/cli/test_cli_runner.dart +++ b/lib/src/cli/test_cli_runner.dart @@ -103,6 +103,8 @@ class TestCLIRunner { GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, List? reportOn, bool checkIgnore = false, + int? shardIndex, + int? totalShards, @visibleForTesting VeryGoodTestRunner? overrideTestRunner, }) async { final initialCwd = cwd; @@ -140,7 +142,11 @@ class TestCLIRunner { '''Shuffling test order with --test-randomize-ordering-seed=$randomSeed\n''', ); } - var vars = {'package-root': workingDirectory}; + var vars = { + 'package-root': workingDirectory, + 'shard-index': ?shardIndex, + 'total-shards': ?totalShards, + }; if (optimizePerformance) { final optimizationProgress = logger.progress('Optimizing tests'); try { @@ -162,6 +168,20 @@ class TestCLIRunner { final notOptimizedTests = vars['notOptimizedTests'] as List? ?? []; + + // A shard can legitimately end up empty when there are more shards + // than test files. That is not a failure: the shard has nothing to do, + // so report success instead of letting the test runner exit with + // "No tests were found", which would fail the CI job. + if (shardIndex != null && + optimizePerformance && + (vars['tests'] as List? ?? []).isEmpty && + notOptimizedTests.isEmpty) { + stdout?.call('No tests found for shard $shardIndex in $path\n'); + await _cleanupOptimizerFile(cwd); + return ExitCode.success.code; + } + return _overrideAnsiOutput( forceAnsi, () => diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index b5dabf9ae..f617eb21d 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -7,6 +7,7 @@ import 'package:mason/mason.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/commands/test/test.dart'; import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; /// Options for configuring the Dart test command. @@ -30,6 +31,8 @@ class DartTestOptions { required this.runSkipped, required this.checkIgnore, required this.fileReporter, + required this.shardIndex, + required this.totalShards, }); /// Parses [ArgResults] into a [DartTestOptions] instance. @@ -102,6 +105,12 @@ class DartTestOptions { 'file-reporter', testConfig.fileReporter, ); + final shardIndex = int.tryParse( + argResults['shard-index'] as String? ?? '', + ); + final totalShards = int.tryParse( + argResults['total-shards'] as String? ?? '', + ); final rest = argResults.rest; return DartTestOptions._( @@ -122,6 +131,8 @@ class DartTestOptions { runSkipped: runSkipped, checkIgnore: checkIgnore, fileReporter: fileReporter, + shardIndex: shardIndex, + totalShards: totalShards, rest: rest, ); } @@ -179,6 +190,12 @@ class DartTestOptions { /// `:` (e.g. `json:reports/tests.json`). final String? fileReporter; + /// The 1-based index of the shard to run, when sharding is enabled. + final int? shardIndex; + + /// The total number of shards the test suite is split into. + final int? totalShards; + /// The remaining arguments passed to the `dart test` command. final List rest; } @@ -204,6 +221,8 @@ typedef DartTestCommandCall = Future> Function({ void Function(String)? stderr, List? reportOn, bool checkIgnore, + int? shardIndex, + int? totalShards, }); /// {@template dart_test_command} @@ -335,6 +354,22 @@ class DartTestCommand extends Command { 'Enable an additional reporter writing test results to a file. ' 'Should be in the form : (e.g. "json:reports/tests.json").', valueHelp: 'name:path', + ) + ..addOption( + 'shard-index', + help: + 'The 1-based index of the shard to run. ' + 'Must be used together with --total-shards. ' + 'Requires optimization to be enabled.', + valueHelp: '1', + ) + ..addOption( + 'total-shards', + help: + 'Split the test suite into this many shards and run only the one ' + 'selected by --shard-index. Useful to parallelize tests across ' + 'multiple CI runners.', + valueHelp: '3', ); } @@ -374,6 +409,17 @@ This command should be run from the root of your Dart project.'''); final options = DartTestOptions.parse(_argResults, config: config); + final shardingError = validateSharding( + rawShardIndex: _argResults['shard-index'] as String?, + rawTotalShards: _argResults['total-shards'] as String?, + optimizePerformance: options.optimizePerformance, + minCoverage: options.minCoverage, + ); + if (shardingError != null) { + _logger.err(shardingError); + return ExitCode.usage.code; + } + if (isDartInstalled) { try { final results = await _dartTest( @@ -410,6 +456,8 @@ This command should be run from the root of your Dart project.'''); ], reportOn: options.reportOn.isEmpty ? null : options.reportOn, checkIgnore: options.checkIgnore, + shardIndex: options.shardIndex, + totalShards: options.totalShards, ); if (results.any((code) => code != ExitCode.success.code)) { return ExitCode.unavailable.code; diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index ca565a4f9..45fe05df9 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -33,6 +33,8 @@ class FlutterTestOptions { required this.flavor, required this.timeout, required this.fileReporter, + required this.shardIndex, + required this.totalShards, required this.rest, }); @@ -124,6 +126,12 @@ class FlutterTestOptions { 'file-reporter', testConfig.fileReporter, ); + final shardIndex = int.tryParse( + argResults['shard-index'] as String? ?? '', + ); + final totalShards = int.tryParse( + argResults['total-shards'] as String? ?? '', + ); final rest = argResults.rest; return FlutterTestOptions._( @@ -148,6 +156,8 @@ class FlutterTestOptions { flavor: flavor, timeout: effectiveTimeout, fileReporter: fileReporter, + shardIndex: shardIndex, + totalShards: totalShards, rest: rest, ); } @@ -218,10 +228,71 @@ class FlutterTestOptions { /// `:` (e.g. `json:reports/tests.json`). final String? fileReporter; + /// The 1-based index of the shard to run, when sharding is enabled. + final int? shardIndex; + + /// The total number of shards the test suite is split into. + final int? totalShards; + /// The remaining arguments passed to the test command. final List rest; } +/// Validates the `--shard-index` / `--total-shards` combination. +/// +/// Returns an error message describing the problem, or `null` when the +/// configuration is valid. +/// +/// [rawShardIndex] and [rawTotalShards] are the unparsed command line values, +/// so that "not provided" can be told apart from "provided but not a number". +String? validateSharding({ + required String? rawShardIndex, + required String? rawTotalShards, + required bool optimizePerformance, + required double? minCoverage, +}) { + if (rawShardIndex == null && rawTotalShards == null) return null; + + if (rawShardIndex == null || rawTotalShards == null) { + return '--shard-index and --total-shards must be used together.'; + } + + final totalShards = int.tryParse(rawTotalShards); + if (totalShards == null || totalShards < 1) { + return '--total-shards must be a positive integer, ' + 'but got "$rawTotalShards".'; + } + + final shardIndex = int.tryParse(rawShardIndex); + if (shardIndex == null || shardIndex < 1) { + return '--shard-index must be a positive integer, ' + 'but got "$rawShardIndex".'; + } + + if (shardIndex > totalShards) { + return '--shard-index ($shardIndex) must be less than or equal to ' + '--total-shards ($totalShards).'; + } + + // Sharding partitions the test files consolidated by the optimizer, so it + // cannot work when the optimization is turned off. + if (!optimizePerformance) { + return 'Sharding requires optimization to be enabled. ' + 'Remove --no-optimization or --platform to use sharding.'; + } + + // Each shard only exercises a fraction of the codebase, so its coverage is + // not representative of the whole suite. Merge the lcov files from every + // shard and enforce the threshold in a separate job instead. + if (minCoverage != null) { + return '--min-coverage cannot be combined with sharding. Collect coverage ' + 'per shard with --coverage, merge the lcov reports, then check the ' + 'threshold in a separate job.'; + } + + return null; +} + /// Signature for the [Flutter.installed] method. typedef FlutterInstalledCommand = Future Function({ required Logger logger, @@ -244,6 +315,8 @@ typedef FlutterTestCommand = Future> Function({ void Function(String)? stdout, void Function(String)? stderr, List? reportOn, + int? shardIndex, + int? totalShards, }); /// {@template test_command} @@ -411,6 +484,22 @@ class TestCommand extends Command { 'Enable an additional reporter writing test results to a file. ' 'Should be in the form : (e.g. "json:reports/tests.json").', valueHelp: 'name:path', + ) + ..addOption( + 'shard-index', + help: + 'The 1-based index of the shard to run. ' + 'Must be used together with --total-shards. ' + 'Requires optimization to be enabled.', + valueHelp: '1', + ) + ..addOption( + 'total-shards', + help: + 'Split the test suite into this many shards and run only the one ' + 'selected by --shard-index. Useful to parallelize tests across ' + 'multiple CI runners.', + valueHelp: '3', ); } @@ -452,6 +541,17 @@ This command should be run from the root of your Flutter project.'''); final options = FlutterTestOptions.parse(_argResults, config: config); + final shardingError = validateSharding( + rawShardIndex: _argResults['shard-index'] as String?, + rawTotalShards: _argResults['total-shards'] as String?, + optimizePerformance: options.optimizePerformance, + minCoverage: options.minCoverage, + ); + if (shardingError != null) { + _logger.err(shardingError); + return ExitCode.usage.code; + } + if (isFlutterInstalled) { try { final results = await _flutterTest( @@ -477,6 +577,8 @@ This command should be run from the root of your Flutter project.'''); randomSeed: options.randomSeed, forceAnsi: options.forceAnsi, reportOn: options.reportOn.isEmpty ? null : options.reportOn, + shardIndex: options.shardIndex, + totalShards: options.totalShards, arguments: [ if (options.excludeTags != null) ...['-x', options.excludeTags!], if (options.tags != null) ...['-t', options.tags!], diff --git a/site/docs/commands/test.md b/site/docs/commands/test.md index 505fc3d8d..b256a15bb 100644 --- a/site/docs/commands/test.md +++ b/site/docs/commands/test.md @@ -51,6 +51,12 @@ very_good test [arguments] Useful when tests hang due to an unbounded pumpAndSettle() call. --file-reporter= Enable an additional reporter writing test results to a file. Should be in the form : (e.g. "json:reports/tests.json"). + --shard-index=<1> The 1-based index of the shard to run. + Must be used together with --total-shards. + Requires optimization to be enabled. + --total-shards=<3> Split the test suite into this many shards and run only the one + selected by --shard-index. + Useful to parallelize tests across multiple CI runners. Run "very_good help" to see global options. ``` @@ -74,6 +80,48 @@ For **Dart** projects, use **`very_good dart test`** instead. All test parameters and options work identically with this command. ::: +### Sharding tests across CI runners + +When the test phase is your CI bottleneck, split the suite across several +runners with `--shard-index` and `--total-shards`. Each runner generates its own +optimized entrypoint containing only its slice of the test files, so the shards +together run every test exactly once. + +```yaml +jobs: + test: + strategy: + matrix: + shard: [1, 2, 3] + steps: + - run: very_good test --shard-index ${{ matrix.shard }} --total-shards 3 +``` + +Test files are sorted and then dealt out round-robin, which keeps the partition +deterministic: the same suite always produces the same shards, on every machine. +Files tagged with `skip_very_good_optimization` are sharded as well, so they do +not run on every runner. + +A shard with no test files (more shards than test files) succeeds without +running anything, so an oversized matrix will not fail your build. + +When combined with `--recursive`, each package is sharded independently, so a +given runner executes a slice of every package. Balance therefore degrades when +a workspace contains many packages with few tests each. + +:::caution +Sharding cannot be combined with `--min-coverage`. Each shard only exercises a +subset of the codebase, so its coverage is not representative of the whole +suite. Collect coverage per shard with `--coverage`, merge the resulting lcov +reports once every shard has finished, and enforce the threshold on the merged +report in a separate job. +::: + +:::info +Sharding requires the test optimizer, so it cannot be used with +`--no-optimization` or with `--platform` (which disables the optimizer). +::: + ### Passing Flutter specific arguments The `dart test` and `flutter test` commands expose more arguments than those available through `very_good dart test` or `very_good test`. However, you can use the argument terminator `--` to pass additional arguments directly to the underlying test commands. Any arguments after `--` will be forwarded to `dart test` or `flutter test`, making all their options available. diff --git a/test/src/cli/test_cli_runner_test.dart b/test/src/cli/test_cli_runner_test.dart index 0ad1e1744..2fdead1af 100644 --- a/test/src/cli/test_cli_runner_test.dart +++ b/test/src/cli/test_cli_runner_test.dart @@ -1083,6 +1083,163 @@ void main() { }, ); + test('passes shard vars to the generator', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function(Map vars)) + .call({ + 'package-root': tempDirectory.path, + 'tests': [ + {'path': 'a_test.dart', 'identifier': 'a'}, + ], + }); + }); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + logger: logger, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + buildGenerator: generatorBuilder(), + optimizePerformance: true, + shardIndex: 2, + totalShards: 3, + overrideTestRunner: testRunner( + Stream.fromIterable([ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ]), + ), + ), + completion(equals([ExitCode.success.code])), + ); + + verify( + () => hooks.preGen( + vars: { + 'package-root': tempDirectory.path, + 'shard-index': 2, + 'total-shards': 3, + }, + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: tempDirectory.path, + ), + ).called(1); + }); + + test('succeeds without running tests when the shard is empty', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function(Map vars)) + .call({ + 'package-root': tempDirectory.path, + 'tests': >[], + 'notOptimizedTests': [], + }); + }); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + logger: logger, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + buildGenerator: generatorBuilder(), + optimizePerformance: true, + shardIndex: 9, + totalShards: 9, + overrideTestRunner: testRunner(const Stream.empty()), + ), + completion(equals([ExitCode.success.code])), + ); + + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + 'No tests found for shard 9 in .\n', + ]), + ); + expect(testRunnerArgs, isEmpty); + }); + + test( + 'runs the shard when it only contains non optimized tests', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function(Map vars)) + .call({ + 'package-root': tempDirectory.path, + 'tests': >[], + 'notOptimizedTests': ['skip0_test.dart'], + }); + }); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + logger: logger, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + buildGenerator: generatorBuilder(), + optimizePerformance: true, + shardIndex: 2, + totalShards: 2, + overrideTestRunner: testRunner( + Stream.fromIterable([ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ]), + ), + ), + completion(equals([ExitCode.success.code])), + ); + + expect( + testRunnerArgs, + contains(p.join('test', 'skip0_test.dart')), + ); + }, + ); + test('runs tests w/optimizations (passing)', () async { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index 589183c29..5910ef444 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -50,6 +50,8 @@ const expectedTestUsage = [ ' --[no-]check-ignore Whether to check for and respect coverage ignore comments (e.g. // coverage:ignore-line).\n' ' (defaults to on)\n' ' --file-reporter= Enable an additional reporter writing test results to a file. Should be in the form : (e.g. "json:reports/tests.json").\n' + ' --shard-index=<1> The 1-based index of the shard to run. Must be used together with --total-shards. Requires optimization to be enabled.\n' + ' --total-shards=<3> Split the test suite into this many shards and run only the one selected by --shard-index. Useful to parallelize tests across multiple CI runners.\n' '\n' 'Run "very_good help" to see global options.', ]; @@ -75,6 +77,8 @@ abstract class DartTestCommandCall { void Function(String)? stderr, bool? forceAnsi, List? reportOn, + int? shardIndex, + int? totalShards, }); } @@ -122,6 +126,8 @@ void main() { stderr: any(named: 'stderr'), forceAnsi: any(named: 'forceAnsi'), reportOn: any(named: 'reportOn'), + shardIndex: any(named: 'shardIndex'), + totalShards: any(named: 'totalShards'), ), ).thenAnswer((_) async => [0]); when(() => argResults['concurrency']).thenReturn(concurrency); @@ -133,6 +139,8 @@ void main() { when(() => argResults['run-skipped']).thenReturn(false); when(() => argResults['optimization']).thenReturn(true); when(() => argResults['platform']).thenReturn(null); + when(() => argResults['shard-index']).thenReturn(null); + when(() => argResults['total-shards']).thenReturn(null); when( () => argResults['collect-coverage-from'], ).thenReturn('imports'); @@ -831,6 +839,90 @@ void main() { ).called(1); }); + group('sharding', () { + void withShards(String? index, String? total) { + when(() => argResults['shard-index']).thenReturn(index); + when(() => argResults['total-shards']).thenReturn(total); + } + + test('forwards shard values to the test runner', () async { + withShards('2', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + shardIndex: 2, + totalShards: 3, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('fails when only one shard option is provided', () async { + withShards('1', null); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index and --total-shards must be used together.', + ), + ).called(1); + }); + + test('fails when --shard-index exceeds --total-shards', () async { + withShards('4', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index (4) must be less than or equal to ' + '--total-shards (3).', + ), + ).called(1); + }); + + test('fails when sharding with optimization disabled', () async { + withShards('1', '3'); + when(() => argResults['optimization']).thenReturn(false); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Sharding requires optimization to be enabled. ' + 'Remove --no-optimization or --platform to use sharding.', + ), + ).called(1); + }); + + test('fails when sharding is combined with --min-coverage', () async { + withShards('1', '3'); + when(() => argResults['min-coverage']).thenReturn('100'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--min-coverage cannot be combined with sharding. Collect ' + 'coverage per shard with --coverage, merge the lcov reports, ' + 'then check the threshold in a separate job.', + ), + ).called(1); + }); + }); + group('very_good.yaml configuration', () { test( 'fails with exit code ${ExitCode.config.code} ' diff --git a/test/src/commands/test/test_test.dart b/test/src/commands/test/test_test.dart index 4dd2070ac..6d44867e7 100644 --- a/test/src/commands/test/test_test.dart +++ b/test/src/commands/test/test_test.dart @@ -53,6 +53,8 @@ const expectedTestUsage = [ ' --flavor Build a custom app flavor as defined by platform-specific build setup. Supports the use of product flavors in Android Gradle scripts, and the use of custom Xcode schemes.\n' ' --timeout= Maximum seconds to let tests run before killing the process. Useful when tests hang due to an unbounded pumpAndSettle() call.\n' ' --file-reporter= Enable an additional reporter writing test results to a file. Should be in the form : (e.g. "json:reports/tests.json").\n' + ' --shard-index=<1> The 1-based index of the shard to run. Must be used together with --total-shards. Requires optimization to be enabled.\n' + ' --total-shards=<3> Split the test suite into this many shards and run only the one selected by --shard-index. Useful to parallelize tests across multiple CI runners.\n' '\n' 'Run "very_good help" to see global options.', ]; @@ -77,6 +79,8 @@ abstract class FlutterTestCommand { void Function(String)? stderr, bool? forceAnsi, List? reportOn, + int? shardIndex, + int? totalShards, }); } @@ -123,6 +127,8 @@ void main() { stderr: any(named: 'stderr'), forceAnsi: any(named: 'forceAnsi'), reportOn: any(named: 'reportOn'), + shardIndex: any(named: 'shardIndex'), + totalShards: any(named: 'totalShards'), ), ).thenAnswer((_) async => [0]); when(() => argResults['concurrency']).thenReturn(concurrency); @@ -133,6 +139,8 @@ void main() { when(() => argResults['fail-fast']).thenReturn(false); when(() => argResults['optimization']).thenReturn(true); when(() => argResults['platform']).thenReturn(null); + when(() => argResults['shard-index']).thenReturn(null); + when(() => argResults['total-shards']).thenReturn(null); when(() => argResults['report-on']).thenReturn([]); when(() => argResults['run-skipped']).thenReturn(false); when(() => argResults['flavor']).thenReturn(null); @@ -937,6 +945,163 @@ void main() { ); }); + group('sharding', () { + void withShards(String? index, String? total) { + when(() => argResults['shard-index']).thenReturn(index); + when(() => argResults['total-shards']).thenReturn(total); + } + + test('forwards shard values to the test runner', () async { + withShards('2', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => flutterTest( + shardIndex: 2, + totalShards: 3, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('does not forward shard values when not sharding', () async { + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => flutterTest( + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('fails when --shard-index is used without --total-shards', () async { + withShards('1', null); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index and --total-shards must be used together.', + ), + ).called(1); + verifyNever( + () => flutterTest( + shardIndex: any(named: 'shardIndex'), + totalShards: any(named: 'totalShards'), + ), + ); + }); + + test('fails when --total-shards is used without --shard-index', () async { + withShards(null, '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index and --total-shards must be used together.', + ), + ).called(1); + }); + + test('fails when --shard-index exceeds --total-shards', () async { + withShards('4', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index (4) must be less than or equal to ' + '--total-shards (3).', + ), + ).called(1); + }); + + test('fails when --shard-index is not a positive integer', () async { + withShards('0', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index must be a positive integer, but got "0".', + ), + ).called(1); + }); + + test('fails when --shard-index is not a number', () async { + withShards('abc', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--shard-index must be a positive integer, but got "abc".', + ), + ).called(1); + }); + + test('fails when --total-shards is not a positive integer', () async { + withShards('1', '0'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--total-shards must be a positive integer, but got "0".', + ), + ).called(1); + }); + + test('fails when sharding with optimization disabled', () async { + withShards('1', '3'); + when(() => argResults['optimization']).thenReturn(false); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Sharding requires optimization to be enabled. ' + 'Remove --no-optimization or --platform to use sharding.', + ), + ).called(1); + }); + + test('fails when sharding is combined with --min-coverage', () async { + withShards('1', '3'); + when(() => argResults['min-coverage']).thenReturn('100'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '--min-coverage cannot be combined with sharding. Collect ' + 'coverage per shard with --coverage, merge the lcov reports, ' + 'then check the threshold in a separate job.', + ), + ).called(1); + }); + }); + group('very_good.yaml configuration', () { test( 'fails with exit code ${ExitCode.config.code} '