diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle index fdde671b93..98a5abacd8 100644 --- a/gradle/publishing.gradle +++ b/gradle/publishing.gradle @@ -16,13 +16,15 @@ subprojects { publishing { publications { - mavenJava(MavenPublication) { - afterEvaluate { - plugins.withId('java-platform') { - from components.javaPlatform - } - plugins.withId('java') { - from components.java + if (name != 'temporal-workflowcheck-gradle-plugin') { + mavenJava(MavenPublication) { + afterEvaluate { + plugins.withId('java-platform') { + from components.javaPlatform + } + plugins.withId('java') { + from components.java + } } } } diff --git a/settings.gradle b/settings.gradle index fe80370b0c..ff748dad0a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,4 +14,5 @@ include 'temporal-spring-boot-starter' include 'temporal-remote-data-encoder' include 'temporal-shaded' include 'temporal-workflowcheck' +include 'temporal-workflowcheck-gradle-plugin' include 'temporal-envconfig' diff --git a/temporal-workflowcheck-gradle-plugin/README.md b/temporal-workflowcheck-gradle-plugin/README.md new file mode 100644 index 0000000000..63e1109b81 --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/README.md @@ -0,0 +1,69 @@ +# Temporal WorkflowCheck Gradle Plugin + +A Gradle plugin that runs the [`temporal-workflowcheck`](../temporal-workflowcheck) bytecode analyzer to detect non-deterministic calls in Temporal Workflow code. + +## Usage + +Apply the plugin to a Java project that depends on the Temporal Java SDK: + +```groovy +plugins { + id 'java' + id 'io.temporal.workflowcheck' +} + +dependencies { + implementation 'io.temporal:temporal-sdk:' +} +``` + +The plugin automatically: + +- Detects the Temporal SDK version from `runtimeClasspath`. +- Downloads the matching `io.temporal:temporal-workflowcheck` analyzer. +- Registers a `workflowCheck` verification task. +- Wires `workflowCheck` into the `check` lifecycle. +- Forks a dedicated JVM to run the analysis, isolated from the Gradle daemon. + +## Configuration + +All options are configured through the `workflowCheck` extension: + +```groovy +workflowCheck { + // Source sets to analyze. Default: ['main'] + sourceSets = ['main'] + + // Fail the build on violations. Default: true + failOnViolation = true + + // Show valid workflow methods alongside invalid ones. Default: false + showValid = false + + // Skip the default workflowcheck configuration. Default: false + noDefaultConfig = false + + // Additional .properties config files merged on top of defaults + configFiles.from(file('workflowcheck-overrides.properties')) + + // Override the ASM version used by temporal-workflowcheck, for example for newer JDK support + asmVersion = '9.9.1' + + // Max heap size for the forked JVM. Default: unset (JVM ergonomics) + maxHeapSize = '1g' +} +``` + +## Command-line options + +```bash +# Show valid methods in the output +./gradlew workflowCheck --show-valid + +# Skip the default workflowcheck config +./gradlew workflowCheck --no-default-config +``` + +## Report + +The check report is written to `build/reports/workflowcheck/report.txt`. diff --git a/temporal-workflowcheck-gradle-plugin/build.gradle b/temporal-workflowcheck-gradle-plugin/build.gradle new file mode 100644 index 0000000000..b86b0bdcb6 --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'java-gradle-plugin' +} + +description = 'Temporal Java WorkflowCheck Gradle Plugin' + +gradlePlugin { + plugins { + workflowCheck { + id = 'io.temporal.workflowcheck' + implementationClass = 'io.temporal.workflowcheck.gradle.WorkflowCheckPlugin' + displayName = 'Temporal WorkflowCheck Plugin' + description = 'Runs temporal-workflowcheck bytecode analyzer to detect non-deterministic calls in Temporal workflow code' + } + } +} + +dependencies { + testImplementation gradleTestKit() + testImplementation "junit:junit:${junitVersion}" +} diff --git a/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckExtension.java b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckExtension.java new file mode 100644 index 0000000000..d0ab4281b5 --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckExtension.java @@ -0,0 +1,32 @@ +package io.temporal.workflowcheck.gradle; + +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; + +/** Configuration for the {@code workflowCheck} task. */ +public abstract class WorkflowCheckExtension { + + public static final String NAME = "workflowCheck"; + + /** Source set names to check. Default: {@code ["main"]}. */ + public abstract ListProperty getSourceSets(); + + /** Additional {@code .properties} config files merged on top of defaults. */ + public abstract ConfigurableFileCollection getConfigFiles(); + + /** Fail the build on violations. Default: {@code true}. */ + public abstract Property getFailOnViolation(); + + /** Show valid workflow methods alongside invalid ones. Default: {@code false}. */ + public abstract Property getShowValid(); + + /** Skip the default bundled workflowcheck config. Default: {@code false}. */ + public abstract Property getNoDefaultConfig(); + + /** Override the ASM version used by temporal-workflowcheck. */ + public abstract Property getAsmVersion(); + + /** Max heap size for the forked JVM. Default: unset, which uses JVM ergonomics. */ + public abstract Property getMaxHeapSize(); +} diff --git a/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckPlugin.java b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckPlugin.java new file mode 100644 index 0000000000..8db814867c --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckPlugin.java @@ -0,0 +1,176 @@ +package io.temporal.workflowcheck.gradle; + +import java.util.Collections; +import java.util.stream.Collectors; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.ResolvedDependency; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; + +/** Gradle plugin that runs the temporal-workflowcheck bytecode analyzer. */ +public class WorkflowCheckPlugin implements Plugin { + + public static final String CONFIGURATION_NAME = "workflowcheck"; + + @Override + public void apply(Project project) { + project + .getPluginManager() + .withPlugin( + "java", + appliedPlugin -> { + WorkflowCheckExtension extension = + project + .getExtensions() + .create(WorkflowCheckExtension.NAME, WorkflowCheckExtension.class); + + extension.getSourceSets().convention(Collections.singletonList("main")); + extension.getFailOnViolation().convention(true); + extension.getShowValid().convention(false); + extension.getNoDefaultConfig().convention(false); + + Configuration toolClasspath = + project + .getConfigurations() + .create( + CONFIGURATION_NAME, + configuration -> { + configuration.setCanBeConsumed(false); + configuration.setCanBeResolved(true); + configuration.setVisible(false); + configuration.setDescription( + "Classpath for the temporal-workflowcheck bytecode analyzer"); + }); + + SourceSetContainer sourceSets = + project.getExtensions().getByType(SourceSetContainer.class); + + toolClasspath.defaultDependencies( + dependencies -> { + String sdkVersion = + findTemporalSdkVersion( + project, sourceSets, extension.getSourceSets().get()); + if (sdkVersion != null) { + dependencies.add( + project + .getDependencies() + .create("io.temporal:temporal-workflowcheck:" + sdkVersion)); + } + }); + + toolClasspath + .getResolutionStrategy() + .eachDependency( + details -> { + if ("org.ow2.asm".equals(details.getRequested().getGroup()) + && extension.getAsmVersion().isPresent()) { + details.useVersion(extension.getAsmVersion().get()); + details.because( + "Overridden by workflowCheck.asmVersion for JDK compatibility"); + } + }); + + TaskProvider workflowCheckTask = + project + .getTasks() + .register( + WorkflowCheckTask.TASK_NAME, + WorkflowCheckTask.class, + task -> { + task.setDescription( + "Checks Temporal workflow code for determinism violations"); + task.setGroup("verification"); + + task.getToolClasspath().from(toolClasspath); + task.getClasspath() + .from( + extension + .getSourceSets() + .map( + names -> + names.stream() + .map(sourceSets::getByName) + .map(SourceSet::getRuntimeClasspath) + .collect(Collectors.toList()))); + task.dependsOn( + extension + .getSourceSets() + .map( + names -> + names.stream() + .map(sourceSets::getByName) + .map(SourceSet::getClassesTaskName) + .collect(Collectors.toList()))); + + task.getConfigFiles().from(extension.getConfigFiles()); + task.getFailOnViolation().set(extension.getFailOnViolation()); + task.getShowValid().set(extension.getShowValid()); + task.getNoDefaultConfig().set(extension.getNoDefaultConfig()); + task.getMaxHeapSize().set(extension.getMaxHeapSize()); + task.getReportFile() + .set( + project + .getLayout() + .getBuildDirectory() + .file("reports/workflowcheck/report.txt")); + }); + + project + .getTasks() + .named("check") + .configure(check -> check.dependsOn(workflowCheckTask)); + }); + } + + /** + * Walks resolved runtime dependencies for the configured source sets to find {@code + * io.temporal:temporal-sdk} and returns its version. + */ + static String findTemporalSdkVersion( + Project project, SourceSetContainer sourceSets, Iterable sourceSetNames) { + for (String sourceSetName : sourceSetNames) { + try { + SourceSet sourceSet = sourceSets.getByName(sourceSetName); + Configuration runtimeClasspath = + project.getConfigurations().getByName(sourceSet.getRuntimeClasspathConfigurationName()); + for (ResolvedDependency dependency : + runtimeClasspath.getResolvedConfiguration().getFirstLevelModuleDependencies()) { + String version = findSdkVersionRecursive(dependency); + if (version != null) { + return version; + } + } + } catch (Exception e) { + project + .getLogger() + .warn( + "WorkflowCheck: Failed to resolve Temporal SDK version from source set {}: {}", + sourceSetName, + e.getMessage()); + } + } + project + .getLogger() + .warn( + "WorkflowCheck: No io.temporal:temporal-sdk found on configured source set runtime classpaths. Task will be skipped."); + return null; + } + + private static String findSdkVersionRecursive(ResolvedDependency dependency) { + ModuleVersionIdentifier id = dependency.getModule().getId(); + if ("io.temporal".equals(id.getGroup()) && "temporal-sdk".equals(id.getName())) { + return id.getVersion(); + } + for (ResolvedDependency child : dependency.getChildren()) { + String found = findSdkVersionRecursive(child); + if (found != null) { + return found; + } + } + return null; + } +} diff --git a/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckTask.java b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckTask.java new file mode 100644 index 0000000000..e6a612f6d0 --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/src/main/java/io/temporal/workflowcheck/gradle/WorkflowCheckTask.java @@ -0,0 +1,179 @@ +package io.temporal.workflowcheck.gradle; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.options.Option; +import org.gradle.process.ExecOperations; +import org.gradle.process.ExecResult; + +/** Runs the temporal-workflowcheck command line analyzer in an isolated JVM. */ +@CacheableTask +public abstract class WorkflowCheckTask extends DefaultTask { + + public static final String TASK_NAME = "workflowCheck"; + + /** The consumer's runtime classpath to analyze. */ + @InputFiles + @Classpath + public abstract ConfigurableFileCollection getClasspath(); + + /** The temporal-workflowcheck tool classpath. */ + @InputFiles + @Classpath + public abstract ConfigurableFileCollection getToolClasspath(); + + /** Additional {@code .properties} config files. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + @Optional + public abstract ConfigurableFileCollection getConfigFiles(); + + @Input + public abstract Property getFailOnViolation(); + + @Input + public abstract Property getShowValid(); + + @Input + public abstract Property getNoDefaultConfig(); + + @Input + @Optional + public abstract Property getMaxHeapSize(); + + @OutputFile + public abstract RegularFileProperty getReportFile(); + + @Option(option = "show-valid", description = "Show valid workflow methods alongside invalid ones") + public void setShowValidOption(boolean value) { + getShowValid().set(value); + } + + @Option( + option = "no-default-config", + description = "Skip the default bundled workflowcheck config") + public void setNoDefaultConfigOption(boolean value) { + getNoDefaultConfig().set(value); + } + + private final ExecOperations execOperations; + + @Inject + public WorkflowCheckTask(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @TaskAction + public void check() { + if (getToolClasspath().isEmpty()) { + getLogger().warn("WorkflowCheck: Tool classpath is empty. Skipping."); + return; + } + + List classpathFiles = new ArrayList<>(getClasspath().getFiles()); + String maxHeap = getMaxHeapSize().getOrNull(); + getLogger() + .lifecycle( + "WorkflowCheck: scanning {} classpath entries (maxHeapSize={})", + classpathFiles.size(), + maxHeap != null ? maxHeap : "default"); + for (File file : classpathFiles) { + getLogger().lifecycle(" {}", file.getAbsolutePath()); + } + + try { + Path temporaryDirectory = getTemporaryDir().toPath(); + Path classpathFile = temporaryDirectory.resolve("classpath.txt"); + String classpathString = joinClasspath(classpathFiles); + Files.write(classpathFile, classpathString.getBytes(StandardCharsets.UTF_8)); + + List args = new ArrayList<>(); + args.add("check"); + + if (getNoDefaultConfig().get()) { + args.add("--no-default-config"); + } + + for (File configFile : getConfigFiles().getFiles()) { + args.add("--config"); + args.add(configFile.getAbsolutePath()); + } + + if (getShowValid().get()) { + args.add("--show-valid"); + } + + args.add("@" + classpathFile.toAbsolutePath()); + + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ExecResult result = + execOperations.javaexec( + spec -> { + spec.getMainClass().set("io.temporal.workflowcheck.Main"); + spec.classpath(getToolClasspath()); + spec.args(args); + if (maxHeap != null) { + spec.setMaxHeapSize(maxHeap); + } + spec.setStandardOutput(stdout); + spec.setIgnoreExitValue(true); + }); + + String output = stdout.toString(StandardCharsets.UTF_8.name()); + File reportFile = getReportFile().getAsFile().get(); + reportFile.getParentFile().mkdirs(); + Files.write(reportFile.toPath(), output.getBytes(StandardCharsets.UTF_8)); + + if (!output.trim().isEmpty()) { + getLogger().lifecycle(stripTrailing(output)); + } + + if (result.getExitValue() != 0 && getFailOnViolation().get()) { + throw new GradleException( + "Workflow determinism violations found. See report: " + reportFile); + } + } catch (IOException e) { + throw new GradleException("WorkflowCheck failed", e); + } + } + + private static String joinClasspath(List files) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < files.size(); i++) { + if (i > 0) { + builder.append(File.pathSeparatorChar); + } + builder.append(files.get(i).getAbsolutePath()); + } + return builder.toString(); + } + + private static String stripTrailing(String value) { + int end = value.length(); + while (end > 0 && Character.isWhitespace(value.charAt(end - 1))) { + end--; + } + return value.substring(0, end); + } +} diff --git a/temporal-workflowcheck-gradle-plugin/src/test/java/io/temporal/workflowcheck/gradle/WorkflowCheckPluginTest.java b/temporal-workflowcheck-gradle-plugin/src/test/java/io/temporal/workflowcheck/gradle/WorkflowCheckPluginTest.java new file mode 100644 index 0000000000..1067fe94e9 --- /dev/null +++ b/temporal-workflowcheck-gradle-plugin/src/test/java/io/temporal/workflowcheck/gradle/WorkflowCheckPluginTest.java @@ -0,0 +1,240 @@ +package io.temporal.workflowcheck.gradle; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class WorkflowCheckPluginTest { + + private static final String TEMPORAL_SDK_VERSION = "1.36.0"; + + @Rule public final TemporaryFolder testProjectDir = new TemporaryFolder(); + + private File buildFile; + + @Before + public void setUp() throws IOException { + write("settings.gradle", "rootProject.name = 'workflowcheck-plugin-test'\n"); + buildFile = testProjectDir.newFile("build.gradle"); + writeBuildFile(""); + } + + @Test + public void passesForValidWorkflow() throws IOException { + writeValidWorkflow(); + + BuildResult result = run("workflowCheck"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + } + + @Test + public void failsForDeterminismViolation() throws IOException { + writeWorkflowWithThreadSleep(); + + BuildResult result = runAndFail("workflowCheck"); + + assertEquals(TaskOutcome.FAILED, result.task(":workflowCheck").getOutcome()); + assertTrue(result.getOutput().contains("invalid member access")); + assertTrue(result.getOutput().contains("determinism violations found")); + } + + @Test + public void reportsViolationsButSucceedsWhenFailOnViolationIsFalse() throws IOException { + writeBuildFile("workflowCheck {\n failOnViolation = false\n}\n"); + writeWorkflowWithThreadSleep(); + + BuildResult result = run("workflowCheck"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + assertTrue(result.getOutput().contains("invalid member access")); + } + + @Test + public void showValidOptionIncludesValidMethodsInOutput() throws IOException { + writeValidWorkflow(); + + BuildResult result = run("workflowCheck", "--show-valid"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + assertTrue(result.getOutput().contains("is valid")); + } + + @Test + public void customConfigFileIsRespected() throws IOException { + writeBuildFile( + "workflowCheck {\n" + " configFiles.from('check/overrides.properties')\n" + "}\n"); + write( + "check/overrides.properties", + "temporal.workflowcheck.invalid.java/lang/Thread.sleep=false\n"); + writeWorkflowWithThreadSleep(); + + BuildResult result = run("workflowCheck"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + } + + @Test + public void checkLifecycleRunsWorkflowCheck() throws IOException { + writeValidWorkflow(); + + BuildResult result = run("check"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + } + + @Test + public void resolvesAnalyzerFromConfiguredSourceSetRuntimeClasspath() throws IOException { + writeBuildFile( + " testImplementation 'io.temporal:temporal-sdk:" + TEMPORAL_SDK_VERSION + "'\n", + "workflowCheck {\n sourceSets = ['test']\n}\n"); + writeTestWorkflow(); + + BuildResult result = run("workflowCheck"); + + assertEquals(TaskOutcome.SUCCESS, result.task(":workflowCheck").getOutcome()); + assertTrue(result.getOutput().contains("Found 1 class(es) with workflow methods")); + } + + @Test + public void taskIsUpToDateOnSecondRun() throws IOException { + writeValidWorkflow(); + + BuildResult firstResult = run("workflowCheck"); + BuildResult secondResult = run("workflowCheck"); + + assertEquals(TaskOutcome.SUCCESS, firstResult.task(":workflowCheck").getOutcome()); + assertEquals(TaskOutcome.UP_TO_DATE, secondResult.task(":workflowCheck").getOutcome()); + } + + private BuildResult run(String... arguments) { + return runner(arguments).build(); + } + + private BuildResult runAndFail(String... arguments) { + return runner(arguments).buildAndFail(); + } + + private GradleRunner runner(String... arguments) { + return GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments(arguments) + .forwardOutput(); + } + + private void writeBuildFile(String extraConfiguration) throws IOException { + writeBuildFile( + " implementation 'io.temporal:temporal-sdk:" + TEMPORAL_SDK_VERSION + "'\n", + extraConfiguration); + } + + private void writeBuildFile(String dependencies, String extraConfiguration) throws IOException { + write( + buildFile, + "plugins {\n" + + " id 'java'\n" + + " id 'io.temporal.workflowcheck'\n" + + "}\n" + + "\n" + + "repositories {\n" + + " mavenCentral()\n" + + "}\n" + + "\n" + + "dependencies {\n" + + dependencies + + "}\n" + + "\n" + + extraConfiguration); + } + + private void writeValidWorkflow() throws IOException { + writeWorkflowInterface(); + write( + "src/main/java/com/example/MyWorkflowImpl.java", + "package com.example;\n" + + "\n" + + "public class MyWorkflowImpl implements MyWorkflow {\n" + + " @Override\n" + + " public String run(String input) {\n" + + " return \"Hello, \" + input;\n" + + " }\n" + + "}\n"); + } + + private void writeWorkflowWithThreadSleep() throws IOException { + writeWorkflowInterface(); + write( + "src/main/java/com/example/MyWorkflowImpl.java", + "package com.example;\n" + + "\n" + + "public class MyWorkflowImpl implements MyWorkflow {\n" + + " @Override\n" + + " public String run(String input) {\n" + + " try {\n" + + " Thread.sleep(1000);\n" + + " } catch (InterruptedException e) {\n" + + " // Ignore for test.\n" + + " }\n" + + " return \"Hello, \" + input;\n" + + " }\n" + + "}\n"); + } + + private void writeWorkflowInterface() throws IOException { + writeWorkflowInterface("src/main/java/com/example/MyWorkflow.java"); + } + + private void writeTestWorkflow() throws IOException { + writeWorkflowInterface("src/test/java/com/example/MyWorkflow.java"); + write( + "src/test/java/com/example/MyWorkflowImpl.java", + "package com.example;\n" + + "\n" + + "public class MyWorkflowImpl implements MyWorkflow {\n" + + " @Override\n" + + " public String run(String input) {\n" + + " return \"Hello, \" + input;\n" + + " }\n" + + "}\n"); + } + + private void writeWorkflowInterface(String relativePath) throws IOException { + write( + relativePath, + "package com.example;\n" + + "\n" + + "import io.temporal.workflow.WorkflowInterface;\n" + + "import io.temporal.workflow.WorkflowMethod;\n" + + "\n" + + "@WorkflowInterface\n" + + "public interface MyWorkflow {\n" + + " @WorkflowMethod\n" + + " String run(String input);\n" + + "}\n"); + } + + private void write(String relativePath, String content) throws IOException { + File file = new File(testProjectDir.getRoot(), relativePath); + write(file, content); + } + + private void write(File file, String content) throws IOException { + File parentFile = file.getParentFile(); + if (parentFile != null) { + parentFile.mkdirs(); + } + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } +}