diff --git a/.github/workflows/build-groovy-executor.yml b/.github/workflows/build-groovy-executor.yml index c1e87f48..f53328c9 100644 --- a/.github/workflows/build-groovy-executor.yml +++ b/.github/workflows/build-groovy-executor.yml @@ -27,6 +27,8 @@ jobs: java: 21 - variant: 'groovy_5_0' java: 21 + - variant: 'groovy_6_0_alpha' + java: 21 steps: - uses: actions/checkout@v6 - name: 'Set up JDK' diff --git a/.github/workflows/deploy-groovy-executor.yml b/.github/workflows/deploy-groovy-executor.yml index d45030ec..3bbb8860 100644 --- a/.github/workflows/deploy-groovy-executor.yml +++ b/.github/workflows/deploy-groovy-executor.yml @@ -15,6 +15,8 @@ jobs: java: 21 - variant: 'groovy_5_0' java: 21 + - variant: 'groovy_6_0_alpha' + java: 21 steps: - uses: actions/checkout@v6 - name: 'Set up JDK' diff --git a/README.md b/README.md index 90eef4e6..44e40e42 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,31 @@ The output will be in `functions/groovy-executor/target/deployment`. There are different profiles, one for each groovy version: +* `groovy_6_0_alpha` (no Spock — see below) * `groovy_5_0` * `groovy_4_0` (default) * `groovy_3_0` Use `../../mvnw package -P groovy_5_0` +> **Switching profiles locally requires `clean`.** Each profile compiles a +> different set of (test) sources, so run e.g. `../../mvnw clean package -P groovy_6_0_alpha`. +> Without `clean`, stale classes from a previous profile linger in `target/` and +> can cause a confusing `spock/lang/Specification` failure when building the +> Spock-free `groovy_6_0_alpha` variant. (CI is unaffected — it builds from a +> fresh checkout.) + +#### Groovy 6 (pre-release, no Spock) + +Spock has no release compatible with Groovy 6 yet, so the `groovy_6_0_alpha` +variant ships **without** Spock. Plain Groovy scripts run normally; submitting a +Spock specification (or using the AST view) returns a "not supported on this +Groovy version yet" message instead. The concrete alpha version is controlled by +the `groovy.6.version` property in `functions/pom.xml`, so bumping to a newer +alpha is a one-line change deployed to the same `groovy_6_0_alpha` function. +Because the runtime id contains `alpha`, the frontend never selects it as the +default version. + ### Deploying the backend Go to https://github.com/groovy-console/groovy-web-console/actions/workflows/deploy.yml and click on `Run Workflow` diff --git a/functions/groovy-executor/pom.xml b/functions/groovy-executor/pom.xml index db8d1ec4..20de3858 100644 --- a/functions/groovy-executor/pom.xml +++ b/functions/groovy-executor/pom.xml @@ -52,7 +52,53 @@ + + + org.spockframework + spock-core + compile + + + + net.bytebuddy + byte-buddy + + + org.objenesis + objenesis + + + org.junit.platform + junit-platform-launcher + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-spock-source + generate-sources + add-source + + src/spock/java + + + + add-spock-resource + generate-resources + add-resource + + src/spock/resources + + + + + + groovy_4_0 @@ -101,7 +147,46 @@ + + net.bytebuddy + byte-buddy + + + org.objenesis + objenesis + + + org.junit.platform + junit-platform-launcher + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-spock-source + generate-sources + add-source + + src/spock/java + + + + add-spock-resource + generate-resources + add-resource + + src/spock/resources + + + + + + groovy_5_0 @@ -147,7 +232,109 @@ + + net.bytebuddy + byte-buddy + + + org.objenesis + objenesis + + + org.junit.platform + junit-platform-launcher + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-spock-source + generate-sources + add-source + + src/spock/java + + + + add-spock-resource + generate-resources + add-resource + + src/spock/resources + + + + + + + + + + groovy_6_0_alpha + + ${groovy.6.version} + 17 + 17 + + + + + org.apache.groovy + groovy-bom + ${groovy.version} + pom + import + + + + + + org.apache.groovy + groovy-all + ${groovy.version} + pom + + + org.codehaus.groovy + groovy-test-testng + + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + + + + ${project.basedir}/src/no-spock-test/groovy + + **/*.groovy + + + + + + + @@ -156,23 +343,10 @@ com.google.cloud.functions functions-framework-api - - org.spockframework - spock-core - compile - - - net.bytebuddy - byte-buddy - - - org.objenesis - objenesis - - - org.junit.platform - junit-platform-launcher - + org.apache.ivy ivy diff --git a/functions/groovy-executor/src/main/java/gwc/GFunctionExecutor.java b/functions/groovy-executor/src/main/java/gwc/GFunctionExecutor.java index 924aef6d..d2a89023 100644 --- a/functions/groovy-executor/src/main/java/gwc/GFunctionExecutor.java +++ b/functions/groovy-executor/src/main/java/gwc/GFunctionExecutor.java @@ -11,7 +11,7 @@ import groovy.lang.*; import groovy.util.logging.Log; import gwc.representations.*; -import gwc.spock.*; +import gwc.spi.SpecSupport; import gwc.util.*; import org.codehaus.groovy.control.MultipleCompilationErrorsException; import org.codehaus.groovy.control.messages.*; @@ -28,6 +28,21 @@ public class GFunctionExecutor implements HttpFunction { "gwc."); private static final Gson GSON = new Gson(); + // Resolved once: present on Spock-supporting builds (Groovy 3/4/5), absent on + // builds without Spock (e.g. Groovy 6), where spec/AST requests are unsupported. + private static final Optional SPEC_SUPPORT = loadSpecSupport(); + + private static Optional loadSpecSupport() { + try { + return ServiceLoader.load(SpecSupport.class).findFirst(); + } catch (ServiceConfigurationError e) { + // A malformed or failing SpecSupport provider must not prevent the function + // from starting; plain Groovy scripts work without it. + LOG.warning("Failed to load SpecSupport provider, spec/AST features disabled: " + e); + return Optional.empty(); + } + } + public GFunctionExecutor() { LOG.info("Groovy function executor initialized"); @@ -71,19 +86,26 @@ private void handleRealInvocation(HttpRequest request, HttpResponse response) th OutputRedirector outputRedirector = new OutputRedirector(); Object result = null; ExecutionInfo stats = new ExecutionInfo(); + SPEC_SUPPORT.map(SpecSupport::spockVersion).ifPresent(stats::setSpockVersion); try (var ignore = new MetaClassRegistryGuard(); var ignore2 = outputRedirector.redirect(); - var igonre3 = new SystemPropertiesGuard()) { + var ignore3 = new SystemPropertiesGuard()) { long executionStart = System.currentTimeMillis(); boolean isSpock = SPOCK_SCRIPT.matcher(inputScriptOrClass).find(); if ("ast".equalsIgnoreCase(scriptRequest.getAction())) { - result = transpileScript(inputScriptOrClass, scriptRequest.getAstPhase(), isSpock); - } else { - if (isSpock) { - result = executeSpock(inputScriptOrClass); + if (SPEC_SUPPORT.isPresent()) { + result = SPEC_SUPPORT.get().renderAst(inputScriptOrClass, scriptRequest.getAstPhase(), isSpock); } else { - result = executeGroovyScript(inputScriptOrClass, outputRedirector); + errorOutput.append(featureUnavailable("The AST view is not supported")); } + } else if (isSpock) { + if (SPEC_SUPPORT.isPresent()) { + result = SPEC_SUPPORT.get().runSpec(inputScriptOrClass); + } else { + errorOutput.append(featureUnavailable("Spock specifications are not supported")); + } + } else { + result = executeGroovyScript(inputScriptOrClass, outputRedirector); } stats.setExecutionTime(System.currentTimeMillis() - executionStart); } catch (MultipleCompilationErrorsException e) { @@ -130,15 +152,10 @@ private Object executeGroovyScript(String inputScriptOrClass, OutputRedirector o return shell.evaluate(inputScriptOrClass); } - private Object executeSpock(String inputScriptOrClass) { - ScriptRunner scriptRunner = new ScriptRunner(); - // TODO revisit colored output - scriptRunner.setDisableColors(true); - return scriptRunner.run(inputScriptOrClass); - } - - private String transpileScript(String script, String astPhase, boolean isSpock) { - return new AstRenderer().render(script, astPhase, isSpock); + private static String featureUnavailable(String lead) { + return lead + " on the selected Groovy version (" + GroovySystem.getVersion() + + "), because Spock has no release compatible with this Groovy version yet. " + + "Select a different Groovy version to use Spock; plain Groovy scripts work on all versions."; } private void handleCompilationErrors(StringBuilder errorOutput, MultipleCompilationErrorsException e) { diff --git a/functions/groovy-executor/src/main/java/gwc/representations/ExecutionInfo.java b/functions/groovy-executor/src/main/java/gwc/representations/ExecutionInfo.java index 98e24988..18087a62 100644 --- a/functions/groovy-executor/src/main/java/gwc/representations/ExecutionInfo.java +++ b/functions/groovy-executor/src/main/java/gwc/representations/ExecutionInfo.java @@ -1,13 +1,14 @@ package gwc.representations; import groovy.lang.GroovySystem; -import org.spockframework.util.SpockReleaseInfo; public class ExecutionInfo { private long executionTime = -1; private String groovyVersion = GroovySystem.getVersion(); - private String spockVersion = SpockReleaseInfo.getVersion().toString(); + // Populated by the executor from the Spock provider; "n/a" when Spock is absent + // (e.g. the Groovy 6 build), since spock-core is not on the classpath here. + private String spockVersion = "n/a"; private String javaVersion = System.getProperty("java.version"); diff --git a/functions/groovy-executor/src/main/java/gwc/spi/SpecSupport.java b/functions/groovy-executor/src/main/java/gwc/spi/SpecSupport.java new file mode 100644 index 00000000..09225f8f --- /dev/null +++ b/functions/groovy-executor/src/main/java/gwc/spi/SpecSupport.java @@ -0,0 +1,36 @@ +package gwc.spi; + +/** + * Optional support for executing Spock specifications and rendering their AST. + * + *

The implementation is Spock-dependent and is only compiled into the + * Spock-supporting build variants (Groovy 3/4/5). On variants without Spock + * (e.g. Groovy 6) no provider is registered and {@link java.util.ServiceLoader} + * resolution yields no result, in which case the executor reports that the + * feature is not supported on the running Groovy version. + */ +public interface SpecSupport { + + /** + * Compiles and runs the given script as one or more Spock specifications. + * + * @param code the script source + * @return the rendered specification result tree + */ + Object runSpec(String code); + + /** + * @return the Spock version available on this build, for reporting in execution info + */ + String spockVersion(); + + /** + * Renders the AST of the given script at the requested compile phase. + * + * @param code the script source + * @param phase the compile phase name (case-insensitive) + * @param isSpock whether the script is a Spock specification + * @return the transpiled source + */ + String renderAst(String code, String phase, boolean isSpock); +} diff --git a/functions/groovy-executor/src/no-spock-test/groovy/gwc/GFunctionExecutorFallbackTest.groovy b/functions/groovy-executor/src/no-spock-test/groovy/gwc/GFunctionExecutorFallbackTest.groovy new file mode 100644 index 00000000..9550b730 --- /dev/null +++ b/functions/groovy-executor/src/no-spock-test/groovy/gwc/GFunctionExecutorFallbackTest.groovy @@ -0,0 +1,81 @@ +package gwc + +import com.google.cloud.functions.HttpRequest +import com.google.cloud.functions.HttpResponse +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertNull +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * Exercises {@link GFunctionExecutor} on a build WITHOUT Spock (the Groovy 6 variant). + * Plain Groovy must run normally; Spock specs and the AST view must report that they + * are not supported instead of failing obscurely. + */ +class GFunctionExecutorFallbackTest { + + private final GFunctionExecutor executor = new GFunctionExecutor() + + private Map invoke(Map payload) { + def reader = new BufferedReader(new StringReader(JsonOutput.toJson(payload))) + def output = new StringWriter() + def writer = new BufferedWriter(output) + def request = [ + getMethod : { 'POST' }, + getContentType: { Optional.of('application/json') }, + getReader : { reader }, + ] as HttpRequest + def response = [ + appendHeader : { String name, String value -> }, + setContentType: { String contentType -> }, + setStatusCode : { int code -> }, + getWriter : { writer }, + ] as HttpResponse + + // The executor writes the response and closes the writer (try-with-resources), + // which flushes it into `output`. + executor.service(request, response) + new JsonSlurper().parseText(output.toString()) as Map + } + + @Test + void 'plain Groovy script returns its result'() { + def response = invoke(code: '1 + 1') + assertEquals(2, response.result) + assertEquals('', response.err) + } + + @Test + void 'plain Groovy script output is captured'() { + def response = invoke(code: "print 'Hello World'") + assertEquals('Hello World', response.out) + assertEquals('', response.err) + } + + @Test + void 'Spock specification reports not supported'() { + def response = invoke(code: ''' +class ASpec extends Specification { + def "hello world"() { + expect: true + } +} +''') + assertTrue( + response.err.contains('Spock specifications are not supported'), + "unexpected err: ${response.err}") + assertNull(response.result) + } + + @Test + void 'AST action reports not supported'() { + def response = invoke(code: 'def x = 1', action: 'ast', astPhase: 'CONVERSION') + assertTrue( + response.err.contains('The AST view is not supported'), + "unexpected err: ${response.err}") + assertNull(response.result) + } +} diff --git a/functions/groovy-executor/src/main/java/gwc/spock/AstRenderer.java b/functions/groovy-executor/src/spock/java/gwc/spock/AstRenderer.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/AstRenderer.java rename to functions/groovy-executor/src/spock/java/gwc/spock/AstRenderer.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/ScriptCompiler.java b/functions/groovy-executor/src/spock/java/gwc/spock/ScriptCompiler.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/ScriptCompiler.java rename to functions/groovy-executor/src/spock/java/gwc/spock/ScriptCompiler.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/ScriptRunner.java b/functions/groovy-executor/src/spock/java/gwc/spock/ScriptRunner.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/ScriptRunner.java rename to functions/groovy-executor/src/spock/java/gwc/spock/ScriptRunner.java diff --git a/functions/groovy-executor/src/spock/java/gwc/spock/SpockSpecSupport.java b/functions/groovy-executor/src/spock/java/gwc/spock/SpockSpecSupport.java new file mode 100644 index 00000000..dcb5ddf1 --- /dev/null +++ b/functions/groovy-executor/src/spock/java/gwc/spock/SpockSpecSupport.java @@ -0,0 +1,29 @@ +package gwc.spock; + +import gwc.spi.SpecSupport; +import org.spockframework.util.SpockReleaseInfo; + +/** + * Spock-backed {@link SpecSupport} provider, registered via {@code ServiceLoader}. + * Only present on the Spock-supporting build variants (Groovy 3/4/5). + */ +public class SpockSpecSupport implements SpecSupport { + + @Override + public Object runSpec(String code) { + ScriptRunner scriptRunner = new ScriptRunner(); + // TODO revisit colored output + scriptRunner.setDisableColors(true); + return scriptRunner.run(code); + } + + @Override + public String renderAst(String code, String phase, boolean isSpock) { + return new AstRenderer().render(code, phase, isSpock); + } + + @Override + public String spockVersion() { + return SpockReleaseInfo.getVersion().toString(); + } +} diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/Color.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/Color.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/Color.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/Color.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/Theme.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/Theme.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/Theme.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/Theme.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/TreeNode.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/TreeNode.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/TreeNode.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/TreeNode.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/TreePrinter.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/TreePrinter.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/TreePrinter.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/TreePrinter.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/TreePrintingListener.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/TreePrintingListener.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/TreePrintingListener.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/TreePrintingListener.java diff --git a/functions/groovy-executor/src/main/java/gwc/spock/output/package-info.java b/functions/groovy-executor/src/spock/java/gwc/spock/output/package-info.java similarity index 100% rename from functions/groovy-executor/src/main/java/gwc/spock/output/package-info.java rename to functions/groovy-executor/src/spock/java/gwc/spock/output/package-info.java diff --git a/functions/groovy-executor/src/spock/resources/META-INF/services/gwc.spi.SpecSupport b/functions/groovy-executor/src/spock/resources/META-INF/services/gwc.spi.SpecSupport new file mode 100644 index 00000000..40ef102a --- /dev/null +++ b/functions/groovy-executor/src/spock/resources/META-INF/services/gwc.spi.SpecSupport @@ -0,0 +1 @@ +gwc.spock.SpockSpecSupport diff --git a/functions/pom.xml b/functions/pom.xml index c02b0196..5be8cf8d 100644 --- a/functions/pom.xml +++ b/functions/pom.xml @@ -16,6 +16,7 @@ 3.0.25 4.0.31 5.0.3 + 6.0.0-alpha-1 ${groovy.4.version} 2.4 4.0 diff --git a/services/frontend/src/ts/groovy-console.ts b/services/frontend/src/ts/groovy-console.ts index 1b95c40a..841aa401 100644 --- a/services/frontend/src/ts/groovy-console.ts +++ b/services/frontend/src/ts/groovy-console.ts @@ -7,7 +7,12 @@ export class GroovyVersion { public name: string constructor (public id: string) { - this.name = 'Groovy ' + id.substring('groovy_'.length).replace(/_/g, '.') + const rest = id.substring('groovy_'.length) + // e.g. "6_0_alpha" -> "6.0-alpha", "4_0" -> "4.0"; leave anything unexpected as-is + const match = rest.match(/^(\d+)_(\d+)(?:_(.+))?$/) + this.name = match + ? `Groovy ${match[1]}.${match[2]}${match[3] ? '-' + match[3].replace(/_/g, '-') : ''}` + : 'Groovy ' + rest.replace(/_/g, '.') } }