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
10 changes: 10 additions & 0 deletions docs/junit5.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ class's single shared `Rift` client.
Reset only touches imposters declared via `@RiftImposter`; imposters created ad hoc inside a test
method (`rift.create(...)`) are the test's own responsibility to clean up.

Clearing proxy responses removes what an imposter's proxy recorded: since rift 0.19.0 that includes
the stubs the proxy recorded (those carrying `recordedFrom`), not only its saved responses, so a
`record()` imposter re-reaches its upstream after every reset. The one exception is the
[`@RiftGolden`](#golden-files-riftgolden) imposter, whose recorded stubs are the golden fixture:
reset still clears its recorded requests and scenario state, but keeps its recorded stubs.

```java
@RiftTest(reset = Reset.PER_CLASS)
class SharedStateTest {
Expand Down Expand Up @@ -254,6 +260,10 @@ class UsersGoldenTest {
- **file present → REPLAY**: the recorded stubs are loaded from `file` and served directly — no
network, so CI (which has the committed golden file) never touches the origin.
- `-Drift.golden=recapture` forces CAPTURE even when the file exists (refresh the recording).
- The `Reset` policy never removes the golden imposter's recorded stubs, in either mode: CAPTURE
accumulates every test method's recordings into the one file, and REPLAY serves the file to every
test method. (Before rift-java 0.3.4, on rift 0.19.0+, a `PER_TEST` reset deleted them — REPLAY
served nothing and CAPTURE kept only the last method's traffic.)

Commit the golden file. The persisted format is the engine's replayable imposter JSON — portable
across the rift SDKs and loadable by `rift --configfile`. `@RiftGolden` targets the sole
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<maven.compiler.release>17</maven.compiler.release>

<!-- Pinned Rift engine release the natives + version preflight track (no leading 'v'). -->
<rift.engine.version>0.18.1</rift.engine.version>
<rift.engine.version>0.19.0</rift.engine.version>

<!-- rift-java-bom's resolve-through-bom invoker IT is self-validation, not a prerequisite for
the BOM artifact. It runs at integration-test (before install) and needs network to resolve
Expand Down
6 changes: 3 additions & 3 deletions rift-java-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ holding `manifest.json` and `corpus/`):
```bash
# Download + extract the corpus for the pinned engine version, then:
./mvnw -pl rift-java-conformance -am test \
-Drift.corpus.root=/path/to/sdk-conformance-v0.18.1
-Drift.corpus.root=/path/to/sdk-conformance-v0.19.0
```

Resolution order for the corpus: `-Drift.corpus.root` → `RIFT_CORPUS_ROOT` → `target/corpus`. When
Expand All @@ -68,14 +68,14 @@ cached on first use):

```bash
RIFT_IT=1 ./mvnw -pl rift-java-conformance -am verify \
-Drift.corpus.root=/path/to/sdk-conformance-v0.18.1
-Drift.corpus.root=/path/to/sdk-conformance-v0.19.0
```

Over the embedded transport (JDK 21 or 22+, with a `librift_ffi` for your platform):

```bash
RIFT_IT=1 CONFORMANCE_TRANSPORT=EMBEDDED ./mvnw -pl rift-java-conformance -am verify \
-Drift.corpus.root=/path/to/sdk-conformance-v0.18.1 \
-Drift.corpus.root=/path/to/sdk-conformance-v0.19.0 \
-Drift.ffi.lib=/path/to/librift_ffi-<platform>.<ext>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ public interface Imposter {
*/
void clearRecorded(MatchClause... filters);

/**
* Clears what this imposter's proxies have saved ({@code DELETE /imposters/{port}/savedProxyResponses}).
*
* <p>From rift 0.19.0 this also deletes every stub a proxy recorded (one carrying {@code
* recordedFrom}, e.g. from {@code predicateGenerators}), matching Mountebank; stubs you declared
* are kept. Through 0.18.x the recorded stubs survived the call.
*/
void clearProxyResponses();

Scenarios scenarios();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ final class RiftTestContext {
private Recording goldenRecording;
private Path goldenFile;

/**
* The {@code @RiftGolden} target, in CAPTURE or REPLAY; {@code null} without {@code @RiftGolden}.
* Its recorded stubs are the golden fixture, so {@link #resetConfiguredImposters()} leaves them.
*/
private Imposter goldenImposter;

/** Set only when {@code @RiftIntercept} is present; {@link #close()} closes it before the engine. */
private Intercept intercept;

Expand Down Expand Up @@ -77,21 +83,34 @@ Intercept intercept() {
return intercept;
}

/** Resets every configured imposter: clears recorded requests, scenario state, and proxy responses. */
/**
* Resets every configured imposter: clears recorded requests, scenario state, and proxy responses
* — except the {@code @RiftGolden} imposter's proxy responses. Since rift 0.19.0, clearing proxy
* responses also deletes the stubs a proxy recorded ({@code recordedFrom}), which on the golden
* imposter are the fixture itself: the stubs REPLAY loaded from the file, and the stubs CAPTURE has
* recorded so far and will persist on class close.
*/
void resetConfiguredImposters() {
for (Map.Entry<String, Imposter> entry : impostersByName.entrySet()) {
Imposter imposter = entry.getValue();
try {
imposter.clearRecorded();
imposter.scenarios().reset();
imposter.clearProxyResponses();
if (imposter != goldenImposter) {
imposter.clearProxyResponses();
}
} catch (RuntimeException e) {
throw new IllegalStateException("failed to reset imposter '" + entry.getKey()
+ "'; configured imposters: " + impostersByName.keySet(), e);
}
}
}

/** Marks {@code imposter} as the {@code @RiftGolden} target, so resets keep its recorded stubs. */
void setGoldenImposter(Imposter imposter) {
this.goldenImposter = imposter;
}

/** Records a golden CAPTURE in progress; {@link #close()} persists it to {@code file} before closing the engine. */
void setGoldenCapture(Recording recording, Path file) {
this.goldenRecording = recording;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ private static void applyGolden(Class<?> testClass, Map<String, Imposter> impost
return;
}
Imposter imposter = goldenImposter(golden, impostersByName);
riftTestContext.setGoldenImposter(imposter);
boolean recapture = "recapture".equals(System.getProperty("rift.golden"));
Path file = Path.of(golden.file());
if (Files.exists(file) && !recapture) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ static final class ImposterState {
final int port;
final List<String> savedRequests = new CopyOnWriteArrayList<>();
final AtomicInteger stubAdds = new AtomicInteger();
final AtomicInteger proxyResponseClears = new AtomicInteger();

ImposterState(int port) {
this.port = port;
Expand Down Expand Up @@ -126,6 +127,12 @@ private String route(String method, String path) {
}
return savedRequestsJson(state);
}
if (path.endsWith("/savedProxyResponses") && method.equals("DELETE")) {
if (state != null) {
state.proxyResponseClears.incrementAndGet();
}
return "{}";
}
if (path.endsWith("/stubs") && method.equals("POST") && state != null) {
state.stubAdds.incrementAndGet();
return "{}";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.achirdlabs.rift.junit5;

import io.github.achirdlabs.rift.Imposter;
import io.github.achirdlabs.rift.dsl.ImposterSpec;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.TestExecutionResult;
Expand All @@ -9,6 +10,7 @@
import java.nio.file.Path;

import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
import static org.junit.platform.testkit.engine.EngineTestKit.engine;
Expand Down Expand Up @@ -38,6 +40,25 @@ void unknownGoldenImposterNameFailsClearly() {
assertFailureContains(UnknownImposterFixture.class, "nope");
}

/**
* Since rift 0.19.0 {@code DELETE savedProxyResponses} also deletes the stubs a proxy recorded
* ({@code recordedFrom}) — on the golden imposter, the fixture itself. The PER_TEST reset must
* therefore leave the golden imposter's proxy responses alone, while still clearing the others'.
*/
@Test
void perTestResetKeepsTheGoldenImpostersRecordedStubs() throws Exception {
Path file = Path.of("target/rift-golden-unit/one-stub.json");
Files.createDirectories(file.getParent());
Files.writeString(file, "{\"stubs\":[{\"predicates\":[{\"equals\":{\"path\":\"/u/1\"}}],"
+ "\"responses\":[{\"is\":{\"statusCode\":200}}],\"recordedFrom\":\"http://origin\"}]}");
engine("junit-jupiter").selectors(selectClass(ReplayResetFixture.class)).execute()
.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).failed(0));
assertEquals(0, ADMIN.state(ReplayResetFixture.goldenPort).proxyResponseClears.get(),
"the golden imposter's recorded stubs survive the PER_TEST reset");
assertEquals(2, ADMIN.state(ReplayResetFixture.otherPort).proxyResponseClears.get(),
"a non-golden imposter's proxy responses are still cleared before each test");
}

private static void assertFailureContains(Class<?> fixture, String expected) {
Events containers = engine("junit-jupiter").selectors(selectClass(fixture)).execute().containerEvents();
String message = containers.failed().stream()
Expand Down Expand Up @@ -68,4 +89,33 @@ static class UnknownImposterFixture {
void unreached() {
}
}

@RiftTest(transport = Transport.CONNECT, adminUri = "${rift.golden.unit.admin}")
@RiftGolden(origin = "http://unused", file = "target/rift-golden-unit/one-stub.json", imposter = "users")
static class ReplayResetFixture {
static volatile int goldenPort;
static volatile int otherPort;

@RiftImposter
static ImposterSpec users = imposter("users").record();

@RiftImposter
static ImposterSpec other = imposter("other").record();

@InjectImposter("users")
Imposter usersImposter;

@InjectImposter("other")
Imposter otherImposter;

@Test
void first() {
goldenPort = usersImposter.port();
otherPort = otherImposter.port();
}

@Test
void second() {
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ class RiftContainerTest {
@Test
void defaultImageUsesPinnedEngineVersion() {
// AC4: single-sourced from the <rift.engine.version> property via resource filtering.
assertEquals("0.18.1", RiftContainer.ENGINE_VERSION, "engine version resolved from filtered resource");
assertEquals("0.19.0", RiftContainer.ENGINE_VERSION, "engine version resolved from filtered resource");
try (RiftContainer container = new RiftContainer()) {
assertEquals("zainalpour/rift-proxy:v0.18.1", container.configuredImageName());
assertEquals("zainalpour/rift-proxy:v0.19.0", container.configuredImageName());
}
}

Expand Down Expand Up @@ -145,7 +145,7 @@ void rejectsUpstreamTrustOnAnImageOlderThan018() {
@Test
void acceptsUpstreamTrustOnA018ImageOrAnUnversionedTag() {
// A tag that is not a version (latest, a digest, a custom build) cannot be checked, so it is let through.
for (String image : new String[] {"zainalpour/rift-proxy:v0.18.1", "zainalpour/rift-proxy:0.19.1",
for (String image : new String[] {"zainalpour/rift-proxy:v0.19.0", "zainalpour/rift-proxy:0.19.1",
"zainalpour/rift-proxy:latest", "acme/rift:nightly"}) {
try (RiftContainer container = new RiftContainer(DockerImageName.parse(image))) {
container.withUpstreamTrust(new UpstreamTrust.SkipVerify());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,45 @@ void goldenCaptureThenReplay() throws Exception {
// The upstream lives in the same engine; the golden imposter proxies to it via container localhost.
try (Rift client = RIFT.client()) {
client.create(imposter("upstream").port(4700)
.stub(onGet("/u/1").willReturn(okJson("{\"id\":1,\"src\":\"upstream\"}"))));
.stub(onGet("/u/1").willReturn(okJson("{\"id\":1,\"src\":\"upstream\"}")))
.stub(onGet("/u/2").willReturn(okJson("{\"id\":2,\"src\":\"upstream\"}"))));
}

// CAPTURE — file absent: the fixture's test drives traffic through the golden imposter, which
// proxies to the upstream and records it; the golden file is persisted on class close.
// CAPTURE — file absent: the fixture's two tests drive traffic through the golden imposter, which
// proxies to the upstream and records it; the golden file is persisted on class close. Two tests
// prove the PER_TEST reset between them keeps the first test's recording (rift >= 0.19.0 deletes
// recorded stubs on DELETE savedProxyResponses, so the reset must spare the golden imposter).
engine("junit-jupiter").selectors(selectClass(CaptureFixture.class)).execute()
.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0));
.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).failed(0));
assertTrue(Files.exists(Path.of(GOLDEN_FILE)), "CAPTURE wrote the golden file");

// Free port 4545 and delete the upstream so REPLAY starts clean with the origin genuinely gone.
clearAllImposters();

// REPLAY — file present, upstream gone: served from the file. Green proves no network was touched.
// REPLAY — file present, upstream gone: served from the file. Green proves no network was touched,
// and the second test proves the PER_TEST reset kept the replayed stubs.
engine("junit-jupiter").selectors(selectClass(ReplayFixture.class)).execute()
.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0));
.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).failed(0));
}

private static void clearAllImposters() throws Exception {
HTTP.send(HttpRequest.newBuilder(RIFT.adminUri().resolve("/imposters")).DELETE().build(),
HttpResponse.BodyHandlers.discarding());
}

private static HttpResponse<String> hitUsers() throws Exception {
private static HttpResponse<String> hitUser(int id) throws Exception {
String base = System.getProperty("rift.golden.users.url");
return HTTP.send(
HttpRequest.newBuilder(URI.create(base + "/u/1")).timeout(Duration.ofSeconds(5)).GET().build(),
HttpRequest.newBuilder(URI.create(base + "/u/" + id)).timeout(Duration.ofSeconds(5)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}

private static void assertServed(HttpResponse<String> response, int id, String what) {
assertEquals(200, response.statusCode(), what + ": " + response.body());
assertTrue(response.body().contains("\"id\":" + id) && response.body().contains("\"src\":\"upstream\""),
what + ": " + response.body());
}

@RiftTest(transport = Transport.CONNECT, adminUri = "${rift.golden.admin}")
@RiftGolden(origin = "http://localhost:4700", file = GOLDEN_FILE)
static class CaptureFixture {
Expand All @@ -96,10 +106,13 @@ static class CaptureFixture {
Imposter usersImposter;

@Test
void recordsThroughTheProxy() throws Exception {
HttpResponse<String> response = hitUsers();
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"src\":\"upstream\""), "captured upstream body: " + response.body());
void recordsUserOneThroughTheProxy() throws Exception {
assertServed(hitUser(1), 1, "captured upstream body");
}

@Test
void recordsUserTwoThroughTheProxy() throws Exception {
assertServed(hitUser(2), 2, "captured upstream body");
}
}

Expand All @@ -113,10 +126,13 @@ static class ReplayFixture {
Imposter usersImposter;

@Test
void servesFromTheGoldenFileWithoutNetwork() throws Exception {
HttpResponse<String> response = hitUsers();
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"src\":\"upstream\""), "replayed body from file: " + response.body());
void servesUserOneFromTheGoldenFileWithoutNetwork() throws Exception {
assertServed(hitUser(1), 1, "replayed body from file");
}

@Test
void servesUserTwoFromTheGoldenFileWithoutNetwork() throws Exception {
assertServed(hitUser(2), 2, "replayed body from file");
}
}
}
Loading