diff --git a/.dockerignore b/.dockerignore index 28eb652b..6d3a7cde 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,3 +19,7 @@ node_modules/* docker/* .git/ **/.DS_Store +frameworks/java/.gradle +frameworks/java/4.0 +frameworks/java/build +frameworks/java/buildOutputCleanup diff --git a/.gitignore b/.gitignore index d534c18f..7676f8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ TestFixture.class TestFixture.java frameworks/java/CwRunListener.class frameworks/java/CwTestRunner.class +frameworks/java/prewarm.status .DS_Store *~ target/* @@ -22,3 +23,7 @@ Dockerfile *.hi \#* frameworks/csharp/extra +frameworks/java/.gradle +frameworks/java/4.0 +frameworks/java/build +frameworks/java/buildOutputCleanup diff --git a/docker-compose.yml b/docker-compose.yml index 933deca2..3e1bdc7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: - ./examples:/runner/examples - ./frameworks:/runner/frameworks - ./test:/runner/test + - ./listen.js:/runner/listen.js entrypoint: '' command: bash @@ -373,7 +374,7 @@ services: - ./examples:/runner/examples - ./frameworks:/runner/frameworks - ./test:/runner/test - entrypoint: '/runner/debug.sh mocha -t 120000 test/runners/java_spec.js' + entrypoint: 'mocha -t 15000 test/runners/java_spec.js' clojure: image: codewars/jvm-runner diff --git a/docker/java.docker b/docker/java.docker index be258e87..261c8aed 100644 --- a/docker/java.docker +++ b/docker/java.docker @@ -35,38 +35,62 @@ ENV PATH /usr/local/groovy/bin:${PATH} RUN mv groovyserv-1.1.0 groovyserv ENV PATH /usr/local/groovyserv/bin:${PATH} +# Install zip utils +RUN apt-get install -y zip unzip --no-install-recommends + +# Install Gradle +ENV GRADLE_HOME /usr/local/gradle +ENV GRADLE_VERSION 4.0 + +ARG GRADLE_DOWNLOAD_SHA256=56bd2dde29ba2a93903c557da1745cafd72cdd8b6b0b83c05a40ed7896b79dfe +RUN set -o errexit -o nounset \ + && echo "Downloading Gradle" \ + && wget --no-verbose --output-document=gradle.zip "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \ + \ + && echo "Checking download hash" \ + && echo "${GRADLE_DOWNLOAD_SHA256} *gradle.zip" | sha256sum --check - \ + \ + && echo "Installing Gradle" \ + && unzip gradle.zip \ + && rm gradle.zip \ + && mv "gradle-${GRADLE_VERSION}" "${GRADLE_HOME}/" \ + && ln --symbolic "${GRADLE_HOME}/bin/gradle" /usr/bin/gradle + +RUN mkdir /usr/local/.gradle +RUN chown codewarrior /usr/local/.gradle + # add the package json first to a tmp directory and build, copy over so that we dont rebuild every time ADD package.json /tmp/package.json RUN cd /tmp && npm install --production RUN mkdir -p /runner && cp -a /tmp/node_modules /runner -# ADD cli-runner and install node deps -ADD . /runner - -RUN ln -s /home/codewarrior /workspace WORKDIR /runner +ADD package.json /runner/package.json RUN npm install -RUN /usr/local/groovyserv/bin/setup.sh +# ADD cli-runner and install node deps +ADD frameworks/java /runner/frameworks/java +RUN chown codewarrior /runner/frameworks/java +RUN ln -s /home/codewarrior /workspace -# create a debug entry point to make running test code easier -RUN echo '#!/bin/bash\ngroovyserver -t 20 --debug --authtoken groovy || echo "server connection error"\n "$@"'>/runner/debug.sh ; chmod +x /runner/debug.sh +RUN gradle --version - # create a specific node entry point for running the node CLI executable with groovyserve started -RUN echo '#!/bin/bash\ngroovyserver -t 10 -q --authtoken groovy || echo ""\n timeout 15 node "$@"'>/runner/nodeentry.sh ; chmod +x /runner/nodeentry.sh +USER codewarrior -RUN /runner/debug.sh groovyclient -Ct 120 -Cdebug -Cauthtoken groovy -e "println 'Hello from GroovyServ'" +# pre-install the default referenced java libraries +RUN cd /runner/frameworks/java && gradle --stacktrace --no-daemon compileTestJava -# Run the test suite to make sure this thing works -USER codewarrior +ADD lib /runner/lib +ADD *.js /runner/ +ADD frameworks/java/prewarm.sh /runner/prewarm.sh # Set environment variables -ENV TIMEOUT 120000 ENV USER codewarrior ENV HOME /home/codewarrior -RUN /runner/debug.sh mocha -t ${TIMEOUT} test/runners/java_spec.js +ADD test /runner/test -USER codewarrior +# Run the test suite to make sure this thing works +RUN mocha -t 20000 /runner/test/runners/java_spec.js -ENTRYPOINT ["/runner/nodeentry.sh"] +ENTRYPOINT ["node"] diff --git a/documentation/environments/java.md b/documentation/environments/java.md new file mode 100644 index 00000000..f66a0456 --- /dev/null +++ b/documentation/environments/java.md @@ -0,0 +1,71 @@ +# Environment + +Code is executed within a Dockerized Ubuntu 14.04 container. + +## Languages + +- Java 8 (1.8.0_91) + +## Loaded Dependencies + +### The following depencies are always loaded + +- junit 4.12 +- lombok 1.16.18 +- mockito-core 2.7.19 +- assertj-core 3.8.0 + +### The following can be loaded through `@config reference` statements + +- joda-time 2.2 +- guava 20.0 +- commons-lang3 3.6 +- commons-math3 3.6.1 +- jsoup 1.10.3 +- dom4j 2.0.1 +- assertj-guava 3.1.0 +- hibernate-core 5.2.10.Final +- mongo-java-driver 3.4.2 +- sqlite-jdbc 3.19.3 +- postgresql 42.1.1 +- spring-boot-starter-web 1.5.4 +- spring-boot-starter-test 1.5.4 +- spring-boot-starter-data-mongodb 1.5.4 +- spring-boot-starter-data-redis 1.5.4 +- spring-boot-starter-data-jpa 1.5.4 +- spring-boot-starter-data-rest 1.5.4 +- spring-boot-starter-validation 1.5.4 + + +To make these packages available to the application, you must have access to the setup code block. +Within the setup code you can load any of these packages using reference config statements. + +**Setup Example:** +```java +// @config reference guava +// @config reference commons-lang3 +``` + + +If you need to reference a package that is a dependency of one of the above packages, you will need to load those packages +in order to make that dependency available. + +### Spring Boot Packages + +If you require support for the Spring framework, you can include `spring-boot` as the reference name. +This will include both the web and test starter dependencies, as well as any additional requirements. + +When including the Spring framework via `spring-boot`, if other services are configured, such as mongodb, then the required spring data packages will also be auto-included into the build. + +# Build Process + +Gradle is used as the build tool. Each time you run code, a fresh Docker container will be used. Under +typical conditions the Gradle daemon should have already loaded, causing build times to typically fall within +the 3 to 4 second range for trivial sized apps. However if the daemon has not finished loading then the build +process may take over 10 seconds to complete. + +# Timeout + +The sandbox environment will timeout the code within 20 seconds. + +> For more information, view the [docker file](https://github.com/Codewars/codewars-runner-cli/blob/master/docker/jvm.docker) diff --git a/frameworks/java/build.gradle b/frameworks/java/build.gradle new file mode 100644 index 00000000..02156f07 --- /dev/null +++ b/frameworks/java/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'groovy' +} + +repositories { + jcenter() +} + +dependencies { + compile 'junit:junit:4.12' + compileOnly 'org.projectlombok:lombok:1.16.18' + compile "joda-time:joda-time:2.2" + compile 'org.mockito:mockito-core:2.7.19' + compile 'com.google.guava:guava:20.0' + compile 'org.apache.commons:commons-lang3:3.6' + compile 'org.apache.commons:commons-math3:3.6.1' + compile 'org.jsoup:jsoup:1.10.3' // jsoup HTML parser library @ https://jsoup.org/ + compile 'org.dom4j:dom4j:2.0.1' // Flexible XML framework + compile 'org.assertj:assertj-core:3.8.0' + compile 'org.assertj:assertj-guava:3.1.0' + compile 'com.fasterxml.jackson.core:jackson-annotations:2.8.0' + compile 'org.hibernate:hibernate-core:5.2.10.Final' + compile 'org.mongodb:mongo-java-driver:3.4.2' + compile 'org.xerial:sqlite-jdbc:3.19.3' + compile 'org.postgresql:postgresql:42.1.1' + compile 'org.springframework:spring-orm:4.3.9.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-web:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-test:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-data-mongodb:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-data-redis:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-data-jpa:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-data-rest:1.5.4.RELEASE' + compile 'org.springframework.boot:spring-boot-starter-validation:1.5.4.RELEASE' +} + +test { + reports { + html.enabled = false + junitXml.enabled = false + } + testLogging { + // TODO: Refactor so that we use Gradle to customize output, instead of custom test listener + // SEE: https://stackoverflow.com/questions/3963708/gradle-how-to-display-test-results-in-the-console-in-real-time + // SEE: https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.Test.html + // Show standard streams so that we can parse STDOUT/ERR output. + showStandardStreams = true + showExceptions = true + showStackTraces = true + } +} diff --git a/frameworks/java/prewarm.sh b/frameworks/java/prewarm.sh new file mode 100644 index 00000000..cb05e35c --- /dev/null +++ b/frameworks/java/prewarm.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "loading" > /workspace/prewarm.status + +# prewarm by starting the gradle daemon. Running an initial test build will also speed things up a bit +cd /runner/frameworks/java && gradle --daemon --offline test + +echo "loaded" > /workspace/prewarm.status + +# node run -l java -c "public class Solution {}" -f "import org.junit.Test;public class TestFixture{}" + diff --git a/frameworks/java/src/main/java/Example.java b/frameworks/java/src/main/java/Example.java new file mode 100644 index 00000000..c01cde6f --- /dev/null +++ b/frameworks/java/src/main/java/Example.java @@ -0,0 +1,11 @@ +import org.junit.Test; +import org.junit.runners.JUnit4; +//import org.hamcrest.Matchers.*; +//import com.google.common.base.Optional; + +public class Example { + public Example() { + } + + public String foo(){ return "foo"; } +} diff --git a/frameworks/java/src/main/java/ExampleTest.java b/frameworks/java/src/main/java/ExampleTest.java new file mode 100644 index 00000000..4b406582 --- /dev/null +++ b/frameworks/java/src/main/java/ExampleTest.java @@ -0,0 +1,10 @@ +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runners.JUnit4; +public class ExampleTest { + @Test + public void myTestFunction(){ + Example e = new Example(); + assertEquals("Failed Message", "foo", e.foo()); + } +} diff --git a/frameworks/java/CwRunListener.java b/frameworks/java/src/test/java/CwRunListener.java similarity index 68% rename from frameworks/java/CwRunListener.java rename to frameworks/java/src/test/java/CwRunListener.java index 8f7a6d64..b000819e 100644 --- a/frameworks/java/CwRunListener.java +++ b/frameworks/java/src/test/java/CwRunListener.java @@ -12,21 +12,26 @@ public void testFailure(final Failure failure) failed = true; final String msg = failure.getMessage(); final boolean hasMessage = msg != null && msg.length() > 0; - System.out.println(String.format("\n%s<:LF:>", formatMessage(hasMessage ? msg : "Runtime Error Occurred"))); - if(!hasMessage && failure.getException() != null) { - System.out.println(formatException(failure.getException())); + System.out.println(String.format("\n%s", formatMessage(hasMessage ? msg : "Runtime Error Occurred"))); + if(failure.getException() != null) { + String prefix = ""; + if (hasMessage) { + prefix = ""; + } + + System.out.println(prefix + formatMessage(formatException(failure.getException()))); } } public void testStarted(final Description description) { - System.out.println(String.format("\n%s<:LF:>", formatMessage(description.getDisplayName()))); + System.out.println(String.format("\n%s", formatMessage(description.getDisplayName()))); failed = false; } public void testFinished(final Description description) { if(!failed) { - System.out.println("\nTest Passed<:LF:>"); + System.out.println("\nTest Passed"); } System.out.println("\n"); } diff --git a/frameworks/java/src/test/java/Start.java b/frameworks/java/src/test/java/Start.java new file mode 100644 index 00000000..93468bcc --- /dev/null +++ b/frameworks/java/src/test/java/Start.java @@ -0,0 +1,14 @@ +import org.junit.runner.JUnitCore; +import org.junit.Test; +import org.junit.runners.JUnit4; + +public class Start { + @Test + public void start(){ + JUnitCore runner = new JUnitCore(); + runner.addListener(new CwRunListener()); + runner.run(ExampleTest.class); + } +} + + diff --git a/lib/config.js b/lib/config.js index e1838b9d..916313b4 100644 --- a/lib/config.js +++ b/lib/config.js @@ -15,6 +15,7 @@ module.exports = { go: 15000, haskell: 15000, sql: 14000, + java: 20000 }, moduleRegExs: { haskell: /module\s+([A-Z]([a-z|A-Z|0-9]|\.[A-Z])*)\W/, diff --git a/lib/options.js b/lib/options.js index 884aeaeb..fbd071e6 100644 --- a/lib/options.js +++ b/lib/options.js @@ -1,3 +1,5 @@ +const splitFiles = require('./utils/split-files.js'); + module.exports.process = function(opts) { opts = assignConfigJson(opts); @@ -123,7 +125,7 @@ function assignPublishing(opts) { if (opts.ably && opts.channel) { try { - var ably = new require('ably').Rest({log: {level: 0}}); + var ably = new require('ably').Rest(opts.ably, {log: {level: 0}}); var channel = ably.channels.get(opts.channel); opts.publish = function(event, data) { if (event && data) { @@ -183,4 +185,18 @@ function assignConfigStatements(opts) { }); } } + + assignSplits(opts, 'setup'); + assignSplits(opts, 'solution'); + assignSplits(opts, 'fixture'); +} + +function assignSplits(opts, field) { + if (opts[field]) { + let fileSplits = splitFiles(opts[field]); + if (fileSplits.splits) { + opts[field] = fileSplits.root; + opts.files = Object.assign(opts.files || {}, fileSplits.files); + } + } } diff --git a/lib/runners/java.js b/lib/runners/java.js index b625d438..48b04f4a 100644 --- a/lib/runners/java.js +++ b/lib/runners/java.js @@ -1,86 +1,200 @@ var shovel = require('../shovel'), exec = require('child_process').exec, - util = require('../util'); + execSync = require('child_process').execSync, + util = require('../util'), + tagHelpers = require('../utils/tag-helpers'), + manipulateFileSync = require('../utils/manipulate-file-sync'), + fs = require('fs-extra'); module.exports.run = function run(opts, cb) { + // we will cache the contents of this file so that we can process it later. + let buildGradle = ''; shovel.start(opts, cb, { - solutionOnly: function(runCode, fail) { + solutionOnly: function (runCode, fail) { + fs.emptydirSync(`${opts.dir}/src`); + const names = extractNames(opts.solution, 'Solution'); - const solutionFile = util.codeWriteSync('javac', opts.solution, opts.dir, names.file); - - compile([solutionFile], function(error, stdout, stderr) { - if (error) return fail(error, stdout, stderr); - opts.publish('stdout', stdout); - runCode({ - name: 'groovyclient', - args: [ - '-cp', opts.dir, - '-e', `import ${names.full}; ${names.full}.main(null);`, - '-Cauthtoken', 'groovy' - ] - }); - }); - }, - testIntegration: function(runCode, fail) { + const solutionFile = util.codeWriteSync(null, opts.solution, `${opts.dir}/src/main/java`, names.file); - const solutionNames = extractNames(opts.solution, 'Solution'); - const solutionFile = util.codeWriteSync('javac', opts.solution, opts.dir, solutionNames.file); + util.codeWriteSync(null, javaMain(names.full), `${opts.dir}/src/test/java`, 'Start.java'); - const fixtureNames = extractNames(opts.fixture, 'TestFixture'); - const fixtureFile = util.codeWriteSync('javac', opts.fixture, opts.dir, fixtureNames.file); + buildAndTest(runCode); + }, + testIntegration: function (runCode, fail) { - const classpaths = [ - '/usr/local/groovy/lib/junit-4.12.jar', - '/usr/local/groovy/lib/hamcrest-core-1.3.jar' - ]; + fs.emptydirSync(`${opts.dir}/src`); + const fixtures = []; - const args = [ - '-cp', classpaths.join(':'), - solutionFile, - fixtureFile, - './frameworks/java/CwRunListener.java', - ]; + if (opts.solution) { + const solutionNames = extractNames(opts.solution, 'Solution'); + util.codeWriteSync(null, opts.solution, `${opts.dir}/src/main/java`, solutionNames.file); + } + + if (opts.fixture) { + const fixtureNames = extractNames(opts.fixture, 'TestFixture'); + util.codeWriteSync(null, opts.fixture, `${opts.dir}/src/main/java`, fixtureNames.file); + fixtures.push(fixtureNames.full); + } if (opts.setup) { const setupNames = extractNames(opts.setup); - args.push(util.codeWriteSync('javac', opts.setup, opts.dir, setupNames.file)); + util.codeWriteSync(null, opts.setup, `${opts.dir}/src/main/java`, setupNames.file); } - compile(args, function(error, stdout, stderr) { - if (error) return fail(error, stdout, stderr); - opts.publish('stdout', stdout); - - const runner = util.codeWriteSync('groovy', groovyTestRunner(fixtureNames.full), opts.dir, 'testRunner.groovy'); + if (opts.files) { + Object.keys(opts.files).forEach(key => { + util.codeWriteSync(null, opts.files[key], `${opts.dir}/src/main/java`, key); - runCode({ - name: 'groovyclient', - args: [ - '-Cauthtoken', 'groovy', - '-cp', opts.dir, - '-cp', '/usr/local/groovy/lib/', - runner - ] + // if there are any @Test attributes then assume test cases are included and add to the fixtures list + if (opts.files[key].indexOf('@Test') > 0) { + fixtures.push(extractNames(opts.files[key], key.split('.')[0]).full); + } }); - }); - }, - sanitizeStdErr: function(error) { - // we need to strip out the authtoken error that may show up - if (error.match(/^WARN: old authtoken/)) { - error = error.substr(error.indexOf('1961 port') + 10); } - return error; + + // setup tests, since we use a custom format we are placing the actual tests within the main folder + // and using the test folder to wrap them. + // TODO: make this less hacky + util.codeWriteSync(null, javaTestRunner(fixtures), `${opts.dir}/src/test/java`, 'Start.java'); + + fs.copySync('/runner/frameworks/java/src/test/java/CwRunListener.java', `${opts.dir}/src/test/java/CwRunListener.java`); + + buildAndTest(runCode); + }, + transformBuffer: function (buffer) { + opts.publish('processing output'); + var stdout = "", stderr = "", buildLines = []; + + // both types of streams will be embedded within the gradle output, so lets break them out + // into their own. + buffer.stdout.split(/^(Start > start |BUILD)/gm).forEach(line => { + if (line.indexOf('STANDARD_OUT') == 0) { + stdout += toStdString(line); + } + else if (line.indexOf('STANDARD_ERROR') == 0) { + stderr += toStdString(line); + } + else { + buildLines.push(line); + } + }); + + // attach build output to beginning of stdout, also attach dependencies so everyone knows whats available + // TODO: eventually we want to support this as its own buffer property, for now we are just embedding it + // within the output in case its helpful for troubleshooting + buildOutput = `Dependencies:\n\n${loadedDependencies()}\n\nTasks:\n\n`; + buildOutput += buildLines.join("\n").replace(/^Start > start.*/gm, ''); + buildOutput = tagHelpers.log('-Build Output', buildOutput) + buffer.stdout = buildOutput + stdout; + + buffer.stderr += stderr; + // let's not show the noisy what went wrong text when there are build errors + buffer.stderr = buffer.stderr.split(/\^* Try:/m)[0]; } }); - function compile(args, cb) { - args.unshift('javac', '-verbose','-cp', opts.dir, '-d', opts.dir, '-sourcepath', opts.dir); - exec(args.join(' '), cb); + /** + * If the prewarming process is in-process, we want to wait until it finishes, otherwise two builds happen and that + * will kill performance and the process will likely fail due to a timeout. + * multiple + * @param cb Function callback. True will be passed to the callback if the prewarm happened, which + * will indicate that the deamon should have been started. + */ + function whenReady(cb, notified) { + const path = "/workspace/prewarm.status"; + if (!fs.pathExistsSync(path)) { + cb(false); + } + else if (fs.readFileSync(path).toString().indexOf('loaded') === 0) { + cb(true); + } + else { + if (!notified) { + opts.publish('status', 'Waiting for Gradle daemon to start...'); + } + setTimeout(() => whenReady(cb, true), 200); + } } - function extractNames(code, defaultClassName) { - const packageName = (code.match(/\bpackage\s+([A-Z|a-z](?:[a-z|A-Z|0-9|_]|\.[A-Z|a-z])*)\W/)||[])[1]; - const className = (code.match(/\bclass\s+([A-Z][a-z|A-Z|0-9|_]*)\W/)||[])[1] || defaultClassName; + // we always build and test code with the gradle test task, even if we are not actually testing anything. + // Currently this is because we have test mode setup to embed its output within the build output. + function buildAndTest(runCode) { + processReferences(); + + let templateOptions = {}; + + // only keep the referenced dependencies, always keep junit, lombok and the other common testing libraries + templateOptions.keeps = [{ + target: /^dependencies {\n( .*\n)*}/gm, + select: '^ compile.*:$keep:.*\n', + values: ['junit', 'lombok', 'assertj-core', 'mockito-core', 'sqlite-jdbc'].concat(opts.references || []) + }]; + + buildGradle = manipulateFileSync(`/runner/frameworks/java/build.gradle`, `${opts.dir}/build.gradle`, templateOptions); + + whenReady(prewarmed => { + runCode({ + name: 'gradle', + // reuse the java dir cache since thats where we originally built from + args: [ + prewarmed ? '--daemon' : '--no-daemon', // if not prewarmed don't bother + '--stacktrace', + '--project-cache-dir', '/runner/frameworks/java', + // '--offline', + '--exclude-task', 'compileGroovy', + '--exclude-task', 'processResources', + '--exclude-task', 'compileTestGroovy', + '--exclude-task', 'processTestResources', + 'test' + ], + options: { + cwd: opts.dir + } + }); + }); + } + + // checks if the spring-boot reference is loaded and adds additional settings + function processReferences() { + // bump the timeout to 25 seconds if spring is activated + if ((opts.references || []).indexOf('spring-boot') >= 0) { + opts.timeout = opts.timeout || 25000; + opts.references.push('jackson-annotations'); + opts.references.push('spring-boot-starter-web'); + opts.references.push('spring-boot-starter-test'); + + if (opts.services) { + if (opts.services.indexOf('mongodb') >= 0) { + opts.references.push('spring-boot-starter-data-mongodb'); + } + if (opts.services.indexOf('redis') >= 0) { + opts.references.push('spring-boot-starter-data-redis'); + } + if (opts.services.indexOf('postgres') >= 0) { + opts.references.push('spring-boot-starter-data-jpa'); + } + } + } + } + + function loadedDependencies() { + return (buildGradle.match(/ compile(?:Only)? [\'\"].*[\'\"]/g) || []) + .map(l => l.replace(/ compile.*[\'\"](.*)[\'\"]/g, '$1')).join('\n'); + } + + // converts the embedded STDOUT and STDERR streams text to its non-embedded format + function toStdString(text) { + return text + .split(/\n/) + .filter(l => l.indexOf(' ') === 0) + .map(l => l.replace(' ', '')) + .join('\n') + '\n'; + } + + function extractNames (code, defaultClassName) { + const packageName = (code.match(/\bpackage\s+([A-Z|a-z](?:[a-z|A-Z|0-9|_]|\.[A-Z|a-z])*)\W/) || [])[1]; + const className = (code.match(/\bclass\s+([A-Z][a-z|A-Z|0-9|_]*)\W/) || [])[1] || defaultClassName; return { 'package': packageName, @@ -90,15 +204,43 @@ module.exports.run = function run(opts, cb) { }; } - function groovyTestRunner(fixtureName) { - return `import org.junit.runner.JUnitCore; - import CwRunListener; - import ${fixtureName}; - - def runner = new JUnitCore(); - runner.addListener(new CwRunListener()); - runner.run(${fixtureName}.class); - `; + // hacky solution for starting the dynamic main class. We need to use test mode since thats currently how we + // have STDOUT/ERR setup to output anything + function javaMain (mainClass) { + const importClass = mainClass.indexOf('.') > 0 ? `import ${mainClass};` : ''; + return ` + import org.junit.Test; + ${importClass} + + public class Start { + @Test + public void start(){ + ${mainClass}.main(null); + } + } + ` } -}; + // Our hacky way of running tests with our custom listener. + // TODO: this is configurable via gradle, we just need to take the time to figure that bit out. + function javaTestRunner (fixtures) { + // add any imports needed, use a set to make sure we have unique values + let imports = new Set(fixtures.filter(f => f.indexOf('.') > 0).map(f => `import ${f};\n`)); + let runs = fixtures.map(f => `runner.run(${f}.class);\n`); + + return ` + import org.junit.runner.JUnitCore; + import org.junit.Test; + ${[...imports].join()} + + public class Start { + @Test + public void start(){ + JUnitCore runner = new JUnitCore(); + runner.addListener(new CwRunListener()); + ${runs.join()} + } + } + `; + }; +} diff --git a/lib/shovel.js b/lib/shovel.js index 398e41fe..1059cbbb 100644 --- a/lib/shovel.js +++ b/lib/shovel.js @@ -36,7 +36,7 @@ module.exports.start = function start(opts, cb, strategies) { function run(opts, strategies, cb) { // this is the "run/exec" method that is passed in to the shovel methods as the callback. function runCode(params) { - exec(opts, params.name, params.args, params.options, params.stdin, cb); + spawnEx(opts, params.name, params.args, params.options, params.stdin, cb); } // called if the compile process fails @@ -135,7 +135,7 @@ function runShell(opts, resolve, status) { compiling: true // set to true so that the script doesn't exit }; - exec(shellOpts, 'bash', [file], {}, null, function(result) { + spawnEx(shellOpts, 'bash', [file], {}, null, function(result) { opts.shell = result; resolve(); }); @@ -187,7 +187,15 @@ function cleanup() { spawnSync('bash', ['/runner/lib/cleanup.sh']); } -function exec(opts, name, args, processOptions, processStdin, cb) { +// an extended version of spawn that does a number of additional things such as: +// - time process +// - sets a max output buffer +// - adds stdout/in to a buffer object which is passed to callback +// - publishes statuses +// - prevents the process from running too long (set via opts.timeout), defaults to language specific default +// TODO: move to own file and cleanup +// TODO: support single params object instead of long args chain (runCode already wraps with this interface) +function spawnEx(opts, name, args, processOptions, processStdin, cb) { opts.publish = opts.publish || function() {}; opts.publish("status", "Running..."); diff --git a/lib/util.js b/lib/util.js index 95d38b9b..82968707 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,3 +1,5 @@ +// TODO: start to move each utility function into its own file within /lib/utils + var fs = require('fs'), path = require('path'), config = require('./config'), diff --git a/lib/utils/manipulate-file-sync.js b/lib/utils/manipulate-file-sync.js new file mode 100644 index 00000000..07654742 --- /dev/null +++ b/lib/utils/manipulate-file-sync.js @@ -0,0 +1,85 @@ +const fs = require('fs-extra'), + wrap = require('./wrap'); + +/** + * Takes the content of the source file and moves them to the dest, allowing you to manipulate the contents first. + * @param src A string representing the source path + * @param dest A string for representing the destination path + * @param options A required object of settings: + * transforms: A function or an array of transform functions + * replacements: An array of replacement definitions + * keeps: An Array of keep definitions + * write: writeFileSync options + * + */ +module.exports = function manipulateFileSync(src, dest, options) { + let content = fs.readFileSync(src, 'utf8'); + + // transforms are raw methods which will transform the content + if (options.transforms) { + wrap(options.transforms).forEach(t => content = t(content)); + } + + // replacements are objects which indicate how a content should be replaced. + if (options.replacements) { + wrap(options.replacements).forEach(r => content = replace(content, r)); + } + + // keeps are objects which indicate which content should be kept + if (options.keeps) { + wrap(options.keeps).forEach(k => content = keep(content, k)); + } + + fs.outputFileSync(dest, content, options.write); + + return content; +} + +/** + * Instead of replacing content, this method tries to determine which content should be kept + * @param content String + * @param options Object + * target: a regexp that determines which content should be targetted, if only a subset should be eligible + * select: a string regexp that determines how to select content. $keep is a special token used to indicate how keep values are included + * values: an array of strings representing capture group values + * @returns String transformed content + */ +function keep(content, options) { + const targets = options.target ? content.match(options.target) : null + let targetContent = targets && targets.length ? targets[0] : content; + + if (targetContent) { + const keepsRegex = new RegExp(options.select.replace('$keep', `(${options.values.join('|')})`), 'gm'); + const cleanRegex = new RegExp(options.select.replace('$keep', '.*'), 'gm'); + + const keepLines = targetContent.match(keepsRegex); + const insertIndex = targetContent.search(cleanRegex); + targetContent = insert(targetContent.replace(cleanRegex, ''), insertIndex, `${keepLines.join(options.join || '')}\n`); + + content = options.target ? content.replace(options.target, targetContent) : targetContent; + } + + return content; +} + +/** + * Replaces part of the content + * @param content String + * @param options Object + * replace: String or Regex that must be provided to determine how to replace + * content: String to use as the replacement content + * file: String of the file path to the file to be used as content for the replacement + * encoding: String the encoding of the file + */ +function replace(content, options) { + let substitute = options.content || ''; + if (options.file) { + substitute = fs.readFileSync(options.file, options.encoding || 'utf8'); + } + + return content.replace(options.replace, substitute); +} + +function insert(str, index, value) { + return str.substr(0, index) + value + str.substr(index); +} diff --git a/lib/utils/split-files.js b/lib/utils/split-files.js new file mode 100644 index 00000000..a98f230a --- /dev/null +++ b/lib/utils/split-files.js @@ -0,0 +1,29 @@ +/** + * splits a single string into multiple files. The initial content is the root content, the additional splits will + * show up within thier own "files" object within the result object returned. + * @param content The string to be split + * @param regex defaults to a regex that supports `// @config: split-file File.ext` + * @returns {{root: string, files: {}, splits: number}} + */ +module.exports = function splitFiles(content, regex = /^[ \t#|\/]* ?@config[: ] ?split-file (.*$)/gm) { + const parts = content.split(regex), + result = {root: '', files: {}, splits: 0}; + + let fileName; + + parts.forEach((part, ndx) => { + if (ndx === 0) { + result.root = part; + } + else if (ndx % 2 === 1) { + fileName = part; + } + else { + // assign the content, we can remove the first character since it will be an extra line break + result.files[fileName] = part.substr(1); + result.splits++; + } + }); + + return result; +} diff --git a/lib/utils/tag-helpers.js b/lib/utils/tag-helpers.js new file mode 100644 index 00000000..e21bf257 --- /dev/null +++ b/lib/utils/tag-helpers.js @@ -0,0 +1,3 @@ +module.exports.log = function log(label, value) { + return `${(value || '').replace(/\n/g, "<:LF:>")}\n`; +} diff --git a/lib/utils/wrap.js b/lib/utils/wrap.js new file mode 100644 index 00000000..09a1b9eb --- /dev/null +++ b/lib/utils/wrap.js @@ -0,0 +1,3 @@ +module.exports = function wrap(value) { + return Array.isArray(value) ? value : [value]; +} diff --git a/listen.js b/listen.js index caf0f119..dae50ccd 100644 --- a/listen.js +++ b/listen.js @@ -1,5 +1,9 @@ // this file is used to keep a image alive so that it can be pre-warmed and communicated with. -var net = require('net'); +const net = require('net'), + execSync = require('child_process').execSync; + +// if the script is available, it will call it +console.log(execSync('sh /runner/prewarm.sh').toString()); // Creates a new TCP server. The handler argument is automatically set as a listener for the 'connection' event var server = net.createServer(function(socket) { diff --git a/package.json b/package.json index c5ee1941..3b604d17 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "bluebird": "*", "chai": "^3.5.0", "escape-html": "1.0.3", + "fs-extra": "^3.0.1", "js-yaml": "^3.8.3", "lodash": "^4.17.4", "mocha": "2.5.3", diff --git a/test/runners/java_spec.js b/test/runners/java_spec.js index 9eb8e377..a8f42de3 100644 --- a/test/runners/java_spec.js +++ b/test/runners/java_spec.js @@ -1,8 +1,13 @@ var expect = require('chai').expect; var runner = require('../runner'); +var execSync = require('child_process').execSync; describe('java runner', function() { + + console.log("Starting daemon with test run to ensure tests run within their allowed time..."); + console.log(execSync('sh /runner/frameworks/java/prewarm.sh').toString()); + describe('.run', function() { it('should handle basic code evaluation', function(done) { runner.run({ @@ -41,23 +46,26 @@ describe('java runner', function() { it('should handle basic junit tests', function(done) { runner.run({ language: 'java', - code: `public class Solution { - public Solution(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - public class TestFixture { - public TestFixture(){} - @Test - public void myTestFunction(){ - Solution s = new Solution(); - assertEquals("wow", 3, s.testthing()); - System.out.println("test out"); - }}` + code: ` + public class Solution { + public Solution(){} + public int testthing(){return 3;} + }`, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + public class TestFixture { + public TestFixture(){} + @Test + public void myTestFunction(){ + Solution s = new Solution(); + assertEquals("wow", 3, s.testthing()); + System.out.println("test out"); + System.err.println("error test"); + }}` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(TestFixture)<:LF:>\ntest out\n\nTest Passed<:LF:>\n'); + expect(buffer.stdout).to.contain('myTestFunction(TestFixture)\ntest out\nTest Passed\n'); done(); }); }); @@ -65,23 +73,25 @@ describe('java runner', function() { it('should handle junit tests failing', function(done) { runner.run({ language: 'java', - code: `public class Solution { - public Solution(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - public class TestFixture { - public TestFixture(){} - @Test - public void myTestFunction(){ - Solution s = new Solution(); - assertEquals("Failed Message", 5, s.testthing()); - System.out.println("test out"); - }}` + code: ` + public class Solution { + public Solution(){} + public int testthing(){return 3;} + }`, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + public class TestFixture { + public TestFixture(){} + @Test + public void myTestFunction(){ + Solution s = new Solution(); + assertEquals("Failed Message", 5, s.testthing()); + System.out.println("test out"); + }}` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(TestFixture)<:LF:>\n\nFailed Message expected:<5> but was:<3><:LF:>\n'); + expect(buffer.stdout).to.contain('myTestFunction(TestFixture)\nFailed Message expected:<5> but was:<3>\n'); done(); }); }); @@ -89,19 +99,21 @@ describe('java runner', function() { it('should report junit messages', function(done) { runner.run({ language: 'java', - code: `public class Solution { - public Solution(){} - public String testthing(){ return null; } - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - public class TestFixture { - @Test - public void myTestFunction(){ - Solution s = new Solution(); - assertEquals("Failed Message", 1, s.testthing().length()); - }}` + code: ` + public class Solution { + public Solution(){} + public String testthing(){ return null; } + }`, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + public class TestFixture { + @Test + public void myTestFunction(){ + Solution s = new Solution(); + assertEquals("Failed Message", 1, s.testthing().length()); + }}` }, function(buffer) { expect(buffer.stdout).to.contain('Runtime Error Occurred'); expect(buffer.stdout).to.contain('NullPointerException'); @@ -109,146 +121,197 @@ describe('java runner', function() { }); }); - it('should hide groovyserve warning', function(done) { + it('should handle custom class names', function(done) { runner.run({ language: 'java', - code: `public class Solution { - public Solution(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - public class TestFixture { - public TestFixture(){} - @Test - public void myTestFunction(){ - Solution s = new Solution(); - assertEquals("Failed Message", 5, s.testthing()); - System.stdout.println("test err"); - }}` + code: ` + public class Challenge { + public Challenge(){} + public int testthing(){return 3;} + }`, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + public class Fixture { + public Fixture(){} + @Test + public void myTestFunction(){ + Challenge s = new Challenge(); + assertEquals("wow", 3, s.testthing()); + System.out.println("test out"); + }}` }, function(buffer) { - expect(buffer.stderr).to.contain('test err'); - expect(buffer.stderr).to.not.contain('WARN: old authtoken'); - expect(buffer.stderr).to.not.contain('1961 port'); + expect(buffer.stdout).to.contain('myTestFunction(Fixture)\ntest out\nTest Passed\n'); done(); }); }); - it('should handle custom class names', function(done) { + it('should handle packages', function(done) { runner.run({ language: 'java', - code: `public class Challenge { - public Challenge(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - public class Fixture { - public Fixture(){} - @Test - public void myTestFunction(){ - Challenge s = new Challenge(); - assertEquals("wow", 3, s.testthing()); - System.out.println("test out"); - }}` + code: ` + package stuff; + public class Challenge { + public Challenge(){} + public int testthing(){return 3;} + }`, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + import stuff.Challenge; + + public class Fixture { + public Fixture(){} + @Test + public void myTestFunction(){ + Challenge s = new Challenge(); + assertEquals("wow", 3, s.testthing()); + System.out.println("test out"); + }}` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(Fixture)<:LF:>\ntest out\n\nTest Passed<:LF:>\n'); + expect(buffer.stdout).to.contain('myTestFunction(Fixture)\ntest out\nTest Passed\n'); done(); }); }); - it('should handle packages', function(done) { + it('should handle support setup code', function(done) { runner.run({ language: 'java', code: ` - package stuff; - public class Challenge { - public Challenge(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - import stuff.Challenge; - - public class Fixture { - public Fixture(){} - @Test - public void myTestFunction(){ - Challenge s = new Challenge(); - assertEquals("wow", 3, s.testthing()); - System.out.println("test out"); - }}` + public class Challenge { + public Challenge(){} + public int testthing(){return 3;} + }`, + setup: ` + class Node { + } + + class Helpers { + static String out() { + return "test out"; + } + } + `, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + + public class Fixture { + public Fixture(){} + @Test + public void myTestFunction(){ + Challenge s = new Challenge(); + assertEquals("wow", 3, s.testthing()); + System.out.println(Helpers.out()); + }}` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(Fixture)<:LF:>\ntest out\n\nTest Passed<:LF:>\n'); + expect(buffer.stdout).to.contain('myTestFunction(Fixture)\ntest out\nTest Passed\n'); done(); }); }); - it('should handle packages', function(done) { + it('should handle support split files', function(done) { runner.run({ language: 'java', code: ` - package stuff; - public class Challenge { - public Challenge(){} - public int testthing(){return 3;} - }`, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - import stuff.Challenge; - - public class Fixture { - public Fixture(){} - @Test - public void myTestFunction(){ - Challenge s = new Challenge(); - assertEquals("wow", 3, s.testthing()); - System.out.println("test out"); - }}` + public class Challenge { + public Challenge(){} + public int testthing(){return 3;} + } + + // @config: split-file Helpers.java + public class Helpers { + static String out() { + return "test out"; + } + } + `, + fixture: ` + import static org.junit.Assert.assertEquals; + import org.junit.Test; + import org.junit.runners.JUnit4; + + public class Fixture { + public Fixture(){} + @Test + public void myTestFunction(){ + Challenge s = new Challenge(); + assertEquals("wow", 3, s.testthing()); + System.out.println(Helpers.out()); + }}` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(Fixture)<:LF:>\ntest out\n\nTest Passed<:LF:>\n'); + expect(buffer.stdout).to.contain('myTestFunction(Fixture)\ntest out\nTest Passed\n'); done(); }); }); + }); - it('should handle support setup code', function(done) { + describe('spring', function() { + it('should handle basic junit tests', function(done) { runner.run({ language: 'java', code: ` - package stuff; - public class Challenge { - public Challenge(){} - public int testthing(){return 3;} - }`, + package hello; + + import org.springframework.boot.autoconfigure.*; + import org.springframework.stereotype.Controller; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.ResponseBody; + + @Controller + @EnableAutoConfiguration + public class HomeController { + + @RequestMapping("/") + public @ResponseBody String greeting() { + return "Hello World"; + } + + }`, setup: ` - class Node { - } + // @config: reference spring-boot + + package hello; - class Helpers { - static String out() { - return "test out"; - } + import org.springframework.boot.SpringApplication; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } } `, - fixture: `import static org.junit.Assert.assertEquals; - import org.junit.Test; - import org.junit.runners.JUnit4; - import stuff.Challenge; - - public class Fixture { - public Fixture(){} - @Test - public void myTestFunction(){ - Challenge s = new Challenge(); - assertEquals("wow", 3, s.testthing()); - System.out.println(Helpers.out()); - }}` + fixture: ` + package hello; + import static org.assertj.core.api.Assertions.assertThat; + + import org.junit.Test; + import org.junit.runner.RunWith; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.boot.test.context.SpringBootTest; + import org.springframework.test.context.junit4.SpringRunner; + + @RunWith(SpringRunner.class) + @SpringBootTest + public class SmokeTest { + + @Autowired + private HomeController controller; + + @Test + public void contexLoads() throws Exception { + assertThat(controller).isNotNull(); + } + }` }, function(buffer) { - expect(buffer.stdout).to.contain('myTestFunction(Fixture)<:LF:>\ntest out\n\nTest Passed<:LF:>\n'); + console.log(buffer.stderr); + expect(buffer.stdout).to.contain('Test Passed\n'); done(); }); }); diff --git a/test/utils/manipulate-file-sync_spec.js b/test/utils/manipulate-file-sync_spec.js new file mode 100644 index 00000000..568fcc2d --- /dev/null +++ b/test/utils/manipulate-file-sync_spec.js @@ -0,0 +1,17 @@ +const expect = require('chai').expect, + manipulateFileSync = require('../../lib/utils/manipulate-file-sync'); + +describe('manipulateFileSync', function() { + it ("should support keeps", function() { + const result = manipulateFileSync('/runner/frameworks/java/build.gradle', '/workspace/build.gradle', { + keeps: [{ + target: /^dependencies {\n( .*\n)*}/gm, + select: '^ compile.*$keep.*\n', + values: ['junit', 'lombok'] + }] + }); + + expect(result).to.not.contain("dom4j"); + expect(result).to.contain("repositories {\n jcenter()"); + }); +}); diff --git a/test/utils/split-files_spec.js b/test/utils/split-files_spec.js new file mode 100644 index 00000000..1fa97169 --- /dev/null +++ b/test/utils/split-files_spec.js @@ -0,0 +1,42 @@ +const expect = require('chai').expect, + split = require('../../lib/utils/split-files'); + +describe('splitFiles', function() { + it ("should ignore files without splits", function() { + const content = ` + this is root content + + this is Stuff.java + + this is Other.java + ` + + const result = split(content); + + expect(result.root).to.contain('this is root content'); + expect(result.root).to.contain('this is Stuff.java'); + expect(result.splits).to.eq(0); + }); + + it ("should split into multiple files", function() { + const content = ` + this is root content + + // @config: split-file Stuff.java + + this is Stuff.java + + // @config:split-file Other.java + + this is Other.java + ` + + const result = split(content); + + expect(result.root).to.contain('this is root content'); + expect(result.root).to.not.contain('this is Stuff.java'); + expect(result.splits).to.eq(2); + expect(result.files['Stuff.java']).to.contain('this is Stuff.java'); + expect(result.files['Stuff.java']).to.not.contain('this is Other.java'); + }); +});