Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ body:
- allure-jupiter
- allure-jupiter-assert
- allure-karate
- allure-kotlin-coroutines
- allure-kotlin-extensions
- allure-model
- allure-okhttp3
- allure-playwright
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ Run your tests, then generate or serve the report from the produced Allure resul
| --- | --- |
| [`allure-bom`](allure-bom/README.md) | Maven/Gradle dependency alignment |
| [`allure-java-commons`](allure-java-commons/README.md) | Runtime API, lifecycle, annotations, aspects, and test-plan filtering |
| [`allure-kotlin-coroutines`](allure-kotlin-coroutines/README.md) | Suspend steps and Allure context propagation for Kotlin coroutines |
| [`allure-kotlin-extensions`](allure-kotlin-extensions/README.md) | Kotlin-first facade for the public Allure runtime API |
| [`allure-model`](allure-model/README.md) | Serializable Allure result model |
| [`allure-descriptions-javadoc`](allure-descriptions-javadoc/README.md) | Annotation processor for JavaDoc-based test descriptions |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
* restore only when the calling thread's root is the stopped key.</li>
* <li><b>Ambient group</b> — keyless overloads that resolve their target from the calling thread's binding.</li>
* <li><b>Thread group</b> — explicit binding control: {@link #setCurrent(AllureExternalKey)},
* {@link #clearCurrent()}, {@link #bind(AllureExternalKey)}, {@link #bindDetached(AllureExternalKey)}, and the
* current-key accessors.</li>
* {@link #clearCurrent()}, {@link #bind(AllureExternalKey)}, {@link #bindDetached(AllureExternalKey)},
* {@link #bindEmpty()}, and the current-key accessors.</li>
* </ul>
*
* <p>Integration adapters model suite-level grouping through flat scopes using
Expand Down Expand Up @@ -1230,6 +1230,16 @@ public AllureThreadBinding bindDetached(final AllureExternalKey key) {
return threadContext.pushBinding(snapshot.copy().child());
}

/**
* Temporarily binds an empty execution context to the calling thread. The returned binding masks any current
* context until it is closed, then restores the previous context.
*
* @return the thread binding
*/
public AllureThreadBinding bindEmpty() {
return threadContext.pushBinding(new AllureExecutionContext());
}

// ── Internals ────────────────────────────────────────────────────────────────────────────

private <T> T getItem(final AllureExternalKey key, final Class<T> type, final String operation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
package io.qameta.allure;

/**
* Thread binding returned by {@link AllureLifecycle#bind(AllureExternalKey)} and
* {@link AllureLifecycle#bindDetached(AllureExternalKey)} that restores the previous thread context when closed.
* A binding may be closed from another thread; it always restores the context stack on the thread that created it.
* Thread binding returned by {@link AllureLifecycle#bind(AllureExternalKey)},
* {@link AllureLifecycle#bindDetached(AllureExternalKey)}, or {@link AllureLifecycle#bindEmpty()} that restores the
* previous thread context when closed. A binding may be closed from another thread; it always restores the context
* stack on the thread that created it.
*/
public interface AllureThreadBinding extends AutoCloseable {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,24 @@ void shouldBindMissingContextAsNoopUntilClosed() {
.isEmpty();
}

@Test
void shouldTemporarilyBindEmptyContext() {
final AllureExternalKey testKey = AllureExternalKey.random(AllureLifecycleTest.class);
lifecycle.scheduleTest(testKey, new TestResult().setName(randomName()));
lifecycle.startTest(testKey);

try (AllureThreadBinding ignored = lifecycle.bindEmpty()) {
assertThat(lifecycle.getCurrentExecutableKey())
.isEmpty();
}

assertThat(lifecycle.getCurrentExecutableKey())
.hasValue(testKey);

lifecycle.stopTest(testKey);
lifecycle.writeTest(testKey);
}

@Test
void shouldAttachAsync() {
final String attachment1Name = randomName();
Expand Down
105 changes: 105 additions & 0 deletions allure-kotlin-coroutines/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# allure-kotlin-coroutines

Coroutine-aware extensions for the Allure Java runtime API. This module builds on `allure-kotlin-extensions` and
keeps Kotlin coroutine dependencies out of `allure-java-commons`.

## Supported Versions

- Allure Java 3.x requires Java 17 or newer.
- This module supports Kotlin 2.0 and newer.
- The dependency baseline is `kotlinx-coroutines-core` 1.9.0; applications may align it to a newer compatible version.

## Installation

Gradle:

```kotlin
dependencies {
testImplementation(platform("io.qameta.allure:allure-bom:<allure-version>"))
testImplementation("io.qameta.allure:allure-kotlin-coroutines")
testImplementation("io.qameta.allure:allure-jupiter") // or another test-framework adapter
}
```

Maven, with `allure-bom` imported in dependency management:

```xml
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-kotlin-coroutines</artifactId>
<scope>test</scope>
</dependency>
```

`allure-kotlin-coroutines` transitively includes `allure-kotlin-extensions`.

## Suspend Steps

Use the coroutine package when a step body needs to suspend:

```kotlin
import io.qameta.allure.kotlin.coroutines.step

val order = step("Load order") {
val loaded = withContext(Dispatchers.IO) {
orderClient.load()
}
parameter("orderId", loaded.id)
name("Load order ${loaded.id}")
loaded
}
```

The suspend function uses the same Kotlin-native `StepScope` as `allure-kotlin-extensions`, including named
`parameter` options. The step remains current when the coroutine changes threads. Nested synchronous steps and
attachments are therefore recorded beneath the suspend step. The function also supports `Unit` bodies and the default
name `step`.

Cancellation is never swallowed. A cancelled step is stopped, marked `broken` using the standard Allure throwable
mapping, and the cancellation exception is rethrown.

## Propagating An Existing Allure Context

Use `withAllureContext` to capture the current test, fixture, or step before launching structured coroutine work:

```kotlin
import io.qameta.allure.kotlin.coroutines.withAllureContext
import io.qameta.allure.kotlin.extensions.attachment

withAllureContext {
coroutineScope {
launch(Dispatchers.IO) {
attachment(name = "worker output", content = loadOutput())
}
launch(Dispatchers.Default) {
step("calculate summary") {
calculateSummary()
}
}
}
}
```

The capture happens when `withAllureContext` is called. Child coroutines receive isolated local execution stacks, so
parallel steps are siblings rather than accidentally nesting inside one another. The context restores each worker
thread after suspension or completion. If no Allure executable is current at capture time, that absence is propagated
too, preventing a coroutine from inheriting an unrelated Allure context already present on a worker.

Pass a non-global lifecycle as `withAllureContext(lifecycle) { ... }`.

For direct context composition, use `allureContext()` or `lifecycle.asCoroutineContext()`:

```kotlin
launch(Dispatchers.IO + allureContext()) {
// ...
}
```

## Boundaries

- Keep coroutine work structured: the owning test, fixture, or step must remain active until its children complete.
- Do not use `GlobalScope` or another unstructured launch for report work that can outlive its owner.
- A manually started ambient `AllureLifecycle` step or stage must not remain open across a suspension. Use the
coroutine `step` function for any operation whose body can suspend.
- `@Step` does not become coroutine-aware by adding this module. Annotated suspend functions are still woven as JVM
methods and do not provide coroutine-context propagation; use the suspend step DSL instead.
64 changes: 64 additions & 0 deletions allure-kotlin-coroutines/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion

plugins {
kotlin("jvm")
id("com.diffplug.spotless")
}

description = "Allure Kotlin Coroutines"

dependencies {
api(project(":allure-kotlin-extensions"))
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")

testImplementation("org.assertj:assertj-core")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.slf4j:slf4j-simple")
testImplementation(project(":allure-java-commons-test"))
testImplementation(project(":allure-junit-platform"))
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

kotlin {
explicitApi()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
languageVersion.set(KotlinVersion.KOTLIN_2_0)
apiVersion.set(KotlinVersion.KOTLIN_2_0)
// The older language level is intentional: it keeps published metadata readable by Kotlin 2.0 consumers.
freeCompilerArgs.add("-Xsuppress-version-warnings")
javaParameters.set(true)
}
}

spotless {
kotlin {
target("src/**/*.kt")
ktlint("1.8.0")
licenseHeaderFile(
rootProject.file("gradle/quality-configs/spotless/header.java"),
"(@file:|package)",
)
endWithNewline()
}
}

tasks.jar {
manifest {
attributes(
mapOf(
"Automatic-Module-Name" to "io.qameta.allure.kotlin.coroutines",
),
)
}
}

tasks.compileTestKotlin {
compilerOptions.jvmTarget.set(JvmTarget.fromTarget(tasks.compileTestJava.get().targetCompatibility))
}

tasks.test {
useJUnitPlatform()
}
Loading