diff --git a/.github/workflows/junit5-sdk.yml b/.github/workflows/junit5-sdk.yml
new file mode 100644
index 0000000..bdaa300
--- /dev/null
+++ b/.github/workflows/junit5-sdk.yml
@@ -0,0 +1,77 @@
+name: keploy-junit5
+
+on:
+ pull_request:
+ paths:
+ - "keploy-junit5/**"
+ - ".github/workflows/junit5-sdk.yml"
+ push:
+ branches: [main]
+ paths:
+ - "keploy-junit5/**"
+ - ".github/workflows/junit5-sdk.yml"
+ # keploy-junit5-release.yml runs these rows and the e2e lane on a release
+ # tag before it publishes.
+ workflow_call:
+
+concurrency:
+ group: keploy-junit5-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ rows:
+ name: rows (JDK ${{ matrix.java }}, JUnit ${{ matrix.junit }})
+ runs-on: ubuntu-latest
+ strategy:
+ # One leg failing must not cancel the others: each leg's result is its
+ # own.
+ fail-fast: false
+ matrix:
+ # The oldest JUnit this extension supports (README.md) on the oldest
+ # JDK it supports, the last JUnit 5, and JUnit 6 (JDK 17 or later).
+ include:
+ - { java: "8", junit: "5.10.5" }
+ - { java: "17", junit: "5.14.4" }
+ - { java: "21", junit: "6.1.3" }
+ defaults:
+ run:
+ working-directory: keploy-junit5
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ cache: maven
+ - run: mvn -B -ntp -Djunit.version=${{ matrix.junit }} verify
+
+ e2e:
+ # e2e/lane.sh: this commit's extension, installed with Maven into the
+ # lane's own Maven repository and used by a small JUnit 5 suite, run with
+ # Maven under keploy mock record with its provider up, then keploy mock
+ # replay with the provider stopped; a test whose call has no recorded
+ # answer, and one that no longer makes its recorded call, fail with
+ # keploy's text; a quarantined test is skipped with keploy's reason.
+ name: e2e (keploy mock record and replay)
+ runs-on: ubuntu-latest
+ # The lane takes a few minutes, Maven's downloads included: a run past 15
+ # is a hang.
+ timeout-minutes: 15
+ env:
+ # keploy's enterprise build, which judges each test under
+ # KEPLOY_PROVEN=1. Pinned, and checked against its digest, so that a
+ # new keploy release cannot change this job's result unseen.
+ KEPLOY_VERSION: "3.8.106"
+ KEPLOY_SHA256: "2c4c9bcf0a703b65827d1cee5ca88fe9d7856844a08cac368b42b83345fef3ce"
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: install keploy ${{ env.KEPLOY_VERSION }}
+ run: |
+ curl -fsSL --retry 5 -o "$RUNNER_TEMP/keploy" "https://keploy.io/ent/dl/$KEPLOY_VERSION/enterprise_linux_amd64"
+ echo "$KEPLOY_SHA256 $RUNNER_TEMP/keploy" | sha256sum -c -
+ chmod +x "$RUNNER_TEMP/keploy"
+ - run: KEPLOY_BIN="$RUNNER_TEMP/keploy" bash keploy-junit5/e2e/lane.sh
diff --git a/.github/workflows/keploy-junit5-release.yml b/.github/workflows/keploy-junit5-release.yml
new file mode 100644
index 0000000..168fdf8
--- /dev/null
+++ b/.github/workflows/keploy-junit5-release.yml
@@ -0,0 +1,141 @@
+name: keploy-junit5 release
+
+# Publishes io.keploy:keploy-junit5 to Maven Central from a tag.
+#
+# To release X.Y.Z: push the tag keploy-junit5-vX.Y.Z on a commit of main.
+# The rows on every JDK and JUnit leg and the e2e lane (junit5-sdk.yml) run
+# on the tagged commit first; the publish runs only when they all pass. It
+# sets keploy-junit5/pom.xml's version to X.Y.Z in this run only (the pom on
+# main stays a SNAPSHOT), builds the jar with its sources and javadoc jars,
+# signs each, and deploys them to Central, which refuses a version it already
+# has.
+#
+# keploy-junit5 is a Maven project of its own, released with its own
+# version: release.yml (v*.*.* tags) builds and deploys only the root pom's
+# modules, and never this one. This workflow signs and deploys with the same
+# key and the same secret names as release.yml: CENTRAL_USERNAME and
+# CENTRAL_PASSWORD (a Central Portal user token for the io.keploy namespace),
+# MAVEN_GPG_PRIVATE_KEY (the ASCII-armored release key, id 8541784E4EC36FB8)
+# and MAVEN_GPG_PASSPHRASE. A step below fails, naming each one that is not
+# set, before anything is built or signed.
+# Every action this workflow runs, the rows' included, is pinned by commit:
+# the publish job holds the signing key and the Central token.
+#
+# On a pull request the same release build runs with no signing and no
+# deploy, so a javadoc error or a missing jar shows before a tag.
+on:
+ push:
+ tags: ["keploy-junit5-v*"]
+ pull_request:
+ paths:
+ - "keploy-junit5/**"
+ - ".github/workflows/keploy-junit5-release.yml"
+ - ".github/workflows/junit5-sdk.yml"
+
+permissions:
+ contents: read
+
+# Never let two releases of the same tag overlap, and never cancel one
+# mid-deploy.
+concurrency:
+ group: keploy-junit5-release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ rows:
+ if: github.event_name == 'push'
+ uses: ./.github/workflows/junit5-sdk.yml
+
+ publish:
+ name: build, sign and publish to Maven Central
+ if: github.event_name == 'push'
+ needs: rows
+ runs-on: ubuntu-latest
+ # central-publishing-maven-plugin waits until Central reports the
+ # deployment PUBLISHED (waitMaxTime 7200 s), plus the build ahead of it.
+ timeout-minutes: 150
+ env:
+ # The public key id of keploy's release signing key, as release.yml.
+ GPG_KEY_ID: 8541784E4EC36FB8
+ defaults:
+ run:
+ shell: bash # -eo pipefail: a failed command before a pipe fails the step
+ working-directory: keploy-junit5
+ steps:
+ - name: The tag names a release version
+ id: ver
+ # Before the signing key is imported. The tag name is interpolated
+ # into Maven's arguments below, so only digits and dots pass.
+ working-directory: .
+ run: |
+ version="${GITHUB_REF_NAME#keploy-junit5-v}"
+ if [[ "$GITHUB_REF_NAME" != keploy-junit5-v* || ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
+ echo "::error::refusing to publish from '$GITHUB_REF_NAME': the tag must be keploy-junit5-vX.Y.Z, X.Y.Z a release version with no leading zeros"
+ exit 1
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ - name: The Central and signing secrets are set
+ working-directory: .
+ env:
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
+ MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }}
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
+ run: |
+ missing=()
+ for s in CENTRAL_USERNAME CENTRAL_PASSWORD MAVEN_GPG_PRIVATE_KEY MAVEN_GPG_PASSPHRASE; do
+ [[ -n "${!s}" ]] || missing+=("$s")
+ done
+ if (( ${#missing[@]} )); then
+ echo "::error::the secret(s) ${missing[*]} are not set; refusing to publish"
+ exit 1
+ fi
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - name: Set up JDK 17
+ uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
+ with:
+ distribution: temurin
+ java-version: "17"
+ # Writes ~/.m2/settings.xml with a `central` server entry read from
+ # the env vars named here, and imports the signing key.
+ server-id: central
+ server-username: CENTRAL_USERNAME
+ server-password: CENTRAL_PASSWORD
+ gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }}
+ gpg-passphrase: MAVEN_GPG_PASSPHRASE
+ - name: Set the release version in the pom
+ # Pinned, as release.yml pins it: an unpinned plugin would run
+ # unreviewed code in the job that holds the signing key.
+ env:
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.21.0:set -DnewVersion="$VERSION" -DgenerateBackupPoms=false
+ - name: Build, sign and publish
+ env:
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
+ run: mvn -B -ntp -P release -DskipTests clean deploy -Dgpg.keyname="$GPG_KEY_ID"
+
+ dry-run:
+ name: release build, dry run
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash # -eo pipefail: a failed command before a pipe fails the step
+ working-directory: keploy-junit5
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Release build, unsigned
+ run: mvn -B -ntp -P release -DskipTests -Dgpg.skip clean verify
+ - name: The release carries its jar, sources and javadoc
+ run: |
+ v=$(mvn -B -ntp -q help:evaluate -Dexpression=project.version -DforceStdout)
+ for f in "keploy-junit5-$v.jar" "keploy-junit5-$v-sources.jar" "keploy-junit5-$v-javadoc.jar"; do
+ test -s "target/$f" || { echo "::error::the release would ship without $f"; exit 1; }
+ done
+ unzip -l "target/keploy-junit5-$v.jar" | grep -q 'io/keploy/junit5/' || { echo "::error::keploy-junit5-$v.jar holds no io/keploy/junit5 classes"; exit 1; }
diff --git a/keploy-junit5/README.md b/keploy-junit5/README.md
new file mode 100644
index 0000000..be02354
--- /dev/null
+++ b/keploy-junit5/README.md
@@ -0,0 +1,122 @@
+# keploy-junit5
+
+A JUnit 5 (Jupiter) extension that joins a test suite to keploy's Universal
+Mocker. `keploy mock record` keeps the calls each test's code makes to its
+dependencies; `keploy mock replay` answers them from the recording, so the
+suite runs with every dependency stopped. keploy judges each test: a call with
+no recorded answer, or a recorded call the test no longer makes, fails that
+test with keploy's text.
+
+The extension only tells keploy where each test begins and ends; keploy keeps
+every rule. It speaks the same wire as go-sdk's `keploytest`,
+`@keploy/playwright`, `@keploy/jest` and `keploy-pytest`.
+
+## Use
+
+JUnit 5.10 or later (JUnit 6 included), Java 8 or later. The package is not
+published yet: install it from this folder with `mvn install`, then add it to
+the test scope:
+
+```xml
+
+ io.keploy
+ keploy-junit5
+ 0.1.0-SNAPSHOT
+ test
+
+```
+
+Let JUnit load it for the whole run, in `src/test/resources/junit-platform.properties`:
+
+```properties
+junit.jupiter.extensions.autodetection.enabled=true
+```
+
+(Gradle: `test { useJUnitPlatform(); systemProperty 'junit.jupiter.extensions.autodetection.enabled', 'true' }`.)
+
+keploy needs the project's `keploy.yml` to name the recording and CI's test
+command; without `mock.command`, keploy refuses the run with
+`no keploy project here: set mock.command ...`:
+
+```yaml
+# keploy.yml
+mock:
+ name: shop # the recording: keploy/shop/
+ command: mvn test # CI's test command
+```
+
+Then run the suite under keploy with `KEPLOY_PROVEN=1`, in one test JVM.
+`--local` keeps the recording on disk, in `keploy//`:
+
+```sh
+KEPLOY_PROVEN=1 keploy mock record --local -c "mvn test"
+KEPLOY_PROVEN=1 keploy mock replay --local -c "mvn test"
+```
+
+Without keploy (`KEPLOY_MOCK_AGENT` unset or `off`) the extension does
+nothing, and the suite runs as plain JUnit.
+
+## What it does under keploy
+
+- The test JVM claims keploy's run once, before the first test class's
+ `@BeforeAll`, naming keploy's project folder (`KEPLOY_MOCK_PROJECT`).
+- Each test begins after its class's `@BeforeAll` methods, before every other
+ extension's BeforeEach callback and its own `@BeforeEach` methods. It ends
+ after its `@AfterEach` methods and every other extension's AfterEach
+ callback, with its outcome: passed, failed, or skipped (a failed
+ assumption). The calls `@BeforeAll` and `@AfterAll` methods make belong to
+ the test JVM, outside every test.
+- A test keploy set aside is aborted (JUnit's skip) with keploy's reason, and
+ none of its `@BeforeEach`, test or `@AfterEach` methods runs (other
+ extensions' AfterEach callbacks do, as JUnit runs them after any BeforeEach
+ callback stops a test). A line on stderr names it.
+- A test keploy failed at its end fails with keploy's text; when it had failed
+ already, keploy's text is added to its failure.
+- Its ID is `::`: the class's binary name
+ (`com.shop.OrdersTest$Refunds` for a `@Nested` class) and JUnit's name of
+ the method, with a parameterized or repeated test's invocation index:
+ `com.shop.OrdersTest::price(java.lang.String)[2]`. Each invocation of a
+ `@ParameterizedClass` (JUnit 5.13 and later) runs each of the class's tests
+ as a test of its own, with the class's invocation index before the method:
+ `com.shop.OrdersTest::[2]price(java.lang.String)`. A test run through a
+ JUnit Platform `@Suite` has the same ID as when it runs directly.
+
+## Limits
+
+Each of these fails loudly under keploy, so no Jupiter test runs unjudged:
+
+- One test JVM at a time: keploy refuses a second claim. Keep Surefire's
+ `forkCount` at 1 (its default), and Gradle's `maxParallelForks` at 1.
+- JUnit's parallel execution (`junit.jupiter.execution.parallel.enabled=true`)
+ fails every test class under keploy.
+- Registering the extension only with `@ExtendWith`, without autodetection,
+ fails every test class under keploy: some classes could run without it,
+ and it must wrap every other extension.
+- A `@TestFactory`'s dynamic tests run inside one BeforeEach/AfterEach pair,
+ so keploy cannot judge them one by one: the factory fails under keploy.
+ Write them as `@ParameterizedTest`.
+- A test begun a second time in one JVM fails (a rerun, such as Surefire's
+ `rerunFailingTestsCount`, or a second `@Suite` that selects the same test
+ class): keploy names a test by its class and name.
+- A JVM cannot change its own environment, so it does not export
+ `KEPLOY_TEST_PID` to the processes its tests start; one of them that runs
+ tests under a keploy SDK claims too, and keploy refuses it.
+
+Only JUnit Jupiter tests are judged. The tests of every other JUnit Platform
+engine run unjudged, as without keploy: JUnit 4 tests on the Vintage engine,
+and Spock, Kotest, jqwik and Cucumber tests. This extension is a Jupiter
+extension, and no other engine calls it; their calls are the test JVM's.
+
+## Releasing
+
+keploy-junit5 is released on its own tag, with its own version, apart from
+the repository's `v*.*.*` releases of the agent. To release X.Y.Z, push the
+tag `keploy-junit5-vX.Y.Z` on a commit of `main`:
+`git tag keploy-junit5-vX.Y.Z && git push origin keploy-junit5-vX.Y.Z`.
+The tag runs the rows and the e2e lane, then
+`.github/workflows/keploy-junit5-release.yml` sets the pom's version to X.Y.Z
+for that run only, builds the jar with its sources and javadoc jars, signs
+each, and deploys them to Maven Central. It uses the repository secrets that
+`release.yml` uses: `CENTRAL_USERNAME` and `CENTRAL_PASSWORD` (a Central
+Portal user token for the `io.keploy` namespace), `MAVEN_GPG_PRIVATE_KEY` and
+`MAVEN_GPG_PASSPHRASE`; it refuses to start, naming each one that is not set.
diff --git a/keploy-junit5/e2e/lane.sh b/keploy-junit5/e2e/lane.sh
new file mode 100755
index 0000000..56cdbf2
--- /dev/null
+++ b/keploy-junit5/e2e/lane.sh
@@ -0,0 +1,224 @@
+#!/usr/bin/env bash
+# The e2e lane of keploy-junit5: this commit's extension, installed with
+# Maven into a Maven repository of the lane's own, used by a small JUnit 5
+# suite (e2e/shop) run with Maven under a real keploy that judges each test.
+# The suite's code calls a provider over HTTP; keploy records those calls
+# with the provider up, and replays them with the provider stopped.
+#
+# KEPLOY_BIN=/path/to/keploy bash keploy-junit5/e2e/lane.sh
+#
+# KEPLOY_BIN is a keploy build that judges each test under KEPLOY_PROVEN=1
+# (keploy's enterprise build; the CI job downloads a pinned release). keploy
+# needs root for its eBPF hooks: the lane runs it through sudo unless it is
+# root already. java (17 or later: the suite's release, and the provider runs
+# as a source file) and mvn must be on PATH.
+#
+# Checks, each judged by what a user reads (exit codes, Surefire's summary
+# and each test's report, the files keploy wrote):
+# 1. without keploy the suite passes as plain JUnit, and nothing says
+# keploy;
+# 2. keploy mock record keeps each test's calls under the test's name;
+# 3. keploy mock replay, with the provider stopped, passes every test;
+# 4. a test whose call has no recorded answer fails, with keploy's text
+# added to its failure; the others pass;
+# 5. a test that no longer makes its recorded call fails, by keploy, with
+# keploy's text; the others pass;
+# 6. a test quarantined in the set is skipped by JUnit with keploy's
+# reason, and the replay passes.
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PKG="$(cd "$HERE/.." && pwd)"
+KEPLOY_BIN="${KEPLOY_BIN:?set KEPLOY_BIN to a keploy binary}"
+PORT="${SHOP_PORT:-18751}"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/junit5-e2e.XXXXXX")"
+FAILED=0
+PROVIDER_PID=""
+export SHOP_PROVIDER="http://127.0.0.1:$PORT"
+# The lane's own Maven repository: this commit's extension, and everything
+# the suite needs, fetched once (step 0) so that every later run is offline.
+export MAVEN_OPTS="${MAVEN_OPTS:+$MAVEN_OPTS }-Dmaven.repo.local=$WORK/m2"
+
+cleanup() {
+ [ -z "$PROVIDER_PID" ] || kill "$PROVIDER_PID" 2>/dev/null
+ wait 2>/dev/null
+}
+trap cleanup EXIT
+
+ok() { echo " ok: $*"; }
+fail() { echo " FAIL: $*"; FAILED=1; }
+# show ID: the run's output, for a failing check.
+show() {
+ local stream
+ for stream in out err; do
+ echo " --- $1: std$stream (last 150 lines, keploy's banner left out) ---"
+ grep -av '38;5;' "$WORK/logs/$1.$stream" | tail -n 150 | sed 's/^/ | /'
+ done
+}
+
+if [ "$(id -u)" -eq 0 ]; then AS_ROOT=(env); else AS_ROOT=(sudo -E env "PATH=$PATH" "HOME=$HOME"); fi
+
+# The extension as Maven Central would serve it (its jar, installed), and
+# the suite, from a copy of e2e/shop.
+mkdir -p "$WORK/logs" "$WORK/pkg"
+tar -C "$PKG" --exclude=./e2e --exclude=./target -cf - . | tar -C "$WORK/pkg" -xf -
+cp -R "$HERE/shop" "$WORK/shop"
+(cd "$WORK/pkg" && mvn -B -ntp -DskipTests install) >"$WORK/logs/install.log" 2>&1 ||
+ { cat "$WORK/logs/install.log"; echo "FAIL: could not build and install the extension"; exit 1; }
+JAR=$(ls "$WORK"/m2/io/keploy/keploy-junit5/*/keploy-junit5-*.jar)
+cd "$WORK/shop" || exit 1
+echo "installed $(basename "$JAR") into $WORK/m2; $(mvn -v 2>&1 | head -n 1); $(java -version 2>&1 | head -n 1); the suite is at $WORK/shop"
+
+start_provider() {
+ : >"$WORK/logs/provider.log"
+ java "$WORK/shop/Provider.java" "$PORT" >"$WORK/logs/provider.log" 2>&1 &
+ PROVIDER_PID=$!
+ until grep -qx ready "$WORK/logs/provider.log"; do
+ kill -0 "$PROVIDER_PID" 2>/dev/null || { cat "$WORK/logs/provider.log"; echo "FAIL: the provider exited before it listened"; exit 1; }
+ sleep 0.2
+ done
+}
+stop_provider() {
+ kill "$PROVIDER_PID" 2>/dev/null
+ wait "$PROVIDER_PID" 2>/dev/null
+ PROVIDER_PID=""
+}
+
+# run ID CMD...: one run, stdin closed, stdout and stderr kept apart. Sets EC.
+run() {
+ local id=$1
+ shift
+ echo
+ echo "== $id: $*"
+ "$@" "$WORK/logs/$id.out" 2>"$WORK/logs/$id.err"
+ EC=$?
+ echo " exit $EC"
+ # LANE_VERBOSE=1 shows every run's output, not only a failing check's.
+ [ -z "${LANE_VERBOSE:-}" ] || show "$id"
+}
+# keploy ID ARGS...: one keploy run under the proven switch, as root.
+keploy() {
+ local id=$1
+ shift
+ run "$id" "${AS_ROOT[@]}" KEPLOY_PROVEN=1 KEPLOY_INVOKER=ci "SHOP_PROVIDER=$SHOP_PROVIDER" \
+ "SHOP_CHANGE=${SHOP_CHANGE:-}" "MAVEN_OPTS=$MAVEN_OPTS" ${JAVA_HOME:+"JAVA_HOME=$JAVA_HOME"} "$KEPLOY_BIN" "$@" --disable-tele
+}
+# report_of ID TEST: Surefire's report of the failed test TEST in a run
+# ("[ERROR] TEST -- Time elapsed: ... <<< FAILURE!"): the lines after it, up
+# to Maven's next log line.
+report_of() {
+ awk -v h="[ERROR] $2 -- " '
+ on && /^\[[A-Z]+\] / { on = 0 }
+ index($0, h) == 1 && / <<< (FAILURE|ERROR)!$/ { on = 1; next }
+ on' "$WORK/logs/$1.out"
+}
+# surefire_summary ID: Surefire's closing count of a run.
+surefire_summary() {
+ grep -aE '^\[[A-Z]+\] Tests run: [0-9]+, Failures: [0-9]+, Errors: [0-9]+, Skipped: [0-9]+$' "$WORK/logs/$1.out" |
+ tail -n 1 | sed -E 's/^\[[A-Z]+\] //'
+}
+# has ID TEXT: whether the run's output (Maven's and keploy's) has a line
+# that is TEXT.
+has() { grep -aqxF -- "$2" "$WORK/logs/$1.out" "$WORK/logs/$1.err"; }
+
+MVN='mvn -B -ntp -o test'
+# keploy's IDs: class::name, the class's binary name and JUnit's name of the
+# method in it, with a parameterized test's invocation index.
+PRICES='shop.OrdersTest$Books::pricesABook()'
+RESERVES='shop.OrdersTest$Books::reservesABook()'
+CHARGES='shop.PaymentsTest::chargesACard(int)[1]'
+# judge ID WHAT: reports a check from the reasons WHY holds against it.
+judge() {
+ if [ "${#WHY[@]}" -eq 0 ]; then ok "$2"; return; fi
+ fail "$2"
+ printf ' - %s\n' "${WHY[@]}"
+ show "$1"
+}
+
+# 0. Fill the lane's Maven repository: one plain run, online, with the
+# provider up. Every later run is offline.
+start_provider
+run warm mvn -B -ntp test
+[ "$EC" -eq 0 ] || { show warm; echo "FAIL: the suite does not pass online without keploy"; exit 1; }
+
+# 1. Without keploy: plain JUnit, offline.
+run inert $MVN
+WHY=()
+[ "$EC" -eq 0 ] || WHY+=("exit $EC, want 0")
+[ "$(surefire_summary inert)" = "Tests run: 3, Failures: 0, Errors: 0, Skipped: 0" ] || WHY+=("Surefire says '$(surefire_summary inert)'")
+! cat "$WORK/logs/inert.out" "$WORK/logs/inert.err" | grep -aqi keploy || WHY+=("a line says keploy")
+judge inert "without keploy the suite passes as plain JUnit, and nothing says keploy"
+
+# 2. Record, with the provider up: keploy's own rounds replay it twice more.
+keploy record mock record --local -c "$MVN"
+stop_provider
+WHY=()
+[ "$EC" -eq 0 ] || WHY+=("exit $EC, want 0")
+has record "keploy: committed keploy/shop and its verdicts.yaml: 3 replayable, 0 quarantined" ||
+ WHY+=("keploy did not commit 3 replayable tests")
+for t in "$PRICES" "$RESERVES" "$CHARGES"; do
+ grep -aqxF -- " - id: $t" keploy/shop/mappings.yaml 2>/dev/null || WHY+=("keploy/shop/mappings.yaml does not name $t")
+done
+judge record "keploy mock record keeps each test's calls under its class and JUnit's name of it"
+
+# 3. Replay with the provider stopped.
+keploy replay mock replay --local -c "$MVN"
+WHY=()
+[ "$EC" -eq 0 ] || WHY+=("exit $EC, want 0")
+[ "$(surefire_summary replay)" = "Tests run: 3, Failures: 0, Errors: 0, Skipped: 0" ] || WHY+=("Surefire says '$(surefire_summary replay)'")
+has replay "keploy: shop: 3 test(s) ran: 3 passed, 0 failed (0 held by keploy), 0 skipped by the test; 0 quarantined (skipped by keploy)" ||
+ WHY+=("keploy did not judge 3 tests passed")
+has replay "VERDICT passed" || WHY+=("keploy's verdict is not passed")
+judge replay "keploy mock replay with the provider stopped passes every test"
+
+# 4. A call the recording has no answer for: keploy answers it with an
+# error, so the test fails by itself (an error: its IOException), and keploy's
+# text is added to that failure; only that test fails.
+SHOP_CHANGE=miss keploy replay-miss mock replay --local -c "$MVN"
+WHY=()
+[ "$EC" -eq 1 ] || WHY+=("exit $EC, want 1")
+[ "$(surefire_summary replay-miss)" = "Tests run: 3, Failures: 0, Errors: 1, Skipped: 0" ] || WHY+=("Surefire says '$(surefire_summary replay-miss)'")
+# keploy 3.8.106 names this client's destination "[127.0.0.1]:PORT (live
+# 127.0.0.1:PORT)": the check takes any form that names the address and port.
+report_of replay-miss 'shop.OrdersTest$Books.pricesABook' |
+ grep -aqE "^[[:space:]]+Suppressed: io\.keploy\.junit5\.KeployException: keploy: GET /price/pen to .*127\.0\.0\.1.*:$PORT.* had no recorded answer \(path differs\)" ||
+ WHY+=("Surefire's report of pricesABook does not carry keploy's miss, added to its failure")
+has replay-miss "FAILED_BY_KEPLOY $PRICES (failed): keploy/unmocked_call" || WHY+=("keploy does not hold the miss against pricesABook")
+judge replay-miss "a call with no recorded answer fails its own test with keploy's text; the others pass"
+
+# 5. A recorded call the test no longer makes: keploy fails it at its end.
+SHOP_CHANGE=unconsumed keploy replay-unconsumed mock replay --local -c "$MVN"
+WHY=()
+[ "$EC" -eq 1 ] || WHY+=("exit $EC, want 1")
+[ "$(surefire_summary replay-unconsumed)" = "Tests run: 3, Failures: 0, Errors: 1, Skipped: 0" ] || WHY+=("Surefire says '$(surefire_summary replay-unconsumed)'")
+# The mock's number follows the order the recording made its calls in, and
+# Surefire runs the classes in the order the file system lists them.
+report_of replay-unconsumed 'shop.OrdersTest$Books.reservesABook' |
+ grep -aqE "^keploy: recorded call POST /reserve \(mock-[0-9]+\) was never made\." ||
+ WHY+=("Surefire's report of reservesABook does not carry keploy's unmade call")
+has replay-unconsumed "FAILED_BY_KEPLOY $RESERVES (failed): keploy/unconsumed_mock" || WHY+=("keploy does not hold the unmade call against reservesABook")
+judge replay-unconsumed "a recorded call the test no longer makes fails that test with keploy's text; the others pass"
+
+# 6. A test quarantined in the set: JUnit skips it, before its own
+# callbacks, with keploy's reason; the replay passes.
+keploy quarantine mock quarantine "$CHARGES" --reason 'e2e: set aside by the lane' --local --name shop
+WHY=()
+[ "$EC" -eq 0 ] || WHY+=("keploy mock quarantine exited $EC")
+keploy replay-quarantined mock replay --local -c "$MVN"
+[ "$EC" -eq 0 ] || WHY+=("replay exit $EC, want 0")
+[ "$(surefire_summary replay-quarantined)" = "Tests run: 3, Failures: 0, Errors: 0, Skipped: 1" ] || WHY+=("Surefire says '$(surefire_summary replay-quarantined)'")
+# Surefire's XML report, as CI's test reports read it: the abort's message is
+# the skip's text.
+grep -aA1 -F '/dev/null |
+ grep -aqF '&1 | grep -av '&1 | sed 's/^/ | /'; }
+judge replay-quarantined "a quarantined test is skipped by JUnit with keploy's reason; the replay passes"
+
+echo
+if [ "$FAILED" -ne 0 ]; then echo "e2e: FAILED (logs in $WORK/logs)"; exit 1; fi
+echo "e2e: every check passed"
diff --git a/keploy-junit5/e2e/shop/Provider.java b/keploy-junit5/e2e/shop/Provider.java
new file mode 100644
index 0000000..9487843
--- /dev/null
+++ b/keploy-junit5/e2e/shop/Provider.java
@@ -0,0 +1,56 @@
+// The provider the shop calls: up while keploy records, stopped while it
+// replays. Prints "ready" once it listens.
+//
+// java Provider.java PORT
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class Provider {
+ static final Map PRICES = Map.of("book", 4200, "pen", 150);
+
+ public static void main(String[] args) throws IOException {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[0])), 0);
+ server.createContext("/", Provider::handle);
+ server.start();
+ System.out.println("ready");
+ System.out.flush();
+ }
+
+ static void handle(HttpExchange ex) throws IOException {
+ String method = ex.getRequestMethod();
+ String path = ex.getRequestURI().getPath();
+ String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ String out = null;
+ if (method.equals("GET") && path.startsWith("/price/")) {
+ out = "{\"cents\":" + PRICES.getOrDefault(path.substring("/price/".length()), 0) + "}";
+ } else if (method.equals("POST") && path.equals("/reserve")) {
+ out = "{\"ok\":" + PRICES.containsKey(field(body, "item")) + "}";
+ } else if (method.equals("POST") && path.equals("/charge")) {
+ String cents = field(body, "cents");
+ out = "{\"status\":\"" + (cents != null && Integer.parseInt(cents) > 0 ? "captured" : "declined") + "\"}";
+ }
+ byte[] data = (out == null ? "no route" : out).getBytes(StandardCharsets.UTF_8);
+ ex.getResponseHeaders().set("content-type", out == null ? "text/plain" : "application/json");
+ ex.sendResponseHeaders(out == null ? 404 : 200, data.length);
+ try (OutputStream o = ex.getResponseBody()) {
+ o.write(data);
+ }
+ }
+
+ // The value of a member of a JSON object, a string or an integer: enough
+ // for the shop's bodies.
+ static String field(String json, String key) {
+ Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*(?:\"([^\"]*)\"|(-?[0-9]+))").matcher(json);
+ if (!m.find()) {
+ return null;
+ }
+ return m.group(1) != null ? m.group(1) : m.group(2);
+ }
+}
diff --git a/keploy-junit5/e2e/shop/keploy.yml b/keploy-junit5/e2e/shop/keploy.yml
new file mode 100644
index 0000000..31d30a5
--- /dev/null
+++ b/keploy-junit5/e2e/shop/keploy.yml
@@ -0,0 +1,6 @@
+# The keploy project of the e2e suite: the set's name and CI's test command,
+# one test JVM (Surefire's default forkCount, 1): keploy judges one test at a
+# time. Offline: e2e/lane.sh fills the Maven repository first.
+mock:
+ name: shop
+ command: mvn -B -ntp -o test
diff --git a/keploy-junit5/e2e/shop/pom.xml b/keploy-junit5/e2e/shop/pom.xml
new file mode 100644
index 0000000..b70ab1d
--- /dev/null
+++ b/keploy-junit5/e2e/shop/pom.xml
@@ -0,0 +1,62 @@
+
+
+ 4.0.0
+
+
+ example.shop
+ shop
+ 0.1.0
+ jar
+
+
+ UTF-8
+ 17
+
+
+
+
+
+ org.junit
+ junit-bom
+ 5.14.4
+ pom
+ import
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ io.keploy
+ keploy-junit5
+ 0.1.0-SNAPSHOT
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+
+
+
diff --git a/keploy-junit5/e2e/shop/src/main/java/shop/Shop.java b/keploy-junit5/e2e/shop/src/main/java/shop/Shop.java
new file mode 100644
index 0000000..3017a7f
--- /dev/null
+++ b/keploy-junit5/e2e/shop/src/main/java/shop/Shop.java
@@ -0,0 +1,74 @@
+package shop;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The code under test: a shop that prices, reserves and charges through a
+ * provider over HTTP (the JDK's own client, keep-alive as the JDK sets it).
+ */
+public final class Shop {
+ private static final String BASE = base();
+
+ private Shop() {
+ }
+
+ private static String base() {
+ String v = System.getenv("SHOP_PROVIDER");
+ return v == null || v.isEmpty() ? "http://127.0.0.1:18751" : v;
+ }
+
+ public static int price(String item) throws IOException {
+ return Integer.parseInt(field(call("GET", "/price/" + item, null), "cents"));
+ }
+
+ public static boolean reserve(String item) throws IOException {
+ return Boolean.parseBoolean(field(call("POST", "/reserve", "{\"item\":\"" + item + "\"}"), "ok"));
+ }
+
+ public static String charge(int cents) throws IOException {
+ return field(call("POST", "/charge", "{\"cents\":" + cents + "}"), "status");
+ }
+
+ static String call(String method, String path, String body) throws IOException {
+ HttpURLConnection c = (HttpURLConnection) new URL(BASE + path).openConnection();
+ c.setRequestMethod(method);
+ if (body != null) {
+ byte[] data = body.getBytes(StandardCharsets.UTF_8);
+ c.setDoOutput(true);
+ c.setRequestProperty("content-type", "application/json");
+ c.setFixedLengthStreamingMode(data.length);
+ try (OutputStream o = c.getOutputStream()) {
+ o.write(data);
+ }
+ }
+ int status = c.getResponseCode();
+ InputStream in = status < 400 ? c.getInputStream() : c.getErrorStream();
+ String text = "";
+ if (in != null) {
+ try (InputStream i = in) {
+ text = new String(i.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+ if (status != 200) {
+ throw new IOException(method + " " + path + ": HTTP " + status + ": " + text);
+ }
+ return text;
+ }
+
+ // The value of a member of a JSON object: a string's, a number's or a
+ // boolean's. Enough for the provider's answers.
+ static String field(String json, String key) throws IOException {
+ Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*(?:\"([^\"]*)\"|([-0-9a-z]+))").matcher(json);
+ if (!m.find()) {
+ throw new IOException("no " + key + " in " + json);
+ }
+ return m.group(1) != null ? m.group(1) : m.group(2);
+ }
+}
diff --git a/keploy-junit5/e2e/shop/src/test/java/shop/OrdersTest.java b/keploy-junit5/e2e/shop/src/test/java/shop/OrdersTest.java
new file mode 100644
index 0000000..097d53c
--- /dev/null
+++ b/keploy-junit5/e2e/shop/src/test/java/shop/OrdersTest.java
@@ -0,0 +1,36 @@
+package shop;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class OrdersTest {
+ // SHOP_CHANGE: the change a replay of e2e/lane.sh makes to the code under
+ // test. miss: the price test asks for a pen, which no recorded call
+ // answers. unconsumed: the reserve test no longer makes its recorded call.
+ static final String CHANGE = System.getenv().getOrDefault("SHOP_CHANGE", "");
+
+ // A @Nested class: keploy names its tests by its binary name,
+ // shop.OrdersTest$Books.
+ @Nested
+ class Books {
+ @Test
+ void pricesABook() throws Exception {
+ if (CHANGE.equals("miss")) {
+ assertEquals(150, Shop.price("pen"));
+ } else {
+ assertEquals(4200, Shop.price("book"));
+ }
+ }
+
+ @Test
+ void reservesABook() throws Exception {
+ if (CHANGE.equals("unconsumed")) {
+ return;
+ }
+ assertTrue(Shop.reserve("book"));
+ }
+ }
+}
diff --git a/keploy-junit5/e2e/shop/src/test/java/shop/PaymentsTest.java b/keploy-junit5/e2e/shop/src/test/java/shop/PaymentsTest.java
new file mode 100644
index 0000000..b28fb86
--- /dev/null
+++ b/keploy-junit5/e2e/shop/src/test/java/shop/PaymentsTest.java
@@ -0,0 +1,16 @@
+package shop;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class PaymentsTest {
+ // A parameterized test: keploy names each invocation by its index,
+ // chargesACard(int)[1].
+ @ParameterizedTest
+ @ValueSource(ints = {4200})
+ void chargesACard(int cents) throws Exception {
+ assertEquals("captured", Shop.charge(cents));
+ }
+}
diff --git a/keploy-junit5/e2e/shop/src/test/resources/junit-platform.properties b/keploy-junit5/e2e/shop/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000..f05ea8f
--- /dev/null
+++ b/keploy-junit5/e2e/shop/src/test/resources/junit-platform.properties
@@ -0,0 +1,3 @@
+# keploy-junit5 wraps every test of the run, outside every other extension,
+# only when JUnit loads it for the whole run.
+junit.jupiter.extensions.autodetection.enabled=true
diff --git a/keploy-junit5/pom.xml b/keploy-junit5/pom.xml
new file mode 100644
index 0000000..23db0dc
--- /dev/null
+++ b/keploy-junit5/pom.xml
@@ -0,0 +1,185 @@
+
+
+ 4.0.0
+
+
+ io.keploy
+ keploy-junit5
+ 0.1.0-SNAPSHOT
+ jar
+
+ keploy-junit5
+ A JUnit 5 extension that tells keploy mock record and replay where each test begins and ends
+ https://github.com/keploy/java-sdk
+
+
+
+ The Apache License, Version 2.0
+ http://www.apache.org/licenses/LICENSE-2.0.txt
+
+
+
+
+
+ keploy
+ Keploy
+ hello@keploy.io
+ Keploy
+ https://keploy.io
+
+
+
+
+ scm:git:https://github.com/keploy/java-sdk.git
+ scm:git:ssh://git@github.com/keploy/java-sdk.git
+ https://github.com/keploy/java-sdk.git
+
+
+
+ UTF-8
+ 1.8
+ 1.8
+
+ 5.10.5
+
+
+
+
+
+ org.junit
+ junit-bom
+ ${junit.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ provided
+
+
+ org.junit.platform
+ junit-platform-engine
+ provided
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.junit.platform
+ junit-platform-testkit
+ test
+
+
+
+ org.junit.platform
+ junit-platform-suite
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+
+
+
+
+
+
+ release
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.2.1
+
+
+ attach-sources
+ verify
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.4.1
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 1.6
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+
+ --pinentry-mode
+ loopback
+
+
+
+
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.10.0
+ true
+
+ central
+ true
+ published
+ 7200
+
+
+
+
+
+
+
diff --git a/keploy-junit5/src/main/java/io/keploy/junit5/AgentClient.java b/keploy-junit5/src/main/java/io/keploy/junit5/AgentClient.java
new file mode 100644
index 0000000..378b9bb
--- /dev/null
+++ b/keploy-junit5/src/main/java/io/keploy/junit5/AgentClient.java
@@ -0,0 +1,238 @@
+package io.keploy.junit5;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.net.HttpURLConnection;
+import java.net.Proxy;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The client of keploy's agent: scope protocol v1, the same wire go-sdk's
+ * keploytest, @keploy/playwright, @keploy/jest and keploy-pytest speak (POST
+ * /agent/instance, /agent/scope/begin, /agent/scope/end).
+ *
+ *
Every answer of the agent carries the protocol version as "v"; an answer
+ * without it, or with another version, is an error, so this extension on an
+ * old agent (or the reverse) fails loudly instead of running unjudged. Every
+ * way an answer can fail to be trusted is an error. Nothing is retried, and no
+ * clock decides anything: there is no timeout, as the agent answers once the
+ * work it owes is done.
+ */
+final class AgentClient {
+ static final int PROTOCOL_VERSION = 1;
+
+ static final String PATH_INSTANCE = "/agent/instance";
+ static final String PATH_BEGIN = "/agent/scope/begin";
+ static final String PATH_END = "/agent/scope/end";
+
+ static final String ACTION_SKIP = "skip";
+ static final String ACTION_FAIL = "fail";
+
+ // Bounds what is read of one answer, and how much of one an error quotes.
+ static final int MAX_ANSWER = 1 << 20;
+ static final int MAX_QUOTED = 512;
+
+ private final String url;
+ private final String token;
+
+ AgentClient(String url, String token) {
+ this.url = url;
+ this.token = token;
+ }
+
+ static String quoted(String text) {
+ String s = text.trim();
+ if (s.length() > MAX_QUOTED) {
+ return Json.quote(s.substring(0, MAX_QUOTED)) + "...";
+ }
+ return Json.quote(s);
+ }
+
+ /** Sends one call and reads its answer: the answer's object. */
+ Map post(String path, Map body) {
+ byte[] out = Json.object(body).getBytes(StandardCharsets.UTF_8);
+ int status;
+ InputStream in;
+ try {
+ // No proxy from the environment (the agent is keploy's own, on
+ // this machine), and no redirect: a redirect is an answer like
+ // any other non-2xx, and following it would send the call again,
+ // or to somewhere else.
+ HttpURLConnection c = (HttpURLConnection) new URL(url + path).openConnection(Proxy.NO_PROXY);
+ c.setInstanceFollowRedirects(false);
+ c.setRequestMethod("POST");
+ c.setDoOutput(true);
+ // A fixed length streams the body, and HttpURLConnection never
+ // sends a streamed request a second time (it may resend a POST
+ // whose answer it failed to read otherwise).
+ c.setFixedLengthStreamingMode(out.length);
+ c.setRequestProperty("content-type", "application/json");
+ if (!token.isEmpty()) {
+ c.setRequestProperty("authorization", "Bearer " + token);
+ }
+ try (OutputStream o = c.getOutputStream()) {
+ o.write(out);
+ }
+ status = c.getResponseCode();
+ in = status >= 400 ? c.getErrorStream() : c.getInputStream();
+ } catch (IOException e) {
+ throw new KeployException("keploy: cannot reach the keploy agent at " + url + ": " + e, 0, "");
+ }
+ String data;
+ try {
+ data = readBounded(in);
+ } catch (IOException e) {
+ throw new KeployException("keploy: cannot read the keploy agent's answer to " + path + ": " + e, status, "");
+ }
+ if (status < 200 || status > 299) {
+ Object e = parseOrNull(data);
+ if (e instanceof Map && text((Map, ?>) e, "message") != null) {
+ Map, ?> m = (Map, ?>) e;
+ String code = text(m, "error");
+ String remedy = text(m, "remedy");
+ throw new KeployException("keploy: " + text(m, "message") + (remedy == null ? "" : " (" + remedy + ")"),
+ status, code == null ? "" : code);
+ }
+ throw new KeployException("keploy: the keploy agent answered " + status + " to " + path + ": "
+ + quoted(data), status, "");
+ }
+ Object a;
+ try {
+ a = Json.parse(data);
+ } catch (IllegalArgumentException e) {
+ throw new KeployException("keploy: the keploy agent's answer to " + path + " cannot be read (" + e.getMessage()
+ + "): " + quoted(data), status, "");
+ }
+ if (!(a instanceof Map)) {
+ throw new KeployException("keploy: the keploy agent's answer to " + path + " cannot be read (not an object): "
+ + quoted(data), status, "");
+ }
+ @SuppressWarnings("unchecked")
+ Map m = (Map) a;
+ if (m.get("v") == null) {
+ throw new KeployException("keploy: the keploy agent answered " + path + " with " + quoted(data)
+ + ", which names no scope protocol version; this keploy-junit5 needs a keploy agent that speaks scope protocol v"
+ + PROTOCOL_VERSION + ": upgrade keploy", status, "");
+ }
+ Object v = m.get("v");
+ if (!(v instanceof BigDecimal) || ((BigDecimal) v).compareTo(BigDecimal.valueOf(PROTOCOL_VERSION)) != 0) {
+ throw new KeployException("keploy: the keploy agent answered " + path + " in scope protocol v"
+ + (v instanceof String ? Json.quote((String) v) : String.valueOf(v)) + " (" + quoted(data)
+ + "); this keploy-junit5 speaks scope protocol v" + PROTOCOL_VERSION
+ + ": use a keploy and a keploy-junit5 of the same release", status, "");
+ }
+ return m;
+ }
+
+ /** Claims keploy's run for this test process. */
+ Map claim(String key, String dir) {
+ Map body = new LinkedHashMap<>();
+ body.put("key", key);
+ body.put("dir", dir);
+ Map a;
+ try {
+ a = post(PATH_INSTANCE, body);
+ } catch (KeployException e) {
+ if (e.status == 404 && e.code.isEmpty()) {
+ // keploy speaks scope protocol v1 only under its proven
+ // switch. With it off (keploy's default) the agent has no
+ // claim route and answers the mux's plain 404, as a keploy
+ // from before the protocol does. The claim is this
+ // extension's first call, so it is the one that names the
+ // switch.
+ throw new KeployException("keploy: this keploy agent answered 404 to POST " + PATH_INSTANCE
+ + ", so this JUnit process cannot claim keploy's run: keploy speaks keploy-junit5's wire, scope protocol v1,"
+ + " only when it runs with KEPLOY_PROVEN=1 (KEPLOY_PROVEN=1 keploy mock record|replay -c \"mvn test\");"
+ + " a keploy run without KEPLOY_PROVEN=1, or one from before scope protocol v1, answers 404 there",
+ 404, "");
+ }
+ throw e;
+ }
+ if (text(a, "instance") == null) {
+ throw new KeployException("keploy: the keploy agent answered the claim of this test process without an instance", 0, "");
+ }
+ return a;
+ }
+
+ /** Asks the agent to open a test. */
+ Map begin(TestRef t) {
+ Map body = t.wire();
+ body.put("canSkip", Boolean.TRUE);
+ Map a = post(PATH_BEGIN, body);
+ if (!"ok".equals(a.get("status"))) {
+ throw new KeployException("keploy: the keploy agent answered the begin of " + t.name + " with status "
+ + shown(a.get("status")), 0, "");
+ }
+ if (!none(a.get("action")) && !ACTION_SKIP.equals(a.get("action"))) {
+ throw new KeployException("keploy: the keploy agent answered the begin of " + t.name + " with the unknown action "
+ + shown(a.get("action")), 0, "");
+ }
+ return a;
+ }
+
+ /** Tells the agent the test ended, with its outcome. */
+ Map end(TestRef t, String outcome) {
+ Map body = t.wire();
+ body.put("outcome", outcome);
+ Map a = post(PATH_END, body);
+ if (!"ok".equals(a.get("status"))) {
+ throw new KeployException("keploy: the keploy agent answered the end of " + t.name + " with status "
+ + shown(a.get("status")), 0, "");
+ }
+ if (!none(a.get("action")) && !ACTION_FAIL.equals(a.get("action"))) {
+ throw new KeployException("keploy: the keploy agent answered the end of " + t.name + " with the unknown action "
+ + shown(a.get("action")), 0, "");
+ }
+ return a;
+ }
+
+ /** Whether an answer's action is none: absent, null or "" (anything else is an action). */
+ static boolean none(Object action) {
+ return action == null || "".equals(action);
+ }
+
+ /** A string member of an answer, or null when it is absent, empty or no string. */
+ static String text(Map, ?> m, String key) {
+ Object v = m.get(key);
+ return v instanceof String && !((String) v).isEmpty() ? (String) v : null;
+ }
+
+ private static String shown(Object v) {
+ if (v == null) {
+ return "\"\"";
+ }
+ return v instanceof String ? Json.quote((String) v) : String.valueOf(v);
+ }
+
+ private static Object parseOrNull(String data) {
+ try {
+ return Json.parse(data);
+ } catch (IllegalArgumentException e) {
+ return null; // not the protocol's error body
+ }
+ }
+
+ private static String readBounded(InputStream in) throws IOException {
+ if (in == null) {
+ return ""; // an answer with no body
+ }
+ try (InputStream r = in) {
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = r.read(buf)) > 0) {
+ b.write(buf, 0, n);
+ if (b.size() > MAX_ANSWER) {
+ throw new IOException("the answer is larger than " + MAX_ANSWER + " bytes");
+ }
+ }
+ return new String(b.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/keploy-junit5/src/main/java/io/keploy/junit5/Joining.java b/keploy-junit5/src/main/java/io/keploy/junit5/Joining.java
new file mode 100644
index 0000000..11c387c
--- /dev/null
+++ b/keploy-junit5/src/main/java/io/keploy/junit5/Joining.java
@@ -0,0 +1,146 @@
+package io.keploy.junit5;
+
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.function.LongPredicate;
+
+/**
+ * Whether this process reports to keploy: the same rule go-sdk's keploytest
+ * follows. keploy mock record and keploy mock replay export KEPLOY_MOCK_AGENT
+ * (the agent's URL), KEPLOY_MOCK_AGENT_TOKEN and KEPLOY_MOCK_PROJECT (the
+ * project's directory) into the test command. Unset or "off", the extension
+ * is inert and every test runs as it would without keploy.
+ */
+final class Joining {
+ static final String ENV_AGENT = "KEPLOY_MOCK_AGENT";
+ static final String ENV_TOKEN = "KEPLOY_MOCK_AGENT_TOKEN";
+ static final String ENV_PROJECT = "KEPLOY_MOCK_PROJECT";
+ // The claimer's pid, exported by a test process (of go-sdk, keploy-pytest,
+ // ...) once it has claimed the run, so the processes its tests start know
+ // they belong to it and do not claim the run themselves. A JVM cannot
+ // change its own environment, so this extension exports none: a process
+ // its tests start that claims too is refused by the agent.
+ static final String ENV_TEST_PID = "KEPLOY_TEST_PID";
+
+ static final Joining INERT = new Joining(null, "", "", null);
+
+ /** The agent's URL, without a trailing slash; null when inert or refused. */
+ final String url;
+ final String token;
+ /** KEPLOY_MOCK_PROJECT, trimmed; "" when keploy names none. */
+ final String project;
+ /** Why this process cannot join keploy's run; null when it can. */
+ final String refusal;
+
+ private Joining(String url, String token, String project, String refusal) {
+ this.url = url;
+ this.token = token;
+ this.project = project;
+ this.refusal = refusal;
+ }
+
+ boolean inert() {
+ return url == null && refusal == null;
+ }
+
+ /** Inert, refused or active, from the environment of process pid. */
+ static Joining decide(Map env, long pid, LongPredicate runsInside) {
+ String v = get(env, ENV_AGENT).trim();
+ if (v.isEmpty() || v.equalsIgnoreCase("off")) {
+ return INERT;
+ }
+ if (!httpURL(v)) {
+ return new Joining(null, "", "", "keploy: " + ENV_AGENT + "=" + Json.quote(v)
+ + " is neither off nor the http URL of a keploy agent; set it to off to run without keploy");
+ }
+ String tp = get(env, ENV_TEST_PID).trim();
+ if (!tp.equals(Long.toString(pid))) {
+ // Another process joined keploy's run. This one is a member only
+ // when it runs inside that process; a marker naming any other
+ // process was inherited from elsewhere, and this process claims
+ // like any test process (the agent refuses a second live one).
+ long n = 0;
+ try {
+ n = Long.parseLong(tp);
+ } catch (NumberFormatException e) {
+ // no marker ("") or no pid
+ }
+ if (n > 0 && runsInside.test(n)) {
+ return INERT;
+ }
+ }
+ return new Joining(v.replaceAll("/+$", ""), get(env, ENV_TOKEN), get(env, ENV_PROJECT).trim(), null);
+ }
+
+ private static String get(Map env, String key) {
+ String v = env.get(key);
+ return v == null ? "" : v;
+ }
+
+ private static boolean httpURL(String v) {
+ try {
+ URI u = new URI(v);
+ // getHost is null when the authority is no host[:port] (a port
+ // that is not a number, say).
+ return ("http".equalsIgnoreCase(u.getScheme()) || "https".equalsIgnoreCase(u.getScheme())) && u.getHost() != null;
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ }
+
+ /** This process's pid. */
+ static long pid() {
+ String n = ManagementFactory.getRuntimeMXBean().getName();
+ return Long.parseLong(n.substring(0, n.indexOf('@')));
+ }
+
+ /**
+ * Whether this process runs inside process pid (pid is an ancestor), read
+ * from /proc. Without /proc (a system other than Linux) it cannot tell,
+ * and takes the marker at its word.
+ */
+ static boolean runsInsideProc(long pid) {
+ if (!Files.exists(Paths.get("/proc/self/stat"))) {
+ return true;
+ }
+ long p = parent("self");
+ // Bounded: a walk that cycles (a pid reused while it is read) ends.
+ for (int k = 0; k < 128; k++) {
+ if (p == pid) {
+ return true;
+ }
+ p = parent(Long.toString(p));
+ if (p < 0) { // past the first process (its parent, 0, has no entry), or gone
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /** The parent pid of /proc/{proc}; -1 when it cannot be read. */
+ static long parent(String proc) {
+ try {
+ return ppid(new String(Files.readAllBytes(Paths.get("/proc", proc, "stat")), StandardCharsets.ISO_8859_1));
+ } catch (IOException e) {
+ return -1;
+ }
+ }
+
+ /** The parent pid in a /proc stat line; -1 when it holds none. */
+ static long ppid(String stat) {
+ try {
+ // The command name, in parentheses, may hold spaces and
+ // parentheses: the fields after it start at the last ')'.
+ String[] rest = stat.substring(stat.lastIndexOf(')') + 2).split(" ");
+ return Long.parseLong(rest[1]);
+ } catch (RuntimeException e) {
+ return -1;
+ }
+ }
+}
diff --git a/keploy-junit5/src/main/java/io/keploy/junit5/Json.java b/keploy-junit5/src/main/java/io/keploy/junit5/Json.java
new file mode 100644
index 0000000..336e7cc
--- /dev/null
+++ b/keploy-junit5/src/main/java/io/keploy/junit5/Json.java
@@ -0,0 +1,246 @@
+package io.keploy.junit5;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The JSON of keploy's wire, without a library on the test suite's class
+ * path: a strict reader (RFC 8259; anything else is an error) and a writer of
+ * flat objects. An object reads as a Map, an array as a List, a number as a
+ * BigDecimal, a string as a String, true and false as Boolean, null as null.
+ */
+final class Json {
+ // A deeper answer is no answer of keploy's: bounded so that one cannot
+ // overflow the stack.
+ static final int MAX_DEPTH = 64;
+
+ private final String s;
+ private int i;
+
+ private Json(String s) {
+ this.s = s;
+ }
+
+ /** Reads one JSON text; IllegalArgumentException says why it is none. */
+ static Object parse(String s) {
+ Json j = new Json(s);
+ Object v = j.value(0);
+ j.space();
+ if (j.i != s.length()) {
+ throw j.error("text after the value");
+ }
+ return v;
+ }
+
+ /** Writes a flat object of String, Boolean and Number values, in order. */
+ static String object(Map m) {
+ StringBuilder b = new StringBuilder("{");
+ for (Map.Entry e : m.entrySet()) {
+ if (b.length() > 1) {
+ b.append(',');
+ }
+ b.append(quote(e.getKey())).append(':');
+ Object v = e.getValue();
+ b.append(v instanceof String ? quote((String) v) : String.valueOf(v));
+ }
+ return b.append('}').toString();
+ }
+
+ /** s as a JSON string. */
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int k = 0; k < s.length(); k++) {
+ char c = s.charAt(k);
+ if (c == '"' || c == '\\') {
+ b.append('\\').append(c);
+ } else if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ return b.append('"').toString();
+ }
+
+ private IllegalArgumentException error(String what) {
+ return new IllegalArgumentException(what + " at offset " + i);
+ }
+
+ private void space() {
+ while (i < s.length() && " \t\r\n".indexOf(s.charAt(i)) >= 0) {
+ i++;
+ }
+ }
+
+ private Object value(int depth) {
+ if (depth > MAX_DEPTH) {
+ throw error("nested deeper than " + MAX_DEPTH);
+ }
+ space();
+ if (i >= s.length()) {
+ throw error("no value");
+ }
+ char c = s.charAt(i);
+ switch (c) {
+ case '{':
+ return object(depth);
+ case '[':
+ return array(depth);
+ case '"':
+ return string();
+ case 't':
+ return word("true", Boolean.TRUE);
+ case 'f':
+ return word("false", Boolean.FALSE);
+ case 'n':
+ return word("null", null);
+ default:
+ return number();
+ }
+ }
+
+ private Object word(String w, Object v) {
+ if (!s.startsWith(w, i)) {
+ throw error("not a value");
+ }
+ i += w.length();
+ return v;
+ }
+
+ private Map object(int depth) {
+ Map m = new LinkedHashMap<>();
+ i++;
+ space();
+ if (i < s.length() && s.charAt(i) == '}') {
+ i++;
+ return m;
+ }
+ while (true) {
+ space();
+ if (i >= s.length() || s.charAt(i) != '"') {
+ throw error("no member name");
+ }
+ String k = string();
+ space();
+ expect(':');
+ m.put(k, value(depth + 1));
+ space();
+ if (i < s.length() && s.charAt(i) == ',') {
+ i++;
+ continue;
+ }
+ expect('}');
+ return m;
+ }
+ }
+
+ private List