diff --git a/.agents/skills/groovy-tests/SKILL.md b/.agents/skills/groovy-tests/SKILL.md index c5dc111d5b6..1479fdcb0f9 100644 --- a/.agents/skills/groovy-tests/SKILL.md +++ b/.agents/skills/groovy-tests/SKILL.md @@ -69,6 +69,21 @@ These are the recurring mistakes when working with Groovy tests: 7. **Orphaned tagged regions.** A `// tag::...[] ... // end::...[]` block in `src/spec/test/` that no AsciiDoc file `include::`'s is dead weight. If you removed the include, remove the tagged region too. 8. **`./gradlew test` as the inner loop.** It builds and runs the whole core suite. Use targeted runs (`:test --tests ` or `::test --tests `) for development; reserve the full run for the final pre-PR check. 9. **JDK-preview-dependent test in the wrong location.** Tests that need `--enable-preview` go in `subprojects/tests-preview/src/test/`, not core `src/test/`. +10. **Using `String.valueOf(object)` in test assertions.** It calls Java's static `String.valueOf` and bypasses Groovy MetaClass dispatch — Maps render as `{k=v}` instead of `[k:v]`, and similar mismatches hit other Groovy-flavoured collections. Use `object.toString()` so the Groovy extensions apply. (`null.toString()` returns `'null'` in Groovy, so no separate null guard is needed.) +11. **Locale-, platform-, or format-dependent assertions.** Don't bake JVM defaults into expected output — locale (number/date formatting), default timezone, line endings, file path separators, and default charset all vary across CI agents and contributor machines. Symptom: a test passes for the author and fails on a colleague's Windows box, or starts failing when CI rotates locales. Two patterns that bite repeatedly: + - **Path strings interpolated into a parsed command line.** A Windows-native `Path.toString()` like `C:\Users\…\foo.json` interpolated into a `system.execute("cmd ${file}")`-style line gets its backslashes eaten by JLine's `DefaultParser` (which treats `\` as an escape). Forward-slash the path before interpolating: `path.toString().replace('\\', '/')`. Java NIO accepts forward-slash paths on Windows. + - **Output captured from `PrintStream.println`.** `println` uses `System.lineSeparator()`, which is `\r\n` on Windows. Line-aware assertions (`output.split('\n')`, `output.contains('foo\n')`) silently fail on Windows. Use Groovy's `String.normalize()` extension to collapse platform line separators to `\n` before splitting/comparing. + Other defences: `Locale.ROOT` for date/number formatting, explicit `StandardCharsets.UTF_8` rather than the platform default, or assert on parsed values rather than their stringified forms. +12. **`-Djunit.network` doesn't reach the test JVM by default.** The build-logic uses it at the source-set filter level (excluding `groovy/grape/` paths from compilation when unset). If you need the property visible at runtime — for example to gate a non-Grape network test via `@EnabledIfSystemProperty(named = 'junit.network', matches = 'true')` — the subproject's `build.gradle` has to forward it: + + ```groovy + tasks.named('test') { + def network = System.getProperty('junit.network') + if (network) systemProperty 'junit.network', network + } + ``` + + Without forwarding the gated test always skips, even with `-Djunit.network=true` on the Gradle CLI. ## Procedure for a JIRA regression test diff --git a/.agents/skills/groovysh/SKILL.md b/.agents/skills/groovysh/SKILL.md new file mode 100644 index 00000000000..831e8b19a06 --- /dev/null +++ b/.agents/skills/groovysh/SKILL.md @@ -0,0 +1,158 @@ + +--- +name: groovysh +description: Guidance for changes in subprojects/groovy-groovysh/ — REPL command implementations, JLine integration, vendored fork files, and the layered terminal-aware test stack. Use when modifying anything in that subproject's tree, bumping the JLine version, or syncing the vendored fork files against upstream. +license: Apache-2.0 +compatibility: claude, codex, copilot, cursor, gemini, aider +metadata: + audience: contributors to apache/groovy + scope: subproject-groovy-groovysh +--- + +# groovysh + +Use this skill for work in the `subprojects/groovy-groovysh/` tree — +the interactive Groovy REPL. The subproject is unusual in two ways: + +1. It vendors files from JLine because we can't depend on the upstream + `jline-groovy` artifact (circular dependency on Groovy itself). +2. Its tests touch a real JLine `Terminal`, making them more + platform-sensitive than the rest of the codebase. + +This skill layers on top of [`groovy-tests`](../groovy-tests/SKILL.md) — +load both together when adding tests for groovysh code. + +## When to use this skill + +**Use it for:** + +- REPL command changes (`subprojects/groovy-groovysh/src/main/groovy/.../jline/`). +- Anything terminal-related: writing tests, integrating JLine APIs, image rendering. +- Bumping the JLine version in `versions.properties`. +- Syncing vendored fork files against upstream JLine. + +**Don't use it for:** + +- Pure compiler/runtime changes elsewhere — use [`groovy-internals`](../groovy-internals/SKILL.md). +- Changes to the project-wide build (root `build.gradle`, `build-logic/`) — use [`groovy-build`](../groovy-build/SKILL.md). + +## Read first + +- [`subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc`](../../../subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc) — user-facing reference, the source of truth for command behaviour. +- The test support classes under `subprojects/groovy-groovysh/src/test/.../commands/`: `ConsoleTestSupport` (engine + console + printer) and `SystemTestSupport` (adds dumb terminal + system registry). + +## Vendored JLine files + +Five BSD-licensed files under `src/main/groovy/.../jline/` (`GroovyEngine.java`, +`PackageHelper.java`, `JrtJavaBasePackages.java`, `ObjectInspector.groovy`, +`Utils.groovy`) are forks of JLine sources, kept in-tree because the +upstream artifact (`org.jline:jline-groovy`) depends on `org.apache.groovy:groovy` +and would create a circular dependency. `GroovyPosixCommands.java` is +similarly derived but diverged enough to be Apache-licensed. + +If our customisations are merged upstream, the goal is to delete the +in-tree forks and depend on the upstream artifact instead. Until then, +treat the forks as code we own — but check the upstream version when +bumping JLine, in case there are fixes worth picking up. + +## Test layers + +Three layers of decreasing portability — prefer the lowest one that +demonstrates the property under test: + +1. **Engine** — `GroovyEngine` directly; no terminal, no registry. See `GroovyEngineTest`. +2. **Registry** — `GroovySystemRegistry` over a dumb terminal. See `GroovySystemRegistryTest`. +3. **Command** — full `SystemTestSupport` stack. See `DelTest`, `ImportTest` for printer-based assertions; `HelpCommandTest` for the `terminalOutput()` capture pattern when a builtin writes through `terminal.writer()` instead of the printer. + +## Top failure modes + +1. **`TerminalBuilder.builder().build()` in a test.** Auto-detects the + JVM's TTY and may probe native bindings. Use + `TerminalBuilder.builder().dumb(true).streams(...).build()` instead; + `SystemTestSupport` already does this. +2. **Asserting on full terminal output strings.** Prefer + `printer.output` — the `DummyPrinter` captures `object.toString()`, + bypassing ANSI rendering. For JLine builtins that write through + `terminal.writer()` instead (e.g. `/help`), use + `SystemTestSupport.terminalOutput()` and assert on stable + substrings, never on full-string compares. The dumb terminal still + emits capability-probe escapes (`\e[?2027$p\e[c`) at startup, and + JLine layout/spacing shifts between releases. +3. **Treating the vendored forks as independent.** They are tightly + coupled to the `GroovyEngine` deep fork — the small files reference + `GroovyEngine.Format` etc. Don't delete one without re-deriving the + coupling. +4. **Confusing "we changed it" vs "upstream changed it".** When + syncing forks, diff against the *fork-base* tag (the JLine version + we originally forked from), not just current upstream. Otherwise + our renames look like upstream additions. +5. **Network/Maven tests without `-Djunit.network=true` gating.** + `/grab` and similar pull from the network; they must be opt-in. +6. **Hard-coded or implicit terminal width.** The dumb terminal + reports columns/rows of 0. If a test cares, set + `terminal.size = new Size(120, 40)` explicitly. +7. **Calling `getWidth()`/`getHeight()` after a JLine bump.** + Deprecated since JLine 4.x; use `getColumns()`/`getRows()`. +8. **`/grep` and similar Posix commands emit ANSI match highlights + by default.** The colour decision is per-command, not per-terminal, + so a dumb terminal doesn't suppress it. When unit-testing, pass + `--color=never` (or strip ANSI from the captured output) so + substring assertions match contiguously. +9. **Assuming `/save ` captures variable assignments.** It + serialises `engine.buffer`, which in default mode includes only + `IMPORT|TYPE|METHOD` snippets — bare variable assignments aren't + there. Variables enter the buffer only when interpreter mode is + enabled (`GROOVYSH_OPTIONS[INTERPRETER_MODE_PREFERENCE_KEY] = true`). + The no-arg `/save` form is a separate path that JSON-serialises + `engine.sharedData` and *does* include variables. Round-trip tests + for the file-form should exercise definitions, not bare variables. + +(Locale/platform/format brittleness is covered by the project-wide +[`groovy-tests`](../groovy-tests/SKILL.md) skill — it's not unique to +groovysh, though terminal-aware tests are particularly exposed.) + +## Procedure for a JLine version bump + +1. Bump `versions.properties:jline=...`. +2. Run `:groovy-groovysh:test`. +3. Compile-scan for new deprecation warnings; fix at the call site. +4. Diff each vendored fork against the new upstream tag. Pick up + substantive upstream fixes; skip cosmetic noise. +5. Update this skill if any finding above changed. + +## Validation checklist + +Before declaring a groovysh change ready: + +- [ ] Tests use `dumb(true).streams(...)` for any terminal they construct. +- [ ] Output assertions use stable substrings, not full-string compares. Prefer `printer.output`; for terminal-side use `terminalOutput()`; for Posix commands use the context's `out` buffer with `--color=never` where applicable. +- [ ] No locale-, platform-, or width-dependent assumptions. +- [ ] `@AfterEach` closes any terminal the test constructed. +- [ ] Network/Maven tests are gated by `-Djunit.network=true` or skipped. +- [ ] No new calls to deprecated JLine APIs (`getWidth`/`getHeight`). +- [ ] If a fork file was synced, the diff against fork-base distinguishes our changes from upstream. + +## References + +- [`subprojects/groovy-groovysh/AGENTS.md`](../../../subprojects/groovy-groovysh/AGENTS.md) — subproject pointer file that loads this skill. +- [`subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc`](../../../subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc) — user-facing reference. +- [`subprojects/groovy-groovysh/LICENSE`](../../../subprojects/groovy-groovysh/LICENSE) — provenance for the BSD-licensed vendored files. +- [`groovy-tests`](../groovy-tests/SKILL.md) — sister skill for test conventions; load alongside this one. +- [`AGENTS.md`](../../../AGENTS.md) — root agent guide. diff --git a/AGENTS.md b/AGENTS.md index b19bac690c7..a35f2094f55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,8 +127,21 @@ into the human-facing docs above. | Skill | Use for | |---|---| +| [`groovy-build`](.agents/skills/groovy-build/SKILL.md) | Gradle build changes — convention plugins, build files, dependency verification, ASM/ANTLR repackaging, OSGi, release pipeline | | [`groovy-internals`](.agents/skills/groovy-internals/SKILL.md) | Compiler and runtime work — parser, AST, type checker, transforms, class generation | | [`groovy-tests`](.agents/skills/groovy-tests/SKILL.md) | Adding or modifying tests, including JIRA regression tests and executable AsciiDoc examples | +| [`groovysh`](.agents/skills/groovysh/SKILL.md) | Work in `subprojects/groovy-groovysh/` — REPL commands, JLine integration, vendored forks, terminal-aware test stack | + +## Subproject guides + +Some subprojects have their own `AGENTS.md` with content specific to +that module — additional architecture, test infrastructure, or +conventions that don't apply elsewhere. Load the relevant subproject's +guide when working in its directory tree. + +| Subproject | Scope | +|---|---| +| [`groovy-groovysh`](subprojects/groovy-groovysh/AGENTS.md) | Interactive REPL, JLine integration, vendored forks, terminal-aware test stack | ## Where to ask diff --git a/subprojects/groovy-groovysh/AGENTS.md b/subprojects/groovy-groovysh/AGENTS.md new file mode 100644 index 00000000000..afc82406e7a --- /dev/null +++ b/subprojects/groovy-groovysh/AGENTS.md @@ -0,0 +1,41 @@ + + +# Agent Guide for groovy-groovysh + +Subproject-specific supplement to the [root `AGENTS.md`](../../AGENTS.md). + +## What's special about this subproject + +- groovysh is the interactive Groovy REPL, built on JLine 4.x. +- It vendors a small set of files derived from JLine sources, plus a + deep fork of `GroovyEngine.java` that we maintain ourselves. +- Tests touch a real JLine `Terminal`, which makes them more + platform-sensitive than the rest of the codebase. + +For substantive guidance — what to read first, the vendored fork +inventory, test layers, top failure modes, the JLine bump procedure, +and the platform-fragility checklist — load the +[`groovysh`](../../.agents/skills/groovysh/SKILL.md) skill. + +## References + +- [Root `AGENTS.md`](../../AGENTS.md) — licensing, commit conventions, project-wide rules. +- [`src/spec/doc/groovysh.adoc`](src/spec/doc/groovysh.adoc) — user-facing reference. +- [`LICENSE`](LICENSE) — provenance for the BSD-licensed vendored files. diff --git a/subprojects/groovy-groovysh/build.gradle b/subprojects/groovy-groovysh/build.gradle index dc23b9eb445..9469ea0a602 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 c4b99fe65f1..ac2513bc2dc 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/ClassLoaderTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ClassLoaderTest.groovy new file mode 100644 index 00000000000..9bda1f336f0 --- /dev/null +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ClassLoaderTest.groovy @@ -0,0 +1,46 @@ +/* + * 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 /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 ClassLoaderTest extends SystemTestSupport { + + @Test + 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() + // 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 4782c4244b8..970ca949879 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 workDir = { -> temp } as Supplier 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 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 00000000000..70565df6bb6 --- /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 bb441de2e2e..00000000000 --- 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 00000000000..662b75a890c --- /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 63f0b9146a3..00000000000 --- 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 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 output) { - this.output = output - } - private List output - - @Override - void println(Map 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 3c715550a26..12ec3799863 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 00000000000..b5ec346c0a9 --- /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 08977f91598..f591b1f2b26 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 00000000000..1c201857224 --- /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>; 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 00000000000..63d764330a8 --- /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 00000000000..4fb64a5eb33 --- /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 00000000000..27f0f89a6a2 --- /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 e9bd2a82157..f2ba1c8746f 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. + * + *

Two output-capture paths are available; pick by where the command writes: + *

    + *
  • {@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]}. + *
  • {@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. + *
+ * + * 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 8080b4f3d23..ceadf798221 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 00000000000..da853d70ed7 --- /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 00000000000..ee42b41ddf1 --- /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) + } + + 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 00000000000..deccd9b528e --- /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') + } +}