diff --git a/AGENTS.md b/AGENTS.md index 5256069a..1e23d10f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,9 @@ This is a **multi-module** plugin with 19 submodules: **plugin-script-php:** - `io.kestra.plugin.scripts.php.Commands` +- `io.kestra.plugin.scripts.php.CommandsTrigger` - `io.kestra.plugin.scripts.php.Script` +- `io.kestra.plugin.scripts.php.ScriptTrigger` **plugin-script-powershell:** diff --git a/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/CommandsTrigger.java b/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/CommandsTrigger.java new file mode 100644 index 00000000..70a0f385 --- /dev/null +++ b/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/CommandsTrigger.java @@ -0,0 +1,280 @@ +package io.kestra.plugin.scripts.php; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.annotations.PluginProperty; +import io.kestra.core.models.conditions.ConditionContext; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTaskException; +import io.kestra.core.models.tasks.runners.TaskException; +import io.kestra.core.models.triggers.AbstractTrigger; +import io.kestra.core.models.triggers.PollingTriggerInterface; +import io.kestra.core.models.triggers.TriggerContext; +import io.kestra.core.models.triggers.TriggerOutput; +import io.kestra.core.models.triggers.TriggerService; +import io.kestra.core.runners.RunContext; +import io.kestra.core.storages.kv.KVStore; +import io.kestra.core.storages.kv.KVValueAndMetadata; +import io.kestra.plugin.scripts.exec.ExitConditionRegex; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger a flow when PHP commands match a condition", + description = """ + Polls by running PHP commands in a script container (default image php) and emits when exitCondition matches. \ + Edge mode (the default) emits only on a transition from not matching to matching, remembering the previous result in the namespace KV store. \ + Polls every 60s by default. Accepts 'exit N' or a regex (fallback substring) matched against emitted vars; a failed run has no vars, so only 'exit N' can match a failure. \ + Only containerImage, commands, interval, edge, and exitCondition are configurable here; the Commands task's taskRunner, env, beforeCommands, inputFiles, and namespaceFiles are not exposed on this trigger. + """ +) +@Plugin( + examples = { + @Example( + title = "Trigger when commands fail with an implicit error (exit 1).", + full = true, + code = """ + id: php_commands_trigger + namespace: company.team + + triggers: + - id: commands_failure + type: io.kestra.plugin.scripts.php.CommandsTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: php + commands: + - php -r 'exit(1);' + + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" + """ + ) + } +) +public class CommandsTrigger extends AbstractTrigger + implements PollingTriggerInterface, TriggerOutput { + + private static final String DEFAULT_IMAGE = "php"; + private static final Pattern EXIT_CONDITION_PATTERN = Pattern.compile("^\\s*exit\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE); + + @Schema( + title = "Docker image used to execute the commands", + description = """ + Container image used by the underlying Commands task to run PHP commands. + Defaults to 'php'. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); + + @Schema( + title = "PHP commands to execute", + description = "Commands executed on each poll (same semantics as the PHP Commands task)." + ) + @NotNull + @PluginProperty(group = "main") + protected Property> commands; + + @Schema( + title = "Condition to match", + description = """ + Condition evaluated after each commands execution. The trigger emits an event only when this condition matches. + + Supported forms: + - 'exit N' (example: 'exit 1'): matches when the process exit code equals N. + - Any other string: treated as a regex (or substring if regex is invalid) matched against the + task's 'vars' (when commands emit ::{"outputs":...}::). On a failed run no vars are available + to match against, so only an 'exit N' condition can match a failure. + """ + ) + @NotNull + @PluginProperty(group = "main") + protected Property exitCondition; + + @Schema( + title = "Check interval", + description = "Interval between polling evaluations." + ) + @Builder.Default + @PluginProperty(group = "execution") + private final Duration interval = Duration.ofSeconds(60); + + @Schema( + title = "Edge trigger mode", + description = """ + If true (default), the trigger emits only on a transition from 'not matching' to 'matching' \ + (anti-spam). The previous result is kept in the namespace KV store, keyed by flow and trigger id. \ + If false, the trigger emits on every poll where the condition matches. + """ + ) + @Builder.Default + @PluginProperty(group = "advanced") + protected Property edge = Property.ofValue(true); + + @Override + public Optional evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception { + RunContext runContext = conditionContext.getRunContext(); + boolean renderedEdge = runContext.render(this.edge).as(Boolean.class).orElse(true); + String renderedExitCondition = renderExitCondition(runContext); + Output out = runOnce(runContext, renderedExitCondition); + + boolean matched = matchesCondition(out); + + boolean emit = shouldEmit(runContext, context, renderedEdge, matched); + + if (!emit) { + return Optional.empty(); + } + + return Optional.of(TriggerService.generateExecution(this, conditionContext, context, out)); + } + + boolean shouldEmit(RunContext runContext, TriggerContext context, boolean edge, boolean matched) throws Exception { + if (!edge) { + return matched; + } + + KVStore kvStore = runContext.namespaceKv(context.getNamespace()); + String key = edgeStateKey(context); + + boolean previouslyMatched = kvStore.getValue(key) + .map(value -> Boolean.parseBoolean(String.valueOf(value.value()))) + .orElse(false); + + if (matched != previouslyMatched) { + kvStore.put(key, new KVValueAndMetadata(null, matched)); + } + + return matched && !previouslyMatched; + } + + static String edgeStateKey(TriggerContext context) { + return "trigger-edge-" + context.getFlowId().length() + "-" + context.getFlowId() + "-" + context.getTriggerId(); + } + + private String renderExitCondition(RunContext runContext) throws IllegalVariableEvaluationException { + return ScriptTrigger.renderExitCondition(runContext, this.exitCondition); + } + + private Output runOnce(RunContext runContext, String renderedExitCondition) throws Exception { + Commands task = Commands.builder() + .id(this.getId()) + .type(Commands.class.getName()) + .containerImage(this.containerImage) + .commands(this.commands) + .build(); + + try { + ScriptOutput taskOutput = task.run(TriggerRunContext.forEmbeddedTask(runContext, task)); + + return new Output(Instant.now(), renderedExitCondition, taskOutput.getExitCode(), taskOutput.getVars()); + } catch (RunnableTaskException e) { + ExtractedFailure failure = extractFailure(e); + return new Output(Instant.now(), renderedExitCondition, failure.exitCode, null); + } + } + + boolean matchesCondition(Output out) { + String cond = out.getCondition() == null ? "" : out.getCondition().trim(); + + Matcher exitMatcher = EXIT_CONDITION_PATTERN.matcher(cond); + if (exitMatcher.matches()) { + try { + long expected = Long.parseLong(exitMatcher.group(1)); + return out.getExitCode() != null && out.getExitCode() == expected; + } catch (NumberFormatException e) { + return false; + } + } + + String haystack = buildHaystack(out); + if (haystack.isEmpty() || cond.isEmpty()) { + return false; + } + + return ExitConditionRegex.find(cond, haystack); + } + + private String buildHaystack(Output out) { + if (out.getVars() == null || out.getVars().isEmpty()) { + return ""; + } + return out.getVars().toString(); + } + + private record ExtractedFailure(Integer exitCode) { + } + + private ExtractedFailure extractFailure(RunnableTaskException e) { + Integer exitCode = null; + + Throwable cur = e.getCause(); + while (cur != null) { + if (cur instanceof TaskException te) { + exitCode = te.getExitCode(); + break; + } + cur = cur.getCause(); + } + + return new ExtractedFailure(exitCode); + } + + @Data + @AllArgsConstructor + public static class Output implements io.kestra.core.models.tasks.Output { + @Schema(title = "Timestamp of the event that fired the trigger") + private Instant timestamp; + + @Schema( + title = "Rendered condition", + description = "Rendered value of the exitCondition property for this poll." + ) + private String condition; + + @Schema( + title = "Commands exit code", + description = "Exit code returned by the PHP process (may be null if not available)." + ) + private Integer exitCode; + + @Schema( + title = "Commands vars", + description = """ + Vars produced by the task (e.g. via ::{"outputs":{...}}:: convention). May contain sensitive values \ + from command output; treat trigger payloads accordingly. + """ + ) + private Map vars; + } +} diff --git a/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/ScriptTrigger.java b/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/ScriptTrigger.java new file mode 100644 index 00000000..20fc1a48 --- /dev/null +++ b/plugin-script-php/src/main/java/io/kestra/plugin/scripts/php/ScriptTrigger.java @@ -0,0 +1,289 @@ +package io.kestra.plugin.scripts.php; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.annotations.PluginProperty; +import io.kestra.core.models.conditions.ConditionContext; +import io.kestra.core.models.enums.MonacoLanguages; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTaskException; +import io.kestra.core.models.tasks.runners.TaskException; +import io.kestra.core.models.triggers.AbstractTrigger; +import io.kestra.core.models.triggers.PollingTriggerInterface; +import io.kestra.core.models.triggers.TriggerContext; +import io.kestra.core.models.triggers.TriggerOutput; +import io.kestra.core.models.triggers.TriggerService; +import io.kestra.core.runners.RunContext; +import io.kestra.core.storages.kv.KVStore; +import io.kestra.core.storages.kv.KVValueAndMetadata; +import io.kestra.plugin.scripts.exec.ExitConditionRegex; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger on PHP script condition", + description = """ + Polls by running an inline PHP script in a container (default image php) and emits when exitCondition matches. \ + Edge mode (the default) emits only on a transition from not matching to matching, remembering the previous result in the namespace KV store. \ + Polls every 60s by default. Accepts 'exit N' or a regex (fallback substring) matched against emitted vars; a failed run has no vars, so only 'exit N' can match a failure. \ + Only containerImage, script, interval, edge, and exitCondition are configurable here; the Script task's taskRunner, env, beforeCommands, inputFiles, and namespaceFiles are not exposed on this trigger. + """ +) +@Plugin( + examples = { + @Example( + title = "Trigger when the script fails with an implicit error (exit 1).", + full = true, + code = """ + id: php_script_trigger + namespace: company.team + + triggers: + - id: script_failure + type: io.kestra.plugin.scripts.php.ScriptTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: php + script: | + { + + private static final String DEFAULT_IMAGE = "php"; + private static final Pattern EXIT_CONDITION_PATTERN = Pattern.compile("^\\s*exit\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE); + + @Schema( + title = "Container image for script execution", + description = """ + Image used by the Script task to run the inline PHP script; defaults to 'php'. + Provide an image that includes the PHP runtime and any required extensions. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); + + @Schema( + title = "Inline PHP script", + description = """ + Multi-line script executed on each poll, with the same semantics as the PHP Script task. + """ + ) + @NotNull + @PluginProperty(language = MonacoLanguages.PHP, group = "main") + protected Property script; + + @Schema( + title = "Condition to match", + description = """ + Rendered condition evaluated after each execution; the trigger emits only when it matches. + 'exit N' compares the exit code, otherwise the string is used as a regex (or substring fallback) \ + against emitted vars (from ::{"outputs":...}::). On a failed run no vars are available to match \ + against, so only an 'exit N' condition can match a failure. + """ + ) + @NotNull + @PluginProperty(group = "main") + protected Property exitCondition; + + @Schema( + title = "Check interval", + description = """ + Interval between polls; default PT60S. The scheduler uses this to schedule the next evaluation. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + private final Duration interval = Duration.ofSeconds(60); + + @Schema( + title = "Edge trigger mode", + description = """ + When true (default), emit only on a transition from not matching to matching, so a condition that \ + stays true does not fire on every poll. The previous result is kept in the namespace KV store, keyed \ + by flow and trigger id. When false, emit on every poll that matches. + """ + ) + @Builder.Default + @PluginProperty(group = "advanced") + protected Property edge = Property.ofValue(true); + + @Override + public Optional evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception { + RunContext runContext = conditionContext.getRunContext(); + boolean renderedEdge = runContext.render(this.edge).as(Boolean.class).orElse(true); + String renderedExitCondition = renderExitCondition(runContext); + Output out = runOnce(runContext, renderedExitCondition); + + boolean matched = matchesCondition(out); + + boolean emit = shouldEmit(runContext, context, renderedEdge, matched); + + if (!emit) { + return Optional.empty(); + } + + return Optional.of(TriggerService.generateExecution(this, conditionContext, context, out)); + } + + boolean shouldEmit(RunContext runContext, TriggerContext context, boolean edge, boolean matched) throws Exception { + if (!edge) { + return matched; + } + + KVStore kvStore = runContext.namespaceKv(context.getNamespace()); + String key = edgeStateKey(context); + + boolean previouslyMatched = kvStore.getValue(key) + .map(value -> Boolean.parseBoolean(String.valueOf(value.value()))) + .orElse(false); + + if (matched != previouslyMatched) { + kvStore.put(key, new KVValueAndMetadata(null, matched)); + } + + return matched && !previouslyMatched; + } + + static String edgeStateKey(TriggerContext context) { + return "trigger-edge-" + context.getFlowId().length() + "-" + context.getFlowId() + "-" + context.getTriggerId(); + } + + static String renderExitCondition(RunContext runContext, Property exitCondition) throws IllegalVariableEvaluationException { + return runContext.render(exitCondition).as(String.class) + .filter(condition -> !condition.isBlank()) + .orElseThrow(() -> new IllegalArgumentException("exitCondition must render to a non-empty value")); + } + + private String renderExitCondition(RunContext runContext) throws IllegalVariableEvaluationException { + return renderExitCondition(runContext, this.exitCondition); + } + + private Output runOnce(RunContext runContext, String renderedExitCondition) throws Exception { + Script task = Script.builder() + .id(this.getId()) + .type(Script.class.getName()) + .containerImage(this.containerImage) + .script(this.script) + .build(); + + try { + ScriptOutput taskOutput = task.run(TriggerRunContext.forEmbeddedTask(runContext, task)); + + return new Output(Instant.now(), renderedExitCondition, taskOutput.getExitCode(), taskOutput.getVars()); + } catch (RunnableTaskException e) { + ExtractedFailure failure = extractFailure(e); + return new Output(Instant.now(), renderedExitCondition, failure.exitCode, null); + } + } + + boolean matchesCondition(Output out) { + String cond = out.getCondition() == null ? "" : out.getCondition().trim(); + + Matcher exitMatcher = EXIT_CONDITION_PATTERN.matcher(cond); + if (exitMatcher.matches()) { + try { + long expected = Long.parseLong(exitMatcher.group(1)); + return out.getExitCode() != null && out.getExitCode() == expected; + } catch (NumberFormatException e) { + return false; + } + } + + String haystack = buildHaystack(out); + if (haystack.isEmpty() || cond.isEmpty()) { + return false; + } + + return ExitConditionRegex.find(cond, haystack); + } + + private String buildHaystack(Output out) { + if (out.getVars() == null || out.getVars().isEmpty()) { + return ""; + } + return out.getVars().toString(); + } + + private record ExtractedFailure(Integer exitCode) { + } + + private ExtractedFailure extractFailure(RunnableTaskException e) { + Integer exitCode = null; + + Throwable cur = e.getCause(); + while (cur != null) { + if (cur instanceof TaskException te) { + exitCode = te.getExitCode(); + break; + } + cur = cur.getCause(); + } + + return new ExtractedFailure(exitCode); + } + + @Data + @AllArgsConstructor + public static class Output implements io.kestra.core.models.tasks.Output { + @Schema(title = "Timestamp of the event that fired the trigger") + private Instant timestamp; + + @Schema( + title = "Rendered condition", + description = "Rendered value of the exitCondition property for this poll." + ) + private String condition; + + @Schema( + title = "Script exit code", + description = "Exit code returned by the PHP process (may be null if not available)." + ) + private Integer exitCode; + + @Schema( + title = "Script vars", + description = """ + Vars produced by the task (e.g. via ::{"outputs":{...}}:: convention). May contain sensitive values \ + from script output; treat trigger payloads accordingly. This is the main structured way to evaluate \ + non-exit conditions on successful runs. + """ + ) + private Map vars; + } +} diff --git a/plugin-script-php/src/main/resources/doc/io.kestra.plugin.scripts.php.md b/plugin-script-php/src/main/resources/doc/io.kestra.plugin.scripts.php.md index d3739090..d61cdfe5 100644 --- a/plugin-script-php/src/main/resources/doc/io.kestra.plugin.scripts.php.md +++ b/plugin-script-php/src/main/resources/doc/io.kestra.plugin.scripts.php.md @@ -4,10 +4,31 @@ Run PHP scripts inside a container as flow steps. ## Common properties -`containerImage` defaults to a PHP image. `taskRunner` defaults to Docker and can be overridden for other execution environments. +`containerImage` defaults to the `php` image on `Script` and `Commands` tasks. `taskRunner` defaults to Docker on those tasks and can be overridden for other execution environments. The polling triggers below do not expose `taskRunner`, `env`, `beforeCommands`, `inputFiles`, or `namespaceFiles`; only `containerImage`, the script or commands, `interval`, `edge`, and `exitCondition` are configurable on the trigger. ## Tasks `Script` runs inline PHP code defined in the `script` property — best for short, flow-specific logic. `Commands` runs shell commands (e.g., `php main.php`) against script files; use it when your code lives in [namespace files](https://kestra.io/docs/concepts/namespace-files) shared across flows, or is cloned from a Git repository with a preceding `Clone` task. Manage dependencies with Composer — include a `composer.json` via namespace files and run `composer install` in `beforeCommands`. For projects without Composer dependencies, a plain PHP image is sufficient. + +## Triggers + +### ScriptTrigger + +Polls on an interval by running an inline PHP script the same way the `Script` task does, and starts an execution when `exitCondition` matches. The script runs in a fresh container on every poll, so keep it quick. + +Required properties: +- `script`: inline PHP script body +- `exitCondition`: either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` + +Optional: +- `interval`: time between polls, defaults to `PT60S` +- `edge`: defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll +- `containerImage`: defaults to `php` + +The trigger outputs are available as `{{ trigger.timestamp }}`, `{{ trigger.condition }}`, `{{ trigger.exitCode }}` and `{{ trigger.vars }}`. A failed run has no vars, so only an `exit N` condition can match a failure. + +### CommandsTrigger + +Same behavior as `ScriptTrigger`, but runs a list of shell commands the way the `Commands` task does. Required properties are `commands` and `exitCondition`; `interval`, `edge` and `containerImage` work as above. diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerConditionTest.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerConditionTest.java new file mode 100644 index 00000000..c88eb804 --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerConditionTest.java @@ -0,0 +1,50 @@ +package io.kestra.plugin.scripts.php; + +import io.kestra.plugin.scripts.exec.ExitConditionRegex; + +import java.time.Instant; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +class CommandsTriggerConditionTest { + + private final CommandsTrigger trigger = CommandsTrigger.builder().build(); + + private CommandsTrigger.Output output(String condition, Integer exitCode, Map vars) { + return new CommandsTrigger.Output(Instant.now(), condition, exitCode, vars); + } + + @ParameterizedTest + @CsvSource({ + "exit 0, 0, true", + "exit 1, 1, true", + "exit 1, 0, false", + }) + void exitCodeCondition(String condition, int exitCode, boolean expected) { + assertThat(trigger.matchesCondition(output(condition, exitCode, null)), is(expected)); + } + + @Test + void regexCondition_delegatesToHelperAndReturnsItsResult() { + String condition = "status=\\w+"; + String haystack = "{status=status=ready}"; + var out = output(condition, 0, Map.of("status", "status=ready")); + + try (var helper = mockStatic(ExitConditionRegex.class)) { + helper.when(() -> ExitConditionRegex.find(condition, haystack)).thenReturn(true); + + assertThat(trigger.matchesCondition(out), is(true)); + + helper.verify(() -> ExitConditionRegex.find(condition, haystack), times(1)); + helper.verifyNoMoreInteractions(); + } + } +} diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerTest.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerTest.java new file mode 100644 index 00000000..8e855ad4 --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/CommandsTriggerTest.java @@ -0,0 +1,121 @@ +package io.kestra.plugin.scripts.php; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.serializers.JacksonMapper; +import io.kestra.core.utils.IdUtils; +import io.kestra.core.utils.TestsUtils; + +import jakarta.inject.Inject; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@KestraTest +class CommandsTriggerTest { + @Inject + private RunContextFactory runContextFactory; + + @Test + void commandsTrigger_shouldTriggerOnImplicitFailureExit1() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + CommandsTrigger trigger = CommandsTrigger.builder() + .id("commands-trigger-" + IdUtils.create()) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue("php")) + .commands(Property.ofValue(List.of("php -r 'exit(1);'"))) + .build(); + + var context = TestsUtils.mockTrigger(runContextFactory, trigger); + Optional execution = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat(execution.isPresent(), is(true)); + + Map triggerVars = execution.get().getTrigger().getVariables(); + assertThat("condition should be present", triggerVars.get("condition"), is("exit 1")); + assertThat("exitCode should be present", triggerVars.get("exitCode"), notNullValue()); + assertThat("exitCode should be 1", triggerVars.get("exitCode"), is(1)); + assertThat("timestamp should be present", triggerVars.get("timestamp"), notNullValue()); + } + + @Test + void commandsTrigger_shouldTriggerOnStdoutMatchUsingStructuredOutputs() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + CommandsTrigger trigger = CommandsTrigger.builder() + .id("commands-stdout-match-trigger-" + IdUtils.create()) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("toto")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue("php")) + .commands(Property.ofValue(List.of("echo '::{\"outputs\":{\"listing\":\"toto\"}}::'"))) + .build(); + + var context = TestsUtils.mockTrigger(runContextFactory, trigger); + Optional execution = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat(execution.isPresent(), is(true)); + + Map triggerVars = execution.get().getTrigger().getVariables(); + assertThat("condition should be present", triggerVars.get("condition"), is("toto")); + assertThat("exitCode should be present", triggerVars.get("exitCode"), notNullValue()); + assertThat("exitCode should be 0", triggerVars.get("exitCode"), is(0)); + assertThat("timestamp should be present", triggerVars.get("timestamp"), notNullValue()); + assertThat("vars should be present", triggerVars.get("vars"), notNullValue()); + } + + @Test + void commandsTrigger_edgeModeShouldSuppressSecondEmission() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + CommandsTrigger trigger = CommandsTrigger.builder() + .id("commands-edge-trigger-" + IdUtils.create()) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue("php")) + .commands(Property.ofValue(List.of("php -r 'exit(1);'"))) + .build(); + + var context = TestsUtils.mockTrigger(runContextFactory, trigger); + Optional first = trigger.evaluate(context.getKey(), context.getValue()); + assertThat("First evaluation should fire", first.isPresent(), is(true)); + + CommandsTrigger nextPoll = JacksonMapper.ofJson().readValue(JacksonMapper.ofJson().writeValueAsString(trigger), CommandsTrigger.class); + context = TestsUtils.mockTrigger(runContextFactory, nextPoll); + Optional second = nextPoll.evaluate(context.getKey(), context.getValue()); + assertThat("Edge mode should suppress repeated emission", second.isPresent(), is(false)); + } + + @Test + void commandsTrigger_shouldMatchRegexAgainstStructuredOutputs() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + CommandsTrigger trigger = CommandsTrigger.builder() + .id("commands-regex-trigger-" + IdUtils.create()) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("status=\\w+")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue("php")) + .commands(Property.ofValue(List.of("echo '::{\"outputs\":{\"status\":\"status=ready\"}}::'"))) + .build(); + + var context = TestsUtils.mockTrigger(runContextFactory, trigger); + Optional execution = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat("Regex condition should match", execution.isPresent(), is(true)); + + Map triggerVars = execution.get().getTrigger().getVariables(); + assertThat("exitCode should be 0", triggerVars.get("exitCode"), is(0)); + assertThat("vars should be present", triggerVars.get("vars"), notNullValue()); + } +} diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/DockerTestSupport.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/DockerTestSupport.java new file mode 100644 index 00000000..b8ece74c --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/DockerTestSupport.java @@ -0,0 +1,18 @@ +package io.kestra.plugin.scripts.php; + +import java.util.concurrent.TimeUnit; + +final class DockerTestSupport { + + private DockerTestSupport() { + } + + static boolean dockerAvailable() { + try { + var process = new ProcessBuilder("docker", "info").start(); + return process.waitFor(10, TimeUnit.SECONDS) && process.exitValue() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/EdgeStateTest.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/EdgeStateTest.java new file mode 100644 index 00000000..4aece291 --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/EdgeStateTest.java @@ -0,0 +1,227 @@ +package io.kestra.plugin.scripts.php; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.triggers.Trigger; +import io.kestra.core.models.triggers.TriggerContext; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.serializers.JacksonMapper; +import io.kestra.core.storages.kv.KVStore; +import io.kestra.core.utils.IdUtils; +import io.kestra.core.utils.TestsUtils; + +import jakarta.inject.Inject; + +import static io.kestra.core.tenant.TenantService.MAIN_TENANT; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +@KestraTest +class EdgeStateTest { + @Inject + private RunContextFactory runContextFactory; + + private static ScriptTrigger scriptTrigger(String id) { + return ScriptTrigger.builder() + .id(id) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .script(Property.ofValue("unused")) + .build(); + } + + private static CommandsTrigger commandsTrigger(String id) { + return CommandsTrigger.builder() + .id(id) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .commands(Property.ofValue(List.of("unused"))) + .build(); + } + + private static T freshCopy(T trigger, Class type) throws Exception { + return JacksonMapper.ofJson().readValue(JacksonMapper.ofJson().writeValueAsString(trigger), type); + } + + private static TriggerContext contextFor(String flowId, String triggerId, String namespace) { + return Trigger.builder() + .triggerId(triggerId) + .flowId(flowId) + .tenantId(MAIN_TENANT) + .namespace(namespace) + .date(ZonedDateTime.now()) + .build(); + } + + @Test + void scriptTrigger_edgeEmitsOnlyOnTransitionAcrossFreshInstances() throws Exception { + ScriptTrigger original = scriptTrigger("script-edge-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, original); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + + boolean[] polls = { false, true, true, true, false, false, true }; + boolean[] expected = { false, true, false, false, false, false, true }; + + for (int i = 0; i < polls.length; i++) { + boolean emit = freshCopy(original, ScriptTrigger.class).shouldEmit(runContext, context, true, polls[i]); + assertThat("poll " + i + " (matched=" + polls[i] + ")", emit, is(expected[i])); + } + } + + @Test + void commandsTrigger_edgeEmitsOnlyOnTransitionAcrossFreshInstances() throws Exception { + CommandsTrigger original = commandsTrigger("commands-edge-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, original); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + + boolean[] polls = { false, true, true, true, false, false, true }; + boolean[] expected = { false, true, false, false, false, false, true }; + + for (int i = 0; i < polls.length; i++) { + boolean emit = freshCopy(original, CommandsTrigger.class).shouldEmit(runContext, context, true, polls[i]); + assertThat("poll " + i + " (matched=" + polls[i] + ")", emit, is(expected[i])); + } + } + + @Test + void scriptTrigger_edgeDisabledEmitsOnEveryMatchAndKeepsNoState() throws Exception { + ScriptTrigger original = scriptTrigger("script-noedge-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, original); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + + assertThat(original.shouldEmit(runContext, context, false, true), is(true)); + assertThat(original.shouldEmit(runContext, context, false, true), is(true)); + assertThat(original.shouldEmit(runContext, context, false, false), is(false)); + + assertThat( + "edge=false must not write to the KV store", + runContext.namespaceKv(context.getNamespace()).getValue(ScriptTrigger.edgeStateKey(context)).isPresent(), + is(false) + ); + } + + @Test + void commandsTrigger_edgeDisabledEmitsOnEveryMatchAndKeepsNoState() throws Exception { + CommandsTrigger original = commandsTrigger("commands-noedge-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, original); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + + assertThat(original.shouldEmit(runContext, context, false, true), is(true)); + assertThat(original.shouldEmit(runContext, context, false, true), is(true)); + assertThat(original.shouldEmit(runContext, context, false, false), is(false)); + + assertThat( + "edge=false must not write to the KV store", + runContext.namespaceKv(context.getNamespace()).getValue(CommandsTrigger.edgeStateKey(context)).isPresent(), + is(false) + ); + } + + @Test + void scriptTrigger_stateIsScopedToFlowAndTrigger() throws Exception { + String triggerId = "script-scope-" + IdUtils.create(); + ScriptTrigger trigger = scriptTrigger(triggerId); + var mock = TestsUtils.mockTrigger(runContextFactory, trigger); + RunContext runContext = mock.getKey().getRunContext(); + String namespace = mock.getValue().getNamespace(); + + TriggerContext flowA = contextFor("flow-a", triggerId, namespace); + TriggerContext flowB = contextFor("flow-b", triggerId, namespace); + TriggerContext otherTriggerInFlowA = contextFor("flow-a", triggerId + "-other", namespace); + + assertThat(trigger.shouldEmit(runContext, flowA, true, true), is(true)); + assertThat("same trigger id in another flow has its own state", trigger.shouldEmit(runContext, flowB, true, true), is(true)); + assertThat("another trigger in the same flow has its own state", trigger.shouldEmit(runContext, otherTriggerInFlowA, true, true), is(true)); + + assertThat(trigger.shouldEmit(runContext, flowA, true, true), is(false)); + assertThat(trigger.shouldEmit(runContext, flowB, true, true), is(false)); + } + + @Test + void commandsTrigger_stateIsScopedToFlowAndTrigger() throws Exception { + String triggerId = "commands-scope-" + IdUtils.create(); + CommandsTrigger trigger = commandsTrigger(triggerId); + var mock = TestsUtils.mockTrigger(runContextFactory, trigger); + RunContext runContext = mock.getKey().getRunContext(); + String namespace = mock.getValue().getNamespace(); + + TriggerContext flowA = contextFor("flow-a", triggerId, namespace); + TriggerContext flowB = contextFor("flow-b", triggerId, namespace); + TriggerContext otherTriggerInFlowA = contextFor("flow-a", triggerId + "-other", namespace); + + assertThat(trigger.shouldEmit(runContext, flowA, true, true), is(true)); + assertThat("same trigger id in another flow has its own state", trigger.shouldEmit(runContext, flowB, true, true), is(true)); + assertThat("another trigger in the same flow has its own state", trigger.shouldEmit(runContext, otherTriggerInFlowA, true, true), is(true)); + + assertThat(trigger.shouldEmit(runContext, flowA, true, true), is(false)); + assertThat(trigger.shouldEmit(runContext, flowB, true, true), is(false)); + } + + @Test + void edgeStateKey_doesNotCollideWhenIdsShareHyphens() { + // "a-b" + "c" and "a" + "b-c" would both read "a-b-c" without the length prefix. + TriggerContext first = contextFor("a-b", "c", "company.team"); + TriggerContext second = contextFor("a", "b-c", "company.team"); + + assertThat(ScriptTrigger.edgeStateKey(first), is(not(ScriptTrigger.edgeStateKey(second)))); + assertThat(CommandsTrigger.edgeStateKey(first), is(not(CommandsTrigger.edgeStateKey(second)))); + } + + @Test + void edgeStateKey_isAValidKvKey() { + String key = ScriptTrigger.edgeStateKey(contextFor("my_flow-1", "my_trigger-2", "company.team")); + + assertThat(key.matches("[a-zA-Z0-9][a-zA-Z0-9._-]*"), is(true)); + } + + @Test + void scriptTrigger_doesNotRewriteTheEntryWhenThePollRepeatsTheSameResult() throws Exception { + ScriptTrigger trigger = scriptTrigger("script-nowrite-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, trigger); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + KVStore kvStore = runContext.namespaceKv(context.getNamespace()); + String key = ScriptTrigger.edgeStateKey(context); + + trigger.shouldEmit(runContext, context, true, true); + int versionAfterFirstWrite = kvStore.get(key).orElseThrow().version(); + + trigger.shouldEmit(runContext, context, true, true); + trigger.shouldEmit(runContext, context, true, true); + assertThat("repeating the same result should not rewrite the entry", kvStore.get(key).orElseThrow().version(), is(versionAfterFirstWrite)); + + trigger.shouldEmit(runContext, context, true, false); + assertThat("an actual transition should still write", kvStore.get(key).orElseThrow().version(), is(versionAfterFirstWrite + 1)); + } + + @Test + void commandsTrigger_doesNotRewriteTheEntryWhenThePollRepeatsTheSameResult() throws Exception { + CommandsTrigger trigger = commandsTrigger("commands-nowrite-" + IdUtils.create()); + var mock = TestsUtils.mockTrigger(runContextFactory, trigger); + RunContext runContext = mock.getKey().getRunContext(); + TriggerContext context = mock.getValue(); + KVStore kvStore = runContext.namespaceKv(context.getNamespace()); + String key = CommandsTrigger.edgeStateKey(context); + + trigger.shouldEmit(runContext, context, true, true); + int versionAfterFirstWrite = kvStore.get(key).orElseThrow().version(); + + trigger.shouldEmit(runContext, context, true, true); + trigger.shouldEmit(runContext, context, true, true); + assertThat("repeating the same result should not rewrite the entry", kvStore.get(key).orElseThrow().version(), is(versionAfterFirstWrite)); + + trigger.shouldEmit(runContext, context, true, false); + assertThat("an actual transition should still write", kvStore.get(key).orElseThrow().version(), is(versionAfterFirstWrite + 1)); + } +} diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerEvaluateTest.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerEvaluateTest.java new file mode 100644 index 00000000..986e7348 --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerEvaluateTest.java @@ -0,0 +1,161 @@ +package io.kestra.plugin.scripts.php; + +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.serializers.JacksonMapper; +import io.kestra.core.storages.kv.KVStore; +import io.kestra.core.utils.IdUtils; +import io.kestra.core.utils.TestsUtils; + +import jakarta.inject.Inject; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@KestraTest +class ScriptTriggerEvaluateTest { + private static final String IMAGE = "php"; + + @Inject + private RunContextFactory runContextFactory; + + private static ScriptTrigger trigger(String prefix, String script) { + return ScriptTrigger.builder() + .id(prefix + "-" + IdUtils.create()) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue(IMAGE)) + .script(Property.ofValue(script)) + .build(); + } + + @Test + void evaluate_blankExitCondition_propagatesIllegalArgumentException() throws Exception { + ScriptTrigger trigger = ScriptTrigger.builder() + .id("blank-exit-" + IdUtils.create()) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue(" ")) + .script(Property.ofValue(" trigger.evaluate(context.getKey(), context.getValue()); + + assertThrows(IllegalArgumentException.class, evaluate); + } + + @Test + void evaluate_executionFailure_doesNotAdvanceEdgeState() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = ScriptTrigger.builder() + .id("script-no-emit-" + IdUtils.create()) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue(IMAGE)) + .script(Property.ofValue(" execution = trigger.evaluate(mock.getKey(), mock.getValue()); + + assertThat(execution.isPresent(), is(false)); + assertThat(kvStore.getValue(key).isPresent(), is(false)); + } + + @Test + void evaluate_infrastructureFailure_propagatesWithoutAdvancingEdgeState() throws Exception { + assumeFalse(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = trigger("script-infra-fail", " trigger.evaluate(mock.getKey(), mock.getValue())); + assertThat(kvStore.getValue(key).isPresent(), is(false)); + } + + @Test + void evaluate_edgeDisabledEmitsOnEveryMatchWhenDockerWorks() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = ScriptTrigger.builder() + .id("edge-off-" + IdUtils.create()) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 0")) + .edge(Property.ofValue(false)) + .containerImage(Property.ofValue(IMAGE)) + .script(Property.ofValue(" first = trigger.evaluate(context.getKey(), context.getValue()); + Optional second = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat(first.isPresent(), is(true)); + assertThat(second.isPresent(), is(true)); + } + + @Test + void scriptTrigger_shouldEmitWhenExitCodeMatches() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = trigger("script-exit1", " execution = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat(execution.isPresent(), is(true)); + + Map triggerVars = execution.get().getTrigger().getVariables(); + assertThat(triggerVars.get("condition"), is("exit 1")); + assertThat(triggerVars.get("exitCode"), is(1)); + assertThat(triggerVars.get("timestamp"), notNullValue()); + } + + @Test + void scriptTrigger_shouldStayQuietWhenConditionDoesNotMatch() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = trigger("script-exit0", " execution = trigger.evaluate(context.getKey(), context.getValue()); + + assertThat(execution.isPresent(), is(false)); + } + + @Test + void scriptTrigger_edgeModeShouldSuppressSecondEmissionAcrossFreshInstances() throws Exception { + assumeTrue(DockerTestSupport.dockerAvailable()); + + ScriptTrigger trigger = trigger("script-edge", " first = trigger.evaluate(context.getKey(), context.getValue()); + assertThat("first poll should fire", first.isPresent(), is(true)); + + ScriptTrigger nextPoll = JacksonMapper.ofJson().readValue(JacksonMapper.ofJson().writeValueAsString(trigger), ScriptTrigger.class); + context = TestsUtils.mockTrigger(runContextFactory, nextPoll); + Optional second = nextPoll.evaluate(context.getKey(), context.getValue()); + assertThat("edge mode should suppress the repeat", second.isPresent(), is(false)); + } + +} diff --git a/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerTest.java b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerTest.java new file mode 100644 index 00000000..e95923e6 --- /dev/null +++ b/plugin-script-php/src/test/java/io/kestra/plugin/scripts/php/ScriptTriggerTest.java @@ -0,0 +1,96 @@ +package io.kestra.plugin.scripts.php; + +import io.kestra.plugin.scripts.exec.ExitConditionRegex; + +import java.time.Instant; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +class ScriptTriggerTest { + + private final ScriptTrigger trigger = ScriptTrigger.builder().build(); + + private ScriptTrigger.Output output(String condition, Integer exitCode, Map vars) { + return new ScriptTrigger.Output(Instant.now(), condition, exitCode, vars); + } + + @ParameterizedTest + @CsvSource( + { + "exit 0, 0, true", + "exit 1, 1, true", + "EXIT 1, 1, true", + "exit 0, 1, false", + "exit 1, 127, false", + "exit 42, 42, true", + } + ) + void exitCodeCondition(String condition, int exitCode, boolean expected) { + assertThat(trigger.matchesCondition(output(condition, exitCode, null)), is(expected)); + } + + @Test + void exitCondition_nullExitCode_doesNotMatch() { + assertThat(trigger.matchesCondition(output("exit 1", null, null)), is(false)); + } + + @Test + void exitCodeCondition_overflowingInteger_doesNotMatch() { + assertThat(trigger.matchesCondition(output("exit 9999999999999", 0, null)), is(false)); + } + + @Test + void substringMatch_inVars() { + assertThat(trigger.matchesCondition(output("toto", 0, Map.of("listing", "toto"))), is(true)); + } + + @Test + void noMatch_whenSubstringAbsentFromVars() { + assertThat(trigger.matchesCondition(output("toto", 0, Map.of("listing", "something_else"))), is(false)); + } + + @Test + void regexMatch_inVars() { + assertThat(trigger.matchesCondition(output("status=\\w+", 0, Map.of("status", "status=ready"))), is(true)); + } + + @Test + void noMatch_emptyHaystack() { + assertThat(trigger.matchesCondition(output("something", 0, null)), is(false)); + } + + @Test + void noMatch_emptyCondition() { + assertThat(trigger.matchesCondition(output("", 0, Map.of("k", "v"))), is(false)); + } + + @Test + void nullCondition_doesNotMatch() { + assertThat(trigger.matchesCondition(output(null, 0, Map.of("k", "v"))), is(false)); + } + + @Test + void regexCondition_delegatesToHelperAndReturnsItsResult() { + String condition = "status=\\w+"; + String haystack = "{status=status=ready}"; + var out = output(" " + condition + " ", 0, Map.of("status", "status=ready")); + + try (var helper = mockStatic(ExitConditionRegex.class)) { + helper.when(() -> ExitConditionRegex.find(condition, haystack)).thenReturn(true, false); + + assertThat(trigger.matchesCondition(out), is(true)); + assertThat(trigger.matchesCondition(out), is(false)); + + helper.verify(() -> ExitConditionRegex.find(condition, haystack), times(2)); + helper.verifyNoMoreInteractions(); + } + } +}