();
+ for (const b of boundaries) {
+ if (b.type === "START") {
+ startMap.set(b.lineIndex, b.title ?? "Section");
+ } else {
+ endSet.add(b.lineIndex);
+ }
+ }
+
+ const result: ConsoleSectionNode[] = [];
+ let activeGroup: ConsoleSectionGroup | null = null;
+ const emit = (n: ConsoleSectionNode) =>
+ activeGroup ? activeGroup.children.push(n) : result.push(n);
+
+ for (const node of nodes) {
+ if (node.kind === "group") {
+ emit(node);
+ continue;
+ }
+
+ const lineIdx = node.index;
+
+ // Check end before start so same-line close+open works.
+ if (activeGroup && endSet.has(lineIdx)) {
+ activeGroup.endIndex = lineIdx;
+ result.push(activeGroup);
+ activeGroup = null;
+ continue;
+ }
+
+ const startTitle = startMap.get(lineIdx);
+ if (startTitle) {
+ activeGroup = {
+ kind: "group",
+ title: startTitle,
+ startIndex: lineIdx,
+ endIndex: -1,
+ children: [],
+ };
+ continue;
+ }
+
+ emit(node);
+ }
+
+ if (activeGroup) result.push(activeGroup);
+ return result;
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotator.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotator.java
new file mode 100644
index 000000000..d5e4a104a
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotator.java
@@ -0,0 +1,148 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.ExtensionList;
+import hudson.ExtensionPoint;
+
+/**
+ * Extension point for stateful console section detection.
+ *
+ * Unlike {@link ConsoleSectionRule} which uses simple regex pairs,
+ * this extension allows stateful, line-by-line analysis for richer
+ * section detection (multi-line pattern matching, context-dependent
+ * titles, dynamic enable/disable conditions).
+ *
+ *
Implementations receive lines one at a time via {@link #detect(String)}
+ * and return section boundary events. Annotator instances from {@code all()}
+ * are shared singletons. The processor clones each annotator via
+ * {@link #clone()} so shared instances are never mutated concurrently,
+ * then calls {@link #reset()} before processing each log stream.
+ * Implementations should keep state in instance fields reset by
+ * {@link #reset()}.
+ *
+ *
Example:
+ *
+ * {@literal @}Extension
+ * public class StackTraceAnnotator extends ConsoleSectionAnnotator {
+ * private boolean inTrace = false;
+ *
+ * {@literal @}Override public String getId() { return "stack-trace"; }
+ * {@literal @}Override public String getDisplayName() { return "Stack Trace"; }
+ *
+ * {@literal @}Override
+ * public SectionBoundary detect(String line) {
+ * if (!inTrace && line.matches(".*Exception.*")) {
+ * inTrace = true;
+ * return SectionBoundary.start(line.trim());
+ * }
+ * if (inTrace && !line.startsWith("\tat ") && !line.startsWith("Caused by:")) {
+ * inTrace = false;
+ * return SectionBoundary.END;
+ * }
+ * return SectionBoundary.NONE;
+ * }
+ * }
+ *
+ */
+public abstract class ConsoleSectionAnnotator implements ExtensionPoint, Cloneable {
+
+ /**
+ * Unique identifier for this annotator.
+ */
+ public abstract String getId();
+
+ /**
+ * Human-readable name for the settings panel.
+ */
+ public abstract String getDisplayName();
+
+ /**
+ * Whether this annotator is enabled by default.
+ */
+ public boolean isEnabledByDefault() {
+ return true;
+ }
+
+ /**
+ * Analyze a single line and return a section boundary event.
+ *
+ * Called once per line, in order. The annotator may maintain
+ * internal state across calls within a single log stream.
+ *
+ *
Implementations must never return {@code null}; return
+ * {@link SectionBoundary#NONE} if this line does not start or end
+ * a section.
+ *
+ * @param line the raw console output line (may contain ANSI escapes)
+ * @return a boundary event, or {@link SectionBoundary#NONE} if no transition
+ */
+ @NonNull
+ public abstract SectionBoundary detect(String line);
+
+ /**
+ * Reset internal state. Called before processing a new log stream.
+ */
+ public void reset() {
+ // Default no-op; subclasses override as needed.
+ }
+
+ /**
+ * Create a shallow copy of this annotator for per-request isolation.
+ * Subclasses with complex state should override this.
+ */
+ @Override
+ public ConsoleSectionAnnotator clone() {
+ try {
+ return (ConsoleSectionAnnotator) super.clone();
+ } catch (CloneNotSupportedException e) {
+ throw new AssertionError("ConsoleSectionAnnotator must be Cloneable", e);
+ }
+ }
+
+ /**
+ * All registered annotators.
+ */
+ public static ExtensionList all() {
+ return ExtensionList.lookup(ConsoleSectionAnnotator.class);
+ }
+
+ /**
+ * Represents a section boundary event returned by {@link #detect(String)}.
+ */
+ public static final class SectionBoundary {
+ /** No section transition on this line. */
+ public static final SectionBoundary NONE = new SectionBoundary(Type.NONE, null);
+
+ /** Close the current section. */
+ public static final SectionBoundary END = new SectionBoundary(Type.END, null);
+
+ private final Type type;
+ private final String title;
+
+ private SectionBoundary(Type type, String title) {
+ this.type = type;
+ this.title = title;
+ }
+
+ /** Open a new section with the given title. */
+ public static SectionBoundary start(String title) {
+ return new SectionBoundary(Type.START, title);
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ @CheckForNull
+ public String getTitle() {
+ return title;
+ }
+
+ public enum Type {
+ NONE,
+ START,
+ END
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessor.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessor.java
new file mode 100644
index 000000000..ec8afca73
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessor.java
@@ -0,0 +1,209 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Processes console log text through registered {@link ConsoleSectionAnnotator}
+ * instances and collects section boundary events.
+ *
+ * A fresh processor is created per request. It clones each annotator so
+ * shared singleton instances from {@code ExtensionList} are never mutated
+ * concurrently.
+ */
+public class ConsoleSectionProcessor {
+
+ private final List annotators;
+
+ public ConsoleSectionProcessor(List annotators) {
+ // Filter to only enabled annotators and clone each so shared singletons
+ // are never mutated by concurrent requests.
+ List cloned = new ArrayList<>();
+ for (ConsoleSectionAnnotator a : annotators) {
+ if (a.isEnabledByDefault()) {
+ cloned.add(a.clone());
+ }
+ }
+ this.annotators = Collections.unmodifiableList(cloned);
+ }
+
+ /**
+ * Process raw log text and return boundary events.
+ *
+ * @param logText raw plain-text log output (not HTML)
+ * @return ordered list of boundary events with line indices
+ */
+ public List process(String logText) {
+ if (annotators.isEmpty() || logText.isEmpty()) {
+ return Collections.emptyList();
+ }
+ try {
+ return process(new java.io.ByteArrayInputStream(logText.getBytes(StandardCharsets.UTF_8)));
+ } catch (IOException e) {
+ // ByteArrayInputStream never throws IOException in practice.
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Process log content from an input stream, reading line by line to avoid
+ * buffering the entire log as a single String.
+ *
+ * @param input stream of raw plain-text log output (UTF-8)
+ * @return ordered list of boundary events with line indices
+ */
+ public List process(InputStream input) throws IOException {
+ if (annotators.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ List events = new ArrayList<>();
+ for (ConsoleSectionAnnotator a : annotators) {
+ a.reset();
+ }
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
+ String line;
+ int lineIndex = 0;
+ while ((line = reader.readLine()) != null) {
+ for (ConsoleSectionAnnotator annotator : annotators) {
+ ConsoleSectionAnnotator.SectionBoundary boundary = annotator.detect(line);
+ if (boundary.getType() != ConsoleSectionAnnotator.SectionBoundary.Type.NONE) {
+ events.add(new BoundaryEvent(
+ lineIndex,
+ boundary.getType() == ConsoleSectionAnnotator.SectionBoundary.Type.START
+ ? "START"
+ : "END",
+ boundary.getTitle()));
+ break;
+ }
+ }
+ lineIndex++;
+ }
+ }
+ return events;
+ }
+
+ /**
+ * Creates an OutputStream that processes log lines incrementally as bytes
+ * are written. Call {@link LineProcessingOutputStream#getEvents()} after
+ * writing is complete to retrieve the collected boundary events.
+ *
+ * This avoids buffering the entire log in memory - lines are processed
+ * and discarded as they arrive.
+ */
+ public LineProcessingOutputStream createOutputStream() {
+ return new LineProcessingOutputStream();
+ }
+
+ /**
+ * An OutputStream that feeds lines directly into the processor's annotators
+ * as they are written, avoiding a full in-memory buffer of the log.
+ */
+ public class LineProcessingOutputStream extends OutputStream {
+ private final ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(512);
+ private final List events = new ArrayList<>();
+ private int lineIndex = 0;
+
+ private LineProcessingOutputStream() {
+ for (ConsoleSectionAnnotator a : annotators) {
+ a.reset();
+ }
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ if (b == '\n') {
+ processLine();
+ } else {
+ lineBuffer.write(b);
+ }
+ }
+
+ @Override
+ public void write(byte[] buf, int off, int len) throws IOException {
+ int end = off + len;
+ for (int i = off; i < end; i++) {
+ if (buf[i] == '\n') {
+ // Flush everything before the newline as part of the current line
+ if (i > off) {
+ lineBuffer.write(buf, off, i - off);
+ }
+ processLine();
+ off = i + 1;
+ }
+ }
+ // Buffer remaining bytes (partial line)
+ if (off < end) {
+ lineBuffer.write(buf, off, end - off);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ // Process any remaining partial line (no trailing newline)
+ if (lineBuffer.size() > 0) {
+ processLine();
+ }
+ }
+
+ private void processLine() {
+ String line = lineBuffer.toString(StandardCharsets.UTF_8);
+ lineBuffer.reset();
+ for (ConsoleSectionAnnotator annotator : annotators) {
+ ConsoleSectionAnnotator.SectionBoundary boundary = annotator.detect(line);
+ if (boundary.getType() != ConsoleSectionAnnotator.SectionBoundary.Type.NONE) {
+ events.add(new BoundaryEvent(
+ lineIndex,
+ boundary.getType() == ConsoleSectionAnnotator.SectionBoundary.Type.START ? "START" : "END",
+ boundary.getTitle()));
+ break;
+ }
+ }
+ lineIndex++;
+ }
+
+ /** Returns the boundary events collected during writing. */
+ public List getEvents() {
+ return Collections.unmodifiableList(events);
+ }
+ }
+
+ /**
+ * A single section boundary event at a specific line index.
+ */
+ public static final class BoundaryEvent {
+ private final int lineIndex;
+ private final String type;
+ private final String title;
+
+ public BoundaryEvent(int lineIndex, String type, @CheckForNull String title) {
+ this.lineIndex = lineIndex;
+ this.type = type;
+ this.title = title;
+ }
+
+ public int getLineIndex() {
+ return lineIndex;
+ }
+
+ /** "START" or "END". */
+ public String getType() {
+ return type;
+ }
+
+ @CheckForNull
+ public String getTitle() {
+ return title;
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRule.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRule.java
new file mode 100644
index 000000000..986348c18
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRule.java
@@ -0,0 +1,70 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import hudson.ExtensionList;
+import hudson.ExtensionPoint;
+
+/**
+ * Extension point for contributing console section detection rules.
+ *
+ * Plugin authors can implement this to define regex-based collapsible sections
+ * in Pipeline Graph View console output. Each rule specifies a start pattern (that
+ * opens a collapsible section) and an end pattern (that closes it).
+ *
+ *
Rules are discovered via {@link ExtensionList} and sent to the frontend
+ * as part of the console view configuration.
+ *
+ *
Example:
+ *
+ * {@literal @}Extension
+ * public class MavenPhaseRule extends ConsoleSectionRule {
+ * {@literal @}Override public String getId() { return "maven-phase"; }
+ * {@literal @}Override public String getDisplayName() { return "Maven Phase"; }
+ * {@literal @}Override public String getStartPattern() { return "\\[INFO\\] --- .+ ---"; }
+ * {@literal @}Override public String getEndPattern() { return "\\[INFO\\] --- .+ ---|\\[INFO\\] BUILD"; }
+ * {@literal @}Override public boolean isEnabledByDefault() { return true; }
+ * }
+ *
+ */
+public abstract class ConsoleSectionRule implements ExtensionPoint {
+
+ /**
+ * Unique identifier for this rule.
+ * Used as a key for user preference toggles.
+ */
+ public abstract String getId();
+
+ /**
+ * Human-readable name shown in the settings panel.
+ */
+ public abstract String getDisplayName();
+
+ /**
+ * ECMAScript-compatible regex pattern that matches the start of a collapsible section.
+ * These patterns are compiled on the frontend with {@code new RegExp(...)}, so they must
+ * use ECMAScript regex syntax (avoid Java-only features like possessive quantifiers or
+ * {@code \p{}} Unicode categories).
+ * The first capture group, if present, is used as the section title.
+ */
+ public abstract String getStartPattern();
+
+ /**
+ * ECMAScript-compatible regex pattern that matches the end of a collapsible section.
+ * See {@link #getStartPattern()} for syntax requirements.
+ */
+ public abstract String getEndPattern();
+
+ /**
+ * Whether this rule is enabled by default.
+ * Users can override per-job or globally.
+ */
+ public boolean isEnabledByDefault() {
+ return true;
+ }
+
+ /**
+ * All registered section rules.
+ */
+ public static ExtensionList all() {
+ return ExtensionList.lookup(ConsoleSectionRule.class);
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/MarkerConsoleSectionAnnotator.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/MarkerConsoleSectionAnnotator.java
new file mode 100644
index 000000000..389501d32
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/MarkerConsoleSectionAnnotator.java
@@ -0,0 +1,55 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Built-in annotator that detects {@code ##[group]Title / ##[endgroup]}
+ * and {@code ::group::Title / ::endgroup::} markers in console output.
+ *
+ * This is the server-side counterpart to the frontend marker parser
+ * in {@code parseConsoleSections.ts}. Both must agree on the syntax.
+ *
+ *
Not registered as an {@code @Extension} because the frontend already
+ * handles these markers directly. This class exists for third-party code
+ * that may instantiate it explicitly.
+ */
+public class MarkerConsoleSectionAnnotator extends ConsoleSectionAnnotator {
+
+ // Same ANSI stripping as the frontend.
+ private static final Pattern ANSI_RE = Pattern.compile("\033\\[[0-9;]*[a-zA-Z]");
+ private static final Pattern GROUP_START = Pattern.compile("^(?:##\\[group\\]|::group::)\\s*(.*)$");
+ private static final Pattern GROUP_END = Pattern.compile("^(?:##\\[endgroup\\]|::endgroup::)\\s*$");
+
+ @Override
+ public String getId() {
+ return "markers";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Section Markers";
+ }
+
+ @Override
+ public SectionBoundary detect(String line) {
+ String stripped = ANSI_RE.matcher(line).replaceAll("").stripLeading();
+
+ // Reject shell trace lines.
+ if (stripped.startsWith("+ ")) {
+ return SectionBoundary.NONE;
+ }
+
+ Matcher startMatch = GROUP_START.matcher(stripped);
+ if (startMatch.matches()) {
+ String title = startMatch.group(1);
+ return SectionBoundary.start(title.isEmpty() ? "Section" : title);
+ }
+
+ if (GROUP_END.matcher(stripped).matches()) {
+ return SectionBoundary.END;
+ }
+
+ return SectionBoundary.NONE;
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
index 79e52ce4f..49f6c4836 100644
--- a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
@@ -386,6 +386,82 @@ public String getNextBuildNumber() {
return getBuildNumber(run.getNextBuild());
}
+ @GET
+ @WebMethod(name = "consoleSectionRules")
+ public void getConsoleSectionRules(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {
+ run.checkPermission(Item.READ);
+ rsp.setStatus(200);
+ rsp.setContentType("application/json;charset=UTF-8");
+ rsp.setHeader("Cache-Control", "private, max-age=300");
+
+ List rules = new ArrayList<>();
+ for (ConsoleSectionRule rule : ConsoleSectionRule.all()) {
+ JSONObject obj = new JSONObject();
+ obj.put("id", rule.getId());
+ obj.put("displayName", rule.getDisplayName());
+ obj.put("startPattern", rule.getStartPattern());
+ obj.put("endPattern", rule.getEndPattern());
+ obj.put("enabledByDefault", rule.isEnabledByDefault());
+ rules.add(obj);
+ }
+ net.sf.json.JSONArray.fromObject(rules).write(rsp.getWriter());
+ }
+
+ @GET
+ @WebMethod(name = "consoleSectionBoundaries")
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ @SuppressFBWarnings(
+ value = "RV_RETURN_VALUE_IGNORED",
+ justification = "writeLogTo return value not needed; we only care about the buffered output")
+ public void getConsoleSectionBoundaries(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {
+ run.checkPermission(Item.READ);
+ String nodeId = req.getParameter("nodeId");
+ if (nodeId == null) {
+ rsp.setStatus(400);
+ rsp.setContentType("application/json;charset=UTF-8");
+ rsp.getWriter().write("[]");
+ return;
+ }
+
+ AnnotatedLargeText extends FlowNode> logText = getLogForNode(nodeId);
+ if (logText == null) {
+ rsp.setStatus(200);
+ rsp.setContentType("application/json;charset=UTF-8");
+ rsp.setHeader("Cache-Control", "private, no-store");
+ rsp.getWriter().write("[]");
+ return;
+ }
+
+ // Stream log directly through the processor line-by-line to avoid
+ // buffering the entire log in memory.
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(
+ ConsoleSectionAnnotator.all().stream().toList());
+ ConsoleSectionProcessor.LineProcessingOutputStream out = processor.createOutputStream();
+ logText.writeLogTo(0L, out);
+ out.close();
+ List events = out.getEvents();
+
+ rsp.setStatus(200);
+ rsp.setContentType("application/json;charset=UTF-8");
+ if (logText.isComplete()) {
+ rsp.setHeader("Cache-Control", "private, max-age=300");
+ } else {
+ rsp.setHeader("Cache-Control", "private, no-store");
+ }
+
+ List jsonEvents = new ArrayList<>();
+ for (ConsoleSectionProcessor.BoundaryEvent event : events) {
+ JSONObject obj = new JSONObject();
+ obj.put("lineIndex", event.getLineIndex());
+ obj.put("type", event.getType());
+ if (event.getTitle() != null) {
+ obj.put("title", event.getTitle());
+ }
+ jsonEvents.add(obj);
+ }
+ net.sf.json.JSONArray.fromObject(jsonEvents).write(rsp.getWriter());
+ }
+
@GET
@WebMethod(name = "tree")
public void getTree(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException {
diff --git a/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotatorTest.java b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotatorTest.java
new file mode 100644
index 000000000..64f6f7eee
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionAnnotatorTest.java
@@ -0,0 +1,96 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+class ConsoleSectionAnnotatorTest {
+
+ @Test
+ void markerAnnotatorIsNotAutoDiscovered(JenkinsRule j) {
+ // MarkerConsoleSectionAnnotator no longer has @Extension because the
+ // frontend handles markers directly. Verify it does NOT appear in all().
+ List annotators =
+ ConsoleSectionAnnotator.all().stream().toList();
+ List ids =
+ annotators.stream().map(ConsoleSectionAnnotator::getId).toList();
+ assertThat(ids, not(hasItem("markers")));
+ }
+
+ @Test
+ void markerAnnotatorDetectsAzureDevOpsGroup(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("##[group]Build");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.START));
+ assertThat(result.getTitle(), is("Build"));
+ }
+
+ @Test
+ void markerAnnotatorDetectsGitHubActionsGroup(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("::group::Test Suite");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.START));
+ assertThat(result.getTitle(), is("Test Suite"));
+ }
+
+ @Test
+ void markerAnnotatorDetectsEndgroup(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ assertThat(annotator.detect("##[endgroup]").getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.END));
+ assertThat(annotator.detect("::endgroup::").getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.END));
+ }
+
+ @Test
+ void markerAnnotatorRejectsShellTrace(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("+ echo ##[group]Build");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.NONE));
+ }
+
+ @Test
+ void markerAnnotatorHandlesAnsiEscapes(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("\033[32m##[group]Colored\033[0m");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.START));
+ assertThat(result.getTitle(), is("Colored"));
+ }
+
+ @Test
+ void markerAnnotatorUsesDefaultTitleWhenEmpty(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("##[group]");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.START));
+ assertThat(result.getTitle(), is("Section"));
+ }
+
+ @Test
+ void markerAnnotatorReturnsNoneForPlainLine(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionAnnotator.SectionBoundary result = annotator.detect("just a normal line");
+ assertThat(result.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.NONE));
+ }
+
+ @Test
+ void sectionBoundaryStartCarriesTitle(JenkinsRule j) {
+ ConsoleSectionAnnotator.SectionBoundary boundary = ConsoleSectionAnnotator.SectionBoundary.start("My Title");
+ assertThat(boundary.getType(), is(ConsoleSectionAnnotator.SectionBoundary.Type.START));
+ assertThat(boundary.getTitle(), is("My Title"));
+ }
+
+ @Test
+ void sectionBoundaryEndAndNoneHaveNoTitle(JenkinsRule j) {
+ assertThat(ConsoleSectionAnnotator.SectionBoundary.END.getTitle(), nullValue());
+ assertThat(ConsoleSectionAnnotator.SectionBoundary.NONE.getTitle(), nullValue());
+ }
+
+ @Test
+ void markerAnnotatorIsEnabledByDefault(JenkinsRule j) {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ assertThat(annotator.isEnabledByDefault(), is(true));
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessorTest.java b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessorTest.java
new file mode 100644
index 000000000..42a51b464
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionProcessorTest.java
@@ -0,0 +1,162 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ConsoleSectionProcessorTest {
+
+ @Test
+ void emptyLogProducesNoEvents() {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(annotator));
+ List events = processor.process("");
+ assertThat(events, is(empty()));
+ }
+
+ @Test
+ void noAnnotatorsProducesNoEvents() {
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of());
+ List events = processor.process("hello\nworld");
+ assertThat(events, is(empty()));
+ }
+
+ @Test
+ void detectsGroupMarkers() {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(annotator));
+
+ String log = "line 0\n##[group]Build\ncompiling...\n##[endgroup]\nline 4";
+ List events = processor.process(log);
+
+ assertThat(events, hasSize(2));
+
+ assertThat(events.get(0).getLineIndex(), is(1));
+ assertThat(events.get(0).getType(), is("START"));
+ assertThat(events.get(0).getTitle(), is("Build"));
+
+ assertThat(events.get(1).getLineIndex(), is(3));
+ assertThat(events.get(1).getType(), is("END"));
+ assertThat(events.get(1).getTitle(), nullValue());
+ }
+
+ @Test
+ void detectsGitHubActionsMarkers() {
+ MarkerConsoleSectionAnnotator annotator = new MarkerConsoleSectionAnnotator();
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(annotator));
+
+ String log = "::group::Test Suite\nrunning tests\n::endgroup::";
+ List events = processor.process(log);
+
+ assertThat(events, hasSize(2));
+ assertThat(events.get(0).getType(), is("START"));
+ assertThat(events.get(0).getTitle(), is("Test Suite"));
+ assertThat(events.get(1).getType(), is("END"));
+ }
+
+ @Test
+ void skipsDisabledAnnotators() {
+ ConsoleSectionAnnotator disabled = new ConsoleSectionAnnotator() {
+ @Override
+ public String getId() {
+ return "disabled";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Disabled";
+ }
+
+ @Override
+ public boolean isEnabledByDefault() {
+ return false;
+ }
+
+ @Override
+ public SectionBoundary detect(String line) {
+ return SectionBoundary.start("Should not appear");
+ }
+ };
+
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(disabled));
+ List events = processor.process("any line");
+ assertThat(events, is(empty()));
+ }
+
+ @Test
+ void firstAnnotatorWinsOnSameLine() {
+ ConsoleSectionAnnotator first = new ConsoleSectionAnnotator() {
+ @Override
+ public String getId() {
+ return "first";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "First";
+ }
+
+ @Override
+ public SectionBoundary detect(String line) {
+ return line.contains("match") ? SectionBoundary.start("First") : SectionBoundary.NONE;
+ }
+ };
+
+ ConsoleSectionAnnotator second = new ConsoleSectionAnnotator() {
+ @Override
+ public String getId() {
+ return "second";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Second";
+ }
+
+ @Override
+ public SectionBoundary detect(String line) {
+ return line.contains("match") ? SectionBoundary.start("Second") : SectionBoundary.NONE;
+ }
+ };
+
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(first, second));
+ List events = processor.process("match");
+
+ assertThat(events, hasSize(1));
+ assertThat(events.get(0).getTitle(), is("First"));
+ }
+
+ @Test
+ void resetIsCalledBeforeProcessing() {
+ int[] resetCount = {0};
+ ConsoleSectionAnnotator counting = new ConsoleSectionAnnotator() {
+ @Override
+ public String getId() {
+ return "counting";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Counting";
+ }
+
+ @Override
+ public void reset() {
+ resetCount[0]++;
+ }
+
+ @Override
+ public SectionBoundary detect(String line) {
+ return SectionBoundary.NONE;
+ }
+ };
+
+ ConsoleSectionProcessor processor = new ConsoleSectionProcessor(List.of(counting));
+ processor.process("line 1\nline 2");
+ processor.process("line 3");
+
+ assertThat(resetCount[0], is(2));
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRuleTest.java b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRuleTest.java
new file mode 100644
index 000000000..12024de4c
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/pipelinegraphview/consoleview/ConsoleSectionRuleTest.java
@@ -0,0 +1,51 @@
+package io.jenkins.plugins.pipelinegraphview.consoleview;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+import java.util.List;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+class ConsoleSectionRuleTest {
+
+ @Test
+ void extensionPointIsDiscoverable(JenkinsRule j) {
+ // No built-in rules shipped; the list should be empty by default.
+ // Plugin maintainers contribute their own rules via @Extension.
+ List rules = ConsoleSectionRule.all().stream().toList();
+ assertThat(rules, is(empty()));
+ }
+
+ @Test
+ void defaultEnabledByDefaultReturnsTrue(JenkinsRule j) {
+ ConsoleSectionRule stub = new ConsoleSectionRule() {
+ @Override
+ public String getId() {
+ return "test";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Test Rule";
+ }
+
+ @Override
+ public String getStartPattern() {
+ return "^START$";
+ }
+
+ @Override
+ public String getEndPattern() {
+ return "^END$";
+ }
+ };
+ assertThat(stub.isEnabledByDefault(), is(true));
+ // Patterns compile without error.
+ Pattern.compile(stub.getStartPattern());
+ Pattern.compile(stub.getEndPattern());
+ }
+}