This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 49b577350e893cd20e1f6002cfeaad02864e50bb
Author: Paul King <[email protected]>
AuthorDate: Sat May 9 08:37:47 2026 +1000

    groovysh additional tests
---
 subprojects/groovy-groovysh/build.gradle           |  12 ++
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |   4 +-
 .../{TypesTest.groovy => ClassLoaderTest.groovy}   |  37 +++---
 .../groovysh/commands/ConsoleTestSupport.groovy    |  19 ++-
 .../apache/groovy/groovysh/commands/DelTest.groovy |  56 +++++++++
 .../groovysh/commands/GrabCommandTest.groovy       |  85 -------------
 .../groovy/groovysh/commands/GrabTest.groovy       |  53 ++++++++
 .../commands/GroovyCommandTestSupport.groovy       |  64 ----------
 .../groovysh/commands/HelpCommandTest.groovy       |  33 +++--
 .../groovy/groovysh/commands/InspectTest.groovy    |  53 ++++++++
 .../groovy/groovysh/commands/MethodsTest.groovy    |   9 ++
 .../groovy/groovysh/commands/PipeTest.groovy       |  61 +++++++++
 .../groovy/groovysh/commands/SaveLoadTest.groovy   |  76 ++++++++++++
 .../groovysh/commands/SlurpCsvFallbackTest.groovy  |  79 ++++++++++++
 .../groovy/groovysh/commands/SlurpTest.groovy      |  74 +++++++++++
 .../groovysh/commands/SystemTestSupport.groovy     |  72 ++++++++++-
 .../groovy/groovysh/commands/TypesTest.groovy      |   9 ++
 .../groovy/groovysh/jline/GroovyEngineTest.groovy  |  73 +++++++++++
 .../groovysh/jline/GroovyPosixCommandsTest.groovy  | 138 +++++++++++++++++++++
 .../groovysh/jline/GroovySystemRegistryTest.groovy |  74 +++++++++++
 20 files changed, 896 insertions(+), 185 deletions(-)

diff --git a/subprojects/groovy-groovysh/build.gradle 
b/subprojects/groovy-groovysh/build.gradle
index dc23b9eb44..9469ea0a60 100644
--- a/subprojects/groovy-groovysh/build.gradle
+++ b/subprojects/groovy-groovysh/build.gradle
@@ -34,6 +34,8 @@ dependencies {
     implementation projects.groovyJson
     implementation projects.groovyNio
     testImplementation projects.groovyTest
+    testImplementation projects.groovyCsv         // for /slurp .csv default 
coverage
+    testImplementation projects.groovyTestJunit6  // for @ForkedJvm in CSV 
fallback tests
     implementation "net.java.dev.jna:jna:${versions.jna}"
     implementation "org.jline:jansi:${versions.jline}"
     implementation "org.jline:jline-builtins:${versions.jline}"
@@ -51,6 +53,16 @@ plugins.withId('eclipse') {
     }
 }
 
+// Forward -Djunit.network from the Gradle invocation to the test JVM so that
+// @EnabledIfSystemProperty in network-gated tests (e.g. SlurpCsvFallbackTest)
+// can see it.
+tasks.named('test') {
+    def network = System.getProperty('junit.network')
+    if (network) {
+        systemProperty 'junit.network', network
+    }
+}
+
 tasks.named('rat') {
     excludes << '**/jline/GroovyEngine.java' // BSD license as per 
NOTICE/LICENSE files
     excludes << '**/jline/ObjectInspector.groovy' // BSD license as per 
NOTICE/LICENSE files
diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index c4b99fe65f..ac2513bc2d 100644
--- 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -393,7 +393,7 @@ class Main {
                 }
                 name('groovysh')
             }.build()
-            if (terminal.width == 0 || terminal.height == 0) {
+            if (terminal.columns == 0 || terminal.rows == 0) {
                 terminal.size = new Size(120, 40) // hard-coded terminal size 
when redirecting
             }
             Thread executeThread = Thread.currentThread()
@@ -502,7 +502,7 @@ class Main {
                 println render(messages['startup_banner.1'])
                 println render(messages['startup_banner.2'])
             }
-            println '-' * (terminal.width - 1)
+            println '-' * (terminal.columns - 1)
 // for debugging
 //            def index = 0
 //            def lines = ['/slurp 
/Users/paulk/Projects/groovy/subprojects/groovy-json/src/test/resources/groovy9802.json',
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ClassLoaderTest.groovy
similarity index 50%
copy from 
subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
copy to 
subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ClassLoaderTest.groovy
index 8080b4f3d2..9bda1f336f 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ClassLoaderTest.groovy
@@ -21,27 +21,26 @@ package org.apache.groovy.groovysh.commands
 import org.junit.jupiter.api.Test
 
 /**
- * Tests for the {@code /types} command.
+ * Tests for the {@code /classloader} command.
+ *
+ * The command renders the engine's class loader using the {@code COLUMNS}
+ * option of {@code Printer.println(options, object)}; the support class's
+ * {@code DummyPrinter} extracts each column as a {@code name=value} entry
+ * so substring assertions remain straightforward.
  */
-class TypesTest extends SystemTestSupport {
+class ClassLoaderTest extends SystemTestSupport {
+
     @Test
-    void testImport() {
-        def names = ['C', 'I', 'T', 'R', 'E', 'A']
-        system.execute('/types')
+    void viewExposesColumnDataForLoadedClassLoader() {
+        // Define a type so the class loader has at least one entry to render.
+        engine.execute('class ClassLoaderProbe {}')
+        system.execute('/classloader')
         def out = printer.output.join()
-        names.each{ name -> assert !out.contains(name) }
-        system.execute('class C {}')
-        system.execute('interface I {}')
-        system.execute('trait T {}')
-        system.execute('record R() {}')
-        system.execute('enum E {}')
-        system.execute('@interface A {}')
-        system.execute('/types')
-        out = printer.output.join()
-        names.each{ name -> assert out.contains(name) }
-        assert engine.types.keySet() == ['C', 'I', 'T', 'R', 'E', 'A'] as Set
-        system.execute('/types -d C')
-        system.execute('/types -d R')
-        assert engine.types.keySet() == ['I', 'T', 'E', 'A'] as Set
+        // Each column name from the /classloader command appears as a
+        // `name=...` entry. Don't assert on values' exact shape (lists vary
+        // by JDK and previous test state); just confirm the columns rendered.
+        assert out.contains('loadedClasses=')
+        assert out.contains('definedPackages=')
+        assert out.contains('classPath=')
     }
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleTestSupport.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleTestSupport.groovy
index 4782c4244b..970ca94987 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleTestSupport.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleTestSupport.groovy
@@ -22,6 +22,7 @@ import org.apache.groovy.groovysh.Main
 import org.apache.groovy.groovysh.jline.GroovyCommands
 import org.apache.groovy.groovysh.jline.GroovyConsoleEngine
 import org.apache.groovy.groovysh.jline.GroovyEngine
+import org.jline.console.Printer
 import org.jline.builtins.ClasspathResourceUtil
 import org.jline.builtins.ConfigurationPath
 import org.jline.builtins.SyntaxHighlighter
@@ -34,6 +35,7 @@ import org.jline.reader.impl.DefaultParser
 import org.junit.jupiter.api.BeforeEach
 
 import java.nio.file.Path
+import java.util.function.Supplier
 
 /**
  * Support for testing {@link ConsoleEngine} instances.
@@ -44,9 +46,10 @@ abstract class ConsoleTestSupport {
     private Path root = ClasspathResourceUtil.getResourcePath(rootURL)
     private Path temp = File.createTempDir().toPath()
     protected ConfigurationPath configPath = new ConfigurationPath(root, temp)
+    protected Supplier<Path> workDir = { -> temp } as Supplier<Path>
     protected DummyPrinter printer = new DummyPrinter(configPath)
     private highlighter = SyntaxHighlighter.build(root, "DUMMY")
-    protected CommandRegistry groovy = new GroovyCommands(engine, null, 
printer, highlighter)
+    protected CommandRegistry groovy = new GroovyCommands(engine, workDir, 
printer, highlighter)
     protected ConsoleEngine console
     protected CommandRegistry.CommandSession session = new 
CommandRegistry.CommandSession()
     protected LineReader reader
@@ -70,7 +73,19 @@ abstract class ConsoleTestSupport {
 
         @Override
         void println(Map<String, Object> options, Object object) {
-            output << object.toString()
+            // /classloader --view passes the EngineClassLoader as `object`
+            // with COLUMNS option naming the fields to render. DefaultPrinter
+            // would format those as a table; in tests we capture each
+            // column as `name=value` so substring assertions still work.
+            if (object instanceof GroovyEngine.EngineClassLoader) {
+                options?[Printer.COLUMNS]?.each { col ->
+                    output << "$col=" + object."$col"
+                }
+            } else {
+                // .toString() (not String.valueOf) preserves Groovy MetaClass
+                // extensions — Map renders as [k:v] not {k=v}.
+                output << object.toString()
+            }
         }
 
         @Override
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DelTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DelTest.groovy
new file mode 100644
index 0000000000..70565df6bb
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DelTest.groovy
@@ -0,0 +1,56 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the {@code /del} command. Uses the full {@link SystemTestSupport}
+ * stack so each invocation passes through registry parsing, pipe handling,
+ * and console dispatch — exercising the same path as a real REPL session.
+ */
+class DelTest extends SystemTestSupport {
+
+    @Test
+    void testDelVariable() {
+        console.execute('dummyName', "x = 42")
+        assert console.hasVariable('x')
+        system.execute('/del x')
+        assert !console.hasVariable('x')
+    }
+
+    @Test
+    void testDelMultipleVariables() {
+        console.execute('dummyName', "a = 1; b = 2; c = 3")
+        ['a', 'b', 'c'].each { assert console.hasVariable(it) }
+        system.execute('/del a c')
+        assert !console.hasVariable('a')
+        assert console.hasVariable('b')
+        assert !console.hasVariable('c')
+    }
+
+    @Test
+    void testDelNonexistentIsHarmless() {
+        // /del on an unknown variable should be a no-op, not a hard failure
+        // that aborts the REPL session.
+        system.execute('/del thisVariableDoesNotExist')
+        // No exception — pre-existing variables (none) are unaffected.
+        assert !console.hasVariable('thisVariableDoesNotExist')
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabCommandTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabCommandTest.groovy
deleted file mode 100644
index bb441de2e2..0000000000
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabCommandTest.groovy
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.apache.groovy.groovysh.commands
-
-import groovy.grape.Grape
-import groovy.mock.interceptor.StubFor
-
-import static groovy.test.GroovyAssert.shouldFail
-
-/**
- * Tests for the {@code /grab} command.
- */
-class GrabCommandTest /*extends CommandTestSupport*/ {
-
-//    protected GrabCommand command
-    def grapeStub = new StubFor(Grape.class)
-
-    void setUp() {
-/*
-        Groovysh groovysh = new Groovysh()
-        PackageHelperImpl packageHelper = new PackageHelperImpl()
-        packageHelper.metaClass.reset = { }
-        groovysh.metaClass.packageHelper = packageHelper
-        command = new GrabCommand(groovysh)
-        command.metaClass.fail = { String message ->
-            throw new RuntimeException("fail(${message}) called")
-        }
-        def stubber = new StubFor(Grape.class)
-*/
-    }
-
-    void testWrongNumberOfArguments() {
-        shouldFail(RuntimeException) { command.execute([]) }
-        shouldFail(RuntimeException) { command.execute(['alpha', 'beta']) }
-    }
-
-    void testInvalidDependencyFormat() {
-        shouldFail(RuntimeException) { command.execute(['net.sf.json-lib']) }
-    }
-
-    void testGroup_Module() {
-        grapeStub.demand.grab()  { arg1, arg2 -> }
-        grapeStub.use {
-            command.execute(['net.sf.json-lib:json-lib'])
-        }
-    }
-
-    void testGroupModuleVersion() {
-        grapeStub.demand.grab()  { arg1, arg2 -> }
-        grapeStub.use {
-            command.execute(['net.sf.json-lib:json-lib:2.2.3'])
-        }
-    }
-
-    void testGroupModuleVersionWildcard() {
-        grapeStub.demand.grab()  { arg1, arg2 -> }
-        grapeStub.use {
-            command.execute(['net.sf.json-lib:json-lib:*'])
-        }
-    }
-
-    void testGroupModuleVersionClassifier() {
-        grapeStub.demand.grab()  { arg1, arg2 -> }
-        grapeStub.use {
-            command.execute(['net.sf.json-lib:json-lib:2.2.3:jdk15'])
-        }
-    }
-
-}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
new file mode 100644
index 0000000000..662b75a890
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
@@ -0,0 +1,53 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty
+
+/**
+ * Tests for the {@code /grab} command — Maven-coordinate dependency
+ * resolution via Grape. The actual artifact-fetching test is forked and
+ * network-gated; the no-arg test runs always to lock in the documented
+ * "no args is a no-op" behaviour that {@code GroovyCommands.grab} relies
+ * on for {@code grab(input)} when no xargs are supplied.
+ */
+class GrabTest extends SystemTestSupport {
+
+    @Test
+    void grabWithNoArgsIsNoOp() {
+        // grab() returns null when input.xargs() is empty; this should
+        // succeed silently rather than throw.
+        system.execute('/grab')
+    }
+
+    @Test
+    @ForkedJvm
+    @EnabledIfSystemProperty(named = 'junit.network', matches = 'true')
+    void grabFetchesArtifactAndMakesItLoadable() {
+        // commons-lang3 is small, stable, and uses well-known coordinates.
+        // After the grab, the artifact's classes should resolve through
+        // the engine's classloader.
+        system.execute('/grab org.apache.commons:commons-lang3:3.14.0')
+        def cls = 
engine.execute("Class.forName('org.apache.commons.lang3.StringUtils')")
+        assert cls != null
+        assert cls.name == 'org.apache.commons.lang3.StringUtils'
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GroovyCommandTestSupport.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GroovyCommandTestSupport.groovy
deleted file mode 100644
index 63f0b9146a..0000000000
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GroovyCommandTestSupport.groovy
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.apache.groovy.groovysh.commands
-
-import org.apache.groovy.groovysh.jline.GroovyCommands
-import org.apache.groovy.groovysh.jline.GroovyEngine
-import org.jline.console.CommandRegistry
-import org.jline.console.Printer
-
-/**
- * Support for testing commands from {@link GroovyCommands}.
- */
-abstract class GroovyCommandTestSupport {
-    protected GroovyEngine engine = new GroovyEngine() {
-        def getLoader() {
-            classLoader
-        }
-    }
-    protected List<String> output = []
-    protected Printer printer = new DummyPrinter(output)
-    protected CommandRegistry groovy = new GroovyCommands(engine, null, 
printer, null)
-    protected CommandRegistry.CommandSession session = new 
CommandRegistry.CommandSession()
-
-    static class DummyPrinter implements Printer {
-        DummyPrinter(List<String> output) {
-            this.output = output
-        }
-        private List<String> output
-
-        @Override
-        void println(Map<String, Object> options, Object object) {
-            // a bit ugly to partially replicate the logic from
-            // DefaultPrinter here, but it isn't easy to mock out
-            if (object instanceof GroovyEngine.EngineClassLoader) {
-                options?.columns?.each { col ->
-                    output << "$col=" + object."$col"
-                }
-                return
-            }
-            output << object.toString()
-        }
-
-        @Override
-        boolean refresh() {
-            false
-        }
-    }
-}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/HelpCommandTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/HelpCommandTest.groovy
index 3c715550a2..12ec379986 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/HelpCommandTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/HelpCommandTest.groovy
@@ -18,19 +18,30 @@
  */
 package org.apache.groovy.groovysh.commands
 
+import org.junit.jupiter.api.Test
+
 /**
- * Tests for the {@link HelpCommand} class.
+ * Tests for the {@code /help} command, registered by JLine's
+ * {@code SystemRegistryImpl} and renamed in {@link SystemTestSupport} to
+ * match the leading-slash convention production uses.
+ *
+ * The help builtin writes through {@code terminal.writer()} rather than
+ * through the printer, so this test demonstrates the
+ * {@link SystemTestSupport#terminalOutput()} capture pattern for any
+ * future test that needs to assert on terminal-side output.
  */
-class HelpCommandTest /*extends CommandTestSupport */{
-    void testList() {
-//        shell.execute(HelpCommand.COMMAND_NAME)
-    }
-
-    void testCommandHelp() {
-//        shell.execute(HelpCommand.COMMAND_NAME + ' exit')
-    }
+class HelpCommandTest extends SystemTestSupport {
 
-    void testCommandHelpInvalidCommand() {
-//        shell.execute(HelpCommand.COMMAND_NAME + ' no-such-command')
+    @Test
+    void helpListsKnownCommands() {
+        system.execute('/help')
+        def out = terminalOutput()
+        assert !out.empty
+        // A handful of stable command names that should appear in the listing.
+        // Names only — don't assert on layout, alignment, or descriptions, so
+        // the test stays robust across JLine cosmetic changes and platforms.
+        assert out.contains('help')
+        assert out.contains('show')
+        assert out.contains('exit')
     }
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
new file mode 100644
index 0000000000..b5ec346c0a
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/InspectTest.groovy
@@ -0,0 +1,53 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the {@code /inspect} command. Exercises GroovyCommands and the
+ * vendored ObjectInspector — neither of which had any direct test coverage
+ * before. The command writes via the printer, so assertions go against
+ * {@code printer.output}.
+ */
+class InspectTest extends SystemTestSupport {
+
+    @Test
+    void inspectMethodsListsKnownMembers() {
+        console.execute('dummy', "data = [a: 1, b: 2]")
+        system.execute('/inspect --methods $data')
+        def out = printer.output.join()
+        // LinkedHashMap exposes these methods; assert on names without
+        // pinning to formatting/columns/parameter shapes.
+        assert out.contains('get')
+        assert out.contains('put')
+        assert out.contains('size')
+    }
+
+    @Test
+    void inspectInfoListsPropertyCategories() {
+        console.execute('dummy', "data = [a: 1, b: 2]")
+        system.execute('/inspect --info $data')
+        def out = printer.output.join()
+        // ObjectInspector.properties() returns a map with these keys.
+        assert out.contains('propertyInfo')
+        assert out.contains('publicFields')
+        assert out.contains('classProps')
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/MethodsTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/MethodsTest.groovy
index 08977f9159..f591b1f2b2 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/MethodsTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/MethodsTest.groovy
@@ -35,4 +35,13 @@ class MethodsTest extends SystemTestSupport {
         system.execute('/methods -d twice')
         assert !engine.methodNames.contains('twice')
     }
+
+    @Test
+    void testDeleteNonexistentMethodIsHarmless() {
+        // /methods -d on an unknown method should not throw; the engine's
+        // method registry stays stable.
+        def before = engine.methodNames.toSet()
+        system.execute('/methods -d noSuchMethodEverDefined')
+        assert engine.methodNames.toSet() == before
+    }
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/PipeTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/PipeTest.groovy
new file mode 100644
index 0000000000..1c20185722
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/PipeTest.groovy
@@ -0,0 +1,61 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the {@code /pipe} command — JLine's user-defined pipe operator
+ * registry. Operators take the form {@code /pipe OPERATOR PREFIX POSTFIX};
+ * uses of {@code OPERATOR} on subsequent lines have the right-hand side
+ * wrapped between {@code PREFIX} and {@code POSTFIX} before evaluation.
+ *
+ * Documented in {@code groovysh.adoc}; this is the first automated coverage
+ * for the user-defined pipe path. The tests cover the {@code /pipe} command
+ * surface (define, list, delete, reserved-name rejection) — the actual
+ * pipeline-rewriting machinery is JLine's responsibility upstream.
+ */
+class PipeTest extends SystemTestSupport {
+
+    @Test
+    void definedPipeAppearsInList() {
+        system.execute("/pipe |? '.findAll{' '}'")
+        printer.output.clear()
+        system.execute('/pipe --list')
+        // pipes is rendered as a Map<String, List<String>>; the key is the
+        // operator name. Substring-match on the operator (and the prefix)
+        // tolerates rendering changes between JLine versions.
+        def out = printer.output.join()
+        assert out.contains('|?')
+        assert out.contains('.findAll{')
+    }
+
+    @Test
+    void deleteAllRemovesPreviouslyDefinedPipes() {
+        system.execute("/pipe |? '.findAll{' '}'")
+        system.execute("/pipe |* '.collect{' '}'")
+        system.execute('/pipe --delete *')
+        printer.output.clear()
+        system.execute('/pipe --list')
+        def out = printer.output.join()
+        assert !out.contains('|?')
+        assert !out.contains('|*')
+    }
+
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SaveLoadTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SaveLoadTest.groovy
new file mode 100644
index 0000000000..63d764330a
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SaveLoadTest.groovy
@@ -0,0 +1,76 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Tests for the {@code /save} and {@code /load} commands — round-tripping
+ * the engine's buffer (variables, methods, type definitions) to a file and
+ * back. Flagship persistence feature; previously had no automated coverage.
+ */
+class SaveLoadTest extends SystemTestSupport {
+
+    @TempDir
+    Path tmp
+
+    @Test
+    void saveLoadRoundTrip() {
+        // Establish session state. Note: /save serialises the engine's
+        // *buffer*, which in default (non-interpreter) mode contains
+        // imports/types/methods but not bare variable assignments. So we
+        // round-trip definitions; the variables-via-shared-data path is a
+        // separate code branch (no-arg /save) not covered here.
+        engine.execute('import java.awt.Point')
+        engine.execute('def doubler(n) { n * 2 }')
+        engine.execute('class Probe {}')
+        assert engine.methodNames.contains('doubler')
+        assert engine.types.containsKey('Probe')
+        assert engine.imports.values().any { it.contains('java.awt.Point') }
+
+        Path file = tmp.resolve('session.groovy')
+        system.execute("/save ${forwardSlashes(file)}")
+
+        assert Files.exists(file)
+        def saved = file.text
+        // Assert on identifiers that must round-trip; don't pin to
+        // whitespace, ordering, or how the snippets are joined.
+        assert saved.contains('java.awt.Point')
+        assert saved.contains('doubler')
+        assert saved.contains('Probe')
+
+        // Wipe the engine; verify a clean slate.
+        engine.reset()
+        assert !engine.methodNames.contains('doubler')
+        assert !engine.types.containsKey('Probe')
+
+        // Replay the saved buffer.
+        system.execute("/load ${forwardSlashes(file)}")
+
+        // Loaded state matches the original; methods evaluate.
+        assert engine.methodNames.contains('doubler')
+        assert engine.execute('doubler(21)') == 42
+        assert engine.types.containsKey('Probe')
+        assert engine.imports.values().any { it.contains('java.awt.Point') }
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpCsvFallbackTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpCsvFallbackTest.groovy
new file mode 100644
index 0000000000..4fb64a5eb3
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpCsvFallbackTest.groovy
@@ -0,0 +1,79 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import groovy.grape.Grape
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Variant tests for the {@code /slurp} CSV path that exercise classpath
+ * configurations the default test classpath can't represent. Each test runs
+ * in a freshly forked JVM with {@code groovy-csv} filtered off the
+ * classpath, so the engine genuinely cannot resolve {@code 
groovy.csv.CsvSlurper}.
+ *
+ * The "happy path" (groovy-csv available, preferred over commons-csv) is
+ * covered in-process by {@link SlurpTest#slurpCsvProducesListOfMaps}.
+ */
+class SlurpCsvFallbackTest extends SystemTestSupport {
+
+    @TempDir
+    Path tmp
+
+    @Test
+    @ForkedJvm(excludeFromClasspath = ['groovy-csv'])
+    void slurpCsvErrorsWhenNoCsvLibraryAvailable() {
+        Path file = Files.writeString(tmp.resolve('whiskey.csv'), 
"name,region\nLagavulin,Islay\n")
+        // parseCsv throws IllegalArgumentException when neither
+        // groovy.csv.CsvSlurper nor org.apache.commons.csv.CSVFormat is on
+        // the classpath; slurpcmd's outer catch re-throws so the user sees
+        // a clear error message rather than a silently null result.
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute("data = /slurp ${forwardSlashes(file)}")
+        }
+        assert thrown.message.contains('CSV format requires')
+        assert thrown.message.contains('groovy.csv.CsvSlurper')
+        assert thrown.message.contains('org.apache.commons.csv.CSVFormat')
+    }
+
+    @Test
+    @ForkedJvm(excludeFromClasspath = ['groovy-csv'])
+    @EnabledIfSystemProperty(named = 'junit.network', matches = 'true')
+    void slurpCsvUsesCommonsCsvFallback() {
+        // groovy-csv is filtered off the classpath; pull commons-csv via
+        // Grape so parseCsv's second branch is the one that fires.
+        Grape.grab(group: 'org.apache.commons', module: 'commons-csv', 
version: '1.14.1',
+                classLoader: engine.classLoader, transitive: false)
+        Path file = Files.writeString(tmp.resolve('whiskey.csv'),
+                "name,region\nLagavulin,Islay\nMacallan,Speyside\n")
+        system.execute("rows = /slurp ${forwardSlashes(file)}")
+        def rows = console.getVariable('rows')
+        assert rows instanceof List
+        assert rows.size() == 2
+        assert rows[0].name == 'Lagavulin' && rows[0].region == 'Islay'
+        assert rows[1].name == 'Macallan' && rows[1].region == 'Speyside'
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpTest.groovy
new file mode 100644
index 0000000000..27f0f89a6a
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SlurpTest.groovy
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Tests for the {@code /slurp} command, registered by JLine's
+ * {@code ConsoleEngineImpl}. Confirms format detection by file extension
+ * for the formats most groovysh users reach for, and verifies the result
+ * is bound to a Groovy variable when assigned with {@code = /slurp ...}.
+ *
+ * Uses {@link TempDir} for fixture lifecycle so the support class doesn't
+ * grow a dedicated tempDir field for every file-touching test.
+ */
+class SlurpTest extends SystemTestSupport {
+
+    @TempDir
+    Path tmp
+
+    @Test
+    void slurpJsonProducesMap() {
+        Path file = Files.writeString(tmp.resolve('answer.json'), 
'{"answer":42,"name":"groovysh"}')
+        system.execute("data = /slurp ${forwardSlashes(file)}")
+        def value = console.getVariable('data')
+        assert value != null
+        assert value.answer == 42
+        assert value.name == 'groovysh'
+    }
+
+    @Test
+    void slurpPropertiesProducesMap() {
+        Path file = Files.writeString(tmp.resolve('app.properties'), 
"name=groovysh\nversion=4.x\n")
+        system.execute("config = /slurp ${forwardSlashes(file)}")
+        def value = console.getVariable('config')
+        assert value != null
+        assert value.name == 'groovysh'
+        assert value.version == '4.x'
+    }
+
+    @Test
+    void slurpCsvProducesListOfMaps() {
+        // Requires groovy.csv.CsvSlurper on the classpath; supplied here via
+        // the testImplementation projects.groovyCsv dependency.
+        Path file = Files.writeString(tmp.resolve('whiskey.csv'),
+                "name,region\nLagavulin,Islay\nMacallan,Speyside\n")
+        system.execute("rows = /slurp ${forwardSlashes(file)}")
+        def rows = console.getVariable('rows')
+        assert rows instanceof List
+        assert rows.size() == 2
+        assert rows[0].name == 'Lagavulin' && rows[0].region == 'Islay'
+        assert rows[1].name == 'Macallan' && rows[1].region == 'Speyside'
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SystemTestSupport.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SystemTestSupport.groovy
index e9bd2a8215..f2ba1c8746 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SystemTestSupport.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/SystemTestSupport.groovy
@@ -21,27 +21,95 @@ package org.apache.groovy.groovysh.commands
 import org.apache.groovy.groovysh.jline.GroovySystemRegistry
 import org.jline.terminal.Terminal
 import org.jline.terminal.TerminalBuilder
-
+import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 
+import java.nio.charset.StandardCharsets
+import java.nio.file.Path
 import java.util.function.Supplier
 
 /**
  * Support for testing commands involving {@link GroovySystemRegistry}.
+ *
+ * The terminal is built explicitly as a {@code dumb} terminal with empty input
+ * and a captured byte buffer for output. This keeps tests deterministic across
+ * platforms — no TTY probing, no native FFM/JNI bindings, no signal handlers
+ * tied to the JVM's actual stdin/stdout.
+ *
+ * <p>Two output-capture paths are available; pick by where the command writes:
+ * <ul>
+ *   <li>{@code printer.output} — for commands that produce results via
+ *       {@code printer.println(options, object)}. Most {@code GroovyCommands}
+ *       commands ({@code /show}, {@code /prnt}, {@code /inspect},
+ *       {@code /classloader}, {@code /types}, {@code /methods}, …) take this
+ *       path. The captured strings are the {@code object.toString()} forms;
+ *       Groovy MetaClass dispatch is preserved, so a Map renders as
+ *       {@code [k:v]}.
+ *   <li>{@link #terminalOutput()} — for JLine builtins that write directly
+ *       through {@code terminal.writer()} (e.g. {@code /help}). Returns the
+ *       raw bytes decoded as UTF-8; the dumb terminal also emits a couple of
+ *       capability-probe escapes at startup, so prefer substring matches over
+ *       full-string compares.
+ * </ul>
+ *
+ * See {@code subprojects/groovy-groovysh/AGENTS.md} for the platform-fragility
+ * rationale and the layered test design.
  */
 abstract class SystemTestSupport extends ConsoleTestSupport {
 
     protected GroovySystemRegistry system
+    protected Terminal terminal
+    private ByteArrayOutputStream terminalBytes
 
     @BeforeEach
     @Override
     void setUp() {
         super.setUp()
         Supplier workDir = { configPath.getUserConfig('.') }
-        Terminal terminal = TerminalBuilder.builder().build()
+        terminalBytes = new ByteArrayOutputStream()
+        terminal = TerminalBuilder.builder()
+                .dumb(true)
+                .streams(new ByteArrayInputStream(new byte[0]), terminalBytes)
+                .encoding(StandardCharsets.UTF_8)
+                .name('groovysh-test')
+                .build()
         system = new GroovySystemRegistry(reader.parser, terminal, workDir, 
configPath).tap {
             setCommandRegistries(console, groovy)
+            // Match production wiring: SystemRegistryImpl's built-in commands
+            // are renamed to use the leading-slash convention groovysh exposes
+            // to users.
+            renameLocal 'exit', '/exit'
+            renameLocal 'help', '/help'
         }
     }
 
+    @AfterEach
+    void tearDownSystem() {
+        terminal?.close()
+    }
+
+    /**
+     * Returns text written to the terminal so far, decoded as UTF-8. The
+     * underlying terminal is {@code dumb}, so no ANSI escape sequences are
+     * produced — the returned string is plain text suitable for substring
+     * assertions.
+     */
+    protected String terminalOutput() {
+        terminal?.writer()?.flush()
+        new String(terminalBytes.toByteArray(), StandardCharsets.UTF_8)
+    }
+
+    /**
+     * Returns a forward-slash form of the supplied path, suitable for
+     * interpolating into a {@code system.execute(...)} line. JLine's
+     * DefaultParser treats {@code \} as an escape character, so a
+     * Windows-native path like {@code C:\Users\runner\…\foo.json} would
+     * have its separators eaten before reaching the command. Java NIO
+     * accepts forward-slash paths on Windows, so this normalisation
+     * works on every platform.
+     */
+    protected static String forwardSlashes(Path path) {
+        path.toString().replace('\\', '/')
+    }
+
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
index 8080b4f3d2..ceadf79822 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/TypesTest.groovy
@@ -44,4 +44,13 @@ class TypesTest extends SystemTestSupport {
         system.execute('/types -d R')
         assert engine.types.keySet() == ['I', 'T', 'E', 'A'] as Set
     }
+
+    @Test
+    void testDeleteNonexistentTypeIsHarmless() {
+        // /types -d on an unknown type should not throw or corrupt the
+        // type registry.
+        def before = engine.types.keySet().toSet()
+        system.execute('/types -d NoSuchType')
+        assert engine.types.keySet().toSet() == before
+    }
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
new file mode 100644
index 0000000000..da853d70ed
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
@@ -0,0 +1,73 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.jline
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Direct tests for {@link GroovyEngine}. The engine is the foundation of the
+ * groovysh stack — it holds the binding, runs scripts, and tracks user-defined
+ * imports / variables / methods / types. Exercising it directly (no JLine
+ * registry, console, or terminal) gives the most portable test layer.
+ */
+class GroovyEngineTest {
+
+    private final GroovyEngine engine = new GroovyEngine()
+
+    @Test
+    void executeReturnsLastValue() {
+        assert engine.execute('1 + 1') == 2
+        assert engine.execute("'hi' + ' there'") == 'hi there'
+    }
+
+    @Test
+    void variablesPersistAcrossExecutes() {
+        engine.execute('x = 5')
+        assert engine.hasVariable('x')
+        assert engine.execute('x * 2') == 10
+    }
+
+    @Test
+    void putAndHasVariable() {
+        engine.put('answer', 42)
+        assert engine.hasVariable('answer')
+        assert engine.execute('answer') == 42
+    }
+
+    @Test
+    void methodDefinitionsTracked() {
+        engine.execute('def twice(n) { n * 2 }')
+        assert engine.methodNames.contains('twice')
+        assert engine.execute('twice(21)') == 42
+    }
+
+    @Test
+    void typesAccumulate() {
+        engine.execute('class Foo {}')
+        engine.execute('interface Bar {}')
+        engine.execute('enum Baz { A, B }')
+        assert engine.types.keySet().containsAll(['Foo', 'Bar', 'Baz'])
+    }
+
+    @Test
+    void importsTracked() {
+        engine.execute('import java.awt.Point')
+        assert engine.imports.values().any { it.contains('java.awt.Point') }
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommandsTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommandsTest.groovy
new file mode 100644
index 0000000000..ee42b41ddf
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPosixCommandsTest.groovy
@@ -0,0 +1,138 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.jline
+
+import org.jline.terminal.Terminal
+import org.jline.terminal.TerminalBuilder
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.function.Function
+
+/**
+ * Direct unit tests for static methods in {@link GroovyPosixCommands} — the
+ * Apache-licensed fork of JLine's PosixCommands. Bypasses the registry stack
+ * and constructs a {@link GroovyPosixContext} directly so each test exercises
+ * exactly one command function. JLine refactors PosixCommands frequently;
+ * these tests reduce regression risk on bumps.
+ */
+class GroovyPosixCommandsTest {
+
+    private Terminal terminal
+    private ByteArrayOutputStream out
+    private ByteArrayOutputStream err
+    private Path tempDir
+
+    @BeforeEach
+    void setUp() {
+        terminal = TerminalBuilder.builder()
+                .dumb(true)
+                .streams(new ByteArrayInputStream(new byte[0]), new 
ByteArrayOutputStream())
+                .encoding(StandardCharsets.UTF_8)
+                .name('groovysh-test')
+                .build()
+        out = new ByteArrayOutputStream()
+        err = new ByteArrayOutputStream()
+        tempDir = Files.createTempDirectory('groovysh-test-')
+    }
+
+    @AfterEach
+    void tearDown() {
+        terminal?.close()
+        // Clean up any files left in the temp dir, then the dir itself.
+        tempDir?.toFile()?.deleteDir()
+    }
+
+    private GroovyPosixContext context() {
+        new GroovyPosixContext(
+                new ByteArrayInputStream(new byte[0]),
+                new PrintStream(out, true, StandardCharsets.UTF_8),
+                new PrintStream(err, true, StandardCharsets.UTF_8),
+                tempDir,
+                terminal,
+                { name -> null } as Function<String, Object>)
+    }
+
+    private String stdout() {
+        // .normalize() collapses platform line separators to "\n" so
+        // line-aware assertions work uniformly — PrintStream.println uses
+        // System.lineSeparator() which is "\r\n" on Windows.
+        new String(out.toByteArray(), StandardCharsets.UTF_8).normalize()
+    }
+
+    @Test
+    void catReadsFileContents() {
+        Path file = Files.writeString(tempDir.resolve('hello.txt'), "first 
line\nsecond line\n")
+        GroovyPosixCommands.cat(context(), ['/cat', file.toString()] as 
Object[])
+        def output = stdout()
+        assert output.contains('first line')
+        assert output.contains('second line')
+    }
+
+    @Test
+    void catWithNumberFlagPrependsLineNumbers() {
+        Path file = Files.writeString(tempDir.resolve('numbered.txt'), 
"alpha\nbeta\n")
+        GroovyPosixCommands.cat(context(), ['/cat', '-n', file.toString()] as 
Object[])
+        def output = stdout()
+        // -n produces lines like "     1\talpha". Don't assert on exact 
spacing
+        // (it's right-aligned in 6 columns); check the line numbers and 
content
+        // appear together.
+        assert output =~ /1\s*\talpha/
+        assert output =~ /2\s*\tbeta/
+    }
+
+    @Test
+    void grepReturnsOnlyMatchingLines() {
+        Path file = Files.writeString(tempDir.resolve('fruits.txt'),
+                "apple\nbanana\ncherry\nblueberry\n")
+        // --color=never disables ANSI match highlighting so the asserted
+        // substrings appear contiguously in the output.
+        GroovyPosixCommands.grep(context(), ['/grep', '--color=never', 'b', 
file.toString()] as Object[])
+        def output = stdout()
+        assert output.contains('banana')
+        assert output.contains('blueberry')
+        assert !output.contains('apple')
+        assert !output.contains('cherry')
+    }
+
+    @Test
+    void sortReorderLinesAlphabetically() {
+        Path file = Files.writeString(tempDir.resolve('mix.txt'),
+                "cherry\napple\nbanana\n")
+        GroovyPosixCommands.sort(context(), ['/sort', file.toString()] as 
Object[])
+        def lines = stdout().split('\n').findAll { it }
+        assert lines == ['apple', 'banana', 'cherry']
+    }
+
+    @Test
+    void headDefaultsToFirstTenLines() {
+        def content = (1..15).collect { "line${it}" }.join('\n') + '\n'
+        Path file = Files.writeString(tempDir.resolve('many.txt'), content)
+        GroovyPosixCommands.head(context(), ['/head', file.toString()] as 
Object[])
+        def output = stdout()
+        // First ten lines appear; eleventh and beyond don't.
+        assert output.contains('line1\n')
+        assert output.contains('line10')
+        assert !output.contains('line11')
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovySystemRegistryTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovySystemRegistryTest.groovy
new file mode 100644
index 0000000000..deccd9b528
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovySystemRegistryTest.groovy
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.groovysh.jline
+
+import org.apache.groovy.groovysh.commands.SystemTestSupport
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the Groovy-specific overrides in {@link GroovySystemRegistry}:
+ * pipe-operator renames, {@code /!} command-prefix recognition, and the
+ * {@code execute()} rewriting that strips whitespace around {@code =} when
+ * the right-hand side is a command. Uses {@link SystemTestSupport} so the
+ * full registry stack is available for the execute-path assertions.
+ */
+class GroovySystemRegistryTest extends SystemTestSupport {
+
+    @Test
+    void pipeOperatorsAreRenamedForGroovy() {
+        // The Groovy fork rebinds the SystemRegistryImpl pipe operators so 
they
+        // don't collide with Groovy operators (||, &&, >>, etc).
+        def names = system.pipeNames
+        assert names.contains('|||')   // Pipe.OR  (was '||')
+        assert names.contains('|&&')   // Pipe.AND (was '&&')
+        assert names.contains('|>')    // Pipe.REDIRECT (was '>')
+        assert names.contains('|>>')   // Pipe.APPEND (was '>>')
+    }
+
+    @Test
+    void bangPrefixIsRecognisedAsCommand() {
+        // /!ls etc must be claimed by isCommandOrScript so the parser routes
+        // them to the registered shell-out handler instead of evaluating them
+        // as Groovy expressions.
+        assert system.isCommandOrScript('/!ls')
+        assert system.isCommandOrScript('/!cd')
+        assert !system.isCommandOrScript('groovyExpression')
+    }
+
+    @Test
+    void plainGroovyAssignmentPassesThrough() {
+        // When the right-hand side is not a command (no leading slash), the
+        // execute() override leaves the line unchanged and Groovy evaluates 
it.
+        system.execute('answer = 42')
+        assert console.hasVariable('answer')
+        assert console.getVariable('answer') == 42
+    }
+
+    @Test
+    void commandResultAssignmentSurvivesWhitespaceAroundEquals() {
+        // SystemRegistryImpl assumes no whitespace around `=` in 
command-result
+        // assignments. Our execute() rewrite normalises `x = /show` to
+        // `x=/show` so it parses as one. Without the rewrite, JLine would
+        // either error or hand the line to Groovy for evaluation, which
+        // would not bind `result` here.
+        console.execute('dummy', 'a = 99')
+        system.execute('result = /show')
+        assert console.hasVariable('result')
+    }
+}

Reply via email to