Skip to content

Commit 0c9dbc2

Browse files
authored
fix awaitility step hierarchy with reusable executor threads (fixes #891, via #1350)
1 parent f781a6a commit 0c9dbc2

9 files changed

Lines changed: 476 additions & 82 deletions

File tree

allure-awaitility/src/main/java/io/qameta/allure/awaitility/AllureAwaitilityListener.java

Lines changed: 98 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import java.io.StringWriter;
3636
import java.nio.charset.StandardCharsets;
3737
import java.util.concurrent.TimeUnit;
38+
import java.util.concurrent.atomic.AtomicBoolean;
39+
import java.util.concurrent.atomic.AtomicReference;
3840

3941
import static java.util.concurrent.TimeUnit.MILLISECONDS;
4042

@@ -60,6 +62,12 @@
6062
* </code>
6163
* </pre>
6264
* </p>
65+
* <p>
66+
* A listener instance tracks a single condition at a time, so prefer one instance per condition when tests run in
67+
* parallel. Awaitility fires no callback when a non-ignored exception escapes a condition; such an aborted wait
68+
* stays the reporting parent until the same listener starts its next condition, which finalizes the aborted one,
69+
* or until the test ends.
70+
* </p>
6371
*
6472
* @see org.awaitility.core.ConditionEvaluationListener
6573
* @see Awaitility#setDefaultConditionEvaluationListener(ConditionEvaluationListener)
@@ -69,17 +77,15 @@
6977
@SuppressWarnings("unused")
7078
public class AllureAwaitilityListener implements ConditionEvaluationListener<Object> {
7179

72-
private TimeUnit unit;
73-
private boolean logIgnoredExceptions;
80+
private final AtomicReference<TimeUnit> unit = new AtomicReference<>(MILLISECONDS);
81+
private final AtomicBoolean logIgnoredExceptions = new AtomicBoolean(true);
7482
private final String onStartStepTextPattern;
7583
private final String onSatisfiedStepTextPattern;
7684
private final String onAwaitStepTextPattern;
7785
private final String onTimeoutStepTextPattern;
7886
private final String onExceptionStepTextPattern;
7987

80-
private AllureExternalKey currentConditionStepKey;
81-
82-
private AllureThreadBinding currentConditionBinding;
88+
private final AtomicReference<ConditionState> currentCondition = new AtomicReference<>();
8389

8490
/**
8591
* Returns the lifecycle.
@@ -94,8 +100,6 @@ public static AllureLifecycle getLifecycle() {
94100
* Default all args constructor with default params.
95101
*/
96102
public AllureAwaitilityListener() {
97-
this.unit = MILLISECONDS;
98-
this.logIgnoredExceptions = true;
99103
this.onStartStepTextPattern = "Awaitility: %s";
100104
this.onSatisfiedStepTextPattern = "%s after %d %s (remaining time %d %s, last poll interval was %s)";
101105
this.onAwaitStepTextPattern = "%s (elapsed time %d %s, remaining time %d %s (last poll interval was %s))";
@@ -110,7 +114,7 @@ public AllureAwaitilityListener() {
110114
* @return this factory
111115
*/
112116
public AllureAwaitilityListener setUnit(final TimeUnit unit) {
113-
this.unit = unit;
117+
this.unit.set(unit);
114118
return this;
115119
}
116120

@@ -121,7 +125,7 @@ public AllureAwaitilityListener setUnit(final TimeUnit unit) {
121125
* @return this factory
122126
*/
123127
public AllureAwaitilityListener setLogIgnoredExceptions(final boolean logging) {
124-
this.logIgnoredExceptions = logging;
128+
this.logIgnoredExceptions.set(logging);
125129
return this;
126130
}
127131

@@ -132,24 +136,26 @@ public AllureAwaitilityListener setLogIgnoredExceptions(final boolean logging) {
132136
*/
133137
@Override
134138
public void beforeEvaluation(final StartEvaluationEvent<Object> startEvaluationEvent) {
135-
currentConditionStepKey = null;
136-
getLifecycle().getCurrentExecutableKey().ifPresent(parent -> {
139+
finishCurrentCondition();
140+
final AllureLifecycle lifecycle = getLifecycle();
141+
lifecycle.getCurrentExecutableKey().ifPresent(parent -> {
137142
final String nameWoAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getDescription());
138143
final String nameWithAlias = String.format(onStartStepTextPattern, startEvaluationEvent.getAlias());
139144
final String stepName = startEvaluationEvent.getAlias() != null ? nameWithAlias : nameWoAlias;
140145
final AllureExternalKey conditionStepKey = AllureExternalKey.random(AllureAwaitilityListener.class);
141-
currentConditionStepKey = conditionStepKey;
142-
getLifecycle().startStep(
146+
lifecycle.startStep(
143147
parent,
144148
conditionStepKey,
145149
new StepResult()
146150
.setName(stepName)
147151
.setDescription("Awaitility condition started")
148152
.setStatus(Status.FAILED)
149153
);
150-
// bind the polling thread to the condition step, so steps produced while evaluating the
151-
// condition — for example assertion steps from untilAsserted polls — nest under it
152-
currentConditionBinding = getLifecycle().bindDetached(conditionStepKey);
154+
final ConditionState condition = new ConditionState(lifecycle, conditionStepKey);
155+
currentCondition.set(condition);
156+
// Keep condition-body steps under the wait. The binding remembers the caller thread's stack, so
157+
// Awaitility may close it from a polling callback without mutating a reused worker's unrelated context.
158+
condition.setBinding(lifecycle.bindDetached(conditionStepKey));
153159
});
154160
}
155161

@@ -160,18 +166,18 @@ public void beforeEvaluation(final StartEvaluationEvent<Object> startEvaluationE
160166
*/
161167
@Override
162168
public void onTimeout(final TimeoutEvent timeoutEvent) {
163-
if (currentConditionStepKey == null) {
169+
final ConditionState condition = currentCondition.get();
170+
if (condition == null) {
164171
return;
165172
}
166-
closeConditionBinding();
167-
getLifecycle().logStep(
168-
currentConditionStepKey,
173+
condition.getLifecycle().logStep(
174+
condition.getStepKey(),
169175
new StepResult()
170176
.setName(String.format(onTimeoutStepTextPattern, timeoutEvent.getDescription()))
171177
.setDescription("Awaitility condition timeout")
172178
.setStatus(Status.BROKEN)
173179
);
174-
getLifecycle().stopStep(currentConditionStepKey);
180+
finishCondition(condition);
175181
}
176182

177183
/**
@@ -181,10 +187,11 @@ public void onTimeout(final TimeoutEvent timeoutEvent) {
181187
*/
182188
@Override
183189
public void conditionEvaluated(final EvaluatedCondition<Object> condition) {
190+
final TimeUnit currentUnit = unit.get();
184191
final String description = condition.getDescription();
185-
final long elapsedTime = unit.convert(condition.getElapsedTimeInMS(), MILLISECONDS);
186-
final long remainingTime = unit.convert(condition.getRemainingTimeInMS(), MILLISECONDS);
187-
final String unitAsString = unit.toString().toLowerCase();
192+
final long elapsedTime = currentUnit.convert(condition.getElapsedTimeInMS(), MILLISECONDS);
193+
final long remainingTime = currentUnit.convert(condition.getRemainingTimeInMS(), MILLISECONDS);
194+
final String unitAsString = currentUnit.toString().toLowerCase();
188195

189196
final String message = String.format(
190197
condition.isSatisfied() ? onSatisfiedStepTextPattern : onAwaitStepTextPattern,
@@ -196,29 +203,79 @@ public void conditionEvaluated(final EvaluatedCondition<Object> condition) {
196203
new TemporalDuration(condition.getPollInterval())
197204
);
198205

199-
if (currentConditionStepKey == null) {
206+
final ConditionState current = currentCondition.get();
207+
if (current == null) {
200208
return;
201209
}
202-
getLifecycle().logStep(
203-
currentConditionStepKey,
210+
current.getLifecycle().logStep(
211+
current.getStepKey(),
204212
new StepResult()
205213
.setName(message)
206214
.setDescription("Awaitility condition satisfied or not, but awaiting still in progress")
207215
.setStatus(Status.PASSED)
208216
);
209217
if (condition.isSatisfied()) {
210-
closeConditionBinding();
211-
getLifecycle().updateStep(
212-
currentConditionStepKey, awaitilityCondition -> awaitilityCondition.setStatus(Status.PASSED)
218+
current.getLifecycle().updateStep(
219+
current.getStepKey(), awaitilityCondition -> awaitilityCondition.setStatus(Status.PASSED)
213220
);
214-
getLifecycle().stopStep(currentConditionStepKey);
221+
finishCondition(current);
222+
}
223+
}
224+
225+
private void finishCurrentCondition() {
226+
final ConditionState condition = currentCondition.getAndSet(null);
227+
if (condition != null) {
228+
condition.finish();
215229
}
216230
}
217231

218-
private void closeConditionBinding() {
219-
if (currentConditionBinding != null) {
220-
currentConditionBinding.close();
221-
currentConditionBinding = null;
232+
private void finishCondition(final ConditionState condition) {
233+
currentCondition.compareAndSet(condition, null);
234+
condition.finish();
235+
}
236+
237+
private static final class ConditionState {
238+
239+
private final AllureLifecycle lifecycle;
240+
private final AllureExternalKey stepKey;
241+
private final AtomicReference<AllureThreadBinding> binding = new AtomicReference<>();
242+
private final AtomicBoolean finished = new AtomicBoolean();
243+
244+
private ConditionState(final AllureLifecycle lifecycle, final AllureExternalKey stepKey) {
245+
this.lifecycle = lifecycle;
246+
this.stepKey = stepKey;
247+
}
248+
249+
private AllureLifecycle getLifecycle() {
250+
return lifecycle;
251+
}
252+
253+
private AllureExternalKey getStepKey() {
254+
return stepKey;
255+
}
256+
257+
private void setBinding(final AllureThreadBinding value) {
258+
binding.set(value);
259+
if (finished.get()) {
260+
closeBinding();
261+
}
262+
}
263+
264+
private void finish() {
265+
if (finished.compareAndSet(false, true)) {
266+
try {
267+
closeBinding();
268+
} finally {
269+
lifecycle.stopStep(stepKey);
270+
}
271+
}
272+
}
273+
274+
private void closeBinding() {
275+
final AllureThreadBinding current = binding.getAndSet(null);
276+
if (current != null) {
277+
current.close();
278+
}
222279
}
223280
}
224281

@@ -237,30 +294,31 @@ private void closeConditionBinding() {
237294
*/
238295
@Override
239296
public void exceptionIgnored(final IgnoredException ignoredException) {
240-
if (logIgnoredExceptions && currentConditionStepKey != null) {
297+
final ConditionState condition = currentCondition.get();
298+
if (logIgnoredExceptions.get() && condition != null) {
241299
final AllureExternalKey exceptionIgnoredStepKey = AllureExternalKey.random(AllureAwaitilityListener.class);
242300
final String message = String.format(
243301
onExceptionStepTextPattern, ignoredException.getThrowable().getMessage()
244302
);
245303
final StringWriter stringWriter = new StringWriter();
246304
ignoredException.getThrowable().printStackTrace(new PrintWriter(stringWriter));
247305
final String stackTrace = stringWriter.toString();
248-
getLifecycle().startStep(
249-
currentConditionStepKey,
306+
condition.getLifecycle().startStep(
307+
condition.getStepKey(),
250308
exceptionIgnoredStepKey,
251309
new StepResult()
252310
.setName(message)
253311
.setDescription("Exception occurred and ignored, but awaiting still in progress")
254312
.setStatus(Status.SKIPPED)
255313
);
256-
getLifecycle().addAttachment(
314+
condition.getLifecycle().addAttachment(
257315
exceptionIgnoredStepKey,
258316
ignoredException.getThrowable().getMessage(),
259317
"text/plain",
260318
new ByteArrayInputStream(stackTrace.getBytes(StandardCharsets.UTF_8)),
261319
AttachmentOptions.empty()
262320
);
263-
getLifecycle().stopStep(exceptionIgnoredStepKey);
321+
condition.getLifecycle().stopStep(exceptionIgnoredStepKey);
264322
}
265323
}
266324

allure-awaitility/src/test/java/io/qameta/allure/awaitility/ConditionListenersPositiveTest.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,71 @@ void awaitWithoutAliasShouldDescribeSuccessfulPoll() {
195195
.contains("last poll interval was");
196196
}
197197

198+
/**
199+
* Verifies that Awaitility 4.3's supplier-and-consumer {@code untilAsserted} overload reports every supplied value
200+
* under one successful Awaitility step.
201+
*/
202+
@Description
203+
@Test
204+
void supplierConsumerUntilAssertedShouldReportPollsUnderSingleAwaitilityStep() {
205+
final List<TestResult> testResults = runWithinTestContext(() -> {
206+
final AtomicInteger counter = new AtomicInteger();
207+
await("supplied counter reaches 2").with()
208+
.conditionEvaluationListener(new AllureAwaitilityListener())
209+
.atMost(Duration.of(1000, ChronoUnit.MILLIS))
210+
.pollInterval(Duration.of(10, ChronoUnit.MILLIS))
211+
.untilAsserted(counter::getAndIncrement, value -> assertThat(value).isEqualTo(2));
212+
}).getTestResults();
213+
214+
final TestResult testResult = testResults.get(0);
215+
assertThat(testResult.getSteps())
216+
.singleElement()
217+
.satisfies(step -> {
218+
assertThat(step.getName()).isEqualTo("Awaitility: supplied counter reaches 2");
219+
assertThat(step.getStatus()).isEqualTo(Status.PASSED);
220+
final List<StepResult> evaluationSteps = step.getSteps().stream()
221+
.filter(ConditionListenersPositiveTest::isAwaitilityEvaluationStep)
222+
.toList();
223+
assertThat(evaluationSteps)
224+
.hasSize(3);
225+
});
226+
}
227+
228+
/**
229+
* Verifies that a nested wait with its own listener keeps the outer condition active and reports the inner
230+
* condition beneath it.
231+
*/
232+
@Description
233+
@Test
234+
void nestedAwaitShouldKeepOuterConditionScope() {
235+
final List<TestResult> testResults = runWithinTestContext(
236+
() -> await("outer condition").with()
237+
.pollInSameThread()
238+
.conditionEvaluationListener(new AllureAwaitilityListener())
239+
.atMost(Duration.ofSeconds(1))
240+
.until(() -> {
241+
await("inner condition").with()
242+
.pollInSameThread()
243+
.conditionEvaluationListener(new AllureAwaitilityListener())
244+
.atMost(Duration.ofSeconds(1))
245+
.until(() -> true);
246+
return true;
247+
})
248+
).getTestResults();
249+
250+
final List<StepResult> topLevelSteps = testResults.get(0).getSteps();
251+
assertThat(topLevelSteps)
252+
.extracting(StepResult::getName)
253+
.containsExactly("Awaitility: outer condition");
254+
255+
final List<String> nestedStepNames = topLevelSteps.get(0).getSteps().stream()
256+
.map(StepResult::getName)
257+
.toList();
258+
assertThat(nestedStepNames)
259+
.as("nested condition step names")
260+
.contains("Awaitility: inner condition");
261+
}
262+
198263
private List<StepResult> runAwaitWithoutAliasTopLevelSteps() {
199264
final List<TestResult> testResult = runWithinTestContext(() -> {
200265
final AtomicInteger atomicInteger = new AtomicInteger(0);

0 commit comments

Comments
 (0)