Skip to content

Commit

Permalink
Add BlockListener support... (#1575)
Browse files Browse the repository at this point in the history
This feature allows extension authors to register an `IBlockListener` for
a feature to observe the execution of a feature in more detail.
This surfaces some of Spock's idiosyncrasies, for example interaction
assertions are actually setup right before entering the preceding
`when`-block as well as being evaluated on leaving the `when`-block
before actually entering the `then`-block.

The only valid block description is a constant `String`, although some
users mistakenly try to use a dynamic `GString`. Using anything other
than a `String`, will be treated as a separate statement and thus ignored.

Expose `IErrorContext` in `ErrorInfo` to provide more information in
`IRunListener.error(ErrorInfo)` about where the error happened.

fixes #538
fixes #111
  • Loading branch information
leonard84 authored Dec 27, 2024
1 parent 01f3c4e commit 2c4e38c
Show file tree
Hide file tree
Showing 57 changed files with 1,360 additions and 125 deletions.
23 changes: 23 additions & 0 deletions docs/extensions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1380,3 +1380,26 @@ It is primarily for framework developers who want to provide a default value for
Or users of a framework that doesn't provide default values for their special types.

If you want to change the default response behavior for `Stub` have a look at <<interaction_based_testing.adoc#ALaCarteMocks,A la Carte Mocks>> and how to use your own `org.spockframework.mock.IDefaultResponse`.

=== Listeners

Extensions can register listeners to receive notifications about the progress of the test run.
These listeners are intended to be used for reporting, logging, or other monitoring purposes.
They are not intended to modify the test run in any way.
You can register the same listener instance on multiple specifications or features.
Please consult the JavaDoc of the respective listener interfaces for more information.

==== `IRunListener`

The `org.spockframework.runtime.IRunListener` can be registered via `SpecInfo.addListener(IRunListener)` and will receive notifications about the progress of the test run of a single specification.

[#block-listener]
==== `IBlockListener`

The `org.spockframework.runtime.extension.IBlockListener` can be registered on a feature via, `FeatureInfo.addBlockListener(IBlockListener)` and will receive notifications about the progress of the feature.

It will be called once when entering a block (`blockEntered`) and once when exiting a block (`blockExited`).

When an exception is thrown in a block, the `blockExited` will not be called for that block.
The failed block will be part of the `ErrorContext` in `ErrorInfo` that is passed to `IRunListener.error(ErrorInfo)`.
If a `cleanup` block is present the cleanup block listener methods will still be called.
1 change: 1 addition & 0 deletions docs/release_notes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ include::include.adoc[]

* Add support for combining two or more data providers using cartesian product spockIssue:1062[]
* Add support for a `filter` block after a `where` block to filter out unwanted iterations spockPull:1927[]
* Add <<extensions.adoc#block-listener,`IBlockListener`>> extension point to listen to block execution events within feature methods spockPull:1575[]

=== Misc

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public class AstNodeCache {
public final ClassNode SpecificationContext = ClassHelper.makeWithoutCaching(SpecificationContext.class);
public final ClassNode DataVariableMultiplication = ClassHelper.makeWithoutCaching(DataVariableMultiplication.class);
public final ClassNode DataVariableMultiplicationFactor = ClassHelper.makeWithoutCaching(DataVariableMultiplicationFactor.class);
public final ClassNode BlockInfo = ClassHelper.makeWithoutCaching(BlockInfo.class);

public final MethodNode SpecInternals_GetSpecificationContext =
SpecInternals.getDeclaredMethods(Identifiers.GET_SPECIFICATION_CONTEXT).get(0);
Expand All @@ -71,6 +72,12 @@ public class AstNodeCache {
public final MethodNode SpockRuntime_DespreadList =
SpockRuntime.getDeclaredMethods(org.spockframework.runtime.SpockRuntime.DESPREAD_LIST).get(0);

public final MethodNode SpockRuntime_CallBlockEntered =
SpockRuntime.getDeclaredMethods(org.spockframework.runtime.SpockRuntime.CALL_BLOCK_ENTERED).get(0);

public final MethodNode SpockRuntime_CallBlockExited =
SpockRuntime.getDeclaredMethods(org.spockframework.runtime.SpockRuntime.CALL_BLOCK_EXITED).get(0);

public final MethodNode ValueRecorder_Reset =
ValueRecorder.getDeclaredMethods(org.spockframework.runtime.ValueRecorder.RESET).get(0);

Expand Down Expand Up @@ -107,6 +114,12 @@ public class AstNodeCache {
public final MethodNode SpecificationContext_GetSharedInstance =
SpecificationContext.getDeclaredMethods(org.spockframework.runtime.SpecificationContext.GET_SHARED_INSTANCE).get(0);

public final MethodNode SpecificationContext_GetCurrentBlock =
SpecificationContext.getDeclaredMethods(org.spockframework.runtime.SpecificationContext.GET_CURRENT_BLOCK).get(0);

public final MethodNode SpecificationContext_SetCurrentBlock =
SpecificationContext.getDeclaredMethods(org.spockframework.runtime.SpecificationContext.SET_CURRENT_BLOCK).get(0);

public final MethodNode List_Get =
ClassHelper.LIST_TYPE.getDeclaredMethods("get").get(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.spockframework.compiler;

import org.codehaus.groovy.syntax.Token;
import org.codehaus.groovy.syntax.Types;
import org.spockframework.lang.Wildcard;
import org.spockframework.util.*;
import spock.lang.Specification;
Expand Down Expand Up @@ -390,4 +391,12 @@ public static ConstantExpression primitiveConstExpression(int value) {
public static ConstantExpression primitiveConstExpression(boolean value) {
return new ConstantExpression(value, true);
}

public static BinaryExpression createVariableIsNotNullExpression(VariableExpression var) {
return new BinaryExpression(
var,
Token.newSymbol(Types.COMPARE_NOT_EQUAL, -1, -1),
new ConstantExpression(null));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.ast.expr.*;
import org.spockframework.util.Assert;

import static java.util.stream.Collectors.*;
import static org.spockframework.compiler.AstUtil.*;
Expand Down Expand Up @@ -190,8 +191,9 @@ private void addFeatureMetadata(FeatureMethod feature) {
ann.setMember(FeatureMetadata.BLOCKS, blockAnnElems = new ListExpression());

ListExpression paramNames = new ListExpression();
for (Parameter param : feature.getAst().getParameters())
for (Parameter param : feature.getAst().getParameters()) {
paramNames.addExpression(new ConstantExpression(param.getName()));
}
ann.setMember(FeatureMetadata.PARAMETER_NAMES, paramNames);

feature.getAst().addAnnotation(ann);
Expand All @@ -202,9 +204,13 @@ private void addBlockMetadata(Block block, BlockKind kind) {
blockAnn.setMember(BlockMetadata.KIND, new PropertyExpression(
new ClassExpression(nodeCache.BlockKind), kind.name()));
ListExpression textExprs = new ListExpression();
for (String text : block.getDescriptions())
for (String text : block.getDescriptions()) {
textExprs.addExpression(new ConstantExpression(text));
}
blockAnn.setMember(BlockMetadata.TEXTS, textExprs);
int index = blockAnnElems.getExpressions().size();
Assert.that(index == block.getBlockMetaDataIndex(),
() -> kind + " block mismatch of index: " + index + ", block.getBlockMetaDataIndex(): " + block.getBlockMetaDataIndex());
blockAnnElems.addExpression(new AnnotationConstantExpression(blockAnn));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ private void buildBlocks(Method method) throws InvalidSpecCompileException {
checkIsValidSuccessor(method, BlockParseInfo.METHOD_END,
method.getAst().getLastLineNumber(), method.getAst().getLastColumnNumber());

// set the block metadata index for each block this must be equal to the index of the block in the @BlockMetadata annotation
int i = -1;
for (Block block : method.getBlocks()) {
if(!block.hasBlockMetadata()) continue;
block.setBlockMetaDataIndex(++i);
}
// now that statements have been copied to blocks, the original statement
// list is cleared; statements will be copied back after rewriting is done
stats.clear();
Expand Down
117 changes: 87 additions & 30 deletions spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,22 @@

package org.spockframework.compiler;

import org.spockframework.compiler.model.*;
import org.spockframework.runtime.SpockException;
import org.spockframework.util.*;

import java.lang.reflect.InvocationTargetException;
import java.util.*;

import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.ast.expr.*;
import org.codehaus.groovy.ast.stmt.*;
import org.codehaus.groovy.runtime.MetaClassHelper;
import org.codehaus.groovy.syntax.*;
import org.codehaus.groovy.syntax.Token;
import org.codehaus.groovy.syntax.Types;
import org.jetbrains.annotations.NotNull;
import org.objectweb.asm.Opcodes;
import org.spockframework.compiler.model.*;
import org.spockframework.runtime.SpockException;
import org.spockframework.util.InternalIdentifiers;
import org.spockframework.util.ObjectUtil;
import org.spockframework.util.ReflectionUtil;

import java.lang.reflect.InvocationTargetException;
import java.util.*;

import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
Expand Down Expand Up @@ -159,7 +162,7 @@ private void createFinalFieldGetter(Field field) {

private void createSharedFieldSetter(Field field) {
String setterName = "set" + MetaClassHelper.capitalize(field.getName());
Parameter[] params = new Parameter[] { new Parameter(field.getAst().getType(), "$spock_value") };
Parameter[] params = new Parameter[] { new Parameter(field.getAst().getType(), SpockNames.SPOCK_VALUE) };
MethodNode setter = spec.getAst().getMethod(setterName, params);
if (setter != null) {
errorReporter.error(field.getAst(),
Expand All @@ -180,7 +183,7 @@ private void createSharedFieldSetter(Field field) {
// use internal name
new ConstantExpression(field.getAst().getName())),
Token.newSymbol(Types.ASSIGN, -1, -1),
new VariableExpression("$spock_value"))));
new VariableExpression(SpockNames.SPOCK_VALUE))));

setter.setSourcePosition(field.getAst());
spec.getAst().addMethod(setter);
Expand Down Expand Up @@ -390,13 +393,20 @@ private void handleWhereBlock(Method method) {
public void visitMethodAgain(Method method) {
this.block = null;

if (!movedStatsBackToMethod)
for (Block b : method.getBlocks())
if (!movedStatsBackToMethod) {
for (Block b : method.getBlocks()) {
// This will only run if there was no 'cleanup' block in the method.
// Otherwise, the blocks have already been copied to try block by visitCleanupBlock.
// We need to run as late as possible, so we'll have to do the handling here and in visitCleanupBlock.
addBlockListeners(b);
method.getStatements().addAll(b.getAst());
}
}

// for global required interactions
if (method instanceof FeatureMethod)
if (method instanceof FeatureMethod) {
method.getStatements().add(createMockControllerCall(nodeCache.MockController_LeaveScope));
}

if (methodHasCondition) {
defineValueRecorder(method.getStatements(), "");
Expand All @@ -406,6 +416,56 @@ public void visitMethodAgain(Method method) {
}
}


private void addBlockListeners(Block block) {
BlockParseInfo blockType = block.getParseInfo();
if (!blockType.isSupportingBlockListeners()) return;

// SpockRuntime.callBlockEntered(getSpecificationContext(), blockMetadataIndex)
MethodCallExpression blockEnteredCall = createBlockListenerCall(block, blockType, nodeCache.SpockRuntime_CallBlockEntered);
// SpockRuntime.callBlockExited(getSpecificationContext(), blockMetadataIndex)
MethodCallExpression blockExitedCall = createBlockListenerCall(block, blockType, nodeCache.SpockRuntime_CallBlockExited);

block.getAst().add(0, new ExpressionStatement(blockEnteredCall));
if (blockType == BlockParseInfo.CLEANUP) {
// In case of a cleanup block we need store a reference of the previously `currentBlock` in case that an exception occurred
// and restore it at the end of the cleanup block, so that the correct `BlockInfo` is available for the `IErrorContext`.
// The restoration happens in the `finally` statement created by `createCleanupTryCatch`.
VariableExpression failedBlock = new VariableExpression(SpockNames.FAILED_BLOCK, nodeCache.BlockInfo);
block.getAst().add(0, ifThrowableIsNotNull(storeFailedBlock(failedBlock)));
}
block.getAst().add(new ExpressionStatement(blockExitedCall));
}

private @NotNull Statement storeFailedBlock(VariableExpression failedBlock) {
MethodCallExpression getCurrentBlock = createDirectMethodCall(getSpecificationContext(), nodeCache.SpecificationContext_GetCurrentBlock, ArgumentListExpression.EMPTY_ARGUMENTS);
return new ExpressionStatement(new BinaryExpression(failedBlock, Token.newSymbol(Types.ASSIGN, -1, -1), getCurrentBlock));
}

private @NotNull Statement restoreFailedBlock(VariableExpression failedBlock) {
return new ExpressionStatement(createDirectMethodCall(new CastExpression(nodeCache.SpecificationContext, getSpecificationContext()), nodeCache.SpecificationContext_SetCurrentBlock, new ArgumentListExpression(failedBlock)));
}

private IfStatement ifThrowableIsNotNull(Statement statement) {
return new IfStatement(
// if ($spock_feature_throwable != null)
new BooleanExpression(AstUtil.createVariableIsNotNullExpression(new VariableExpression(SpockNames.SPOCK_FEATURE_THROWABLE, nodeCache.Throwable))),
statement,
EmptyStatement.INSTANCE
);
}

private MethodCallExpression createBlockListenerCall(Block block, BlockParseInfo blockType, MethodNode blockListenerMethod) {
if (block.getBlockMetaDataIndex() < 0) throw new SpockException("Block metadata index not set: " + block);
return createDirectMethodCall(
new ClassExpression(nodeCache.SpockRuntime),
blockListenerMethod,
new ArgumentListExpression(
getSpecificationContext(),
new ConstantExpression(block.getBlockMetaDataIndex(), true)
));
}

@Override
public void visitAnyBlock(Block block) {
this.block = block;
Expand Down Expand Up @@ -484,12 +544,15 @@ private Statement createMockControllerCall(MethodNode method) {
@Override
public void visitCleanupBlock(CleanupBlock block) {
for (Block b : method.getBlocks()) {
// call addBlockListeners() here, as this method will already copy the contents of the blocks,
// so we need to transform the block listeners here as they won't be copied in visitMethodAgain where we normally add them
addBlockListeners(b);
if (b == block) break;
moveVariableDeclarations(b.getAst(), method.getStatements());
}

VariableExpression featureThrowableVar =
new VariableExpression("$spock_feature_throwable", nodeCache.Throwable);
new VariableExpression(SpockNames.SPOCK_FEATURE_THROWABLE, nodeCache.Throwable);
method.getStatements().add(createVariableDeclarationStatement(featureThrowableVar));

List<Statement> featureStats = new ArrayList<>();
Expand All @@ -499,9 +562,10 @@ public void visitCleanupBlock(CleanupBlock block) {
}

CatchStatement featureCatchStat = createThrowableAssignmentAndRethrowCatchStatement(featureThrowableVar);

List<Statement> cleanupStats = singletonList(
createCleanupTryCatch(block, featureThrowableVar));
VariableExpression failedBlock = new VariableExpression(SpockNames.FAILED_BLOCK, nodeCache.BlockInfo);
List<Statement> cleanupStats = asList(
new ExpressionStatement(new DeclarationExpression(failedBlock, Token.newSymbol(Types.ASSIGN, -1, -1), ConstantExpression.NULL)),
createCleanupTryCatch(block, featureThrowableVar, failedBlock));

TryCatchStatement tryFinally =
new TryCatchStatement(
Expand All @@ -517,13 +581,6 @@ public void visitCleanupBlock(CleanupBlock block) {
movedStatsBackToMethod = true;
}

private BinaryExpression createVariableNotNullExpression(VariableExpression var) {
return new BinaryExpression(
new VariableExpression(var),
Token.newSymbol(Types.COMPARE_NOT_EQUAL, -1, -1),
new ConstantExpression(null));
}

private Statement createVariableDeclarationStatement(VariableExpression var) {
DeclarationExpression throwableDecl =
new DeclarationExpression(
Expand All @@ -534,21 +591,21 @@ private Statement createVariableDeclarationStatement(VariableExpression var) {
return new ExpressionStatement(throwableDecl);
}

private TryCatchStatement createCleanupTryCatch(CleanupBlock block, VariableExpression featureThrowableVar) {
private TryCatchStatement createCleanupTryCatch(CleanupBlock block, VariableExpression featureThrowableVar, VariableExpression failedBlock) {
List<Statement> cleanupStats = new ArrayList<>(block.getAst());

TryCatchStatement tryCatchStat =
new TryCatchStatement(
new BlockStatement(cleanupStats, null),
EmptyStatement.INSTANCE);
ifThrowableIsNotNull(restoreFailedBlock(failedBlock))
);

tryCatchStat.addCatch(createHandleSuppressedThrowableStatement(featureThrowableVar));

return tryCatchStat;
}

private CatchStatement createThrowableAssignmentAndRethrowCatchStatement(VariableExpression assignmentVar) {
Parameter catchParameter = new Parameter(nodeCache.Throwable, "$spock_tmp_throwable");
Parameter catchParameter = new Parameter(nodeCache.Throwable, SpockNames.SPOCK_TMP_THROWABLE);

BinaryExpression assignThrowableExpr =
new BinaryExpression(
Expand All @@ -565,9 +622,9 @@ private CatchStatement createThrowableAssignmentAndRethrowCatchStatement(Variabl
}

private CatchStatement createHandleSuppressedThrowableStatement(VariableExpression featureThrowableVar) {
Parameter catchParameter = new Parameter(nodeCache.Throwable, "$spock_tmp_throwable");
Parameter catchParameter = new Parameter(nodeCache.Throwable, SpockNames.SPOCK_TMP_THROWABLE);

BinaryExpression featureThrowableNotNullExpr = createVariableNotNullExpression(featureThrowableVar);
BinaryExpression featureThrowableNotNullExpr = AstUtil.createVariableIsNotNullExpression(featureThrowableVar);

List<Statement> addSuppressedStats =
singletonList(new ExpressionStatement(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package org.spockframework.compiler;

public class SpockNames {
public static final String VALUE_RECORDER = "$spock_valueRecorder";
public static final String ERROR_COLLECTOR = "$spock_errorCollector";
public static final String FAILED_BLOCK = "$spock_failedBlock";
public static final String OLD_VALUE = "$spock_oldValue";
public static final String SPOCK_EX = "$spock_ex";
public static final String SPOCK_FEATURE_THROWABLE = "$spock_feature_throwable";
public static final String SPOCK_TMP_THROWABLE = "$spock_tmp_throwable";
public static final String SPOCK_VALUE = "$spock_value";
public static final String VALUE_RECORDER = "$spock_valueRecorder";
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ public void accept(ISpecVisitor visitor) throws Exception {
public BlockParseInfo getParseInfo() {
return BlockParseInfo.ANONYMOUS;
}

@Override
public boolean hasBlockMetadata() {
return false;
}
}
Loading

0 comments on commit 2c4e38c

Please sign in to comment.