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 d2361e265..d7573a1b1 100644 --- a/bricks/test_optimizer/hooks/lib/pre_gen.dart +++ b/bricks/test_optimizer/hooks/lib/pre_gen.dart @@ -40,6 +40,22 @@ Future run(HookContext context) async { final flutterSdkRegExp = RegExp(r'sdk:\s*flutter$', multiLine: true); final isFlutter = flutterSdkRegExp.hasMatch(pubspecContents); + final shardIndex = context.vars['shard-index'] as int?; + final totalShards = context.vars['total-shards'] as int?; + + // The CLI validates these before it gets here, but `mason make` prompts for + // them directly, so guard the round-robin below against values that would + // never terminate or index out of range. + if (shardIndex != null && + totalShards != null && + (totalShards < 1 || shardIndex < 1 || shardIndex > totalShards)) { + context.logger.err( + 'shard-index must be between 1 and total-shards, but got ' + 'shard-index $shardIndex and total-shards $totalShards', + ); + exitFn(1); + } + final identifierGenerator = DartIdentifierGenerator(); final optimizedTests = >[]; final notOptimizedTests = []; @@ -48,13 +64,23 @@ Future run(HookContext context) async { .listSync(recursive: true) .where((entity) => entity.isTest) .cast(); - final parsedTests = await Future.wait(tests.map(_parse)); - - for (final (file, content, metadata) in parsedTests) { - final relativePath = path - .relative(file.path, from: testDir.path) - .replaceAll(r'\', '/'); - + final parsedTests = await Future.wait( + tests.map((file) => _parse(file, testDir: testDir.path)), + ); + + // 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. Tests kept out of the bundle are + // dealt out in the same round as the optimized ones, which keeps every + // shard within one file of the others. + final shard = _shardOf( + parsedTests..sort((a, b) => a.relativePath.compareTo(b.relativePath)), + shardIndex: shardIndex, + totalShards: totalShards, + ); + + for (final (:relativePath, :content, :metadata) in shard) { if (metadata.skipsOptimization) { notOptimizedTests.add(relativePath); continue; @@ -89,11 +115,39 @@ Future run(HookContext context) async { }; } -typedef _ParsedTest = (File file, String content, TestMetadata metadata); +typedef _ParsedTest = ({ + String relativePath, + String content, + TestMetadata metadata, +}); -Future<_ParsedTest> _parse(File file) async { +/// Reads and parses [file], keyed by its POSIX path relative to [testDir]. +Future<_ParsedTest> _parse(File file, {required String testDir}) async { final content = await file.readAsString(); - return (file, content, parseTestMetadata(content, path: file.path)); + return ( + relativePath: path.relative(file.path, from: testDir).replaceAll(r'\', '/'), + content: content, + metadata: parseTestMetadata(content, path: file.path), + ); +} + +/// Returns the subset of [items] that belongs to the shard [shardIndex] out of +/// [totalShards], or [items] unchanged when sharding is not enabled (either +/// value is `null`). +/// +/// Items are dealt out round-robin (index modulo [totalShards]) over the +/// already sorted [items], which keeps shards balanced in file count and makes +/// the partition stable for a given test suite. +List _shardOf( + List items, { + required int? shardIndex, + required int? totalShards, +}) { + if (shardIndex == null || totalShards == null) return items; + + return [ + for (var i = shardIndex - 1; i < items.length; i += totalShards) items[i], + ]; } extension on FileSystemEntity { diff --git a/bricks/test_optimizer/hooks/test/pre_gen_test.dart b/bricks/test_optimizer/hooks/test/pre_gen_test.dart index d86e17bb0..54f909d59 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:hooks/test_metadata.dart'; @@ -305,6 +306,33 @@ void main() {} expect(context.vars['isFlutter'], isNull); }); + test('when the shard values are out of range', () async { + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(path.join(tempDirectory.path, 'test')).createSync(); + + context.vars['package-root'] = tempDirectory.absolute.path; + context.vars['shard-index'] = 1; + context.vars['total-shards'] = 0; + + await expectLater( + () => pre_gen.run(context), + throwsA( + isA().having( + (ex) => ex.arguments.first, + 'error code', + equals('1'), + ), + ), + ); + + verify( + () => context.logger.err( + 'shard-index must be between 1 and total-shards, but got ' + 'shard-index 1 and total-shards 0', + ), + ).called(1); + }); + test('when target dir does not contain a pubspec.yaml', () async { final testDir = Directory(path.join(tempDirectory.path, 'test')) ..createSync(); @@ -412,5 +440,142 @@ void main() {} expect(metadata.skipsOptimization, isFalse); }); }); + 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('deals optimized and non optimized tests out together', () async { + createPackage(2, notOptimized: 3); + + final sizes = [ + for (var i = 1; i <= 6; i++) (await runShard(i, 6)).length, + ]; + + expect( + sizes, + [1, 1, 1, 1, 1, 0], + reason: + 'Sharding the two lists separately would give the first ' + 'shards a file from each while later shards stay empty', + ); + }); + + 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)); + }); + }); }); } diff --git a/lib/src/cli/dart_cli.dart b/lib/src/cli/dart_cli.dart index d31664405..ea0ee1715 100644 --- a/lib/src/cli/dart_cli.dart +++ b/lib/src/cli/dart_cli.dart @@ -137,6 +137,8 @@ class Dart { void Function(String)? stderr, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, List? reportOn, + int? shardIndex, + int? totalShards, }) { return TestCLIRunner.test( logger: logger, @@ -150,6 +152,8 @@ class Dart { enabled: optimizePerformance, exclude: excludeOptimization, buildGenerator: buildGenerator, + shardIndex: shardIndex, + totalShards: totalShards, ), ignore: ignore, minCoverage: minCoverage, diff --git a/lib/src/cli/flutter_cli.dart b/lib/src/cli/flutter_cli.dart index 9cedc920c..2a922c8f9 100644 --- a/lib/src/cli/flutter_cli.dart +++ b/lib/src/cli/flutter_cli.dart @@ -214,6 +214,8 @@ class Flutter { void Function(String)? stderr, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, List? reportOn, + int? shardIndex, + int? totalShards, }) { return TestCLIRunner.test( logger: logger, @@ -225,6 +227,8 @@ class Flutter { enabled: optimizePerformance, exclude: excludeOptimization, buildGenerator: buildGenerator, + shardIndex: shardIndex, + totalShards: totalShards, ), ignore: ignore, minCoverage: minCoverage, diff --git a/lib/src/cli/templates/test_optimizer_bundle.dart b/lib/src/cli/templates/test_optimizer_bundle.dart index 0cf3785c1..a555b4e36 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": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9kYXJ0X2lkZW50aWZpZXJfZ2VuZXJhdG9yLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6aG9va3MvdGVzdF9tZXRhZGF0YS5kYXJ0JzsKaW1wb3J0ICdwYWNrYWdlOm1hc29uL21hc29uLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6cGF0aC9wYXRoLmRhcnQnIGFzIHBhdGg7Cgp0eXBlZGVmIEV4aXRGbiA9IE5ldmVyIEZ1bmN0aW9uKGludCBjb2RlKTsKCkV4aXRGbiBleGl0Rm4gPSBleGl0OwoKLy8vIFRoZSB0YWcgdGhhdCBvcHRzIGEgdGVzdCBmaWxlIG91dCBvZiB0aGUgb3B0aW1pemVkIGJ1bmRsZS4KY29uc3Qgc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnID0gJ3NraXBfdmVyeV9nb29kX29wdGltaXphdGlvbic7CgpleHRlbnNpb24gVGVzdE1ldGFkYXRhQnVuZGxlIG9uIFRlc3RNZXRhZGF0YSB7CiAgYm9vbCBnZXQgc2tpcHNPcHRpbWl6YXRpb24gPT4gdGFnTmFtZXMuY29udGFpbnMoc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnKTsKCiAgLy8vIFRoZSBuYW1lZCBhcmd1bWVudHMgdG8gYXBwZW5kIHRvIGEgYGdyb3VwYCBjYWxsLCBlYWNoIHByZWZpeGVkIGJ5IGAsIGAuCiAgU3RyaW5nIGdldCBncm91cEFyZ3VtZW50cyB7CiAgICByZXR1cm4gYXJndW1lbnRzLmVudHJpZXMubWFwKChlKSA9PiAnLCAke2Uua2V5fTogJHtlLnZhbHVlfScpLmpvaW4oKTsKICB9Cn0KCkZ1dHVyZTx2b2lkPiBydW4oSG9va0NvbnRleHQgY29udGV4dCkgYXN5bmMgewogIGZpbmFsIHBhY2thZ2VSb290ID0gY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSBhcyBTdHJpbmc7CiAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4ocGFja2FnZVJvb3QsICd0ZXN0JykpOwoKICBpZiAoIXRlc3REaXIuZXhpc3RzU3luYygpKSB7CiAgICBjb250ZXh0LmxvZ2dlci5lcnIoJ0NvdWxkIG5vdCBmaW5kIGRpcmVjdG9yeSAke3Rlc3REaXIucGF0aH0nKTsKICAgIGV4aXRGbigxKTsKICB9CgogIGZpbmFsIHB1YnNwZWMgPSBGaWxlKHBhdGguam9pbihwYWNrYWdlUm9vdCwgJ3B1YnNwZWMueWFtbCcpKTsKICBpZiAoIXB1YnNwZWMuZXhpc3RzU3luYygpKSB7CiAgICBjb250ZXh0LmxvZ2dlci5lcnIoJ0NvdWxkIG5vdCBmaW5kIHB1YnNwZWMueWFtbCBhdCAke3Rlc3REaXIucGF0aH0nKTsKICAgIGV4aXRGbigxKTsKICB9CgogIGZpbmFsIHB1YnNwZWNDb250ZW50cyA9IGF3YWl0IHB1YnNwZWMucmVhZEFzU3RyaW5nKCk7CiAgZmluYWwgZmx1dHRlclNka1JlZ0V4cCA9IFJlZ0V4cChyJ3NkazpccypmbHV0dGVyJCcsIG11bHRpTGluZTogdHJ1ZSk7CiAgZmluYWwgaXNGbHV0dGVyID0gZmx1dHRlclNka1JlZ0V4cC5oYXNNYXRjaChwdWJzcGVjQ29udGVudHMpOwoKICBmaW5hbCBpZGVudGlmaWVyR2VuZXJhdG9yID0gRGFydElkZW50aWZpZXJHZW5lcmF0b3IoKTsKICBmaW5hbCBvcHRpbWl6ZWRUZXN0cyA9IDxNYXA8U3RyaW5nLCBTdHJpbmc+PltdOwogIGZpbmFsIG5vdE9wdGltaXplZFRlc3RzID0gPFN0cmluZz5bXTsKCiAgZmluYWwgdGVzdHMgPSB0ZXN0RGlyCiAgICAgIC5saXN0U3luYyhyZWN1cnNpdmU6IHRydWUpCiAgICAgIC53aGVyZSgoZW50aXR5KSA9PiBlbnRpdHkuaXNUZXN0KQogICAgICAuY2FzdDxGaWxlPigpOwogIGZpbmFsIHBhcnNlZFRlc3RzID0gYXdhaXQgRnV0dXJlLndhaXQodGVzdHMubWFwKF9wYXJzZSkpOwoKICBmb3IgKGZpbmFsIChmaWxlLCBjb250ZW50LCBtZXRhZGF0YSkgaW4gcGFyc2VkVGVzdHMpIHsKICAgIGZpbmFsIHJlbGF0aXZlUGF0aCA9IHBhdGgKICAgICAgICAucmVsYXRpdmUoZmlsZS5wYXRoLCBmcm9tOiB0ZXN0RGlyLnBhdGgpCiAgICAgICAgLnJlcGxhY2VBbGwocidcJywgJy8nKTsKCiAgICBpZiAobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24pIHsKICAgICAgbm90T3B0aW1pemVkVGVzdHMuYWRkKHJlbGF0aXZlUGF0aCk7CiAgICAgIGNvbnRpbnVlOwogICAgfQoKICAgIGlmIChjb250ZW50LmNvbnRhaW5zKHNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZykpIHsKICAgICAgY29udGV4dC5sb2dnZXIud2FybigKICAgICAgICAnJHJlbGF0aXZlUGF0aCBuYW1lcyAkc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnIGJ1dCB3YXMgb3B0aW1pemVkICcKICAgICAgICAnYW55d2F5OiBwYWNrYWdlOnRlc3QgcmVhZHMgQFRhZ3Mgb25seSBmcm9tIHRoZSBtZXRhZGF0YSBvZiB0aGUgJwogICAgICAgICJmaWxlJ3MgZmlyc3QgZGlyZWN0aXZlLiIsCiAgICAgICk7CiAgICB9CgogICAgZm9yIChmaW5hbCBhbm5vdGF0aW9uIGluIG1ldGFkYXRhLmRyb3BwZWRBbm5vdGF0aW9ucykgewogICAgICBjb250ZXh0LmxvZ2dlci53YXJuKAogICAgICAgICckcmVsYXRpdmVQYXRoOiBsZWZ0ICRhbm5vdGF0aW9uIG91dCBvZiB0aGUgb3B0aW1pemVkIGJ1bmRsZSwgd2hpY2ggJwogICAgICAgICdjYW5ub3QgcmVzb2x2ZSBldmVyeSBuYW1lIGl0IHJlZmVyZW5jZXMuJywKICAgICAgKTsKICAgIH0KCiAgICBvcHRpbWl6ZWRUZXN0cy5hZGQoewogICAgICAncGF0aCc6IHJlbGF0aXZlUGF0aCwKICAgICAgJ2lkZW50aWZpZXInOiBpZGVudGlmaWVyR2VuZXJhdG9yLm5leHQoKSwKICAgICAgJ2dyb3VwQXJndW1lbnRzJzogbWV0YWRhdGEuZ3JvdXBBcmd1bWVudHMsCiAgICB9KTsKICB9CgogIGNvbnRleHQudmFycyA9IHsKICAgICd0ZXN0cyc6IG9wdGltaXplZFRlc3RzLAogICAgJ2lzRmx1dHRlcic6IGlzRmx1dHRlciwKICAgICdub3RPcHRpbWl6ZWRUZXN0cyc6IG5vdE9wdGltaXplZFRlc3RzLAogIH07Cn0KCnR5cGVkZWYgX1BhcnNlZFRlc3QgPSAoRmlsZSBmaWxlLCBTdHJpbmcgY29udGVudCwgVGVzdE1ldGFkYXRhIG1ldGFkYXRhKTsKCkZ1dHVyZTxfUGFyc2VkVGVzdD4gX3BhcnNlKEZpbGUgZmlsZSkgYXN5bmMgewogIGZpbmFsIGNvbnRlbnQgPSBhd2FpdCBmaWxlLnJlYWRBc1N0cmluZygpOwogIHJldHVybiAoZmlsZSwgY29udGVudCwgcGFyc2VUZXN0TWV0YWRhdGEoY29udGVudCwgcGF0aDogZmlsZS5wYXRoKSk7Cn0KCmV4dGVuc2lvbiBvbiBGaWxlU3lzdGVtRW50aXR5IHsKICBib29sIGdldCBpc1Rlc3QgewogICAgcmV0dXJuIHRoaXMgaXMgRmlsZSAmJiBwYXRoLmJhc2VuYW1lKHRoaXMucGF0aCkuZW5kc1dpdGgoJ190ZXN0LmRhcnQnKTsKICB9Cn0K", + "data": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9kYXJ0X2lkZW50aWZpZXJfZ2VuZXJhdG9yLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6aG9va3MvdGVzdF9tZXRhZGF0YS5kYXJ0JzsKaW1wb3J0ICdwYWNrYWdlOm1hc29uL21hc29uLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6cGF0aC9wYXRoLmRhcnQnIGFzIHBhdGg7Cgp0eXBlZGVmIEV4aXRGbiA9IE5ldmVyIEZ1bmN0aW9uKGludCBjb2RlKTsKCkV4aXRGbiBleGl0Rm4gPSBleGl0OwoKLy8vIFRoZSB0YWcgdGhhdCBvcHRzIGEgdGVzdCBmaWxlIG91dCBvZiB0aGUgb3B0aW1pemVkIGJ1bmRsZS4KY29uc3Qgc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnID0gJ3NraXBfdmVyeV9nb29kX29wdGltaXphdGlvbic7CgpleHRlbnNpb24gVGVzdE1ldGFkYXRhQnVuZGxlIG9uIFRlc3RNZXRhZGF0YSB7CiAgYm9vbCBnZXQgc2tpcHNPcHRpbWl6YXRpb24gPT4gdGFnTmFtZXMuY29udGFpbnMoc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnKTsKCiAgLy8vIFRoZSBuYW1lZCBhcmd1bWVudHMgdG8gYXBwZW5kIHRvIGEgYGdyb3VwYCBjYWxsLCBlYWNoIHByZWZpeGVkIGJ5IGAsIGAuCiAgU3RyaW5nIGdldCBncm91cEFyZ3VtZW50cyB7CiAgICByZXR1cm4gYXJndW1lbnRzLmVudHJpZXMubWFwKChlKSA9PiAnLCAke2Uua2V5fTogJHtlLnZhbHVlfScpLmpvaW4oKTsKICB9Cn0KCkZ1dHVyZTx2b2lkPiBydW4oSG9va0NvbnRleHQgY29udGV4dCkgYXN5bmMgewogIGZpbmFsIHBhY2thZ2VSb290ID0gY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSBhcyBTdHJpbmc7CiAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4ocGFja2FnZVJvb3QsICd0ZXN0JykpOwoKICBpZiAoIXRlc3REaXIuZXhpc3RzU3luYygpKSB7CiAgICBjb250ZXh0LmxvZ2dlci5lcnIoJ0NvdWxkIG5vdCBmaW5kIGRpcmVjdG9yeSAke3Rlc3REaXIucGF0aH0nKTsKICAgIGV4aXRGbigxKTsKICB9CgogIGZpbmFsIHB1YnNwZWMgPSBGaWxlKHBhdGguam9pbihwYWNrYWdlUm9vdCwgJ3B1YnNwZWMueWFtbCcpKTsKICBpZiAoIXB1YnNwZWMuZXhpc3RzU3luYygpKSB7CiAgICBjb250ZXh0LmxvZ2dlci5lcnIoJ0NvdWxkIG5vdCBmaW5kIHB1YnNwZWMueWFtbCBhdCAke3Rlc3REaXIucGF0aH0nKTsKICAgIGV4aXRGbigxKTsKICB9CgogIGZpbmFsIHB1YnNwZWNDb250ZW50cyA9IGF3YWl0IHB1YnNwZWMucmVhZEFzU3RyaW5nKCk7CiAgZmluYWwgZmx1dHRlclNka1JlZ0V4cCA9IFJlZ0V4cChyJ3NkazpccypmbHV0dGVyJCcsIG11bHRpTGluZTogdHJ1ZSk7CiAgZmluYWwgaXNGbHV0dGVyID0gZmx1dHRlclNka1JlZ0V4cC5oYXNNYXRjaChwdWJzcGVjQ29udGVudHMpOwoKICBmaW5hbCBzaGFyZEluZGV4ID0gY29udGV4dC52YXJzWydzaGFyZC1pbmRleCddIGFzIGludD87CiAgZmluYWwgdG90YWxTaGFyZHMgPSBjb250ZXh0LnZhcnNbJ3RvdGFsLXNoYXJkcyddIGFzIGludD87CgogIC8vIFRoZSBDTEkgdmFsaWRhdGVzIHRoZXNlIGJlZm9yZSBpdCBnZXRzIGhlcmUsIGJ1dCBgbWFzb24gbWFrZWAgcHJvbXB0cyBmb3IKICAvLyB0aGVtIGRpcmVjdGx5LCBzbyBndWFyZCB0aGUgcm91bmQtcm9iaW4gYmVsb3cgYWdhaW5zdCB2YWx1ZXMgdGhhdCB3b3VsZAogIC8vIG5ldmVyIHRlcm1pbmF0ZSBvciBpbmRleCBvdXQgb2YgcmFuZ2UuCiAgaWYgKHNoYXJkSW5kZXggIT0gbnVsbCAmJgogICAgICB0b3RhbFNoYXJkcyAhPSBudWxsICYmCiAgICAgICh0b3RhbFNoYXJkcyA8IDEgfHwgc2hhcmRJbmRleCA8IDEgfHwgc2hhcmRJbmRleCA+IHRvdGFsU2hhcmRzKSkgewogICAgY29udGV4dC5sb2dnZXIuZXJyKAogICAgICAnc2hhcmQtaW5kZXggbXVzdCBiZSBiZXR3ZWVuIDEgYW5kIHRvdGFsLXNoYXJkcywgYnV0IGdvdCAnCiAgICAgICdzaGFyZC1pbmRleCAkc2hhcmRJbmRleCBhbmQgdG90YWwtc2hhcmRzICR0b3RhbFNoYXJkcycsCiAgICApOwogICAgZXhpdEZuKDEpOwogIH0KCiAgZmluYWwgaWRlbnRpZmllckdlbmVyYXRvciA9IERhcnRJZGVudGlmaWVyR2VuZXJhdG9yKCk7CiAgZmluYWwgb3B0aW1pemVkVGVzdHMgPSA8TWFwPFN0cmluZywgU3RyaW5nPj5bXTsKICBmaW5hbCBub3RPcHRpbWl6ZWRUZXN0cyA9IDxTdHJpbmc+W107CgogIGZpbmFsIHRlc3RzID0gdGVzdERpcgogICAgICAubGlzdFN5bmMocmVjdXJzaXZlOiB0cnVlKQogICAgICAud2hlcmUoKGVudGl0eSkgPT4gZW50aXR5LmlzVGVzdCkKICAgICAgLmNhc3Q8RmlsZT4oKTsKICBmaW5hbCBwYXJzZWRUZXN0cyA9IGF3YWl0IEZ1dHVyZS53YWl0KAogICAgdGVzdHMubWFwKChmaWxlKSA9PiBfcGFyc2UoZmlsZSwgdGVzdERpcjogdGVzdERpci5wYXRoKSksCiAgKTsKCiAgLy8gU29ydGluZyBndWFyYW50ZWVzIGEgZGV0ZXJtaW5pc3RpYyBvcmRlciBhY3Jvc3MgbWFjaGluZXMsIHdoaWNoIGlzIHdoYXQKICAvLyBtYWtlcyBzaGFyZGluZyByZXByb2R1Y2libGU6IGBEaXJlY3RvcnkubGlzdFN5bmNgIG9yZGVyIGlzIGZpbGVzeXN0ZW0KICAvLyBkZXBlbmRlbnQsIHNvIHdpdGhvdXQgdGhpcyB0d28gcnVubmVycyBjb3VsZCBkaXNhZ3JlZSBvbiB0aGUgcGFydGl0aW9uCiAgLy8gYW5kIGVpdGhlciBza2lwIG9yIGR1cGxpY2F0ZSB0ZXN0cy4gVGVzdHMga2VwdCBvdXQgb2YgdGhlIGJ1bmRsZSBhcmUKICAvLyBkZWFsdCBvdXQgaW4gdGhlIHNhbWUgcm91bmQgYXMgdGhlIG9wdGltaXplZCBvbmVzLCB3aGljaCBrZWVwcyBldmVyeQogIC8vIHNoYXJkIHdpdGhpbiBvbmUgZmlsZSBvZiB0aGUgb3RoZXJzLgogIGZpbmFsIHNoYXJkID0gX3NoYXJkT2YoCiAgICBwYXJzZWRUZXN0cy4uc29ydCgoYSwgYikgPT4gYS5yZWxhdGl2ZVBhdGguY29tcGFyZVRvKGIucmVsYXRpdmVQYXRoKSksCiAgICBzaGFyZEluZGV4OiBzaGFyZEluZGV4LAogICAgdG90YWxTaGFyZHM6IHRvdGFsU2hhcmRzLAogICk7CgogIGZvciAoZmluYWwgKDpyZWxhdGl2ZVBhdGgsIDpjb250ZW50LCA6bWV0YWRhdGEpIGluIHNoYXJkKSB7CiAgICBpZiAobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24pIHsKICAgICAgbm90T3B0aW1pemVkVGVzdHMuYWRkKHJlbGF0aXZlUGF0aCk7CiAgICAgIGNvbnRpbnVlOwogICAgfQoKICAgIGlmIChjb250ZW50LmNvbnRhaW5zKHNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZykpIHsKICAgICAgY29udGV4dC5sb2dnZXIud2FybigKICAgICAgICAnJHJlbGF0aXZlUGF0aCBuYW1lcyAkc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnIGJ1dCB3YXMgb3B0aW1pemVkICcKICAgICAgICAnYW55d2F5OiBwYWNrYWdlOnRlc3QgcmVhZHMgQFRhZ3Mgb25seSBmcm9tIHRoZSBtZXRhZGF0YSBvZiB0aGUgJwogICAgICAgICJmaWxlJ3MgZmlyc3QgZGlyZWN0aXZlLiIsCiAgICAgICk7CiAgICB9CgogICAgZm9yIChmaW5hbCBhbm5vdGF0aW9uIGluIG1ldGFkYXRhLmRyb3BwZWRBbm5vdGF0aW9ucykgewogICAgICBjb250ZXh0LmxvZ2dlci53YXJuKAogICAgICAgICckcmVsYXRpdmVQYXRoOiBsZWZ0ICRhbm5vdGF0aW9uIG91dCBvZiB0aGUgb3B0aW1pemVkIGJ1bmRsZSwgd2hpY2ggJwogICAgICAgICdjYW5ub3QgcmVzb2x2ZSBldmVyeSBuYW1lIGl0IHJlZmVyZW5jZXMuJywKICAgICAgKTsKICAgIH0KCiAgICBvcHRpbWl6ZWRUZXN0cy5hZGQoewogICAgICAncGF0aCc6IHJlbGF0aXZlUGF0aCwKICAgICAgJ2lkZW50aWZpZXInOiBpZGVudGlmaWVyR2VuZXJhdG9yLm5leHQoKSwKICAgICAgJ2dyb3VwQXJndW1lbnRzJzogbWV0YWRhdGEuZ3JvdXBBcmd1bWVudHMsCiAgICB9KTsKICB9CgogIGNvbnRleHQudmFycyA9IHsKICAgICd0ZXN0cyc6IG9wdGltaXplZFRlc3RzLAogICAgJ2lzRmx1dHRlcic6IGlzRmx1dHRlciwKICAgICdub3RPcHRpbWl6ZWRUZXN0cyc6IG5vdE9wdGltaXplZFRlc3RzLAogIH07Cn0KCnR5cGVkZWYgX1BhcnNlZFRlc3QgPSAoewogIFN0cmluZyByZWxhdGl2ZVBhdGgsCiAgU3RyaW5nIGNvbnRlbnQsCiAgVGVzdE1ldGFkYXRhIG1ldGFkYXRhLAp9KTsKCi8vLyBSZWFkcyBhbmQgcGFyc2VzIFtmaWxlXSwga2V5ZWQgYnkgaXRzIFBPU0lYIHBhdGggcmVsYXRpdmUgdG8gW3Rlc3REaXJdLgpGdXR1cmU8X1BhcnNlZFRlc3Q+IF9wYXJzZShGaWxlIGZpbGUsIHtyZXF1aXJlZCBTdHJpbmcgdGVzdERpcn0pIGFzeW5jIHsKICBmaW5hbCBjb250ZW50ID0gYXdhaXQgZmlsZS5yZWFkQXNTdHJpbmcoKTsKICByZXR1cm4gKAogICAgcmVsYXRpdmVQYXRoOiBwYXRoLnJlbGF0aXZlKGZpbGUucGF0aCwgZnJvbTogdGVzdERpcikucmVwbGFjZUFsbChyJ1wnLCAnLycpLAogICAgY29udGVudDogY29udGVudCwKICAgIG1ldGFkYXRhOiBwYXJzZVRlc3RNZXRhZGF0YShjb250ZW50LCBwYXRoOiBmaWxlLnBhdGgpLAogICk7Cn0KCi8vLyBSZXR1cm5zIHRoZSBzdWJzZXQgb2YgW2l0ZW1zXSB0aGF0IGJlbG9uZ3MgdG8gdGhlIHNoYXJkIFtzaGFyZEluZGV4XSBvdXQgb2YKLy8vIFt0b3RhbFNoYXJkc10sIG9yIFtpdGVtc10gdW5jaGFuZ2VkIHdoZW4gc2hhcmRpbmcgaXMgbm90IGVuYWJsZWQgKGVpdGhlcgovLy8gdmFsdWUgaXMgYG51bGxgKS4KLy8vCi8vLyBJdGVtcyBhcmUgZGVhbHQgb3V0IHJvdW5kLXJvYmluIChpbmRleCBtb2R1bG8gW3RvdGFsU2hhcmRzXSkgb3ZlciB0aGUKLy8vIGFscmVhZHkgc29ydGVkIFtpdGVtc10sIHdoaWNoIGtlZXBzIHNoYXJkcyBiYWxhbmNlZCBpbiBmaWxlIGNvdW50IGFuZCBtYWtlcwovLy8gdGhlIHBhcnRpdGlvbiBzdGFibGUgZm9yIGEgZ2l2ZW4gdGVzdCBzdWl0ZS4KTGlzdDxUPiBfc2hhcmRPZjxUPigKICBMaXN0PFQ+IGl0ZW1zLCB7CiAgcmVxdWlyZWQgaW50PyBzaGFyZEluZGV4LAogIHJlcXVpcmVkIGludD8gdG90YWxTaGFyZHMsCn0pIHsKICBpZiAoc2hhcmRJbmRleCA9PSBudWxsIHx8IHRvdGFsU2hhcmRzID09IG51bGwpIHJldHVybiBpdGVtczsKCiAgcmV0dXJuIFsKICAgIGZvciAodmFyIGkgPSBzaGFyZEluZGV4IC0gMTsgaSA8IGl0ZW1zLmxlbmd0aDsgaSArPSB0b3RhbFNoYXJkcykgaXRlbXNbaV0sCiAgXTsKfQoKZXh0ZW5zaW9uIG9uIEZpbGVTeXN0ZW1FbnRpdHkgewogIGJvb2wgZ2V0IGlzVGVzdCB7CiAgICByZXR1cm4gdGhpcyBpcyBGaWxlICYmIHBhdGguYmFzZW5hbWUodGhpcy5wYXRoKS5lbmRzV2l0aCgnX3Rlc3QuZGFydCcpOwogIH0KfQo=", "type": "text", }, { @@ -45,7 +45,7 @@ final testOptimizerBundle = MasonBundle.fromJson({ }, { "path": "test/pre_gen_test.dart", - "data": "aW1wb3J0ICdkYXJ0OmlvJzsKCmltcG9ydCAncGFja2FnZTpob29rcy9wcmVfZ2VuLmRhcnQnIGFzIHByZV9nZW47CmltcG9ydCAncGFja2FnZTpob29rcy90ZXN0X21ldGFkYXRhLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6bWFzb24vbWFzb24uZGFydCc7CmltcG9ydCAncGFja2FnZTptb2NrdGFpbC9tb2NrdGFpbC5kYXJ0JzsKaW1wb3J0ICdwYWNrYWdlOnBhdGgvcGF0aC5kYXJ0JyBhcyBwYXRoOwppbXBvcnQgJ3BhY2thZ2U6dGVzdC90ZXN0LmRhcnQnOwoKY2xhc3MgX01vY2tMb2dnZXIgZXh0ZW5kcyBNb2NrIGltcGxlbWVudHMgTG9nZ2VyOwoKY2xhc3MgX0Zha2VDb250ZXh0IGV4dGVuZHMgRmFrZSBpbXBsZW1lbnRzIEhvb2tDb250ZXh0IHsKICBAb3ZlcnJpZGUKICBmaW5hbCBsb2dnZXIgPSBfTW9ja0xvZ2dlcigpOwoKICBAb3ZlcnJpZGUKICBNYXA8U3RyaW5nLCBPYmplY3Q/PiB2YXJzID0ge307Cn0KCmNvbnN0IG5vdE9wdGltaXplZFRlc3RDb250ZW50ID0KICAgICcnJwpAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9J10pCmxpYnJhcnk7Cgp2b2lkIG1haW4oKSB7CiAgdGVzdCgndGVzdCcsICgpIHsKICAgIGV4cGVjdCgxLCAxKTsKICB9KTsKfQonJyc7Cgpjb25zdCBhbm90aGVyTm90T3B0aW1pemVkVGVzdENvbnRlbnQgPQogICAgJycnCkBUYWdzKFsnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nLCAnYW5vdGhlcl90YWcnXSkKaW1wb3J0ICdwYWNrYWdlOnRlc3QvdGVzdC5kYXJ0JzsKCnZvaWQgbWFpbigpIHsKICB0ZXN0KCdhbm90aGVyIHRlc3QnLCAoKSB7CiAgICBleHBlY3QoMSwgMSk7CiAgfSk7Cn0KJycnOwoKdm9pZCBtYWluKCkgewogIGxhdGUgRGlyZWN0b3J5IHRlbXBEaXJlY3Rvcnk7CgogIHNldFVwKCgpIHsKICAgIHRlbXBEaXJlY3RvcnkgPSBEaXJlY3Rvcnkuc3lzdGVtVGVtcC5jcmVhdGVUZW1wU3luYygndGVzdF9vcHRpbWl6ZXInKTsKICB9KTsKCiAgdGVhckRvd24oKCkgewogICAgdGVtcERpcmVjdG9yeS5kZWxldGVTeW5jKHJlY3Vyc2l2ZTogdHJ1ZSk7CiAgfSk7CgogIGdyb3VwKCdQcmUgZ2VuIGhvb2snLCAoKSB7CiAgICBsYXRlIEhvb2tDb250ZXh0IGNvbnRleHQ7CgogICAgc2V0VXAoKCkgewogICAgICBjb250ZXh0ID0gX0Zha2VDb250ZXh0KCk7CiAgICB9KTsKCiAgICBncm91cCgnQ29tcGxldGVzJywgKCkgewogICAgICB0ZXN0KCd3aXRoIHRlc3QgZmlsZXMgbGlzdCcsICgpIGFzeW5jIHsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0MV90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QyX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnbm9fdGVzdF9oZXJlLmRhcnQnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgZmluYWwgdGVzdHMgPSBjb250ZXh0LnZhcnNbJ3Rlc3RzJ10gYXMgTGlzdDxNYXA8U3RyaW5nLCBTdHJpbmc+PjsKICAgICAgICBmaW5hbCB0ZXN0c01hcCA9IDxTdHJpbmcsIFN0cmluZz57fTsKICAgICAgICBmb3IgKGZpbmFsIHRlc3QgaW4gdGVzdHMpIHsKICAgICAgICAgIGZpbmFsIHBhdGggPSB0ZXN0WydwYXRoJ10hOwogICAgICAgICAgZmluYWwgaWRlbnRpZmllciA9IHRlc3RbJ2lkZW50aWZpZXInXSE7CiAgICAgICAgICB0ZXN0c01hcFtwYXRoXSA9IGlkZW50aWZpZXI7CiAgICAgICAgfQoKICAgICAgICBmaW5hbCBwYXRocyA9IHRlc3RzTWFwLmtleXM7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDFfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgY29udGFpbnMoJ3Rlc3QyX3Rlc3QuZGFydCcpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGlzTm90KGNvbnRhaW5zKCdub190ZXN0X2hlcmUuZGFydCcpKSk7CgogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHRlc3RzTWFwLnZhbHVlcy50b1NldCgpLmxlbmd0aCwKICAgICAgICAgIGVxdWFscyh0ZXN0cy5sZW5ndGgpLAogICAgICAgICAgcmVhc29uOiAnQWxsIHRlc3RzIGZpbGVzIHNob3VsZCBoYXZlIHVuaXF1ZSBpZGVudGlmaWVycycsCiAgICAgICAgKTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgdGVzdHMubWFwKCh0ZXN0KSA9PiB0ZXN0Wydncm91cEFyZ3VtZW50cyddKSwKICAgICAgICAgIGV2ZXJ5RWxlbWVudChpc0VtcHR5KSwKICAgICAgICAgIHJlYXNvbjogJ0ZpbGVzIHdpdGhvdXQgYW5ub3RhdGlvbnMgZm9yd2FyZCBubyBncm91cCBhcmd1bWVudHMnLAogICAgICAgICk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCBmYWxzZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2l0aCBwcm9wZXIgaXNGbHV0dGVyIGlkZW50aWZpY2F0aW9uJywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKQogICAgICAgICAgLi5jcmVhdGVTeW5jKCkKICAgICAgICAgIC4ud3JpdGVBc1N0cmluZ1N5bmMoJycnCmRlcGVuZGVuY2llczoKICBmbHV0dGVyOgogICAgc2RrOiBmbHV0dGVyJycnKTsKCiAgICAgICAgRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgcHJlX2dlbi5ydW4oY29udGV4dCk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCB0cnVlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCd3aXRoIHByb3BlciBub3Qgb3B0aW1pemVkIHRlc3RzIGlkZW50aWZpY2F0aW9uJywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSkKICAgICAgICAgICAgLndyaXRlQXNTdHJpbmdTeW5jKG5vdE9wdGltaXplZFRlc3RDb250ZW50KTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdhbm90aGVyX25vdF9vcHRpbWl6ZWRfdGVzdC5kYXJ0JykpCiAgICAgICAgICAgIC53cml0ZUFzU3RyaW5nU3luYyhhbm90aGVyTm90T3B0aW1pemVkVGVzdENvbnRlbnQpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgZmluYWwgdGVzdHMgPSBjb250ZXh0LnZhcnNbJ3Rlc3RzJ10gYXMgTGlzdDxNYXA8U3RyaW5nLCBTdHJpbmc+PjsKICAgICAgICBmaW5hbCB0ZXN0c01hcCA9IDxTdHJpbmcsIFN0cmluZz57fTsKICAgICAgICBmb3IgKGZpbmFsIHRlc3QgaW4gdGVzdHMpIHsKICAgICAgICAgIGZpbmFsIHBhdGggPSB0ZXN0WydwYXRoJ10hOwogICAgICAgICAgZmluYWwgaWRlbnRpZmllciA9IHRlc3RbJ2lkZW50aWZpZXInXSE7CiAgICAgICAgICB0ZXN0c01hcFtwYXRoXSA9IGlkZW50aWZpZXI7CiAgICAgICAgfQoKICAgICAgICBmaW5hbCBwYXRocyA9IHRlc3RzTWFwLmtleXM7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDFfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgY29udGFpbnMoJ3Rlc3QyX3Rlc3QuZGFydCcpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGlzTm90KGNvbnRhaW5zKCdub190ZXN0X2hlcmUuZGFydCcpKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBpc05vdChjb250YWlucygnbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSkpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ2Fub3RoZXJfbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSkpOwoKICAgICAgICBleHBlY3QoCiAgICAgICAgICB0ZXN0c01hcC52YWx1ZXMudG9TZXQoKS5sZW5ndGgsCiAgICAgICAgICBlcXVhbHModGVzdHMubGVuZ3RoKSwKICAgICAgICAgIHJlYXNvbjogJ0FsbCB0ZXN0cyBmaWxlcyBzaG91bGQgaGF2ZSB1bmlxdWUgaWRlbnRpZmllcnMnLAogICAgICAgICk7CiAgICAgICAgZmluYWwgbm90T3B0aW1pemVkVGVzdHMgPQogICAgICAgICAgICBjb250ZXh0LnZhcnNbJ25vdE9wdGltaXplZFRlc3RzJ10gYXMgTGlzdDxTdHJpbmc+OwogICAgICAgIGV4cGVjdChub3RPcHRpbWl6ZWRUZXN0cywgY29udGFpbnMoJ25vdF9vcHRpbWl6ZWRfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChub3RPcHRpbWl6ZWRUZXN0cywgY29udGFpbnMoJ2Fub3RoZXJfbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgKICAgICAgICAnd2l0aCBmaWxlIGxldmVsIGFubm90YXRpb25zIGZvcndhcmRlZCBhcyBncm91cCBhcmd1bWVudHMnLAogICAgICAgICgpIGFzeW5jIHsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ2Fubm90YXRlZF90ZXN0LmRhcnQnKSkKICAgICAgICAgICAgICAud3JpdGVBc1N0cmluZ1N5bmMoJycnCkBUYWdzKFsnc2xvdyddKQpAVGltZW91dChEdXJhdGlvbihtaW51dGVzOiA1KSkKbGlicmFyeTsKCnZvaWQgbWFpbigpIHt9CicnJyk7CgogICAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgICAgZXhwZWN0KHRlc3RzLCBoYXNMZW5ndGgoMSkpOwogICAgICAgICAgZXhwZWN0KAogICAgICAgICAgICB0ZXN0cy5zaW5nbGVbJ2dyb3VwQXJndW1lbnRzJ10sCiAgICAgICAgICAgIGVxdWFscygiLCB0aW1lb3V0OiBUaW1lb3V0KER1cmF0aW9uKG1pbnV0ZXM6IDUpKSwgdGFnczogWydzbG93J10iKSwKICAgICAgICAgICk7CiAgICAgICAgfSwKICAgICAgKTsKCiAgICAgIHRlc3QoCiAgICAgICAgJ3dpdGggYSBza2lwIHRhZyBwYWNrYWdlOnRlc3Qgd291bGQgaWdub3JlIGxlZnQgb3B0aW1pemVkJywKICAgICAgICAoKSBhc3luYyB7CiAgICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub19kaXJlY3RpdmVfdGVzdC5kYXJ0JykpCiAgICAgICAgICAgICAgLndyaXRlQXNTdHJpbmdTeW5jKCcnJwpAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9J10pCnZvaWQgbWFpbigpIHt9CicnJyk7CgogICAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgICAgZXhwZWN0KAogICAgICAgICAgICB0ZXN0cy5tYXAoKHRlc3QpID0+IHRlc3RbJ3BhdGgnXSksCiAgICAgICAgICAgIGNvbnRhaW5zKCdub19kaXJlY3RpdmVfdGVzdC5kYXJ0JyksCiAgICAgICAgICApOwogICAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snbm90T3B0aW1pemVkVGVzdHMnXSwgaXNFbXB0eSk7CgogICAgICAgICAgdmVyaWZ5KAogICAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci53YXJuKAogICAgICAgICAgICAgIGFueSgKICAgICAgICAgICAgICAgIHRoYXQ6IGFsbE9mKAogICAgICAgICAgICAgICAgICBjb250YWlucygnbm9fZGlyZWN0aXZlX3Rlc3QuZGFydCcpLAogICAgICAgICAgICAgICAgICBjb250YWlucyhwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZyksCiAgICAgICAgICAgICAgICApLAogICAgICAgICAgICAgICksCiAgICAgICAgICAgICksCiAgICAgICAgICApLmNhbGxlZCgxKTsKICAgICAgICB9LAogICAgICApOwoKICAgICAgdGVzdCgKICAgICAgICAnd2l0aCBhbiB1bnJlc29sdmFibGUgYW5ub3RhdGlvbiBsZWZ0IG91dCBhbmQgd2FybmVkIGFib3V0JywKICAgICAgICAoKSBhc3luYyB7CiAgICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd1bnJlc29sdmFibGVfdGVzdC5kYXJ0JykpCiAgICAgICAgICAgICAgLndyaXRlQXNTdHJpbmdTeW5jKCcnJwpAVGltZW91dChrU2xvd1N1aXRlKQpAUmV0cnkoMikKbGlicmFyeTsKCnZvaWQgbWFpbigpIHt9CicnJyk7CgogICAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgICAgZXhwZWN0KAogICAgICAgICAgICB0ZXN0cy5zaW5nbGVbJ2dyb3VwQXJndW1lbnRzJ10sCiAgICAgICAgICAgIGVxdWFscygnLCByZXRyeTogMicpLAogICAgICAgICAgICByZWFzb246ICdUaGUgcmVzb2x2YWJsZSBzaWJsaW5nIGlzIHN0aWxsIGZvcndhcmRlZCcsCiAgICAgICAgICApOwoKICAgICAgICAgIHZlcmlmeSgKICAgICAgICAgICAgKCkgPT4gY29udGV4dC5sb2dnZXIud2FybigKICAgICAgICAgICAgICBhbnkoCiAgICAgICAgICAgICAgICB0aGF0OiBhbGxPZigKICAgICAgICAgICAgICAgICAgY29udGFpbnMoJ3VucmVzb2x2YWJsZV90ZXN0LmRhcnQnKSwKICAgICAgICAgICAgICAgICAgY29udGFpbnMoJ0BUaW1lb3V0KGtTbG93U3VpdGUpJyksCiAgICAgICAgICAgICAgICApLAogICAgICAgICAgICAgICksCiAgICAgICAgICAgICksCiAgICAgICAgICApLmNhbGxlZCgxKTsKICAgICAgICB9LAogICAgICApOwogICAgfSk7CgogICAgZ3JvdXAoJ0ZhaWxzJywgKCkgewogICAgICBzZXRVcCgoKSB7CiAgICAgICAgcHJlX2dlbi5leGl0Rm4gPSAoY29kZSkgewogICAgICAgICAgdGhyb3cgUHJvY2Vzc0V4Y2VwdGlvbignZXhpdCcsIFtjb2RlLnRvU3RyaW5nKCldKTsKICAgICAgICB9OwogICAgICB9KTsKCiAgICAgIHRlYXJEb3duKCgpIHsKICAgICAgICBwcmVfZ2VuLmV4aXRGbiA9IGV4aXQ7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2hlbiB0YXJnZXQgdGVzdCBkaXIgZG9lcyBub3QgZXhpc3QnLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgZXhwZWN0TGF0ZXIoCiAgICAgICAgICAoKSA9PiBwcmVfZ2VuLnJ1bihjb250ZXh0KSwKICAgICAgICAgIHRocm93c0EoCiAgICAgICAgICAgIGlzQTxQcm9jZXNzRXhjZXB0aW9uPigpLmhhdmluZygKICAgICAgICAgICAgICAoZXgpID0+IGV4LmFyZ3VtZW50cy5maXJzdCwKICAgICAgICAgICAgICAnZXJyb3IgY29kZScsCiAgICAgICAgICAgICAgZXF1YWxzKCcxJyksCiAgICAgICAgICAgICksCiAgICAgICAgICApLAogICAgICAgICk7CgogICAgICAgIHZlcmlmeSgKICAgICAgICAgICgpID0+IGNvbnRleHQubG9nZ2VyLmVycignQ291bGQgbm90IGZpbmQgZGlyZWN0b3J5ICR7dGVzdERpci5wYXRofScpLAogICAgICAgICkuY2FsbGVkKDEpOwoKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWyd0ZXN0cyddLCBpc051bGwpOwogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ2lzRmx1dHRlciddLCBpc051bGwpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3doZW4gdGFyZ2V0IGRpciBkb2VzIG5vdCBjb250YWluIGEgcHVic3BlYy55YW1sJywgKCkgYXN5bmMgewogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IGV4cGVjdExhdGVyKAogICAgICAgICAgKCkgPT4gcHJlX2dlbi5ydW4oY29udGV4dCksCiAgICAgICAgICB0aHJvd3NBKAogICAgICAgICAgICBpc0E8UHJvY2Vzc0V4Y2VwdGlvbj4oKS5oYXZpbmcoCiAgICAgICAgICAgICAgKGV4KSA9PiBleC5hcmd1bWVudHMuZmlyc3QsCiAgICAgICAgICAgICAgJ2Vycm9yIGNvZGUnLAogICAgICAgICAgICAgIGVxdWFscygnMScpLAogICAgICAgICAgICApLAogICAgICAgICAgKSwKICAgICAgICApOwoKICAgICAgICB2ZXJpZnkoCiAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci5lcnIoCiAgICAgICAgICAgICdDb3VsZCBub3QgZmluZCBwdWJzcGVjLnlhbWwgYXQgJHt0ZXN0RGlyLnBhdGh9JywKICAgICAgICAgICksCiAgICAgICAgKS5jYWxsZWQoMSk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ3Rlc3RzJ10sIGlzTnVsbCk7CiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snaXNGbHV0dGVyJ10sIGlzTnVsbCk7CiAgICAgIH0pOwogICAgfSk7CiAgfSk7CgogIGdyb3VwKCdUZXN0TWV0YWRhdGFCdW5kbGUnLCAoKSB7CiAgICBncm91cCgnZ3JvdXBBcmd1bWVudHMnLCAoKSB7CiAgICAgIHRlc3QoJ2lzIGVtcHR5IHdpdGhvdXQgYW5ub3RhdGlvbnMnLCAoKSB7CiAgICAgICAgZmluYWwgbWV0YWRhdGEgPSBwYXJzZVRlc3RNZXRhZGF0YSgndm9pZCBtYWluKCkge30nKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLmdyb3VwQXJndW1lbnRzLCBpc0VtcHR5KTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdlbWl0cyBhIHNpbmdsZSBhbm5vdGF0aW9uJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoIkBTa2lwKCdub3QgcmVhZHknKVxubGlicmFyeTsiKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLmdyb3VwQXJndW1lbnRzLCBlcXVhbHMoIiwgc2tpcDogJ25vdCByZWFkeSciKSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnZW1pdHMgYSBmaXhlZCBvcmRlciByZWdhcmRsZXNzIG9mIHNvdXJjZSBvcmRlcicsICgpIHsKICAgICAgICBmaW5hbCBtZXRhZGF0YSA9IHBhcnNlVGVzdE1ldGFkYXRhKCcnJwpAUmV0cnkoMykKQFRhZ3MoWydzbG93J10pCkBTa2lwKCkKQFRpbWVvdXQubm9uZQpAVGVzdE9uKCd2bScpCmxpYnJhcnk7CicnJyk7CgogICAgICAgIGV4cGVjdCgKICAgICAgICAgIG1ldGFkYXRhLmdyb3VwQXJndW1lbnRzLAogICAgICAgICAgZXF1YWxzKAogICAgICAgICAgICAiLCB0ZXN0T246ICd2bScsIHRpbWVvdXQ6IFRpbWVvdXQubm9uZSwgc2tpcDogdHJ1ZSwgIgogICAgICAgICAgICAidGFnczogWydzbG93J10sIHJldHJ5OiAzIiwKICAgICAgICAgICksCiAgICAgICAgKTsKICAgICAgfSk7CiAgICB9KTsKCiAgICBncm91cCgnc2tpcHNPcHRpbWl6YXRpb24nLCAoKSB7CiAgICAgIHRlc3QoJ2lzIHRydWUgZm9yIHRoZSBza2lwIHRhZyBvbiBpdHMgb3duJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoCiAgICAgICAgICAiQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSddKVxubGlicmFyeTsiLAogICAgICAgICk7CgogICAgICAgIGV4cGVjdChtZXRhZGF0YS5za2lwc09wdGltaXphdGlvbiwgaXNUcnVlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdpcyB0cnVlIHdoZW4gdGhlIHNraXAgdGFnIGlzIG5vdCBmaXJzdCcsICgpIHsKICAgICAgICBmaW5hbCBtZXRhZGF0YSA9IHBhcnNlVGVzdE1ldGFkYXRhKAogICAgICAgICAgIkBUYWdzKFsnb3RoZXInLCAnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nXSlcbiIKICAgICAgICAgICdsaWJyYXJ5OycsCiAgICAgICAgKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLnNraXBzT3B0aW1pemF0aW9uLCBpc1RydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2lzIGZhbHNlIHdpdGhvdXQgdGhlIHRhZycsICgpIHsKICAgICAgICBmaW5hbCBtZXRhZGF0YSA9IHBhcnNlVGVzdE1ldGFkYXRhKCJAVGFncyhbJ290aGVyJ10pXG5saWJyYXJ5OyIpOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24sIGlzRmFsc2UpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2lzIGZhbHNlIHdoZW4gdGhlIHRhZyBpcyBvbmx5IGEgc3Vic3RyaW5nJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoCiAgICAgICAgICAiQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSx0ZXN0J10pXG5saWJyYXJ5OyIsCiAgICAgICAgKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLnNraXBzT3B0aW1pemF0aW9uLCBpc0ZhbHNlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdpcyBmYWxzZSB3aGVuIHRoZSBhbm5vdGF0aW9uIGlzIG5vdCBvbiB0aGUgZmlyc3QgZGlyZWN0aXZlJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoJycnCkBUYWdzKFsnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nXSkKdm9pZCBtYWluKCkge30KJycnKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLnNraXBzT3B0aW1pemF0aW9uLCBpc0ZhbHNlKTsKICAgICAgfSk7CiAgICB9KTsKICB9KTsKfQo=", + "data": "aW1wb3J0ICdkYXJ0OmlvJzsKaW1wb3J0ICdkYXJ0Om1hdGgnOwoKaW1wb3J0ICdwYWNrYWdlOmhvb2tzL3ByZV9nZW4uZGFydCcgYXMgcHJlX2dlbjsKaW1wb3J0ICdwYWNrYWdlOmhvb2tzL3Rlc3RfbWV0YWRhdGEuZGFydCc7CmltcG9ydCAncGFja2FnZTptYXNvbi9tYXNvbi5kYXJ0JzsKaW1wb3J0ICdwYWNrYWdlOm1vY2t0YWlsL21vY2t0YWlsLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6cGF0aC9wYXRoLmRhcnQnIGFzIHBhdGg7CmltcG9ydCAncGFja2FnZTp0ZXN0L3Rlc3QuZGFydCc7CgpjbGFzcyBfTW9ja0xvZ2dlciBleHRlbmRzIE1vY2sgaW1wbGVtZW50cyBMb2dnZXI7CgpjbGFzcyBfRmFrZUNvbnRleHQgZXh0ZW5kcyBGYWtlIGltcGxlbWVudHMgSG9va0NvbnRleHQgewogIEBvdmVycmlkZQogIGZpbmFsIGxvZ2dlciA9IF9Nb2NrTG9nZ2VyKCk7CgogIEBvdmVycmlkZQogIE1hcDxTdHJpbmcsIE9iamVjdD8+IHZhcnMgPSB7fTsKfQoKY29uc3Qgbm90T3B0aW1pemVkVGVzdENvbnRlbnQgPQogICAgJycnCkBUYWdzKFsnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nXSkKbGlicmFyeTsKCnZvaWQgbWFpbigpIHsKICB0ZXN0KCd0ZXN0JywgKCkgewogICAgZXhwZWN0KDEsIDEpOwogIH0pOwp9CicnJzsKCmNvbnN0IGFub3RoZXJOb3RPcHRpbWl6ZWRUZXN0Q29udGVudCA9CiAgICAnJycKQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfScsICdhbm90aGVyX3RhZyddKQppbXBvcnQgJ3BhY2thZ2U6dGVzdC90ZXN0LmRhcnQnOwoKdm9pZCBtYWluKCkgewogIHRlc3QoJ2Fub3RoZXIgdGVzdCcsICgpIHsKICAgIGV4cGVjdCgxLCAxKTsKICB9KTsKfQonJyc7Cgp2b2lkIG1haW4oKSB7CiAgbGF0ZSBEaXJlY3RvcnkgdGVtcERpcmVjdG9yeTsKCiAgc2V0VXAoKCkgewogICAgdGVtcERpcmVjdG9yeSA9IERpcmVjdG9yeS5zeXN0ZW1UZW1wLmNyZWF0ZVRlbXBTeW5jKCd0ZXN0X29wdGltaXplcicpOwogIH0pOwoKICB0ZWFyRG93bigoKSB7CiAgICB0ZW1wRGlyZWN0b3J5LmRlbGV0ZVN5bmMocmVjdXJzaXZlOiB0cnVlKTsKICB9KTsKCiAgZ3JvdXAoJ1ByZSBnZW4gaG9vaycsICgpIHsKICAgIGxhdGUgSG9va0NvbnRleHQgY29udGV4dDsKCiAgICBzZXRVcCgoKSB7CiAgICAgIGNvbnRleHQgPSBfRmFrZUNvbnRleHQoKTsKICAgIH0pOwoKICAgIGdyb3VwKCdDb21wbGV0ZXMnLCAoKSB7CiAgICAgIHRlc3QoJ3dpdGggdGVzdCBmaWxlcyBsaXN0JywgKCkgYXN5bmMgewogICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3Rlc3QxX3Rlc3QuZGFydCcpKS5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDJfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub190ZXN0X2hlcmUuZGFydCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgIGZpbmFsIHRlc3RzTWFwID0gPFN0cmluZywgU3RyaW5nPnt9OwogICAgICAgIGZvciAoZmluYWwgdGVzdCBpbiB0ZXN0cykgewogICAgICAgICAgZmluYWwgcGF0aCA9IHRlc3RbJ3BhdGgnXSE7CiAgICAgICAgICBmaW5hbCBpZGVudGlmaWVyID0gdGVzdFsnaWRlbnRpZmllciddITsKICAgICAgICAgIHRlc3RzTWFwW3BhdGhdID0gaWRlbnRpZmllcjsKICAgICAgICB9CgogICAgICAgIGZpbmFsIHBhdGhzID0gdGVzdHNNYXAua2V5czsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0MV90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDJfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ25vX3Rlc3RfaGVyZS5kYXJ0JykpKTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgdGVzdHNNYXAudmFsdWVzLnRvU2V0KCkubGVuZ3RoLAogICAgICAgICAgZXF1YWxzKHRlc3RzLmxlbmd0aCksCiAgICAgICAgICByZWFzb246ICdBbGwgdGVzdHMgZmlsZXMgc2hvdWxkIGhhdmUgdW5pcXVlIGlkZW50aWZpZXJzJywKICAgICAgICApOwoKICAgICAgICBleHBlY3QoCiAgICAgICAgICB0ZXN0cy5tYXAoKHRlc3QpID0+IHRlc3RbJ2dyb3VwQXJndW1lbnRzJ10pLAogICAgICAgICAgZXZlcnlFbGVtZW50KGlzRW1wdHkpLAogICAgICAgICAgcmVhc29uOiAnRmlsZXMgd2l0aG91dCBhbm5vdGF0aW9ucyBmb3J3YXJkIG5vIGdyb3VwIGFyZ3VtZW50cycsCiAgICAgICAgKTsKCiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snaXNGbHV0dGVyJ10sIGZhbHNlKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCd3aXRoIHByb3BlciBpc0ZsdXR0ZXIgaWRlbnRpZmljYXRpb24nLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpCiAgICAgICAgICAuLmNyZWF0ZVN5bmMoKQogICAgICAgICAgLi53cml0ZUFzU3RyaW5nU3luYygnJycKZGVwZW5kZW5jaWVzOgogIGZsdXR0ZXI6CiAgICBzZGs6IGZsdXR0ZXInJycpOwoKICAgICAgICBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snaXNGbHV0dGVyJ10sIHRydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ3dpdGggcHJvcGVyIG5vdCBvcHRpbWl6ZWQgdGVzdHMgaWRlbnRpZmljYXRpb24nLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDFfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0Ml90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ25vX3Rlc3RfaGVyZS5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICdub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKQogICAgICAgICAgICAud3JpdGVBc1N0cmluZ1N5bmMobm90T3B0aW1pemVkVGVzdENvbnRlbnQpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ2Fub3RoZXJfbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSkKICAgICAgICAgICAgLndyaXRlQXNTdHJpbmdTeW5jKGFub3RoZXJOb3RPcHRpbWl6ZWRUZXN0Q29udGVudCk7CgogICAgICAgIGNvbnRleHQudmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CgogICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgIGZpbmFsIHRlc3RzTWFwID0gPFN0cmluZywgU3RyaW5nPnt9OwogICAgICAgIGZvciAoZmluYWwgdGVzdCBpbiB0ZXN0cykgewogICAgICAgICAgZmluYWwgcGF0aCA9IHRlc3RbJ3BhdGgnXSE7CiAgICAgICAgICBmaW5hbCBpZGVudGlmaWVyID0gdGVzdFsnaWRlbnRpZmllciddITsKICAgICAgICAgIHRlc3RzTWFwW3BhdGhdID0gaWRlbnRpZmllcjsKICAgICAgICB9CgogICAgICAgIGZpbmFsIHBhdGhzID0gdGVzdHNNYXAua2V5czsKICAgICAgICBleHBlY3QocGF0aHMsIGNvbnRhaW5zKCd0ZXN0MV90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBjb250YWlucygndGVzdDJfdGVzdC5kYXJ0JykpOwogICAgICAgIGV4cGVjdChwYXRocywgaXNOb3QoY29udGFpbnMoJ25vX3Rlc3RfaGVyZS5kYXJ0JykpKTsKICAgICAgICBleHBlY3QocGF0aHMsIGlzTm90KGNvbnRhaW5zKCdub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKSk7CiAgICAgICAgZXhwZWN0KHBhdGhzLCBpc05vdChjb250YWlucygnYW5vdGhlcl9ub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKSk7CgogICAgICAgIGV4cGVjdCgKICAgICAgICAgIHRlc3RzTWFwLnZhbHVlcy50b1NldCgpLmxlbmd0aCwKICAgICAgICAgIGVxdWFscyh0ZXN0cy5sZW5ndGgpLAogICAgICAgICAgcmVhc29uOiAnQWxsIHRlc3RzIGZpbGVzIHNob3VsZCBoYXZlIHVuaXF1ZSBpZGVudGlmaWVycycsCiAgICAgICAgKTsKICAgICAgICBmaW5hbCBub3RPcHRpbWl6ZWRUZXN0cyA9CiAgICAgICAgICAgIGNvbnRleHQudmFyc1snbm90T3B0aW1pemVkVGVzdHMnXSBhcyBMaXN0PFN0cmluZz47CiAgICAgICAgZXhwZWN0KG5vdE9wdGltaXplZFRlc3RzLCBjb250YWlucygnbm90X29wdGltaXplZF90ZXN0LmRhcnQnKSk7CiAgICAgICAgZXhwZWN0KG5vdE9wdGltaXplZFRlc3RzLCBjb250YWlucygnYW5vdGhlcl9ub3Rfb3B0aW1pemVkX3Rlc3QuZGFydCcpKTsKICAgICAgfSk7CgogICAgICB0ZXN0KAogICAgICAgICd3aXRoIGZpbGUgbGV2ZWwgYW5ub3RhdGlvbnMgZm9yd2FyZGVkIGFzIGdyb3VwIGFyZ3VtZW50cycsCiAgICAgICAgKCkgYXN5bmMgewogICAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpCiAgICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnYW5ub3RhdGVkX3Rlc3QuZGFydCcpKQogICAgICAgICAgICAgIC53cml0ZUFzU3RyaW5nU3luYygnJycKQFRhZ3MoWydzbG93J10pCkBUaW1lb3V0KER1cmF0aW9uKG1pbnV0ZXM6IDUpKQpsaWJyYXJ5OwoKdm9pZCBtYWluKCkge30KJycnKTsKCiAgICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgICBleHBlY3QodGVzdHMsIGhhc0xlbmd0aCgxKSk7CiAgICAgICAgICBleHBlY3QoCiAgICAgICAgICAgIHRlc3RzLnNpbmdsZVsnZ3JvdXBBcmd1bWVudHMnXSwKICAgICAgICAgICAgZXF1YWxzKCIsIHRpbWVvdXQ6IFRpbWVvdXQoRHVyYXRpb24obWludXRlczogNSkpLCB0YWdzOiBbJ3Nsb3cnXSIpLAogICAgICAgICAgKTsKICAgICAgICB9LAogICAgICApOwoKICAgICAgdGVzdCgKICAgICAgICAnd2l0aCBhIHNraXAgdGFnIHBhY2thZ2U6dGVzdCB3b3VsZCBpZ25vcmUgbGVmdCBvcHRpbWl6ZWQnLAogICAgICAgICgpIGFzeW5jIHsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ25vX2RpcmVjdGl2ZV90ZXN0LmRhcnQnKSkKICAgICAgICAgICAgICAud3JpdGVBc1N0cmluZ1N5bmMoJycnCkBUYWdzKFsnJHtwcmVfZ2VuLnNraXBWZXJ5R29vZE9wdGltaXphdGlvblRhZ30nXSkKdm9pZCBtYWluKCkge30KJycnKTsKCiAgICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgICBleHBlY3QoCiAgICAgICAgICAgIHRlc3RzLm1hcCgodGVzdCkgPT4gdGVzdFsncGF0aCddKSwKICAgICAgICAgICAgY29udGFpbnMoJ25vX2RpcmVjdGl2ZV90ZXN0LmRhcnQnKSwKICAgICAgICAgICk7CiAgICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWydub3RPcHRpbWl6ZWRUZXN0cyddLCBpc0VtcHR5KTsKCiAgICAgICAgICB2ZXJpZnkoCiAgICAgICAgICAgICgpID0+IGNvbnRleHQubG9nZ2VyLndhcm4oCiAgICAgICAgICAgICAgYW55KAogICAgICAgICAgICAgICAgdGhhdDogYWxsT2YoCiAgICAgICAgICAgICAgICAgIGNvbnRhaW5zKCdub19kaXJlY3RpdmVfdGVzdC5kYXJ0JyksCiAgICAgICAgICAgICAgICAgIGNvbnRhaW5zKHByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnKSwKICAgICAgICAgICAgICAgICksCiAgICAgICAgICAgICAgKSwKICAgICAgICAgICAgKSwKICAgICAgICAgICkuY2FsbGVkKDEpOwogICAgICAgIH0sCiAgICAgICk7CgogICAgICB0ZXN0KAogICAgICAgICd3aXRoIGFuIHVucmVzb2x2YWJsZSBhbm5vdGF0aW9uIGxlZnQgb3V0IGFuZCB3YXJuZWQgYWJvdXQnLAogICAgICAgICgpIGFzeW5jIHsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3B1YnNwZWMueWFtbCcpKS5jcmVhdGVTeW5jKCk7CgogICAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ3VucmVzb2x2YWJsZV90ZXN0LmRhcnQnKSkKICAgICAgICAgICAgICAud3JpdGVBc1N0cmluZ1N5bmMoJycnCkBUaW1lb3V0KGtTbG93U3VpdGUpCkBSZXRyeSgyKQpsaWJyYXJ5OwoKdm9pZCBtYWluKCkge30KJycnKTsKCiAgICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICAgIGF3YWl0IHByZV9nZW4ucnVuKGNvbnRleHQpOwoKICAgICAgICAgIGZpbmFsIHRlc3RzID0gY29udGV4dC52YXJzWyd0ZXN0cyddIGFzIExpc3Q8TWFwPFN0cmluZywgU3RyaW5nPj47CiAgICAgICAgICBleHBlY3QoCiAgICAgICAgICAgIHRlc3RzLnNpbmdsZVsnZ3JvdXBBcmd1bWVudHMnXSwKICAgICAgICAgICAgZXF1YWxzKCcsIHJldHJ5OiAyJyksCiAgICAgICAgICAgIHJlYXNvbjogJ1RoZSByZXNvbHZhYmxlIHNpYmxpbmcgaXMgc3RpbGwgZm9yd2FyZGVkJywKICAgICAgICAgICk7CgogICAgICAgICAgdmVyaWZ5KAogICAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci53YXJuKAogICAgICAgICAgICAgIGFueSgKICAgICAgICAgICAgICAgIHRoYXQ6IGFsbE9mKAogICAgICAgICAgICAgICAgICBjb250YWlucygndW5yZXNvbHZhYmxlX3Rlc3QuZGFydCcpLAogICAgICAgICAgICAgICAgICBjb250YWlucygnQFRpbWVvdXQoa1Nsb3dTdWl0ZSknKSwKICAgICAgICAgICAgICAgICksCiAgICAgICAgICAgICAgKSwKICAgICAgICAgICAgKSwKICAgICAgICAgICkuY2FsbGVkKDEpOwogICAgICAgIH0sCiAgICAgICk7CiAgICB9KTsKCiAgICBncm91cCgnRmFpbHMnLCAoKSB7CiAgICAgIHNldFVwKCgpIHsKICAgICAgICBwcmVfZ2VuLmV4aXRGbiA9IChjb2RlKSB7CiAgICAgICAgICB0aHJvdyBQcm9jZXNzRXhjZXB0aW9uKCdleGl0JywgW2NvZGUudG9TdHJpbmcoKV0pOwogICAgICAgIH07CiAgICAgIH0pOwoKICAgICAgdGVhckRvd24oKCkgewogICAgICAgIHByZV9nZW4uZXhpdEZuID0gZXhpdDsKICAgICAgfSk7CgogICAgICB0ZXN0KCd3aGVuIHRhcmdldCB0ZXN0IGRpciBkb2VzIG5vdCBleGlzdCcsICgpIGFzeW5jIHsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBmaW5hbCB0ZXN0RGlyID0gRGlyZWN0b3J5KHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICd0ZXN0JykpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwoKICAgICAgICBhd2FpdCBleHBlY3RMYXRlcigKICAgICAgICAgICgpID0+IHByZV9nZW4ucnVuKGNvbnRleHQpLAogICAgICAgICAgdGhyb3dzQSgKICAgICAgICAgICAgaXNBPFByb2Nlc3NFeGNlcHRpb24+KCkuaGF2aW5nKAogICAgICAgICAgICAgIChleCkgPT4gZXguYXJndW1lbnRzLmZpcnN0LAogICAgICAgICAgICAgICdlcnJvciBjb2RlJywKICAgICAgICAgICAgICBlcXVhbHMoJzEnKSwKICAgICAgICAgICAgKSwKICAgICAgICAgICksCiAgICAgICAgKTsKCiAgICAgICAgdmVyaWZ5KAogICAgICAgICAgKCkgPT4gY29udGV4dC5sb2dnZXIuZXJyKCdDb3VsZCBub3QgZmluZCBkaXJlY3RvcnkgJHt0ZXN0RGlyLnBhdGh9JyksCiAgICAgICAgKS5jYWxsZWQoMSk7CgogICAgICAgIGV4cGVjdChjb250ZXh0LnZhcnNbJ3Rlc3RzJ10sIGlzTnVsbCk7CiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snaXNGbHV0dGVyJ10sIGlzTnVsbCk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2hlbiB0aGUgc2hhcmQgdmFsdWVzIGFyZSBvdXQgb2YgcmFuZ2UnLCAoKSBhc3luYyB7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAncHVic3BlYy55YW1sJykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkuY3JlYXRlU3luYygpOwoKICAgICAgICBjb250ZXh0LnZhcnNbJ3BhY2thZ2Utcm9vdCddID0gdGVtcERpcmVjdG9yeS5hYnNvbHV0ZS5wYXRoOwogICAgICAgIGNvbnRleHQudmFyc1snc2hhcmQtaW5kZXgnXSA9IDE7CiAgICAgICAgY29udGV4dC52YXJzWyd0b3RhbC1zaGFyZHMnXSA9IDA7CgogICAgICAgIGF3YWl0IGV4cGVjdExhdGVyKAogICAgICAgICAgKCkgPT4gcHJlX2dlbi5ydW4oY29udGV4dCksCiAgICAgICAgICB0aHJvd3NBKAogICAgICAgICAgICBpc0E8UHJvY2Vzc0V4Y2VwdGlvbj4oKS5oYXZpbmcoCiAgICAgICAgICAgICAgKGV4KSA9PiBleC5hcmd1bWVudHMuZmlyc3QsCiAgICAgICAgICAgICAgJ2Vycm9yIGNvZGUnLAogICAgICAgICAgICAgIGVxdWFscygnMScpLAogICAgICAgICAgICApLAogICAgICAgICAgKSwKICAgICAgICApOwoKICAgICAgICB2ZXJpZnkoCiAgICAgICAgICAoKSA9PiBjb250ZXh0LmxvZ2dlci5lcnIoCiAgICAgICAgICAgICdzaGFyZC1pbmRleCBtdXN0IGJlIGJldHdlZW4gMSBhbmQgdG90YWwtc2hhcmRzLCBidXQgZ290ICcKICAgICAgICAgICAgJ3NoYXJkLWluZGV4IDEgYW5kIHRvdGFsLXNoYXJkcyAwJywKICAgICAgICAgICksCiAgICAgICAgKS5jYWxsZWQoMSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnd2hlbiB0YXJnZXQgZGlyIGRvZXMgbm90IGNvbnRhaW4gYSBwdWJzcGVjLnlhbWwnLCAoKSBhc3luYyB7CiAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgLi5jcmVhdGVTeW5jKCk7CiAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdDFfdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZXN0RGlyLnBhdGgsICd0ZXN0Ml90ZXN0LmRhcnQnKSkuY3JlYXRlU3luYygpOwogICAgICAgIEZpbGUocGF0aC5qb2luKHRlc3REaXIucGF0aCwgJ25vX3Rlc3RfaGVyZS5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKCiAgICAgICAgY29udGV4dC52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKCiAgICAgICAgYXdhaXQgZXhwZWN0TGF0ZXIoCiAgICAgICAgICAoKSA9PiBwcmVfZ2VuLnJ1bihjb250ZXh0KSwKICAgICAgICAgIHRocm93c0EoCiAgICAgICAgICAgIGlzQTxQcm9jZXNzRXhjZXB0aW9uPigpLmhhdmluZygKICAgICAgICAgICAgICAoZXgpID0+IGV4LmFyZ3VtZW50cy5maXJzdCwKICAgICAgICAgICAgICAnZXJyb3IgY29kZScsCiAgICAgICAgICAgICAgZXF1YWxzKCcxJyksCiAgICAgICAgICAgICksCiAgICAgICAgICApLAogICAgICAgICk7CgogICAgICAgIHZlcmlmeSgKICAgICAgICAgICgpID0+IGNvbnRleHQubG9nZ2VyLmVycigKICAgICAgICAgICAgJ0NvdWxkIG5vdCBmaW5kIHB1YnNwZWMueWFtbCBhdCAke3Rlc3REaXIucGF0aH0nLAogICAgICAgICAgKSwKICAgICAgICApLmNhbGxlZCgxKTsKCiAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1sndGVzdHMnXSwgaXNOdWxsKTsKICAgICAgICBleHBlY3QoY29udGV4dC52YXJzWydpc0ZsdXR0ZXInXSwgaXNOdWxsKTsKICAgICAgfSk7CiAgICB9KTsKICB9KTsKCiAgZ3JvdXAoJ1Rlc3RNZXRhZGF0YUJ1bmRsZScsICgpIHsKICAgIGdyb3VwKCdncm91cEFyZ3VtZW50cycsICgpIHsKICAgICAgdGVzdCgnaXMgZW1wdHkgd2l0aG91dCBhbm5vdGF0aW9ucycsICgpIHsKICAgICAgICBmaW5hbCBtZXRhZGF0YSA9IHBhcnNlVGVzdE1ldGFkYXRhKCd2b2lkIG1haW4oKSB7fScpOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuZ3JvdXBBcmd1bWVudHMsIGlzRW1wdHkpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2VtaXRzIGEgc2luZ2xlIGFubm90YXRpb24nLCAoKSB7CiAgICAgICAgZmluYWwgbWV0YWRhdGEgPSBwYXJzZVRlc3RNZXRhZGF0YSgiQFNraXAoJ25vdCByZWFkeScpXG5saWJyYXJ5OyIpOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuZ3JvdXBBcmd1bWVudHMsIGVxdWFscygiLCBza2lwOiAnbm90IHJlYWR5JyIpKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdlbWl0cyBhIGZpeGVkIG9yZGVyIHJlZ2FyZGxlc3Mgb2Ygc291cmNlIG9yZGVyJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoJycnCkBSZXRyeSgzKQpAVGFncyhbJ3Nsb3cnXSkKQFNraXAoKQpAVGltZW91dC5ub25lCkBUZXN0T24oJ3ZtJykKbGlicmFyeTsKJycnKTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgbWV0YWRhdGEuZ3JvdXBBcmd1bWVudHMsCiAgICAgICAgICBlcXVhbHMoCiAgICAgICAgICAgICIsIHRlc3RPbjogJ3ZtJywgdGltZW91dDogVGltZW91dC5ub25lLCBza2lwOiB0cnVlLCAiCiAgICAgICAgICAgICJ0YWdzOiBbJ3Nsb3cnXSwgcmV0cnk6IDMiLAogICAgICAgICAgKSwKICAgICAgICApOwogICAgICB9KTsKICAgIH0pOwoKICAgIGdyb3VwKCdza2lwc09wdGltaXphdGlvbicsICgpIHsKICAgICAgdGVzdCgnaXMgdHJ1ZSBmb3IgdGhlIHNraXAgdGFnIG9uIGl0cyBvd24nLCAoKSB7CiAgICAgICAgZmluYWwgbWV0YWRhdGEgPSBwYXJzZVRlc3RNZXRhZGF0YSgKICAgICAgICAgICJAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9J10pXG5saWJyYXJ5OyIsCiAgICAgICAgKTsKCiAgICAgICAgZXhwZWN0KG1ldGFkYXRhLnNraXBzT3B0aW1pemF0aW9uLCBpc1RydWUpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2lzIHRydWUgd2hlbiB0aGUgc2tpcCB0YWcgaXMgbm90IGZpcnN0JywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoCiAgICAgICAgICAiQFRhZ3MoWydvdGhlcicsICcke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSddKVxuIgogICAgICAgICAgJ2xpYnJhcnk7JywKICAgICAgICApOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24sIGlzVHJ1ZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnaXMgZmFsc2Ugd2l0aG91dCB0aGUgdGFnJywgKCkgewogICAgICAgIGZpbmFsIG1ldGFkYXRhID0gcGFyc2VUZXN0TWV0YWRhdGEoIkBUYWdzKFsnb3RoZXInXSlcbmxpYnJhcnk7Iik7CgogICAgICAgIGV4cGVjdChtZXRhZGF0YS5za2lwc09wdGltaXphdGlvbiwgaXNGYWxzZSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnaXMgZmFsc2Ugd2hlbiB0aGUgdGFnIGlzIG9ubHkgYSBzdWJzdHJpbmcnLCAoKSB7CiAgICAgICAgZmluYWwgbWV0YWRhdGEgPSBwYXJzZVRlc3RNZXRhZGF0YSgKICAgICAgICAgICJAVGFncyhbJyR7cHJlX2dlbi5za2lwVmVyeUdvb2RPcHRpbWl6YXRpb25UYWd9LHRlc3QnXSlcbmxpYnJhcnk7IiwKICAgICAgICApOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24sIGlzRmFsc2UpOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2lzIGZhbHNlIHdoZW4gdGhlIGFubm90YXRpb24gaXMgbm90IG9uIHRoZSBmaXJzdCBkaXJlY3RpdmUnLCAoKSB7CiAgICAgICAgZmluYWwgbWV0YWRhdGEgPSBwYXJzZVRlc3RNZXRhZGF0YSgnJycKQFRhZ3MoWycke3ByZV9nZW4uc2tpcFZlcnlHb29kT3B0aW1pemF0aW9uVGFnfSddKQp2b2lkIG1haW4oKSB7fQonJycpOwoKICAgICAgICBleHBlY3QobWV0YWRhdGEuc2tpcHNPcHRpbWl6YXRpb24sIGlzRmFsc2UpOwogICAgICB9KTsKICAgIH0pOwogICAgZ3JvdXAoJ1NoYXJkaW5nJywgKCkgewogICAgICAvLy8gQ3JlYXRlcyBhIHBhY2thZ2Ugd2l0aCBbY291bnRdIG9wdGltaXphYmxlIHRlc3QgZmlsZXMsIHBsdXMgYW55CiAgICAgIC8vLyBbbm90T3B0aW1pemVkXSBmaWxlcyBjYXJyeWluZyB0aGUgc2tpcCBvcHRpbWl6YXRpb24gdGFnLgogICAgICBEaXJlY3RvcnkgY3JlYXRlUGFja2FnZShpbnQgY291bnQsIHtpbnQgbm90T3B0aW1pemVkID0gMH0pIHsKICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwogICAgICAgIGZpbmFsIHRlc3REaXIgPSBEaXJlY3RvcnkocGF0aC5qb2luKHRlbXBEaXJlY3RvcnkucGF0aCwgJ3Rlc3QnKSkKICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwgY291bnQ7IGkrKykgewogICAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAndGVzdCR7aX1fdGVzdC5kYXJ0JykpLmNyZWF0ZVN5bmMoKTsKICAgICAgICB9CiAgICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBub3RPcHRpbWl6ZWQ7IGkrKykgewogICAgICAgICAgRmlsZShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnc2tpcCR7aX1fdGVzdC5kYXJ0JykpCiAgICAgICAgICAgICAgLndyaXRlQXNTdHJpbmdTeW5jKG5vdE9wdGltaXplZFRlc3RDb250ZW50KTsKICAgICAgICB9CiAgICAgICAgcmV0dXJuIHRlc3REaXI7CiAgICAgIH0KCiAgICAgIExpc3Q8U3RyaW5nPiBwYXRoc09mKEhvb2tDb250ZXh0IGNvbnRleHQpIHsKICAgICAgICBmaW5hbCB0ZXN0cyA9IGNvbnRleHQudmFyc1sndGVzdHMnXSBhcyBMaXN0PE1hcDxTdHJpbmcsIFN0cmluZz4+OwogICAgICAgIHJldHVybiB0ZXN0cy5tYXAoKGUpID0+IGVbJ3BhdGgnXSEpLnRvTGlzdCgpOwogICAgICB9CgogICAgICBGdXR1cmU8TGlzdDxTdHJpbmc+PiBydW5TaGFyZChpbnQgaW5kZXgsIGludCB0b3RhbCkgYXN5bmMgewogICAgICAgIGZpbmFsIGNvbnRleHQgPSBfRmFrZUNvbnRleHQoKQogICAgICAgICAgLi52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aAogICAgICAgICAgLi52YXJzWydzaGFyZC1pbmRleCddID0gaW5kZXgKICAgICAgICAgIC4udmFyc1sndG90YWwtc2hhcmRzJ10gPSB0b3RhbDsKICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKICAgICAgICByZXR1cm4gWwogICAgICAgICAgLi4ucGF0aHNPZihjb250ZXh0KSwKICAgICAgICAgIC4uLihjb250ZXh0LnZhcnNbJ25vdE9wdGltaXplZFRlc3RzJ10hIGFzIExpc3QpLmNhc3Q8U3RyaW5nPigpLAogICAgICAgIF07CiAgICAgIH0KCiAgICAgIHRlc3QoJ3J1bnMgZXZlcnkgdGVzdCBleGFjdGx5IG9uY2UgYWNyb3NzIGFsbCBzaGFyZHMnLCAoKSBhc3luYyB7CiAgICAgICAgY3JlYXRlUGFja2FnZSg3LCBub3RPcHRpbWl6ZWQ6IDIpOwoKICAgICAgICBmaW5hbCBzaGFyZHMgPSBbZm9yICh2YXIgaSA9IDE7IGkgPD0gMzsgaSsrKSBhd2FpdCBydW5TaGFyZChpLCAzKV07CiAgICAgICAgZmluYWwgdW5pb24gPSBzaGFyZHMuZXhwYW5kKChzaGFyZCkgPT4gc2hhcmQpLnRvTGlzdCgpOwoKICAgICAgICBleHBlY3QoCiAgICAgICAgICB1bmlvbi4uc29ydCgpLAogICAgICAgICAgWwogICAgICAgICAgICBmb3IgKHZhciBpID0gMDsgaSA8IDc7IGkrKykgJ3Rlc3Qke2l9X3Rlc3QuZGFydCcsCiAgICAgICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwgMjsgaSsrKSAnc2tpcCR7aX1fdGVzdC5kYXJ0JywKICAgICAgICAgIF0uLnNvcnQoKSwKICAgICAgICAgIHJlYXNvbjogJ1NoYXJkcyBtdXN0IGJlIGEgY29tcGxldGUgYW5kIGRpc2pvaW50IHBhcnRpdGlvbicsCiAgICAgICAgKTsKICAgICAgfSk7CgogICAgICB0ZXN0KCdzaGFyZHMgbm9uIG9wdGltaXplZCB0ZXN0cyBhcyB3ZWxsJywgKCkgYXN5bmMgewogICAgICAgIGNyZWF0ZVBhY2thZ2UoMCwgbm90T3B0aW1pemVkOiA0KTsKCiAgICAgICAgZmluYWwgZmlyc3QgPSBhd2FpdCBydW5TaGFyZCgxLCAyKTsKICAgICAgICBmaW5hbCBzZWNvbmQgPSBhd2FpdCBydW5TaGFyZCgyLCAyKTsKCiAgICAgICAgZXhwZWN0KGZpcnN0LCBbJ3NraXAwX3Rlc3QuZGFydCcsICdza2lwMl90ZXN0LmRhcnQnXSk7CiAgICAgICAgZXhwZWN0KHNlY29uZCwgWydza2lwMV90ZXN0LmRhcnQnLCAnc2tpcDNfdGVzdC5kYXJ0J10pOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2RlYWxzIG9wdGltaXplZCBhbmQgbm9uIG9wdGltaXplZCB0ZXN0cyBvdXQgdG9nZXRoZXInLCAoKSBhc3luYyB7CiAgICAgICAgY3JlYXRlUGFja2FnZSgyLCBub3RPcHRpbWl6ZWQ6IDMpOwoKICAgICAgICBmaW5hbCBzaXplcyA9IFsKICAgICAgICAgIGZvciAodmFyIGkgPSAxOyBpIDw9IDY7IGkrKykgKGF3YWl0IHJ1blNoYXJkKGksIDYpKS5sZW5ndGgsCiAgICAgICAgXTsKCiAgICAgICAgZXhwZWN0KAogICAgICAgICAgc2l6ZXMsCiAgICAgICAgICBbMSwgMSwgMSwgMSwgMSwgMF0sCiAgICAgICAgICByZWFzb246CiAgICAgICAgICAgICAgJ1NoYXJkaW5nIHRoZSB0d28gbGlzdHMgc2VwYXJhdGVseSB3b3VsZCBnaXZlIHRoZSBmaXJzdCAnCiAgICAgICAgICAgICAgJ3NoYXJkcyBhIGZpbGUgZnJvbSBlYWNoIHdoaWxlIGxhdGVyIHNoYXJkcyBzdGF5IGVtcHR5JywKICAgICAgICApOwogICAgICB9KTsKCiAgICAgIHRlc3QoJ2lzIGRldGVybWluaXN0aWMgYWNyb3NzIHJ1bnMnLCAoKSBhc3luYyB7CiAgICAgICAgY3JlYXRlUGFja2FnZSg5KTsKCiAgICAgICAgZXhwZWN0KGF3YWl0IHJ1blNoYXJkKDIsIDQpLCBhd2FpdCBydW5TaGFyZCgyLCA0KSk7CiAgICAgIH0pOwoKICAgICAgdGVzdCgnYmFsYW5jZXMgc2hhcmRzIHdpdGhpbiBvbmUgZmlsZSBvZiBlYWNoIG90aGVyJywgKCkgYXN5bmMgewogICAgICAgIGNyZWF0ZVBhY2thZ2UoMTApOwoKICAgICAgICBmaW5hbCBzaXplcyA9IFsKICAgICAgICAgIGZvciAodmFyIGkgPSAxOyBpIDw9IDQ7IGkrKykgKGF3YWl0IHJ1blNoYXJkKGksIDQpKS5sZW5ndGgsCiAgICAgICAgXTsKCiAgICAgICAgZXhwZWN0KHNpemVzLnJlZHVjZShtYXgpIC0gc2l6ZXMucmVkdWNlKG1pbiksIGxlc3NUaGFuT3JFcXVhbFRvKDEpKTsKICAgICAgfSk7CgogICAgICB0ZXN0KAogICAgICAgICd5aWVsZHMgYW4gZW1wdHkgc2hhcmQgd2hlbiB0aGVyZSBhcmUgbW9yZSBzaGFyZHMgdGhhbiB0ZXN0cycsCiAgICAgICAgKCkgYXN5bmMgewogICAgICAgICAgY3JlYXRlUGFja2FnZSgyKTsKCiAgICAgICAgICBleHBlY3QoYXdhaXQgcnVuU2hhcmQoMywgMyksIGlzRW1wdHkpOwogICAgICAgIH0sCiAgICAgICk7CgogICAgICB0ZXN0KAogICAgICAgICdleGNsdWRlcyBuZXN0ZWQgbm9uIG9wdGltaXplZCB0ZXN0cyBmcm9tIHRoZSBvcHRpbWl6ZWQgc2V0JywKICAgICAgICAoKSBhc3luYyB7CiAgICAgICAgICBGaWxlKHBhdGguam9pbih0ZW1wRGlyZWN0b3J5LnBhdGgsICdwdWJzcGVjLnlhbWwnKSkuY3JlYXRlU3luYygpOwogICAgICAgICAgZmluYWwgdGVzdERpciA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVtcERpcmVjdG9yeS5wYXRoLCAndGVzdCcpKQogICAgICAgICAgICAuLmNyZWF0ZVN5bmMoKTsKICAgICAgICAgIGZpbmFsIG5lc3RlZCA9IERpcmVjdG9yeShwYXRoLmpvaW4odGVzdERpci5wYXRoLCAnc3ViJykpCiAgICAgICAgICAgIC4uY3JlYXRlU3luYygpOwogICAgICAgICAgRmlsZShwYXRoLmpvaW4obmVzdGVkLnBhdGgsICdza2lwX3Rlc3QuZGFydCcpKQogICAgICAgICAgICAgIC53cml0ZUFzU3RyaW5nU3luYyhub3RPcHRpbWl6ZWRUZXN0Q29udGVudCk7CgogICAgICAgICAgZmluYWwgY29udGV4dCA9IF9GYWtlQ29udGV4dCgpCiAgICAgICAgICAgIC4udmFyc1sncGFja2FnZS1yb290J10gPSB0ZW1wRGlyZWN0b3J5LmFic29sdXRlLnBhdGg7CiAgICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgICBleHBlY3QoCiAgICAgICAgICAgIHBhdGhzT2YoY29udGV4dCksCiAgICAgICAgICAgIGlzRW1wdHksCiAgICAgICAgICAgIHJlYXNvbjoKICAgICAgICAgICAgICAgICdBIHRhZ2dlZCB0ZXN0IGluIGEgc3ViZGlyZWN0b3J5IG11c3Qgbm90IGJlIG9wdGltaXplZCwgJwogICAgICAgICAgICAgICAgJ290aGVyd2lzZSBpdCBydW5zIGJvdGggaW5saW5lZCBhbmQgc3RhbmRhbG9uZScsCiAgICAgICAgICApOwogICAgICAgICAgZXhwZWN0KGNvbnRleHQudmFyc1snbm90T3B0aW1pemVkVGVzdHMnXSwgWydzdWIvc2tpcF90ZXN0LmRhcnQnXSk7CiAgICAgICAgfSwKICAgICAgKTsKCiAgICAgIHRlc3QoJ2luY2x1ZGVzIGV2ZXJ5IHRlc3Qgd2hlbiBzaGFyZGluZyBpcyBub3QgcmVxdWVzdGVkJywgKCkgYXN5bmMgewogICAgICAgIGNyZWF0ZVBhY2thZ2UoMyk7CgogICAgICAgIGZpbmFsIGNvbnRleHQgPSBfRmFrZUNvbnRleHQoKQogICAgICAgICAgLi52YXJzWydwYWNrYWdlLXJvb3QnXSA9IHRlbXBEaXJlY3RvcnkuYWJzb2x1dGUucGF0aDsKICAgICAgICBhd2FpdCBwcmVfZ2VuLnJ1bihjb250ZXh0KTsKCiAgICAgICAgZXhwZWN0KHBhdGhzT2YoY29udGV4dCksIGhhc0xlbmd0aCgzKSk7CiAgICAgIH0pOwogICAgfSk7CiAgfSk7Cn0K", "type": "text", }, { @@ -71,5 +71,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 13f1b1df0..2b3fba6cc 100644 --- a/lib/src/cli/test_cli_runner.dart +++ b/lib/src/cli/test_cli_runner.dart @@ -83,6 +83,65 @@ class TestCLIRunner { return rest.where((arg) => !arg.startsWith('-')).isNotEmpty; } + /// Validates the `--shard-index` / `--total-shards` combination. + /// + /// Returns an error message describing the problem, or `null` when the + /// configuration is valid. + /// + /// [rawShardIndex], [rawTotalShards] and [rawMinCoverage] are the unparsed + /// command line values, so that "not provided" can be told apart from + /// "provided but not a number", and an explicit `--min-coverage` from one + /// inherited from `very_good.yaml`. [optimizePerformance] is the effective + /// value, after every option that disables the optimizer is accounted for. + static String? validateSharding({ + required String? rawShardIndex, + required String? rawTotalShards, + required String? rawMinCoverage, + required bool optimizePerformance, + }) { + 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 the test optimizer, which is disabled by ' + '--no-optimization, --platform, --update-goldens or by targeting ' + 'specific test files.'; + } + + // 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 (rawMinCoverage != 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; + } + /// Run tests (`flutter test`). /// Returns a list of exit codes for each test process. static Future> test({ @@ -145,6 +204,17 @@ class TestCLIRunner { logger: logger, ); + if (optimization.isEmptyShard) { + stdout?.call( + 'No tests found for shard ${optimization.shardIndex} in $path\n', + ); + await optimization.cleanUp(); + // The merge step downstream still expects a report from every + // shard, so leave an empty one behind. + if (collectCoverage) await lcovFile.create(recursive: true); + return ExitCode.success.code; + } + return await _overrideAnsiOutput( forceAnsi, () => @@ -425,6 +495,10 @@ class TestCLIRunner { } } +/// The exit code `dart test` and `flutter test` use when no test ran, for +/// example because `--exclude-tags` filtered out every test. +const _noTestsRanExitCode = 79; + Future _testCommand({ required void Function(String) stdout, required void Function(String) stderr, @@ -605,8 +679,14 @@ Future _testCommand({ unawaited(subscription.cancel()); unawaited(sigintWatchSubscription.cancel()); + // A shard can end up holding only tests that the given tags + // filter out, which is expected and not a failure. + final noTestsRanInShard = + optimization.shardIndex != null && + event.exitCode == _noTestsRanExitCode; + completer.complete( - event.exitCode == ExitCode.success.code + event.exitCode == ExitCode.success.code || noTestsRanInShard ? ExitCode.success.code : ExitCode.unavailable.code, ); diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 3abd54465..ebcfbf509 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -31,6 +31,9 @@ class DartTestOptions { required this.runSkipped, required this.checkIgnore, required this.fileReporter, + required this.shardIndex, + required this.totalShards, + required this.rawMinCoverage, }); /// Parses [ArgResults] into a [DartTestOptions] instance. @@ -107,6 +110,9 @@ class DartTestOptions { 'file-reporter', testConfig.fileReporter, ); + final shardIndex = argResults['shard-index'] as String?; + final totalShards = argResults['total-shards'] as String?; + final rawMinCoverage = argResults['min-coverage'] as String?; final rest = argResults.rest; return DartTestOptions._( @@ -128,6 +134,9 @@ class DartTestOptions { runSkipped: runSkipped, checkIgnore: checkIgnore, fileReporter: fileReporter, + shardIndex: shardIndex, + totalShards: totalShards, + rawMinCoverage: rawMinCoverage, rest: rest, ); } @@ -188,6 +197,19 @@ class DartTestOptions { /// `:` (e.g. `json:reports/tests.json`). final String? fileReporter; + /// The raw `--shard-index` value, validated by + /// [TestCLIRunner.validateSharding]. + final String? shardIndex; + + /// The raw `--total-shards` value, validated by + /// [TestCLIRunner.validateSharding]. + final String? totalShards; + + /// The raw `--min-coverage` value, without the `very_good.yaml` fallback + /// that [minCoverage] applies, so sharding can reject an explicit threshold + /// while ignoring an inherited one. + final String? rawMinCoverage; + /// The remaining arguments passed to the `dart test` command. final List rest; @@ -224,6 +246,8 @@ typedef DartTestCommandCall = Future> Function({ void Function(String)? stderr, List? reportOn, bool checkIgnore, + int? shardIndex, + int? totalShards, }); /// {@template dart_test_command} @@ -366,6 +390,23 @@ 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. ' + 'When omitted, no sharding is applied.', + valueHelp: 'index', + ) + ..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. When omitted, no sharding is applied.', + valueHelp: 'count', ); } @@ -416,14 +457,26 @@ This command should be run from the root of your Dart project.'''); final options = DartTestOptions.parse(_argResults, config: config); + final shardingError = TestCLIRunner.validateSharding( + rawShardIndex: options.shardIndex, + rawTotalShards: options.totalShards, + rawMinCoverage: options.rawMinCoverage, + optimizePerformance: options.shouldOptimize, + ); + if (shardingError != null) { + _logger.err(shardingError); + return ExitCode.usage.code; + } + + // A threshold inherited from very_good.yaml only applies to un-sharded + // runs: a single shard covers a fraction of the code and would fail it. + final minCoverage = options.totalShards == null + ? options.minCoverage + : null; + try { final results = await _dartTest( - optimizePerformance: - options.optimizePerformance && - !TestCLIRunner.isTargettingTestFiles(options.rest) && - // Disabled optimization when platform is specified - // https://github.com/VeryGoodOpenSource/very_good_cli/issues/1363 - options.platform == null, + optimizePerformance: options.shouldOptimize, excludeOptimization: options.excludeOptimization, recursive: recursive, logger: _logger, @@ -431,9 +484,9 @@ This command should be run from the root of your Dart project.'''); stderr: _logger.err, collectCoverage: options.collectCoverage || - options.minCoverage != null || + minCoverage != null || options.showUncovered, - minCoverage: options.minCoverage, + minCoverage: minCoverage, showUncovered: options.showUncovered, excludeFromCoverage: options.excludeFromCoverage, collectCoverageFrom: options.collectCoverageFrom, @@ -452,6 +505,8 @@ This command should be run from the root of your Dart project.'''); ], reportOn: options.reportOn.isEmpty ? null : options.reportOn, checkIgnore: options.checkIgnore, + shardIndex: int.tryParse(options.shardIndex ?? ''), + totalShards: int.tryParse(options.totalShards ?? ''), ); if (results.any((code) => code != ExitCode.success.code)) { @@ -461,7 +516,7 @@ This command should be run from the root of your Dart project.'''); return TestCLIRunner.handleMinCoverageNotMet( error, logger: _logger, - minCoverage: options.minCoverage, + minCoverage: minCoverage, ); } on InvalidOptimizationGlob catch (error) { _logger.err('$error'); diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index f7280d913..8cbe2ea1f 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -34,6 +34,9 @@ class FlutterTestOptions { required this.flavor, required this.timeout, required this.fileReporter, + required this.shardIndex, + required this.totalShards, + required this.rawMinCoverage, required this.rest, }); @@ -129,6 +132,9 @@ class FlutterTestOptions { 'file-reporter', testConfig.fileReporter, ); + final shardIndex = argResults['shard-index'] as String?; + final totalShards = argResults['total-shards'] as String?; + final rawMinCoverage = argResults['min-coverage'] as String?; final rest = argResults.rest; return FlutterTestOptions._( @@ -154,6 +160,9 @@ class FlutterTestOptions { flavor: flavor, timeout: effectiveTimeout, fileReporter: fileReporter, + shardIndex: shardIndex, + totalShards: totalShards, + rawMinCoverage: rawMinCoverage, rest: rest, ); } @@ -227,6 +236,19 @@ class FlutterTestOptions { /// `:` (e.g. `json:reports/tests.json`). final String? fileReporter; + /// The raw `--shard-index` value, validated by + /// [TestCLIRunner.validateSharding]. + final String? shardIndex; + + /// The raw `--total-shards` value, validated by + /// [TestCLIRunner.validateSharding]. + final String? totalShards; + + /// The raw `--min-coverage` value, without the `very_good.yaml` fallback + /// that [minCoverage] applies, so sharding can reject an explicit threshold + /// while ignoring an inherited one. + final String? rawMinCoverage; + /// The remaining arguments passed to the test command. final List rest; @@ -265,6 +287,8 @@ typedef FlutterTestCommand = Future> Function({ void Function(String)? stdout, void Function(String)? stderr, List? reportOn, + int? shardIndex, + int? totalShards, }); /// {@template test_command} @@ -443,6 +467,23 @@ 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. ' + 'When omitted, no sharding is applied.', + valueHelp: 'index', + ) + ..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. When omitted, no sharding is applied.', + valueHelp: 'count', ); } @@ -495,15 +536,26 @@ This command should be run from the root of your Flutter project.'''); final options = FlutterTestOptions.parse(_argResults, config: config); + final shardingError = TestCLIRunner.validateSharding( + rawShardIndex: options.shardIndex, + rawTotalShards: options.totalShards, + rawMinCoverage: options.rawMinCoverage, + optimizePerformance: options.shouldOptimize, + ); + if (shardingError != null) { + _logger.err(shardingError); + return ExitCode.usage.code; + } + + // A threshold inherited from very_good.yaml only applies to un-sharded + // runs: a single shard covers a fraction of the code and would fail it. + final minCoverage = options.totalShards == null + ? options.minCoverage + : null; + try { final results = await _flutterTest( - optimizePerformance: - options.optimizePerformance && - !TestCLIRunner.isTargettingTestFiles(options.rest) && - !options.updateGoldens && - // Disabled optimization when platform is specified - // https://github.com/VeryGoodOpenSource/very_good_cli/issues/1363 - options.platform == null, + optimizePerformance: options.shouldOptimize, excludeOptimization: options.excludeOptimization, recursive: recursive, logger: _logger, @@ -511,15 +563,17 @@ This command should be run from the root of your Flutter project.'''); stderr: _logger.err, collectCoverage: options.collectCoverage || - options.minCoverage != null || + minCoverage != null || options.showUncovered, - minCoverage: options.minCoverage, + minCoverage: minCoverage, showUncovered: options.showUncovered, excludeFromCoverage: options.excludeFromCoverage, collectCoverageFrom: options.collectCoverageFrom, randomSeed: options.randomSeed, forceAnsi: options.forceAnsi, reportOn: options.reportOn.isEmpty ? null : options.reportOn, + shardIndex: int.tryParse(options.shardIndex ?? ''), + totalShards: int.tryParse(options.totalShards ?? ''), arguments: [ if (options.excludeTags != null) ...['-x', options.excludeTags!], if (options.tags != null) ...['-t', options.tags!], @@ -550,7 +604,7 @@ This command should be run from the root of your Flutter project.'''); return TestCLIRunner.handleMinCoverageNotMet( error, logger: _logger, - minCoverage: options.minCoverage, + minCoverage: minCoverage, ); } on InvalidOptimizationGlob catch (error) { _logger.err('$error'); diff --git a/lib/src/test_optimizer/test_optimizer.dart b/lib/src/test_optimizer/test_optimizer.dart index 0445a26c2..13befdb49 100644 --- a/lib/src/test_optimizer/test_optimizer.dart +++ b/lib/src/test_optimizer/test_optimizer.dart @@ -64,13 +64,23 @@ class TestOptimizer { required bool enabled, List? exclude, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, + int? shardIndex, + int? totalShards, }) : this._( enabled, (exclude ?? const []).map((str) => str.toGlob).toList(), buildGenerator, + shardIndex: shardIndex, + totalShards: totalShards, ); - const new _(this._enabled, this._exclusions, this._buildGenerator); + const new _( + this._enabled, + this._exclusions, + this._buildGenerator, { + this.shardIndex, + this.totalShards, + }); /// An optimizer that generates nothing and always yields /// [TestOptimization.none]. @@ -81,6 +91,15 @@ class TestOptimizer { final List _exclusions; final GeneratorBuilder _buildGenerator; + /// The 1-based shard to keep out of [totalShards], or `null` to keep every + /// test. The brick's pre-gen hook deals the sorted test files out + /// round-robin, so every shard holds a disjoint slice of the suite. + final int? shardIndex; + + /// The number of shards the suite is split into, or `null` when not + /// sharding. + final int? totalShards; + /// Generates the entrypoint for the package rooted at [packageRoot], which /// must be absolute: the brick's pre-gen hook resolves the package's `test` /// directory and `pubspec.yaml` from it. @@ -94,7 +113,11 @@ class TestOptimizer { if (!_enabled) return TestOptimization.none; final progress = logger.progress('Optimizing tests'); - var vars = {'package-root': packageRoot}; + var vars = { + 'package-root': packageRoot, + 'shard-index': ?shardIndex, + 'total-shards': ?totalShards, + }; try { final generator = await _buildGenerator(testOptimizerBundle); await generator.hooks.preGen( @@ -118,6 +141,7 @@ class TestOptimizer { return TestOptimization._( hasOptimizedTests, packageRoot: packageRoot, + shardIndex: shardIndex, serialTests: [ ...?(vars['notOptimizedTests'] as List?)?.map( (test) => test.toString(), @@ -163,6 +187,7 @@ class TestOptimization { const new _( this._hasOptimizedTests, { this.packageRoot, + this.shardIndex, this.serialTests = const [], }); @@ -176,15 +201,28 @@ class TestOptimization { /// Test files kept out of the bundle, which run as their own suites. final List serialTests; + /// The shard the bundle was generated for, or `null` when the run is not + /// sharded. + final int? shardIndex; + final bool _hasOptimizedTests; + /// Whether a sharded run ended up with nothing to do, which happens when + /// there are more shards than test files. That is not a failure: the runner + /// reports success instead of letting the test runner exit with "No tests + /// were found", which would fail the CI job. + bool get isEmptyShard => + shardIndex != null && !_hasOptimizedTests && serialTests.isEmpty; + /// The paths to append to the test runner's arguments, empty for [none]. List get testTargets { if (packageRoot == null) return const []; return [ if (_hasOptimizedTests || serialTests.isEmpty) p.join('test', testOptimizerFileName), - for (final test in serialTests) p.join('test', test), + // The hook emits POSIX paths; keep the argument native. + for (final test in serialTests) + p.joinAll(['test', ...p.posix.split(test)]), ]; } diff --git a/site/docs/commands/test.md b/site/docs/commands/test.md index a46c7a052..6c9447fff 100644 --- a/site/docs/commands/test.md +++ b/site/docs/commands/test.md @@ -52,6 +52,14 @@ 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= The 1-based index of the shard to run. + Must be used together with --total-shards. + Requires optimization to be enabled. + When omitted, no sharding is applied. + --total-shards= 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. + When omitted, no sharding is applied. Run "very_good help" to see global options. ``` @@ -75,6 +83,55 @@ 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 kept out of the bundle, whether tagged `skip_very_good_optimization` or +matched by `--exclude-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. The same +applies to a shard whose tests are all filtered out, for example by +`--exclude-tags golden` on a shard that only holds golden tests. + +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`, and a `min_coverage` set in +`very_good.yaml` is ignored while sharding. 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. A shard without tests still writes an empty +`coverage/lcov.info`; pass `--ignore-errors empty` to `lcov` when merging so it +is accepted. +::: + +:::info +Sharding requires the test optimizer, so it is rejected whenever the optimizer +is off: with `--no-optimization`, `--platform`, `--update-goldens`, or when +targeting specific test files. +::: + ### 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 0d49df490..e847bfc91 100644 --- a/test/src/cli/test_cli_runner_test.dart +++ b/test/src/cli/test_cli_runner_test.dart @@ -1135,6 +1135,298 @@ void main() { expect(testRunnerArgs, equals(['--coverage', '--coverage'])); }); + 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, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + 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( + 'writes an empty lcov file for an empty shard with coverage', + () 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, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + shardIndex: 3, + totalShards: 3, + ), + collectCoverage: true, + overrideTestRunner: testRunner(const Stream.empty()), + ), + completion(equals([ExitCode.success.code])), + ); + + final lcov = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + expect(lcov.existsSync(), isTrue); + expect(lcov.lengthSync(), 0); + expect(testRunnerArgs, isEmpty); + }, + ); + + 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, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + 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, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + 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'))); + }, + ); + + group('when no tests ran', () { + // The exit code of `dart test` and `flutter test` when every test was + // filtered out, for example by `--exclude-tags`. + const noTestsRanExitCode = 79; + + late Directory tempDirectory; + + setUp(() { + 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': 'golden_test.dart', 'identifier': 'golden'}, + ], + }); + }); + }); + + test('succeeds when sharding', () async { + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + logger: logger, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + shardIndex: 2, + totalShards: 3, + ), + arguments: ['--exclude-tags', 'golden'], + overrideTestRunner: testRunner( + Stream.fromIterable([ + const ExitTestEvent(exitCode: noTestsRanExitCode, time: 0), + ]), + ), + ), + completion(equals([ExitCode.success.code])), + ); + }); + + test('fails when not sharding', () async { + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + logger: logger, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + optimizer: TestOptimizer( + enabled: true, + buildGenerator: generatorBuilder(), + ), + arguments: ['--exclude-tags', 'golden'], + overrideTestRunner: testRunner( + Stream.fromIterable([ + const ExitTestEvent(exitCode: noTestsRanExitCode, time: 0), + ]), + ), + ), + completion(equals([ExitCode.unavailable.code])), + ); + }); + }); + test('runs tests w/optimizations (passing)', () async { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); @@ -1482,7 +1774,7 @@ void main() { testRunnerArgs, equals([ p.join('test', '.test_optimizer.dart'), - p.join('test', 'integration/login_test.dart'), + p.joinAll(['test', 'integration', 'login_test.dart']), ]), ); verify( @@ -1518,7 +1810,9 @@ void main() { expect( testRunnerArgs, - equals([p.join('test', 'app/view/app_test.dart')]), + equals([ + p.joinAll(['test', 'app', 'view', 'app_test.dart']), + ]), ); }, ); diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index de8fcfd2c..974fc0213 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -51,6 +51,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= The 1-based index of the shard to run. Must be used together with --total-shards. Requires optimization to be enabled. When omitted, no sharding is applied.\n' + ' --total-shards= 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. When omitted, no sharding is applied.\n' '\n' 'Run "very_good help" to see global options.', ]; @@ -76,6 +78,8 @@ abstract class DartTestCommandCall { void Function(String)? stderr, bool? forceAnsi, List? reportOn, + int? shardIndex, + int? totalShards, }); } @@ -124,6 +128,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); @@ -135,6 +141,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'); when(() => argResults['report-on']).thenReturn([]); @@ -917,6 +925,143 @@ 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); + }); + + const optimizerDisabledError = + 'Sharding requires the test optimizer, which is disabled by ' + '--no-optimization, --platform, --update-goldens or by targeting ' + 'specific test files.'; + + 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(optimizerDisabledError)).called(1); + verifyNever(() => dartTest(shardIndex: 1, totalShards: 3)); + }); + + test('fails when sharding with --platform', () async { + withShards('1', '3'); + when(() => argResults['platform']).thenReturn('chrome'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(optimizerDisabledError)).called(1); + }); + + test('fails when sharding while targeting test files', () async { + withShards('1', '3'); + when(() => argResults.rest).thenReturn(['test/foo_test.dart']); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(optimizerDisabledError)).called(1); + }); + + test( + 'ignores a min_coverage from very_good.yaml when sharding', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + Directory.current = tempDirectory.path; + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + File(path.join(tempDirectory.path, 'very_good.yaml')) + .writeAsStringSync('dart:\n test:\n min_coverage: 90\n'); + when(() => argResults.wasParsed(any())).thenReturn(false); + withShards('1', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + shardIndex: 1, + totalShards: 3, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).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('DartTestOptions.shouldOptimize', () { test('is true for a plain run', () { expect(DartTestOptions.parse(argResults).shouldOptimize, isTrue); @@ -1090,6 +1235,7 @@ void main() { ), ); expect(options.minCoverage, equals(50)); + expect(options.rawMinCoverage, equals('50')); }); test('CLI --file-reporter takes precedence over config value', () { diff --git a/test/src/commands/test/test_test.dart b/test/src/commands/test/test_test.dart index b7a369044..4233e6a40 100644 --- a/test/src/commands/test/test_test.dart +++ b/test/src/commands/test/test_test.dart @@ -54,6 +54,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= The 1-based index of the shard to run. Must be used together with --total-shards. Requires optimization to be enabled. When omitted, no sharding is applied.\n' + ' --total-shards= 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. When omitted, no sharding is applied.\n' '\n' 'Run "very_good help" to see global options.', ]; @@ -78,6 +80,8 @@ abstract class FlutterTestCommand { void Function(String)? stderr, bool? forceAnsi, List? reportOn, + int? shardIndex, + int? totalShards, }); } @@ -125,6 +129,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); @@ -135,6 +141,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); @@ -1013,6 +1021,216 @@ 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); + }); + + const optimizerDisabledError = + 'Sharding requires the test optimizer, which is disabled by ' + '--no-optimization, --platform, --update-goldens or by targeting ' + 'specific test files.'; + + 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(optimizerDisabledError)).called(1); + verifyNever(() => flutterTest(shardIndex: 1, totalShards: 3)); + }); + + test('fails when sharding with --platform', () async { + withShards('1', '3'); + when(() => argResults['platform']).thenReturn('chrome'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(optimizerDisabledError)).called(1); + }); + + test('fails when sharding with --update-goldens', () async { + withShards('1', '3'); + when(() => argResults['update-goldens']).thenReturn(true); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(optimizerDisabledError)).called(1); + }); + + test( + 'ignores a min_coverage from very_good.yaml when sharding', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + Directory.current = tempDirectory.path; + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + File(path.join(tempDirectory.path, 'very_good.yaml')) + .writeAsStringSync('test:\n min_coverage: 90\n'); + when(() => argResults.wasParsed(any())).thenReturn(false); + withShards('1', '3'); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => flutterTest( + shardIndex: 1, + totalShards: 3, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).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('FlutterTestOptions.shouldOptimize', () { test('is true for a plain run', () { expect(FlutterTestOptions.parse(argResults).shouldOptimize, isTrue); @@ -1089,6 +1307,7 @@ void main() { ), ); expect(options.minCoverage, equals(90)); + expect(options.rawMinCoverage, isNull); expect(options.excludeFromCoverage, equals('**/*.g.dart')); expect(options.reportOn, equals(['lib/'])); expect(options.fileReporter, equals('json:reports/tests.json')); @@ -1106,6 +1325,7 @@ void main() { ), ); expect(options.minCoverage, equals(50)); + expect(options.rawMinCoverage, equals('50')); }); test('CLI --file-reporter takes precedence over config value', () { diff --git a/test/src/test_optimizer/test_optimizer_test.dart b/test/src/test_optimizer/test_optimizer_test.dart index d0afbc02b..85154bb71 100644 --- a/test/src/test_optimizer/test_optimizer_test.dart +++ b/test/src/test_optimizer/test_optimizer_test.dart @@ -180,6 +180,44 @@ void main() { expect(optimization.serialTests, equals(['tagged_test.dart'])); }); + test('forwards the shard to the hook', () async { + final optimization = await TestOptimizer( + enabled: true, + buildGenerator: (_) async => generator, + shardIndex: 2, + totalShards: 3, + ).apply(packageRoot: tempDirectory.path, logger: logger); + + verify( + () => hooks.preGen( + vars: { + 'package-root': tempDirectory.path, + 'shard-index': 2, + 'total-shards': 3, + }, + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: tempDirectory.path, + ), + ).called(1); + expect(optimization.shardIndex, equals(2)); + }); + + test('leaves the shard vars out when not sharding', () async { + final optimization = await buildOptimizer().apply( + packageRoot: tempDirectory.path, + logger: logger, + ); + + verify( + () => hooks.preGen( + vars: {'package-root': tempDirectory.path}, + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: tempDirectory.path, + ), + ).called(1); + expect(optimization.shardIndex, isNull); + }); + test('moves tests matching a glob out of the bundle', () async { preGenVars['tests'] = [ { @@ -275,6 +313,49 @@ void main() { }); }); + group('.isEmptyShard', () { + Future shard(int index) { + return TestOptimizer( + enabled: true, + buildGenerator: (_) async => generator, + shardIndex: index, + totalShards: 3, + ).apply(packageRoot: tempDirectory.path, logger: logger); + } + + test('is true when the shard holds no tests at all', () async { + preGenVars['tests'] = []; + + expect((await shard(3)).isEmptyShard, isTrue); + }); + + test('is false when the shard holds bundled tests', () async { + expect((await shard(1)).isEmptyShard, isFalse); + }); + + test('is false when the shard only holds tests kept out', () async { + preGenVars['tests'] = []; + preGenVars['notOptimizedTests'] = ['tagged_test.dart']; + + expect((await shard(2)).isEmptyShard, isFalse); + }); + + test('is false for an empty package that is not sharded', () async { + preGenVars['tests'] = []; + + final optimization = await buildOptimizer().apply( + packageRoot: tempDirectory.path, + logger: logger, + ); + + expect(optimization.isEmptyShard, isFalse); + }); + + test('is false when nothing was optimized', () { + expect(TestOptimization.none.isEmptyShard, isFalse); + }); + }); + group('.testTargets', () { Future optimize({List? exclude}) { return buildOptimizer(exclude: exclude) @@ -311,7 +392,9 @@ void main() { expect( (await optimize(exclude: const ['test/integration'])).testTargets, - equals([p.join('test', 'integration/login_test.dart')]), + equals([ + p.joinAll(['test', 'integration', 'login_test.dart']), + ]), ); });