Custom Actions
Custom actions are the extension point for new execution semantics. Implement Action<T> directly when built-in actions such as Step, Lifecycle, Sequential, and Parallel do not express the behavior you need.
For most use cases, prefer composing built-in actions rather than implementing Action directly. Custom actions require careful handling of listener callbacks, status transitions, and mode propagation. Built-in composite actions handle child execution internally; custom composite actions that need to execute children must compose with built-in actions rather than attempting to drive child descriptors directly.
SPI overview
Custom actions use these types:
| Type | Package | Purpose |
|---|---|---|
Action<T> | org.paramixel.api.action | reusable action definition |
Descriptor | org.paramixel.api | active node in the execution tree |
Status | org.paramixel.api | terminal and intermediate statuses |
Context | org.paramixel.api.action | accesses configuration, listener, active descriptor, and status setters |
Mode | org.paramixel.api.action | RUN, SKIP, or ABORT |
Contract
A custom action should:
- Return a nonblank display name from
name(). - Return a nonblank kind from
kind(). - In
execute(Context), set its own status and fire listener callbacks. - Respect
Mode.SKIPandMode.ABORT— propagate non-run modes to children.
Complete example: custom leaf action
Custom leaf actions implement Action<T> directly for their own execution logic. The framework handles child scheduling for built-in composites; custom composite semantics should be achieved by composing built-in actions rather than implementing child execution from scratch.
import org.paramixel.api.Status;
import org.paramixel.api.action.Action;
import org.paramixel.api.action.Context;
import org.paramixel.api.action.Mode;
public final class RetryStep implements Action<Void> {
private final String name;
private final int maxAttempts;
private final Runnable operation;
public RetryStep(String name, int maxAttempts, Runnable operation) {
this.name = name;
this.maxAttempts = maxAttempts;
this.operation = operation;
}
@Override
public String name() {
return name;
}
@Override
public String kind() {
return "RetryStep";
}
@Override
public void execute(Context context) {
var descriptor = context.descriptor();
var listener = context.listener();
listener.onBeforeExecution(descriptor);
context.setStatus(Status.RUNNING);
try {
var mode = descriptor.metadata().mode();
if (mode != Mode.RUN) {
context.setStatus(mode.toStatus());
} else {
Throwable lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
operation.run();
context.setStatus(Status.PASSED);
return;
} catch (Throwable t) {
lastException = t;
}
}
context.setStatus(Status.failed("all " + maxAttempts + " attempts failed", lastException));
}
} catch (Throwable t) {
context.setStatus(Status.fromThrowable(t));
} finally {
listener.onAfterExecution(descriptor);
}
}
@Override
public Action<Void> resolve() {
return this;
}
}
Composing for custom composite semantics
When you need custom composite behavior (e.g. a repeating action that drives children), compose with built-in actions:
public static Action<?> repeated(String name, Action<?> child, int count) {
return Repeat.of(name)
.count(count)
.child(child)
.resolve();
}
The framework's built-in composites (Repeat, Sequential, Parallel, Lifecycle, Instance, Static) handle all child descriptor creation, scheduling, and status aggregation internally. Custom actions should implement only their own execution logic and delegate composite behavior to these built-in types.
Execution lifecycle
Every custom action's execute() method must follow this lifecycle:
- Obtain the
DescriptorandListenerfrom the context. - Call
listener.onBeforeExecution(descriptor)at the start. - Set status to
RUNNINGviacontext.setStatus(Status.RUNNING). - Perform the action's work (propagate mode, aggregate child statuses, etc.).
- Set a terminal status (
PASSED,FAILED,SKIPPED, orABORTED). - Call
listener.onAfterExecution(descriptor)in afinallyblock.
Wrap the work in a try-catch to map exceptions to status via Status.fromThrowable(t). Place listener.onAfterExecution(descriptor) in a finally block to ensure it is always called.
Setting terminal status with detail
When a custom action needs to set a terminal status with a message or throwable, use the named factory methods:
context.setStatus(Status.failed("custom error message"));
context.setStatus(Status.aborted("precondition not met", exception));
Construct the full Status using factory methods before calling setStatus.
Status aggregation
Use Status.aggregate(children) for the built-in severity order:
FAILED > ABORTED > RUNNING/PENDING > SKIPPED > PASSED.
Thread safety
Actions are reusable definitions. Do not store per-execution state in mutable action fields unless you also provide synchronization. Prefer local variables inside execute(...), descriptor metadata, or Instance-managed fixture objects.