1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.adservices.experimental;
17 
18 import static java.lang.annotation.ElementType.METHOD;
19 import static java.lang.annotation.ElementType.TYPE;
20 import static java.lang.annotation.RetentionPolicy.RUNTIME;
21 
22 import com.android.adservices.shared.testing.Logger;
23 import com.android.adservices.shared.testing.Nullable;
24 
25 import com.google.common.collect.ImmutableList;
26 
27 import org.junit.AssumptionViolatedException;
28 import org.junit.runner.Description;
29 import org.junit.runner.notification.RunNotifier;
30 import org.junit.runners.BlockJUnit4ClassRunner;
31 import org.junit.runners.model.FrameworkMethod;
32 import org.junit.runners.model.InitializationError;
33 import org.junit.runners.model.Statement;
34 import org.junit.runners.model.TestClass;
35 
36 import java.lang.annotation.Repeatable;
37 import java.lang.annotation.Retention;
38 import java.lang.annotation.Target;
39 import java.util.ArrayList;
40 import java.util.Arrays;
41 import java.util.Collection;
42 import java.util.Collections;
43 import java.util.List;
44 import java.util.Objects;
45 import java.util.stream.Collectors;
46 
47 // TODO(b/284971005): move to module-utils
48 // TODO(b/284971005): rename / make it clear it's for boolean flags (and/or make it genric?)
49 // TODO(b/284971005): improve javadoc / add  examples
50 // TODO(b/284971005): add unit tests
51 /**
52  * Test runner used to run tests with all combinations of a set of boolean flags (the {@link
53  * #getFlagsRoulette(TestClass, List) "roulette"}).
54  *
55  * <p>For example, if a test depends on flags {@code [foo, bar]}, the runner will run the tests with
56  * 4 combinations:
57  *
58  * <ol>
59  *   <li>foo=false, bar=false
60  *   <li>foo=false, bar=true
61  *   <li>foo=true, bar=false
62  *   <li>foo=true, bar=true
63  * </ol>
64  *
65  * <p>The runner also support test-level optimizations; for example, if a test only needs to run
66  * when the {@code foo} flag is on, it can be annotated with the {@link RequiresFlagOn} annotation
67  * (and value {@code "foo"}).
68  */
69 public abstract class AbstractFlagsRouletteRunner extends BlockJUnit4ClassRunner {
70 
71     // TODO(b/284971005): currently class only support boolean flags - extend it to be more generic?
72     private static final boolean[] FLAG_VALUES = {false, true};
73 
74     // NOTE: super() calls collectInitializationErrors(), so that method (or methods called by it)
75     // will throw a NPE if they access a field that's not initialized yet (that's why this class
76     // doesn't provide a mLog but requires a log(), for example)
77     private final FlagsManager mFlagsManager;
78     private List<String> mFlagsRoulette; // see getFlagsRoulette()
79     private List<FrameworkMethod> mModifiedMethods;
80 
81     private static final ThreadLocal<FlagsRouletteState> sCurrentRunner = new ThreadLocal<>();
82 
AbstractFlagsRouletteRunner(Class<?> testClass, FlagsManager manager)83     protected AbstractFlagsRouletteRunner(Class<?> testClass, FlagsManager manager)
84             throws InitializationError {
85         super(testClass);
86 
87         mFlagsManager = Objects.requireNonNull(manager);
88         log().i(
89                         "AbstractFlagsRouletteRunner(): mFlagsManager=%s, mFlagsRoulette=%s",
90                         mFlagsManager, mFlagsRoulette);
91     }
92 
93     /** Provides the {@link Logger}. */
log()94     protected abstract Logger log();
95 
96     /**
97      * Defines which flags the runner will set before running the tests.
98      *
99      * <p>By default, it will try to infer it from the following annotations:
100      *
101      * <ol>
102      *   <li>{@link FlagsProviderClass}
103      *   <li>{@link FlagsRoulette}
104      * </ol>
105      *
106      * <p>But sub-classes can override it to simplify the tests (for, example to return a
107      * pre-defined combination of flags).
108      *
109      * @param testClass test being run
110      * @param errors used to report errors back to JUNit
111      * @return which flags the runner will set before running the tests
112      */
getFlagsRoulette(TestClass testClass, List<Throwable> errors)113     protected String[] getFlagsRoulette(TestClass testClass, List<Throwable> errors) {
114         // First try provider...
115         FlagsProviderClass flagsProviderClass = testClass.getAnnotation(FlagsProviderClass.class);
116         if (flagsProviderClass != null) {
117             return getFlagsFromProvider(
118                     flagsProviderClass.value(), testClass.getJavaClass(), errors);
119         }
120         // ...then annotation
121         FlagsRoulette annotation = testClass.getAnnotation(FlagsRoulette.class);
122         if (annotation != null) {
123             String[] flags = annotation.value();
124             log().i(
125                             "getRouletteFlags(): returning flags from @%s: %s",
126                             FlagsRoulette.class, Arrays.toString(flags));
127             return flags;
128         }
129         // ...or fail!
130         errors.add(
131                 new IllegalStateException(
132                         "Could not infer roulette flags: "
133                                 + getClass()
134                                 + " doesn't override getRouletteFlags() and "
135                                 + testClass
136                                 + "is not annotated with @"
137                                 + FlagsRoulette.class
138                                 + " or @"
139                                 + FlagsProvider.class));
140         return null;
141     }
142 
143     /**
144      * Override this method and return {@code false} to disable the runner (so it will run every
145      * tests "as-is").
146      */
isDisabled()147     protected boolean isDisabled() {
148         return false;
149     }
150 
151     /**
152      * Gets the flag states that are required by a test method, so the method is skipped when the
153      * test is run with flags that don't match it.
154      *
155      * <p>By default it returns the values provided by the {@link RequiresFlag}, but subclasses can
156      * override it to use other annotations.
157      *
158      * @return flag states that are required by a test method or {@code null} if test doesn't have
159      *     any flag restriction.
160      */
getRequiredFlagStates(FrameworkMethod method)161     protected @Nullable FlagState[] getRequiredFlagStates(FrameworkMethod method) {
162         // Try individual annotation first
163         RequiresFlag singleAnnotation = method.getAnnotation(RequiresFlag.class);
164         if (singleAnnotation != null) {
165             return new FlagState[] {new FlagState(singleAnnotation)};
166         }
167         RequiresFlags groupAnnotation = method.getAnnotation(RequiresFlags.class);
168         return groupAnnotation == null
169                 ? null
170                 : Arrays.stream(groupAnnotation.value())
171                         .map((annotation -> new FlagState(annotation)))
172                         .toArray(FlagState[]::new);
173     }
174 
175     // TODO(b/284971005):  this method (which is called by the constructor) is not only collecting
176     // errors but also initializing mFlagsRoulette, ideally we should split (and move the roulette
177     // initialization to some later stages)
178     @Override
collectInitializationErrors(List<Throwable> errors)179     protected final void collectInitializationErrors(List<Throwable> errors) {
180         if (isDisabled()) {
181             log().v("collectInitializationErrors(): ignoring because isDisabled() returned true");
182             super.collectInitializationErrors(errors);
183             return;
184         }
185         TestClass testClass = getTestClass();
186         String[] flagsRoulette = getFlagsRoulette(testClass, errors);
187         log().v(
188                         "collectInitializationErrors(): flags=%s, errors=%s",
189                         Arrays.toString(flagsRoulette), errors);
190         if (flagsRoulette == null || flagsRoulette.length == 0) {
191             // Test run will eventually fail due to errors, but it's better to set mFlags anyways,
192             // just in case (to avoid NPE)
193             mFlagsRoulette = Collections.emptyList();
194             if (errors.isEmpty()) {
195                 errors.add(
196                         new IllegalStateException(
197                                 getClass() + " overrode getRouletteFlags() but returned null"));
198             }
199         } else {
200             mFlagsRoulette =
201                     new ArrayList<>(Arrays.stream(flagsRoulette).collect(Collectors.toSet()));
202         }
203 
204         super.collectInitializationErrors(errors);
205     }
206 
207     /**
208      * {@inheritDoc}
209      *
210      * <p>This method will create multiple {@link FlagSnapshotMethod FlagSnapshotMethods} for each
211      * "real" method, one with each of the required flag snapshots. For example, if the roulette
212      * contains flags {@code FlagA} and {@code FlagB}, it would by default return 4 {@link
213      * FlagSnapshotMethod FlagSnapshotMethods} for each method (for the 4 combinations of {@code
214      * true} and {@code false}, but it has some additional logic:
215      *
216      * <ul>
217      *   <li>Returns the original methods if the runner is {@link #isDisabled() disabled}.
218      *   <li>Ignores snapshots whose flags values would invalidate the flag requirements the method
219      *       is annotated with (for example, if method requires that {@code FlagA=true}, then it
220      *       would just return 2 snapshots for that method - {@code FlagA=true, FlagB=false} and
221      *       {@code FlagA=true, FlagB=true}).
222      * </ul>
223      */
224     @Override
computeTestMethods()225     protected final List<FrameworkMethod> computeTestMethods() {
226         if (isDisabled()) {
227             log().v("computeTestMethods(): ignoring because isDisabled() returned true");
228             return super.computeTestMethods();
229         }
230         log().d(
231                         "computeTestMethods(): mFlags=%s, mModifiedMethods=%s",
232                         mFlagsRoulette,
233                         mModifiedMethods == null ? "null" : "" + mModifiedMethods.size());
234 
235         if (mModifiedMethods != null) {
236             // TODO(b/284971005): this method is called twice (by validate(), which is called by the
237             // constructor, and by getChildren(), which is called by runChildren()), so we're
238             // caching the results because it seems to be unnecessary to re-calculate them. But we
239             // need to investigate it further to make sure (for example, it's called by
240             // validateInstanceMethods(), which is @Deprecated)
241             return mModifiedMethods;
242         }
243         List<FrameworkMethod> originalMethods = super.computeTestMethods();
244         mModifiedMethods = new ArrayList<>();
245         List<List<FlagState>> flagsSnapshots = getFlagsSnapshots();
246         log().v(
247                         "computeTestMethods(): %d flags snapshots: %s",
248                         flagsSnapshots.size(), flagsSnapshots);
249 
250         for (FrameworkMethod method : originalMethods) {
251             FlagState[] requiredFlagStates = getRequiredFlagStates(method);
252             for (List<FlagState> snapshot : flagsSnapshots) {
253                 FrameworkMethod modifiedMethod = null;
254                 if (requiredFlagStates != null) {
255                     modifiedMethod =
256                             FlagSnapshotMethod.forRequiredFlagStates(
257                                     method, requiredFlagStates, snapshot, log());
258                 }
259                 if (modifiedMethod == null) {
260                     modifiedMethod =
261                             new FlagSnapshotMethod(method, snapshot, /* skippedException= */ null);
262                 }
263                 mModifiedMethods.add(modifiedMethod);
264             }
265         }
266 
267         sortMethodsByFlagSnapshotsAndName(mModifiedMethods);
268 
269         log().d(
270                         "computeTestMethods(): %d originalMethods, %d modifiedMethods",
271                         originalMethods.size(), mModifiedMethods.size());
272         return mModifiedMethods;
273     }
274 
275     @Override
runChild(FrameworkMethod method, RunNotifier notifier)276     protected void runChild(FrameworkMethod method, RunNotifier notifier) {
277         if (isDisabled()) {
278             log().v("runChild(): ignoring because isDisabled() returned true");
279             super.runChild(method, notifier);
280             return;
281         }
282 
283         FlagSnapshotMethod fsMethod = castFlagSnapshotMethod(method);
284         if (fsMethod.mSkippedException != null) {
285             Description description = describeChild(method);
286             Statement statement =
287                     new Statement() {
288                         @Override
289                         public void evaluate() throws Throwable {
290                             throw fsMethod.mSkippedException;
291                         }
292                     };
293             runLeaf(statement, description, notifier);
294             return;
295         }
296 
297         mFlagsManager.setFlags(fsMethod.getFlagsSnapshot());
298         sCurrentRunner.set(new FlagsRouletteState(this));
299         try {
300             super.runChild(method, notifier);
301         } finally {
302             sCurrentRunner.remove();
303             resetFlags();
304         }
305     }
306 
307     @Nullable
getFlagsFromProvider( Class<? extends FlagsProvider> providerClass, Class<?> testClass, List<Throwable> errors)308     private String[] getFlagsFromProvider(
309             Class<? extends FlagsProvider> providerClass,
310             Class<?> testClass,
311             List<Throwable> errors) {
312         FlagsProvider provider = null;
313         try {
314             provider = providerClass.getDeclaredConstructor().newInstance();
315         } catch (Exception e) {
316             errors.add(e);
317             return null;
318         }
319         String[] flags = provider.getFlags(testClass);
320         if (flags == null || flags.length == 0) {
321             errors.add(
322                     new IllegalStateException("Provider " + provider + " didn't return any flag"));
323             return null;
324         }
325         log().d("Flags from %s for %s: %s", provider, testClass, Arrays.toString(flags));
326         return flags;
327     }
328 
sortMethodsByFlagSnapshotsAndName(List<FrameworkMethod> genericMethods)329     private void sortMethodsByFlagSnapshotsAndName(List<FrameworkMethod> genericMethods) {
330         genericMethods.sort(
331                 (o1, o2) -> {
332                     FlagSnapshotMethod method1 = castFlagSnapshotMethod(o1);
333                     FlagSnapshotMethod method2 = castFlagSnapshotMethod(o2);
334                     List<FlagState> snapshots1 = method1.getFlagsSnapshot();
335                     List<FlagState> snapshots2 = method2.getFlagsSnapshot();
336                     int size1 = snapshots1.size();
337                     int size2 = snapshots2.size();
338                     if (size1 != size2) {
339                         // Shouldn't happen
340                         log().e(
341                                         "sortMethodsByFlagSnapshots(): sizes mismatch (%d != %d),"
342                                                 + " method1=%s, method2=%s",
343                                         size1, size2, o1, o2);
344                         return size1 - size2;
345                     }
346                     for (int i = 0; i < size1; i++) {
347                         boolean value1 = snapshots1.get(i).value;
348                         boolean value2 = snapshots2.get(i).value;
349                         if (value1 != value2) {
350                             return sortMethodsByFlags(value1, value2);
351                         }
352                     }
353                     // Tests have same flag snapshots - sort by name
354                     return sortMethodsByName(
355                             method1.getMethod().getName(), method2.getMethod().getName());
356                 });
357     }
358 
359     /** Defines the order of the test execution based on flags values. */
sortMethodsByFlags(boolean flag1, boolean flag2)360     protected int sortMethodsByFlags(boolean flag1, boolean flag2) {
361         return flag1 ? 1 : -1;
362     }
363 
364     /** Defines the order of the test execution based on test name. */
sortMethodsByName(String name1, String name2)365     protected int sortMethodsByName(String name1, String name2) {
366         return name1.compareTo(name2);
367     }
368 
resetFlags()369     private void resetFlags() {
370         mFlagsManager.resetFlags();
371     }
372 
373     // TODO(b/284971005): this is not the most optional way to combine the 2^N values, but it's fine
374     // for now
getFlagsSnapshots()375     private ImmutableList<List<FlagState>> getFlagsSnapshots() {
376         if (mFlagsRoulette.isEmpty()) {
377             return ImmutableList.of();
378         }
379         List<List<FlagState>> snapshots = new ArrayList<>();
380         ArrayList<FlagState> flagsSnapshots = new ArrayList<>();
381         getFlagSnapshots(snapshots, flagsSnapshots, 0);
382         return ImmutableList.copyOf(snapshots);
383     }
384 
getFlagSnapshots( List<List<FlagState>> snapshots, List<FlagState> previousSnapshots, int index)385     private void getFlagSnapshots(
386             List<List<FlagState>> snapshots, List<FlagState> previousSnapshots, int index) {
387         String flag = mFlagsRoulette.get(index);
388         for (boolean value : FLAG_VALUES) {
389             List<FlagState> newSnapshots = new ArrayList<>(previousSnapshots);
390             newSnapshots.add(new FlagState(flag, value));
391             if (index < mFlagsRoulette.size()) {
392                 for (int i = index + 1; i < mFlagsRoulette.size(); i++) {
393                     getFlagSnapshots(snapshots, newSnapshots, i);
394                 }
395             }
396             if (newSnapshots.size() == mFlagsRoulette.size()) {
397                 log().v("Adding new snapshots: %s", newSnapshots);
398                 snapshots.add(newSnapshots);
399             }
400         }
401     }
402 
castFlagSnapshotMethod(FrameworkMethod method)403     private static FlagSnapshotMethod castFlagSnapshotMethod(FrameworkMethod method) {
404         if (!(method instanceof FlagSnapshotMethod)) {
405             throw new IllegalStateException("Invalid method : " + method);
406         }
407         return (FlagSnapshotMethod) method;
408     }
409 
toString(List<FlagState> snapshots)410     private static String toString(List<FlagState> snapshots) {
411         StringBuilder string = new StringBuilder().append('[');
412         int size = snapshots.size();
413         for (int i = 0; i < size; i++) {
414             FlagState snapshot = snapshots.get(i);
415             string.append(snapshot.name).append('=').append(snapshot.value);
416             if (i < size - 1) {
417                 string.append(',');
418             }
419         }
420         return string.append(']').toString();
421     }
422 
423     // TODO(b/284971005): move to its own class?
424     @SuppressWarnings("serial")
425     public static final class FlagStateAssumptionViolatedException
426             extends AssumptionViolatedException {
427 
FlagStateAssumptionViolatedException(FlagState flagState, FrameworkMethod method)428         private FlagStateAssumptionViolatedException(FlagState flagState, FrameworkMethod method) {
429             super(
430                     "Ignoring "
431                             + method.getName()
432                             + " when turning flag "
433                             + flagState.name
434                             + (flagState.value ? " ON" : " OFF"));
435         }
436     }
437 
438     // TODO(b/284971005): move to its own class?
439     private static final class FlagSnapshotMethod extends FrameworkMethod {
440         private final List<FlagState> mFlagsSnapshot;
441         private final @Nullable AssumptionViolatedException mSkippedException;
442 
FlagSnapshotMethod( FrameworkMethod method, List<FlagState> flagsSnapshot, AssumptionViolatedException skippedException)443         private FlagSnapshotMethod(
444                 FrameworkMethod method,
445                 List<FlagState> flagsSnapshot,
446                 AssumptionViolatedException skippedException) {
447             super(method.getMethod());
448             mFlagsSnapshot = flagsSnapshot;
449             mSkippedException = skippedException;
450         }
451 
452         @Nullable
forRequiredFlagStates( FrameworkMethod method, FlagState[] requiredFlagStates, List<FlagState> flagsSnapshot, Logger log)453         private static FlagSnapshotMethod forRequiredFlagStates(
454                 FrameworkMethod method,
455                 FlagState[] requiredFlagStates,
456                 List<FlagState> flagsSnapshot,
457                 Logger log) {
458             if (requiredFlagStates == null) {
459                 return null;
460             }
461             log.v(
462                     "forRequiredFlagStates(): method=%s, requiredFlagStates=%s, flagSnapshots=%s",
463                     method.getName(), requiredFlagStates, flagsSnapshot);
464             // TODO(b/284971005): optimize it (instead of O(N x M)
465             for (FlagState flagSnapshot : flagsSnapshot) {
466                 for (FlagState requiredFlagState : requiredFlagStates) {
467                     if (flagSnapshot.name.equals(requiredFlagState.name)) {
468                         log.v(
469                                 "forRequiredFlags(): found required flag state (%s) on snapshot %s",
470                                 requiredFlagState, flagSnapshot);
471                         boolean requiresOn = requiredFlagState.value;
472                         boolean isOn = flagSnapshot.value;
473                         if (requiresOn != isOn) {
474                             return new FlagSnapshotMethod(
475                                     method,
476                                     flagsSnapshot,
477                                     new FlagStateAssumptionViolatedException(flagSnapshot, method));
478                         }
479                     }
480                 }
481             }
482             log.v(
483                     "forRequiredFlagStates(): method %s is annotated with required flag states"
484                             + " (%s), but they were not found in the flags snapshot (%s)",
485                     method.getName(), requiredFlagStates, flagsSnapshot);
486             return null;
487         }
488 
getFlagsSnapshot()489         private List<FlagState> getFlagsSnapshot() {
490             return mFlagsSnapshot;
491         }
492 
493         @Override
getName()494         public String getName() {
495             return super.getName() + AbstractFlagsRouletteRunner.toString(mFlagsSnapshot);
496         }
497 
498         @Override
hashCode()499         public int hashCode() {
500             final int prime = 31;
501             int result = super.hashCode();
502             result = prime * result + Objects.hash(mFlagsSnapshot);
503             return result;
504         }
505 
506         @Override
equals(Object obj)507         public boolean equals(Object obj) {
508             if (this == obj) return true;
509             if (!super.equals(obj)) return false;
510             if (getClass() != obj.getClass()) return false;
511             FlagSnapshotMethod other = (FlagSnapshotMethod) obj;
512             return Objects.equals(mFlagsSnapshot, other.mFlagsSnapshot);
513         }
514 
515         @Override
toString()516         public String toString() {
517             return "["
518                     + getClass().getSimpleName()
519                     + ": method="
520                     + getMethod()
521                     + ", flags="
522                     + AbstractFlagsRouletteRunner.toString(mFlagsSnapshot)
523                     + ']';
524         }
525     }
526 
527     /**
528      * Annotation used to indicate that a test method should once be run when the given {@link
529      * RequiresFlag#name() name} is set with the given {@link RequiresFlag#value() value}.
530      */
531     @Retention(RUNTIME)
532     @Target({TYPE, METHOD})
533     @Repeatable(RequiresFlags.class)
534     public static @interface RequiresFlag {
535         /** Name of the flag. */
name()536         String name();
537         /** Value the flag should have when the test is running */
value()538         boolean value() default true;
539     }
540 
541     @Retention(RUNTIME)
542     @Target({TYPE, METHOD})
543     public static @interface RequiresFlags {
value()544         RequiresFlag[] value();
545     }
546 
547     // TODO(b/284971005): move to its own class?
548     /** Provides the flags used by the test. */
549     public interface FlagsProvider {
getFlags(Class<?> testClass)550         String[] getFlags(Class<?> testClass);
551     }
552 
553     /**
554      * Annotation used to define which flags will be set when the tests are run - see {@link
555      * AbstractFlagsRouletteRunner#getFlagsRoulette(TestClass, List)}.
556      */
557     @Retention(RUNTIME)
558     @Target(TYPE)
559     public static @interface FlagsRoulette {
value()560         String[] value();
561     }
562 
563     /**
564      * Annotation used to define which flags will be set when the tests are run - see {@link
565      * AbstractFlagsRouletteRunner#getFlagsRoulette(TestClass, List)}.
566      */
567     @Retention(RUNTIME)
568     @Target(TYPE)
569     public static @interface FlagsProviderClass {
value()570         Class<? extends FlagsProvider> value();
571     }
572 
573     /** Abstraction used to manage the values of flags when the tests are run. */
574     public interface FlagsManager {
575 
576         /** Sets the values of the given flags. */
setFlags(Collection<FlagState> flags)577         void setFlags(Collection<FlagState> flags);
578 
579         /** Resets the value of all flags that were set by {@link #setFlags(Collection)}. */
resetFlags()580         void resetFlags();
581     }
582 
583     // TODO(b/284971005): move to its own class?
584     /** POJO (Plain-Old Java Object) containing the value of a flag. */
585     public static final class FlagState {
586         public final String name;
587         public final boolean value;
588 
FlagState(String name, boolean value)589         public FlagState(String name, boolean value) {
590             this.name = name;
591             this.value = value;
592         }
593 
FlagState(RequiresFlag annotation)594         private FlagState(RequiresFlag annotation) {
595             this(annotation.name(), annotation.value());
596         }
597 
598         @Override
hashCode()599         public int hashCode() {
600             return Objects.hash(name, value);
601         }
602 
603         @Override
equals(Object obj)604         public boolean equals(Object obj) {
605             if (this == obj) return true;
606             if (obj == null) return false;
607             if (getClass() != obj.getClass()) return false;
608             FlagState other = (FlagState) obj;
609             return Objects.equals(name, other.name) && value == other.value;
610         }
611 
612         @Override
toString()613         public String toString() {
614             return "[" + name + "=" + value + "]";
615         }
616     }
617 
618     /**
619      * Gets the info about the runner running the current test (or {@code null} if no test is
620      * running).
621      */
getFlagsRouletteState()622     public static @Nullable FlagsRouletteState getFlagsRouletteState() {
623         return sCurrentRunner.get();
624     }
625 
626     /**
627      * Info about the runner running the current test.
628      *
629      * <p>Obtained by {@link #getFlagsRouletteState()}
630      */
631     public static final class FlagsRouletteState {
632         public final String runnerName;
633         public final List<String> flagNames;
634 
FlagsRouletteState(AbstractFlagsRouletteRunner runner)635         private FlagsRouletteState(AbstractFlagsRouletteRunner runner) {
636             runnerName = runner.getClass().getSimpleName();
637             flagNames = Collections.unmodifiableList(new ArrayList<>(runner.mFlagsRoulette));
638         }
639     }
640 }
641