Skip to content

Commit 3969809

Browse files
authored
[JShellAPI][Testing] Setting up First Integration Test - Simple code snippet evaluation scenario (#50)
* [Testing][JShellAPI] Settings up gradle tasks for testing; ; Note: this includes adding a 1st class for tests; * [Testing][JShellAPI] Setting first integration test for /eval endpoint; Note: this includes changes and improvements in gradle.build files; * [Testing][JShellAPI] Synchronizing endpoint paths between controllers and test classes * [Testing][JShellAPI] adding a second subtest for same endpoint with additional checks * [Testing][JShellAPI] write documentation about testing approach * [Testing][JShellAPI] apply pr review fixes
1 parent b359b3c commit 3969809

File tree

12 files changed

+194
-17
lines changed

12 files changed

+194
-17
lines changed

JShellAPI/README.MD

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,4 @@ The maximum ram allocated per container, in megabytes.
101101
### jshellapi.dockerCPUsUsage
102102
The cpu configuration of each container, see [--cpus option of docker](https://docs.docker.com/config/containers/resource_constraints/#cpu).
103103
### jshellapi.schedulerSessionKillScanRate
104-
The rate at which the session killer will check and delete session, in seconds, see [Session timeout](#Session-timeout).
104+
The rate at which the session killer will check and delete session, in seconds, see [Session timeout](#Session-timeout).

JShellAPI/build.gradle

+38-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@ dependencies {
1313
implementation 'com.github.docker-java:docker-java-transport-httpclient5:3.3.6'
1414
implementation 'com.github.docker-java:docker-java-core:3.3.6'
1515

16-
testImplementation 'org.springframework.boot:spring-boot-starter-test'
16+
testImplementation('org.springframework.boot:spring-boot-starter-test') {
17+
// `logback-classic` has been excluded because of an issue encountered when running tests.
18+
// It's about a conflict between some dependencies.
19+
// The solution has been brought based on a good answer on Stackoverflow: https://stackoverflow.com/a/42641450/10000150
20+
exclude group: 'ch.qos.logback', module: 'logback-classic'
21+
}
22+
testImplementation 'org.springframework.boot:spring-boot-starter-webflux'
23+
1724
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
25+
1826
}
1927

2028
jib {
@@ -36,4 +44,32 @@ shadowJar {
3644
archiveBaseName.set('JShellPlaygroundBackend')
3745
archiveClassifier.set('')
3846
archiveVersion.set('')
39-
}
47+
}
48+
49+
// -- Gradle testing configuration
50+
51+
def jshellWrapperImageName = rootProject.ext.jShellWrapperImageName;
52+
53+
processResources {
54+
filesMatching('application.yaml') {
55+
expand("JSHELL_WRAPPER_IMAGE_NAME": jshellWrapperImageName)
56+
}
57+
}
58+
59+
60+
def taskBuildDockerImage = tasks.register('buildDockerImage') {
61+
group = 'docker'
62+
description = 'builds jshellwrapper as docker image'
63+
dependsOn project(':JShellWrapper').tasks.named('jibDockerBuild')
64+
}
65+
66+
def taskRemoveDockerImage = tasks.register('removeDockerImage', Exec) {
67+
group = 'docker'
68+
description = 'removes jshellwrapper image'
69+
commandLine 'docker', 'rmi', '-f', jshellWrapperImageName
70+
}
71+
72+
test {
73+
dependsOn taskBuildDockerImage
74+
finalizedBy taskRemoveDockerImage
75+
}

JShellAPI/src/main/java/org/togetherjava/jshellapi/Config.java

+20-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,28 @@
22

33
import org.springframework.boot.context.properties.ConfigurationProperties;
44
import org.springframework.lang.Nullable;
5+
import org.springframework.util.StringUtils;
56

67
@ConfigurationProperties("jshellapi")
78
public record Config(long regularSessionTimeoutSeconds, long oneTimeSessionTimeoutSeconds,
89
long evalTimeoutSeconds, long evalTimeoutValidationLeeway, int sysOutCharLimit,
910
long maxAliveSessions, int dockerMaxRamMegaBytes, double dockerCPUsUsage,
1011
@Nullable String dockerCPUSetCPUs, long schedulerSessionKillScanRateSeconds,
11-
long dockerResponseTimeout, long dockerConnectionTimeout) {
12+
long dockerResponseTimeout, long dockerConnectionTimeout, String jshellWrapperImageName) {
13+
14+
public static final String JSHELL_WRAPPER_IMAGE_NAME_TAG = ":master";
15+
16+
private static boolean checkJShellWrapperImageName(String imageName) {
17+
if (!StringUtils.hasText(imageName)
18+
|| !imageName.endsWith(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)) {
19+
return false;
20+
}
21+
22+
final String imageNameFirstPart = imageName.split(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)[0];
23+
24+
return StringUtils.hasText(imageNameFirstPart);
25+
}
26+
1227
public Config {
1328
if (regularSessionTimeoutSeconds <= 0)
1429
throw new IllegalArgumentException("Invalid value " + regularSessionTimeoutSeconds);
@@ -35,5 +50,9 @@ public record Config(long regularSessionTimeoutSeconds, long oneTimeSessionTimeo
3550
throw new IllegalArgumentException("Invalid value " + dockerResponseTimeout);
3651
if (dockerConnectionTimeout <= 0)
3752
throw new IllegalArgumentException("Invalid value " + dockerConnectionTimeout);
53+
54+
if (!checkJShellWrapperImageName(jshellWrapperImageName)) {
55+
throw new IllegalArgumentException("Invalid value " + jshellWrapperImageName);
56+
}
3857
}
3958
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.togetherjava.jshellapi.rest;
2+
3+
/**
4+
* Holds endpoints mentioned in controllers.
5+
*/
6+
public final class ApiEndpoints {
7+
private ApiEndpoints() {}
8+
9+
public static final String BASE = "/jshell";
10+
public static final String EVALUATE = "/eval";
11+
public static final String SINGLE_EVALUATE = "/single-eval";
12+
public static final String SNIPPETS = "/snippets";
13+
public static final String STARTING_SCRIPT = "/startup_script";
14+
}

JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/JShellController.java

+6-6
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515

1616
import java.util.List;
1717

18-
@RequestMapping("jshell")
18+
@RequestMapping(ApiEndpoints.BASE)
1919
@RestController
2020
public class JShellController {
2121
private JShellSessionService service;
2222
private StartupScriptsService startupScriptsService;
2323

24-
@PostMapping("/eval/{id}")
24+
@PostMapping(ApiEndpoints.EVALUATE + "/{id}")
2525
public JShellResult eval(@PathVariable String id,
2626
@RequestParam(required = false) StartupScriptId startupScriptId,
2727
@RequestBody String code) throws DockerException {
@@ -32,7 +32,7 @@ public JShellResult eval(@PathVariable String id,
3232
"An operation is already running"));
3333
}
3434

35-
@PostMapping("/eval")
35+
@PostMapping(ApiEndpoints.EVALUATE)
3636
public JShellResultWithId eval(@RequestParam(required = false) StartupScriptId startupScriptId,
3737
@RequestBody String code) throws DockerException {
3838
JShellService jShellService = service.session(startupScriptId);
@@ -42,7 +42,7 @@ public JShellResultWithId eval(@RequestParam(required = false) StartupScriptId s
4242
"An operation is already running")));
4343
}
4444

45-
@PostMapping("/single-eval")
45+
@PostMapping(ApiEndpoints.SINGLE_EVALUATE)
4646
public JShellResult singleEval(@RequestParam(required = false) StartupScriptId startupScriptId,
4747
@RequestBody String code) throws DockerException {
4848
JShellService jShellService = service.oneTimeSession(startupScriptId);
@@ -51,7 +51,7 @@ public JShellResult singleEval(@RequestParam(required = false) StartupScriptId s
5151
"An operation is already running"));
5252
}
5353

54-
@GetMapping("/snippets/{id}")
54+
@GetMapping(ApiEndpoints.SNIPPETS + "/{id}")
5555
public List<String> snippets(@PathVariable String id,
5656
@RequestParam(required = false) boolean includeStartupScript) throws DockerException {
5757
validateId(id);
@@ -71,7 +71,7 @@ public void delete(@PathVariable String id) {
7171
service.deleteSession(id);
7272
}
7373

74-
@GetMapping("/startup_script/{id}")
74+
@GetMapping(ApiEndpoints.STARTING_SCRIPT + "/{id}")
7575
public String startupScript(@PathVariable StartupScriptId id) {
7676
return startupScriptsService.get(id);
7777
}

JShellAPI/src/main/java/org/togetherjava/jshellapi/service/DockerService.java

+11-5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public class DockerService implements DisposableBean {
2929

3030
private final DockerClient client;
3131

32+
private final String jshellWrapperBaseImageName;
33+
3234
public DockerService(Config config) {
3335
DefaultDockerClientConfig clientConfig =
3436
DefaultDockerClientConfig.createDefaultConfigBuilder().build();
@@ -40,6 +42,9 @@ public DockerService(Config config) {
4042
.build();
4143
this.client = DockerClientImpl.getInstance(clientConfig, httpClient);
4244

45+
this.jshellWrapperBaseImageName =
46+
config.jshellWrapperImageName().split(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)[0];
47+
4348
cleanupLeftovers(WORKER_UNIQUE_ID);
4449
}
4550

@@ -59,22 +64,23 @@ private void cleanupLeftovers(UUID currentId) {
5964

6065
public String spawnContainer(long maxMemoryMegs, long cpus, @Nullable String cpuSetCpus,
6166
String name, Duration evalTimeout, long sysoutLimit) throws InterruptedException {
62-
String imageName = "togetherjava.org:5001/togetherjava/jshellwrapper";
67+
6368
boolean presentLocally = client.listImagesCmd()
64-
.withFilter("reference", List.of(imageName))
69+
.withFilter("reference", List.of(jshellWrapperBaseImageName))
6570
.exec()
6671
.stream()
6772
.flatMap(it -> Arrays.stream(it.getRepoTags()))
68-
.anyMatch(it -> it.endsWith(":master"));
73+
.anyMatch(it -> it.endsWith(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG));
6974

7075
if (!presentLocally) {
71-
client.pullImageCmd(imageName)
76+
client.pullImageCmd(jshellWrapperBaseImageName)
7277
.withTag("master")
7378
.exec(new PullImageResultCallback())
7479
.awaitCompletion(5, TimeUnit.MINUTES);
7580
}
7681

77-
return client.createContainerCmd(imageName + ":master")
82+
return client
83+
.createContainerCmd(jshellWrapperBaseImageName + Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)
7884
.withHostConfig(HostConfig.newHostConfig()
7985
.withAutoRemove(true)
8086
.withInit(true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"properties": [
3+
{
4+
"name": "jshellapi.jshellwrapper-imageName",
5+
"type": "java.lang.String",
6+
"description": "JShellWrapper image name injected from the top-level gradle build file."
7+
}
8+
] }

JShellAPI/src/main/resources/application.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ jshellapi:
2020
dockerResponseTimeout: 60
2121
dockerConnectionTimeout: 60
2222

23+
# JShellWrapper related
24+
jshellWrapperImageName: ${JSHELL_WRAPPER_IMAGE_NAME}
25+
2326
server:
2427
error:
2528
include-message: always
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.togetherjava.jshellapi;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.test.context.ActiveProfiles;
8+
import org.springframework.test.context.ContextConfiguration;
9+
import org.springframework.test.web.reactive.server.WebTestClient;
10+
11+
import org.togetherjava.jshellapi.dto.JShellResult;
12+
import org.togetherjava.jshellapi.dto.JShellSnippetResult;
13+
import org.togetherjava.jshellapi.dto.SnippetStatus;
14+
import org.togetherjava.jshellapi.dto.SnippetType;
15+
import org.togetherjava.jshellapi.rest.ApiEndpoints;
16+
17+
import java.time.Duration;
18+
import java.util.List;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
22+
/**
23+
* Integrates tests for JShellAPI.
24+
*/
25+
@ActiveProfiles("testing")
26+
@ContextConfiguration(classes = Main.class)
27+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
28+
public class JShellApiTests {
29+
30+
@Autowired
31+
private WebTestClient webTestClient;
32+
33+
@Autowired
34+
private Config testsConfig;
35+
36+
@Test
37+
@DisplayName("When posting code snippet, evaluate it then return successfully result")
38+
public void evaluateCodeSnippetTest() {
39+
40+
final String testEvalId = "test";
41+
42+
// -- first code snippet eval
43+
executeCodeEvalTest(testEvalId, "int a = 2+2;", 1, "4");
44+
45+
// -- second code snippet eval
46+
executeCodeEvalTest(testEvalId, "a * 2", 2, "8");
47+
}
48+
49+
private void executeCodeEvalTest(String evalId, String codeSnippet, int expectedId,
50+
String expectedResult) {
51+
final JShellSnippetResult jshellCodeSnippet = new JShellSnippetResult(SnippetStatus.VALID,
52+
SnippetType.ADDITION, expectedId, codeSnippet, expectedResult);
53+
54+
assertThat(testEval(evalId, codeSnippet))
55+
.isEqualTo(new JShellResult(List.of(jshellCodeSnippet), null, false, ""));
56+
}
57+
58+
private JShellResult testEval(String testEvalId, String codeInput) {
59+
final String endpoint =
60+
String.join("/", ApiEndpoints.BASE, ApiEndpoints.EVALUATE, testEvalId);
61+
62+
return this.webTestClient.mutate()
63+
.responseTimeout(Duration.ofSeconds(testsConfig.evalTimeoutSeconds()))
64+
.build()
65+
.post()
66+
.uri(endpoint)
67+
.bodyValue(codeInput)
68+
.exchange()
69+
.expectStatus()
70+
.isOk()
71+
.expectBody(JShellResult.class)
72+
.value((JShellResult evalResult) -> assertThat(evalResult).isNotNull())
73+
.returnResult()
74+
.getResponseBody();
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
jshellapi:
2+
3+
# Public API Config
4+
regularSessionTimeoutSeconds: 10
5+
6+
# Internal config
7+
schedulerSessionKillScanRateSeconds: 6
8+
9+
# Docker service config
10+
dockerResponseTimeout: 6
11+
dockerConnectionTimeout: 6

JShellWrapper/build.gradle

+2-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test {
2424
jib {
2525
from.image = 'eclipse-temurin:22-alpine'
2626
to {
27-
image = 'togetherjava.org:5001/togetherjava/jshellwrapper:master' ?: 'latest'
27+
image = rootProject.ext.jShellWrapperImageName
2828
auth {
2929
username = System.getenv('ORG_REGISTRY_USER') ?: ''
3030
password = System.getenv('ORG_REGISTRY_PASSWORD') ?: ''
@@ -41,4 +41,4 @@ shadowJar {
4141
archiveBaseName.set('JShellWrapper')
4242
archiveClassifier.set('')
4343
archiveVersion.set('')
44-
}
44+
}

build.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,7 @@ subprojects {
6868
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
6969
}
7070
}
71+
72+
ext {
73+
jShellWrapperImageName = 'togetherjava.org:5001/togetherjava/jshellwrapper:master' ?: 'latest'
74+
}

0 commit comments

Comments
 (0)