@@ -328,6 +328,9 @@ public void stopTest(final AllureExternalKey key) {
328328 LOGGER .warn ("Could not stop test: test with key {} is not running" , key );
329329 return ;
330330 }
331+ if (isCurrentRoot (key )) {
332+ closeOpenStages ();
333+ }
331334 notifier .beforeTestStop (testResult );
332335 testResult
333336 .setStage (Stage .FINISHED )
@@ -460,6 +463,9 @@ public void stopFixture(final AllureExternalKey key) {
460463 return ;
461464 }
462465 final FixtureResult fixture = item .result ;
466+ if (isCurrentRoot (key )) {
467+ closeOpenStages ();
468+ }
463469 notifier .beforeFixtureStop (fixture );
464470 fixture .setStage (Stage .FINISHED );
465471 fixture .setStop (System .currentTimeMillis ());
@@ -515,6 +521,11 @@ public void startStep(final AllureExternalKey parentKey, final AllureExternalKey
515521
516522 private void startStep (final AllureExternalKey parentKey , final AllureExternalKey key ,
517523 final StepResult result , final boolean bind ) {
524+ startStep (parentKey , key , result , bind , false );
525+ }
526+
527+ private void startStep (final AllureExternalKey parentKey , final AllureExternalKey key ,
528+ final StepResult result , final boolean bind , final boolean stage ) {
518529 Objects .requireNonNull (key , EXTERNAL_KEY );
519530 Objects .requireNonNull (parentKey , EXTERNAL_KEY );
520531 final Object parent = items .get (parentKey );
@@ -542,13 +553,79 @@ private void startStep(final AllureExternalKey parentKey, final AllureExternalKe
542553 final AllureExecutionContext snapshot = bind
543554 ? threadContext .copy ()
544555 : deriveSnapshot (parent , key );
545- items .put (key , new StepItem (result , writeOwnerOf (parentKey , parent ), snapshot ));
556+ items .put (key , new StepItem (result , writeOwnerOf (parentKey , parent ), snapshot , stage ));
546557 synchronized (parent ) {
547558 ((WithSteps ) parentModel ).getSteps ().add (result );
548559 }
549560 notifier .afterStepStart (result );
550561 }
551562
563+ /**
564+ * Starts a stage — a lightweight phase marker rendered as a regular step. A stage has no explicit stop: it stays
565+ * open, collecting the steps and attachments that follow, until the next stage starts at the same level or the
566+ * enclosing step, test, or fixture ends. A stage started inside a step becomes a child of that step. A stage
567+ * with no status when it closes is marked passed.
568+ *
569+ * <p>Stages are an ambient-only concept: their lifetime is defined by the calling thread's binding, so there is
570+ * no key-addressed form. Takes no effect if no executable is running.</p>
571+ *
572+ * @param result the stage step, carrying its name
573+ */
574+ public void startStage (final StepResult result ) {
575+ if (threadContext .getCurrentExecutable ().isEmpty ()) {
576+ LOGGER .warn ("Could not start stage: no test or fixture running" );
577+ return ;
578+ }
579+ closeOpenStages ();
580+ final Optional <AllureExternalKey > parent = threadContext .getCurrentExecutable ();
581+ if (parent .isEmpty ()) {
582+ return ;
583+ }
584+ startStep (parent .get (), AllureExternalKey .random (AllureLifecycle .class ), result , true , true );
585+ }
586+
587+ /**
588+ * Closes consecutive open stages on top of the calling thread's stack. A stage with no status is marked passed.
589+ */
590+ private void closeOpenStages () {
591+ while (true ) {
592+ final Optional <AllureExternalKey > current = threadContext .getCurrentStep ();
593+ if (current .isEmpty ()) {
594+ return ;
595+ }
596+ final Object item = items .get (current .get ());
597+ if (!(item instanceof StepItem ) || !((StepItem ) item ).stage ) {
598+ return ;
599+ }
600+ finalizeStage ((StepItem ) item );
601+ stopStep (current .get (), true );
602+ }
603+ }
604+
605+ /**
606+ * Closes consecutive open stages bound above the given step on the calling thread's stack.
607+ */
608+ private void closeOpenStagesAbove (final AllureExternalKey key ) {
609+ while (true ) {
610+ final Optional <AllureExternalKey > current = threadContext .getCurrentStep ();
611+ if (current .isEmpty () || current .get ().equals (key )) {
612+ return ;
613+ }
614+ final Object item = items .get (current .get ());
615+ if (!(item instanceof StepItem ) || !((StepItem ) item ).stage ) {
616+ return ;
617+ }
618+ finalizeStage ((StepItem ) item );
619+ stopStep (current .get (), true );
620+ }
621+ }
622+
623+ private static void finalizeStage (final StepItem item ) {
624+ if (Objects .isNull (item .result .getStatus ())) {
625+ item .result .setStatus (Status .PASSED );
626+ }
627+ }
628+
552629 /**
553630 * Updates step by specified key.
554631 *
@@ -566,32 +643,55 @@ public void updateStep(final AllureExternalKey key, final Consumer<StepResult> u
566643 }
567644
568645 /**
569- * Updates current step.
646+ * Updates current step. Stages are transparent to this: the update targets the deepest non-stage step, so a
647+ * caller finishing its own step is not misdirected onto a stage the user opened inside it.
570648 *
571649 * @param update the update function.
572650 */
573651 public void updateStep (final Consumer <StepResult > update ) {
574- final Optional <AllureExternalKey > current = threadContext . getCurrent ();
652+ final Optional <AllureExternalKey > current = currentNonStageStep ();
575653 if (current .isEmpty ()) {
576654 LOGGER .warn ("Could not update step: no step running" );
577655 return ;
578656 }
579657 updateStep (current .get (), update );
580658 }
581659
660+ private Optional <AllureExternalKey > currentNonStageStep () {
661+ final AllureExternalKey root = threadContext .getRoot ().orElse (null );
662+ for (final AllureExternalKey key : threadContext .getLocalKeys ()) {
663+ if (Objects .equals (key , root )) {
664+ return Optional .empty ();
665+ }
666+ final Object item = items .get (key );
667+ if (!(item instanceof StepItem )) {
668+ return Optional .empty ();
669+ }
670+ if (!((StepItem ) item ).stage ) {
671+ return Optional .of (key );
672+ }
673+ }
674+ return Optional .empty ();
675+ }
676+
582677 /**
583- * Stops step by given key. Pure manual form: no thread state is touched.
678+ * Stops step by given key. Pure manual form with one thread-affine convenience: when the stopped step is bound
679+ * on the calling thread with open stages above it, those stages are closed first.
584680 *
585681 * @param key the external step key
586682 */
587683 public void stopStep (final AllureExternalKey key ) {
684+ if (threadContext .getLocalKeys ().contains (key )) {
685+ closeOpenStagesAbove (key );
686+ }
588687 stopStep (key , false );
589688 }
590689
591690 /**
592- * Stops the current running step and pops it from the calling thread.
691+ * Stops the current running step and pops it from the calling thread. Open stages above it are closed first.
593692 */
594693 public void stopStep () {
694+ closeOpenStages ();
595695 final Optional <AllureExternalKey > current = threadContext .getCurrentStep ();
596696 if (current .isEmpty ()) {
597697 LOGGER .warn ("Could not stop step: no step running" );
@@ -1312,11 +1412,14 @@ private static final class StepItem {
13121412
13131413 private final AllureExecutionContext contextSnapshot ;
13141414
1415+ private final boolean stage ;
1416+
13151417 private StepItem (final StepResult result , final AllureExternalKey writeOwnerKey ,
1316- final AllureExecutionContext contextSnapshot ) {
1418+ final AllureExecutionContext contextSnapshot , final boolean stage ) {
13171419 this .result = result ;
13181420 this .writeOwnerKey = writeOwnerKey ;
13191421 this .contextSnapshot = contextSnapshot ;
1422+ this .stage = stage ;
13201423 }
13211424 }
13221425
0 commit comments