diff --git a/run-bdd-tests.sh b/run-bdd-tests.sh new file mode 100755 index 00000000000..02a806ea58e --- /dev/null +++ b/run-bdd-tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 && pwd -P)" +TEST_ARTIFACTS=${BDD_TEST_ARTIFACTS:-${REPO_ROOT}/src/test/generated} + +if [ ! -x "${TEST_ARTIFACTS}/test-server" ]; then + echo "Generated test server not found at ${TEST_ARTIFACTS}/test-server" >&2 + exit 1 +fi +if [ ! -f "${TEST_ARTIFACTS}/test-runner-data/manifest.json" ]; then + echo "Generated test runner manifest not found below ${TEST_ARTIFACTS}" >&2 + exit 1 +fi + +TEST_SERVER_PID="" +cleanup() { + if [ -n "${TEST_SERVER_PID}" ]; then + kill "${TEST_SERVER_PID}" 2>/dev/null || true + fi +} +trap cleanup EXIT + +TEST_SERVER_PORT=${BDD_TEST_SERVER_PORT:-18085} +TEST_SERVER_LOG=${BDD_TEST_SERVER_LOG:-${TMPDIR:-/tmp}/datadog-java-test-server.log} +"${TEST_ARTIFACTS}/test-server" --port "${TEST_SERVER_PORT}" >"${TEST_SERVER_LOG}" 2>&1 & +TEST_SERVER_PID=$! +for _ in {1..50}; do + if curl --silent --fail "http://127.0.0.1:${TEST_SERVER_PORT}/__openapi_transformer__/health" >/dev/null; then + break + fi + sleep 0.1 +done +curl --silent --fail "http://127.0.0.1:${TEST_SERVER_PORT}/__openapi_transformer__/health" >/dev/null + +cd "${REPO_ROOT}" +DD_TEST_SERVER_URL="http://127.0.0.1:${TEST_SERVER_PORT}" \ +DD_TEST_RUNNER_DATA="${TEST_ARTIFACTS}/test-runner-data" \ +RECORD=false \ +mvn -P surefire-java16 \ + -Dtest=ScenariosTest \ + -Dcucumber.features="${TEST_ARTIFACTS}/test-runner-data/features" \ + -DargLine="--add-exports java.base/sun.security.x509=ALL-UNNAMED --add-opens java.base/sun.net.www.protocol.https=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED" \ + test "$@" diff --git a/src/test/java/com/datadog/api/ClientSteps.java b/src/test/java/com/datadog/api/ClientSteps.java index 1a4e3a39746..a272a06584c 100644 --- a/src/test/java/com/datadog/api/ClientSteps.java +++ b/src/test/java/com/datadog/api/ClientSteps.java @@ -162,7 +162,7 @@ public void newRequest(String methodName) java.lang.IllegalAccessException, java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException { - world.newRequest(methodName); + if (!TestRunner.runnerEnabled()) world.newRequest(methodName); } @Given("request contains {string} parameter from {string}") @@ -172,7 +172,7 @@ public void requestContainsParameterFrom(String parameterName, String fixturePat java.lang.ClassNotFoundException, java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException { - world.addRequestParameterFixture(parameterName, fixturePath); + if (!TestRunner.runnerEnabled()) world.addRequestParameterFixture(parameterName, fixturePath); } @Given("request contains {string} parameter with value {}") @@ -183,7 +183,7 @@ public void requestContainsParameterWithValue(String parameterName, String value java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException, com.fasterxml.jackson.core.JsonProcessingException { - world.addRequestParameter(parameterName, value); + if (!TestRunner.runnerEnabled()) world.addRequestParameter(parameterName, value); } @Given("body with value {}") @@ -194,7 +194,7 @@ public void setBody(String data) java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException, com.fasterxml.jackson.core.JsonProcessingException { - world.addRequestParameter("body", data); + if (!TestRunner.runnerEnabled()) world.addRequestParameter("body", data); } @Given("body from file {string}") @@ -205,6 +205,7 @@ public void setBodyFromFile(String filename) java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException, IOException { + if (TestRunner.runnerEnabled()) return; Path bodyPath = Paths.get("src/test/resources/com/datadog/api/client/" + apiVersion + "/api/" + filename); String data = new String(Files.readAllBytes(bodyPath)); diff --git a/src/test/java/com/datadog/api/RecorderSteps.java b/src/test/java/com/datadog/api/RecorderSteps.java index 5b78301bc58..ecea0684a3d 100644 --- a/src/test/java/com/datadog/api/RecorderSteps.java +++ b/src/test/java/com/datadog/api/RecorderSteps.java @@ -61,6 +61,14 @@ public void skipReplayOnly() { @Before(order = 1) public void setupClock(Scenario scenario) throws java.io.IOException { + if (TestRunner.serverEnabled()) { + try { + TestRunner.startSession(world, scenario); + } catch (Exception error) { + throw new IOException(error); + } + return; + } if (TestUtils.getRecordingMode().equals(RecordingMode.MODE_IGNORE) || scenario.getSourceTagNames().contains("@integration-only")) { world.now = OffsetDateTime.now(); @@ -108,8 +116,16 @@ public void setupClock(Scenario scenario) throws java.io.IOException { } } - @After + @After(order = 0) public void cleanAndSendExpectations(Scenario scenario) throws IOException { + if (TestRunner.serverEnabled()) { + try { + TestRunner.stopSession(world); + } catch (Exception error) { + throw new IOException(error); + } + return; + } // Cleanup the recorded requests from sensitive information (API keys in headers and query // params), // create the associated expectations and save them to disk in the `cassettes/**/*.json` files @@ -168,11 +184,15 @@ public String getCassetteName() { @When("the request is sent") public void theRequestIsSent() throws Exception { + TestRunner.applyPlan(world, false); world.sendRequest(); + TestRunner.markMainComplete(world); } @When("the request with pagination is sent") public void theRequestSentWithPagination() throws Exception { + TestRunner.applyPlan(world, true); world.sendPaginatedRequest(); + TestRunner.markMainComplete(world); } } diff --git a/src/test/java/com/datadog/api/TestRunner.java b/src/test/java/com/datadog/api/TestRunner.java new file mode 100644 index 00000000000..3828cc883e5 --- /dev/null +++ b/src/test/java/com/datadog/api/TestRunner.java @@ -0,0 +1,221 @@ +package com.datadog.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.cucumber.java.Scenario; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Adapter for language-neutral BDD request plans and recording sessions. */ +public final class TestRunner { + private static final String CONTROL_ROOT = "/__openapi_transformer__"; + private static final String TEMPLATE_KEY = "$openapi_transformer_template"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TestRunner() {} + + public static boolean runnerEnabled() { + return System.getenv("DD_TEST_RUNNER_DATA") != null; + } + + public static boolean serverEnabled() { + return System.getenv("DD_TEST_SERVER_URL") != null; + } + + public static void startSession(World world, Scenario scenario) throws Exception { + Map item = findManifestItem(world, scenario); + if (item == null) { + Instant now = Instant.now(); + world.clock = Clock.fixed(now, ZoneOffset.UTC); + world.now = OffsetDateTime.ofInstant(now, ZoneOffset.UTC); + return; + } + world.testRunnerPlan = readMap(runnerRoot().resolve(item.get("file").toString())); + world.testFeature = item.get("feature").toString(); + + Map payload = new LinkedHashMap<>(); + payload.put("version", world.getVersion()); + payload.put("feature", world.testFeature); + payload.put("scenario", scenario.getName()); + Map session = controlRequest("/sessions", payload); + world.testServerSession = session.get("session").toString(); + Instant frozen = Instant.parse(session.get("frozen_at").toString()); + world.clock = Clock.fixed(frozen, ZoneOffset.UTC); + world.now = OffsetDateTime.ofInstant(frozen, ZoneOffset.UTC); + } + + public static void stopSession(World world) throws Exception { + if (world.testServerSession == null) { + return; + } + Map result = + controlRequest("/sessions/" + world.testServerSession + "/stop", null); + world.testServerSession = null; + if (Boolean.FALSE.equals(result.get("complete"))) { + throw new IllegalStateException( + String.format( + "Test server session consumed %s of %s interactions", + result.get("interactions"), result.get("total_interactions"))); + } + } + + public static void markMainComplete(World world) throws Exception { + if (!serverEnabled() || world.testServerSession == null) { + return; + } + controlRequest("/sessions/" + world.testServerSession + "/main-complete", null); + } + + public static void applyPlan(World world, boolean pagination) throws Exception { + if (!runnerEnabled()) { + return; + } + if (world.testRunnerPlan == null) { + return; + } + Map plan = world.testRunnerPlan; + Map request = (Map) plan.get("request"); + if (!Boolean.valueOf(pagination).equals(request.get("pagination"))) { + throw new IllegalStateException("Generated request plan pagination mismatch"); + } + + world.setupAPI( + world.getVersion(), World.toClassName(plan.get("api").toString().replace("-", ""))); + world.newRequest(plan.get("operation_id").toString()); + + List> parameters = (List>) request.get("parameters"); + for (Map parameter : parameters) { + if ("path".equals(parameter.get("in")) || Boolean.TRUE.equals(parameter.get("required"))) { + applyParameter(world, parameter); + } + } + + Map body = (Map) request.get("body"); + if (body != null) { + Object value = materialize(body.get("value"), world); + world.addMaterializedRequestParameter("body", MAPPER.writeValueAsString(value)); + } + for (Map parameter : parameters) { + if (!"path".equals(parameter.get("in")) && !Boolean.TRUE.equals(parameter.get("required"))) { + applyParameter(world, parameter); + } + } + } + + private static void applyParameter(World world, Map parameter) throws Exception { + Map source = (Map) parameter.get("source"); + if ("fixture".equals(source.get("type"))) { + world.addRequestParameterFixture( + parameter.get("name").toString(), source.get("path").toString()); + } else { + Object value = materialize(source.get("value"), world); + world.addMaterializedRequestParameter( + parameter.get("name").toString(), MAPPER.writeValueAsString(value)); + } + } + + private static Object materialize(Object value, World world) throws Exception { + if (value instanceof Map) { + Map map = (Map) value; + if (map.size() == 1 && map.containsKey(TEMPLATE_KEY)) { + String rendered = World.templated(map.get(TEMPLATE_KEY).toString(), world.context); + return MAPPER.readValue(rendered, Object.class); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + result.put(entry.getKey(), materialize(entry.getValue(), world)); + } + return result; + } + if (value instanceof List) { + List result = new ArrayList<>(); + for (Object item : (List) value) { + result.add(materialize(item, world)); + } + return result; + } + if (value instanceof String) { + String source = value.toString(); + java.util.regex.Matcher literal = + java.util.regex.Pattern.compile("^\\{\\{\\s*(['\"])(.*)\\1\\s*}}$").matcher(source); + if (literal.matches()) { + return literal.group(2); + } + return World.templated(source, world.context); + } + return value; + } + + private static Map findManifestItem(World world, Scenario scenario) + throws Exception { + Map manifest = readMap(runnerRoot().resolve("manifest.json")); + String uri = scenario.getUri().toString().replace('\\', '/'); + for (Map item : (List>) manifest.get("scenarios")) { + if (item.get("version").equals(world.getVersion()) + && item.get("scenario").equals(scenario.getName()) + && uri.endsWith(item.get("feature_file").toString())) { + return item; + } + } + return null; + } + + private static Path runnerRoot() { + return Paths.get(System.getenv("DD_TEST_RUNNER_DATA")).toAbsolutePath(); + } + + private static Map readMap(Path path) throws Exception { + return MAPPER.readValue(Files.readAllBytes(path), new TypeReference>() {}); + } + + private static Map controlRequest(String endpoint, Map payload) + throws Exception { + URL url = new URL(System.getenv("DD_TEST_SERVER_URL") + CONTROL_ROOT + endpoint); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + if (payload != null) { + byte[] body = MAPPER.writeValueAsBytes(payload); + connection.setDoOutput(true); + connection.setRequestProperty("content-type", "application/json"); + try (OutputStream output = connection.getOutputStream()) { + output.write(body); + } + } + int status = connection.getResponseCode(); + InputStream stream = + status >= HttpURLConnection.HTTP_BAD_REQUEST + ? connection.getErrorStream() + : connection.getInputStream(); + StringBuilder response = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } finally { + connection.disconnect(); + } + if (status >= HttpURLConnection.HTTP_BAD_REQUEST) { + throw new IllegalStateException( + String.format("Test server POST %s failed (%d): %s", endpoint, status, response)); + } + return MAPPER.readValue(response.toString(), new TypeReference>() {}); + } +} diff --git a/src/test/java/com/datadog/api/World.java b/src/test/java/com/datadog/api/World.java index 041f66c6e9d..2216d82509b 100644 --- a/src/test/java/com/datadog/api/World.java +++ b/src/test/java/com/datadog/api/World.java @@ -58,6 +58,9 @@ public class World { Scenario scenario; Clock clock; OffsetDateTime now; + String testFeature; + String testServerSession; + Map testRunnerPlan; // Undo public List> undo; @@ -132,9 +135,12 @@ public void sleepAfterRequest() { public String getVersion() { String[] parts = scenario.getUri().toString().split("/"); - // get version - // src/test/resources/com/datadog/api/>>>v2<< clientClass, Object client) @@ -150,6 +156,18 @@ private void configureClient(Class clientClass, Object client) // Enable retry clientClass.getMethod("enableRetry", boolean.class).invoke(client, true); + if (TestRunner.serverEnabled()) { + clientClass + .getMethod("setBasePath", String.class) + .invoke(client, System.getenv("DD_TEST_SERVER_URL")); + clientClass.getMethod("setServerIndex", Integer.class).invoke(client, new Object[] {null}); + clientClass.getMethod("enableRetry", boolean.class).invoke(client, false); + clientClass + .getMethod("addDefaultHeader", String.class, String.class) + .invoke(client, "x-openapi-test-session", testServerSession); + return; + } + // Set debugging based on env // client.setDebugging("true".equals(System.getenv("DEBUG"))) clientClass @@ -283,6 +301,24 @@ public void addRequestParameter(String parameterName, String value) java.lang.NoSuchMethodException, java.lang.reflect.InvocationTargetException, com.fasterxml.jackson.core.JsonProcessingException { + addRequestParameterValue(parameterName, value, true); + } + + public void addMaterializedRequestParameter(String parameterName, String value) + throws java.lang.IllegalAccessException, + java.lang.NoSuchFieldException, + java.lang.NoSuchMethodException, + java.lang.reflect.InvocationTargetException, + com.fasterxml.jackson.core.JsonProcessingException { + addRequestParameterValue(parameterName, value, false); + } + + private void addRequestParameterValue(String parameterName, String value, boolean renderTemplates) + throws java.lang.IllegalAccessException, + java.lang.NoSuchFieldException, + java.lang.NoSuchMethodException, + java.lang.reflect.InvocationTargetException, + com.fasterxml.jackson.core.JsonProcessingException { String propertyName = toPropertyName(parameterName); Class fieldType = null; boolean isOptional = false; @@ -307,7 +343,8 @@ public void addRequestParameter(String parameterName, String value) + requestBuilder.getParameterTypes().length); } - Object data = fromJSON(getObjectMapper(), fieldType, templated(value, context)); + Object data = + fromJSON(getObjectMapper(), fieldType, renderTemplates ? templated(value, context) : value); // Store path parameter for undo operations pathParameters.put(parameterName, data);