diff --git a/.github/workflows/build-and-snapshot.yml b/.github/workflows/build-and-snapshot.yml new file mode 100644 index 0000000..83183b7 --- /dev/null +++ b/.github/workflows/build-and-snapshot.yml @@ -0,0 +1,182 @@ +name: Build, Test and Snapshot Release + +on: + push: + branches: + - main + - master + pull_request: + schedule: + - cron: "0 0 * * 0" # Weekly on Sunday at midnight + workflow_dispatch: # Allows manual triggering + +jobs: + lint-and-test-python: + name: Lint Python Test Suite + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Setup Python test environment + run: | + cd test + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run Python linting + run: | + cd test + source venv/bin/activate + ../scripts/lint-python.sh ci + + build: + name: Build and Test Go Plugin + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: [">=1.23.5"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Install dependencies + run: go mod tidy -e || true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Lint and format Go files + run: ./scripts/lint-go.sh ci + + - name: Build binary + run: | + echo "๐Ÿ”จ Building binary..." + python3 .github/workflows/build.py + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: cf-cli-java-plugin-${{ matrix.os }} + path: dist/ + + release: + name: Create Snapshot Release + needs: [build, lint-and-test-python] + runs-on: ubuntu-latest + if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && (needs.lint-and-test-python.result == 'success' || needs.lint-and-test-python.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install Python dependencies for plugin repo generation + run: | + python -m pip install --upgrade pip + pip install PyYAML + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist/ + + - name: Combine all artifacts + run: | + mkdir -p dist + mv dist/*/* dist/ || true + + - name: Generate plugin repository YAML for snapshot + env: + GITHUB_REF_NAME: snapshot + run: | + echo "๐Ÿ“ Generating plugin repository YAML file..." + python3 -m venv venv + source venv/bin/activate + python3 -m pip install --upgrade pip + pip install PyYAML requests + python3 .github/workflows/generate_plugin_repo.py $GITHUB_REF_NAME + echo "โœ… Plugin repository YAML generated" + + - name: Generate timestamp + id: timestamp + run: echo "timestamp=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT + + - uses: thomashampson/delete-older-releases@main + with: + keep_latest: 0 + delete_tag_regex: snapshot + prerelease_only: true + delete_tags: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Delete and regenerate tag snapshot + run: | + echo "Deleting existing snapshot tag..." + git tag -d snapshot || true + git push origin :snapshot || true + echo "Regenerating snapshot tag..." + git tag snapshot + git push origin snapshot --force + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + dist/* + plugin-repo-entry.yml + plugin-repo-summary.txt + prerelease: false + draft: false + tag_name: snapshot + body: | + This is a snapshot release of the cf-cli-java-plugin. + It includes the latest changes and is not intended for production use. + Please test it and provide feedback. + + **Build Timestamp**: ${{ steps.timestamp.outputs.timestamp }} + + ## Installation + + Download the current snapshot release and install manually: + + ```sh + # on Mac arm64 + cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-macos-arm64 + # on Windows x86 + cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 + # on Linux x86 + cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-linux-amd64 + ``` + + **Note:** On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` on the plugin binary. + On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. + + You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. + + name: Snapshot Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.py b/.github/workflows/build.py new file mode 100644 index 0000000..ab32e1c --- /dev/null +++ b/.github/workflows/build.py @@ -0,0 +1,22 @@ +import os +import platform + +os.makedirs('dist', exist_ok=True) + +os_name_map = { + "darwin": "macos", + "linux": "linux", + "ubuntu": "linux", + "windows": "windows" +} +arch_map = { + "x86_64": "amd64", + "arm64": "arm64", + "aarch64": "arm64", + "amd64": "amd64" +} +os_name = os_name_map[platform.system().lower()] +arch = arch_map[platform.machine().lower()] +print(f"Building for {os_name} {arch}") + +os.system(f"go build -o dist/cf-cli-java-plugin-{os_name}-{arch}") \ No newline at end of file diff --git a/.github/workflows/generate_plugin_repo.py b/.github/workflows/generate_plugin_repo.py new file mode 100644 index 0000000..2424381 --- /dev/null +++ b/.github/workflows/generate_plugin_repo.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Generate plugin repository YAML file for CF CLI plugin repository. +This script creates the YAML file in the required format for the CF CLI plugin repository. +""" + +import os +import sys +import yaml +import hashlib +import requests +from datetime import datetime +from pathlib import Path + +def calculate_sha1(file_path): + """Calculate SHA1 checksum of a file.""" + sha1_hash = hashlib.sha1() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + sha1_hash.update(chunk) + return sha1_hash.hexdigest() + +def generate_plugin_repo_yaml(version: str): + """Generate the plugin repository YAML file.""" + repo_url = "https://github.com/SAP/cf-cli-java-plugin" + + # Define the binary platforms and their corresponding file extensions + platforms = { + "linux64": "cf-cli-java-plugin-linux-amd64", + "osx": "cf-cli-java-plugin-macos-arm64", + "win64": "cf-cli-java-plugin-windows-amd64" + } + + binaries = [] + dist_dir = Path("dist") + + for platform, filename in platforms.items(): + file_path = dist_dir / filename + if file_path.exists(): + checksum = calculate_sha1(file_path) + binary_info = { + "checksum": checksum, + "platform": platform, + "url": f"{repo_url}/releases/download/{version}/{filename}" + } + binaries.append(binary_info) + print(f"Added {platform}: {filename} (checksum: {checksum})") + else: + print(f"Warning: Binary not found for {platform}: {filename}") + + if not binaries: + print("Error: No binaries found in dist/ directory") + sys.exit(1) + + # Create the plugin repository entry + plugin_entry = { + "authors": [{ + "contact": "johannes.bechberger@sap.com", + "homepage": "https://github.com/SAP", + "name": "Johannes Bechberger" + }], + "binaries": binaries, + "company": "SAP", + "created": "2024-01-01T00:00:00Z", # Initial creation date + "description": "Plugin for profiling Java applications and getting heap and thread-dumps", + "homepage": repo_url, + "name": "java", + "updated": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "version": version + } + + # Write the YAML file + output_file = Path("plugin-repo-entry.yml") + with open(output_file, 'w') as f: + yaml.dump(plugin_entry, f, default_flow_style=False, sort_keys=False) + + print(f"Generated plugin repository YAML file: {output_file}") + print(f"Version: {version}") + print(f"Binaries: {len(binaries)} platforms") + + # Also create a human-readable summary + summary_file = Path("plugin-repo-summary.txt") + with open(summary_file, 'w') as f: + f.write(f"CF CLI Java Plugin Repository Entry\n") + f.write(f"====================================\n\n") + f.write(f"Version: {version}\n") + f.write(f"Updated: {plugin_entry['updated']}\n") + f.write(f"Binaries: {len(binaries)} platforms\n\n") + f.write("Platform checksums:\n") + for binary in binaries: + f.write(f" {binary['platform']}: {binary['checksum']}\n") + f.write(f"\nRepository URL: {repo_url}\n") + f.write(f"Release URL: {repo_url}/releases/tag/v{version}\n") + + print(f"Generated summary file: {summary_file}") + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: generate_plugin_repo.py ") + print("Example: generate_plugin_repo.py 4.1.0") + sys.exit(1) + generate_plugin_repo_yaml(sys.argv[1]) \ No newline at end of file diff --git a/.github/workflows/plugin-repo.yml b/.github/workflows/plugin-repo.yml new file mode 100644 index 0000000..711a08a --- /dev/null +++ b/.github/workflows/plugin-repo.yml @@ -0,0 +1,56 @@ +name: Generate Plugin Repository Entry + +on: + release: + types: [published] + +jobs: + generate-plugin-repo: + name: Generate Plugin Repository YAML + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install PyYAML requests + + - name: Download release assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dist + # Download all release assets + gh release download ${{ github.event.release.tag_name }} -D dist/ + + - name: Generate plugin repository YAML + env: + GITHUB_REF_NAME: ${{ github.event.release.tag_name }} + run: python3 .github/workflows/generate_plugin_repo.py $GITHUB_REF_NAME + + - name: Upload plugin repository files to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload ${{ github.event.release.tag_name }} plugin-repo-entry.yml plugin-repo-summary.txt + + - name: Create PR to plugin repository (optional) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Plugin repository entry generated!" + echo "To submit to CF CLI plugin repository:" + echo "1. Fork https://github.com/cloudfoundry-incubator/cli-plugin-repo" + echo "2. Add the contents of plugin-repo-entry.yml to repo-index.yml" + echo "3. Create a pull request" + echo "" + echo "Entry content:" + cat plugin-repo-entry.yml \ No newline at end of file diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..9421282 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,115 @@ +name: Pull Request Validation + +on: + pull_request: + branches: + - main + - master + +jobs: + validate-pr: + name: Validate Pull Request + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ">=1.23.5" + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Set up Node.js for markdownlint + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Install Go dependencies + run: go mod tidy -e || true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Lint Go code + run: ./scripts/lint-go.sh ci + + - name: Check Python test suite + id: check-python + run: | + if [ -f "test/requirements.txt" ] && [ -f "test/setup.sh" ]; then + echo "python_tests_exist=true" >> $GITHUB_OUTPUT + echo "โœ… Python test suite found" + else + echo "python_tests_exist=false" >> $GITHUB_OUTPUT + echo "โš ๏ธ Python test suite not found - skipping Python validation" + fi + + - name: Setup Python environment + if: steps.check-python.outputs.python_tests_exist == 'true' + run: | + cd test + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Validate Python code quality + if: steps.check-python.outputs.python_tests_exist == 'true' + run: ./scripts/lint-python.sh ci + + - name: Lint Markdown files + run: ./scripts/lint-markdown.sh ci + + # TODO: Re-enable Python tests when ready + # - name: Run Python tests + # if: steps.check-python.outputs.python_tests_exist == 'true' + # run: | + # cd test + # source venv/bin/activate + # echo "๐Ÿงช Running Python tests..." + # if ! pytest -v --tb=short; then + # echo "โŒ Python tests failed." + # exit 1 + # fi + # echo "โœ… Python tests passed!" + # env: + # CF_API: ${{ secrets.CF_API }} + # CF_USERNAME: ${{ secrets.CF_USERNAME }} + # CF_PASSWORD: ${{ secrets.CF_PASSWORD }} + # CF_ORG: ${{ secrets.CF_ORG }} + # CF_SPACE: ${{ secrets.CF_SPACE }} + + - name: Build plugin + run: | + echo "๐Ÿ”จ Building plugin..." + if ! python3 .github/workflows/build.py; then + echo "โŒ Build failed." + exit 1 + fi + echo "โœ… Build successful!" + + - name: Validation Summary + run: | + echo "" + echo "๐ŸŽ‰ Pull Request Validation Summary" + echo "==================================" + echo "โœ… Go code formatting and linting" + echo "โœ… Go tests" + echo "โœ… Markdown formatting and linting" + if [ "${{ steps.check-python.outputs.python_tests_exist }}" == "true" ]; then + echo "โœ… Python code quality checks" + echo "โœ… Python tests" + else + echo "โš ๏ธ Python tests skipped (not found)" + fi + echo "โœ… Plugin build" + echo "" + echo "๐Ÿš€ Ready for merge!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8dc27b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,175 @@ +name: Manual Release + +on: + workflow_dispatch: # Allows manual triggering + inputs: + version: + description: 'Release version (e.g., 3.0.4), you must have a changelog ready for this version (e.g. 3.0.4-snapshot-20231001)' + required: true + type: string + +permissions: + contents: write + actions: read + +jobs: + release: + name: Create Proper Release on All Platforms + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: [">=1.23.5"] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install PyYAML + + - name: Install dependencies + run: go mod tidy -e || true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Lint Go files + run: ./scripts/lint-go.sh ci + + - name: Run tests + run: ./scripts/lint-go.sh ci + + - name: Build binary + run: python3 .github/workflows/build.py + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.os }} + path: dist/ + + create-release: + name: Create GitHub Release with Plugin Repository Entry + needs: release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install Python dependencies + run: | + python3 -m venv venv + source venv/bin/activate + python3 -m pip install --upgrade pip + pip install PyYAML requests + + - name: Parse version and update Go source + run: | + # Parse version input (e.g., "4.1.0" -> major=4, minor=1, build=0) + VERSION="${{ github.event.inputs.version }}" + IFS='.' read -r MAJOR MINOR BUILD <<< "$VERSION" + + echo "Updating version to $MAJOR.$MINOR.$BUILD" + python3 .github/workflows/update_version.py "$MAJOR" "$MINOR" "$BUILD" + + # Configure git + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + # Check if there are changes + if git diff --quiet; then + echo "No version changes detected" + else + echo "Committing version update" + git add cf_cli_java_plugin.go README.md + git commit -m "Set version to v$VERSION" + git push + fi + + - name: Create and push tag + run: | + VERSION="${{ github.event.inputs.version }}" + echo "Creating tag $VERSION" + git tag "$VERSION" + git push origin "$VERSION" + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Combine all artifacts + run: | + mkdir -p dist + find artifacts/ -type f -exec cp {} dist/ \; + ls -la dist/ + + - name: Generate plugin repository YAML + env: + GITHUB_REF_NAME: ${{ github.event.inputs.version }} + run: | + source venv/bin/activate + echo "๐Ÿ“ Generating plugin repository YAML file for version ${{ github.event.inputs.version }}..." + python3 .github/workflows/generate_plugin_repo.py "${{ github.event.inputs.version }}" + echo "โœ… Plugin repository YAML generated" + echo "" + echo "Generated files:" + ls -la plugin-repo-* + echo "" + echo "Plugin repository entry preview:" + head -20 plugin-repo-entry.yml + + - name: Generate timestamp + id: timestamp + run: echo "timestamp=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.event.inputs.version }} + name: ${{ github.event.inputs.version }} + files: | + dist/* + plugin-repo-entry.yml + plugin-repo-summary.txt + body_path: release_notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Summary Comment + run: | + echo "## ๐Ÿš€ Release ${{ github.event.inputs.version }} Created Successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Files Generated:" >> $GITHUB_STEP_SUMMARY + echo "- Release binaries for all platforms" >> $GITHUB_STEP_SUMMARY + echo "- \`plugin-repo-entry.yml\` - CF CLI plugin repository entry" >> $GITHUB_STEP_SUMMARY + echo "- \`plugin-repo-summary.txt\` - Human-readable summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps:" >> $GITHUB_STEP_SUMMARY + echo "1. Download \`plugin-repo-entry.yml\` from the release" >> $GITHUB_STEP_SUMMARY + echo "2. Submit to CF CLI plugin repository" >> $GITHUB_STEP_SUMMARY + echo "3. Update documentation if needed" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/update_version.py b/.github/workflows/update_version.py new file mode 100644 index 0000000..3ded24a --- /dev/null +++ b/.github/workflows/update_version.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Update version in cf_cli_java_plugin.go for releases. +This script updates the PluginMetadata version in the Go source code and processes the changelog. +""" + +import sys +import re +from pathlib import Path + +def update_version_in_go_file(file_path, major, minor, build): + """Update the version in the Go plugin metadata.""" + with open(file_path, 'r') as f: + content = f.read() + + # Pattern to match the Version struct in PluginMetadata + pattern = r'(Version: plugin\.VersionType\s*{\s*Major:\s*)\d+(\s*,\s*Minor:\s*)\d+(\s*,\s*Build:\s*)\d+(\s*,\s*})' + + replacement = rf'\g<1>{major}\g<2>{minor}\g<3>{build}\g<4>' + + new_content = re.sub(pattern, replacement, content) + + if new_content == content: + print(f"Warning: Version pattern not found or not updated in {file_path}") + return False + + with open(file_path, 'w') as f: + f.write(new_content) + + print(f"โœ… Updated version to {major}.{minor}.{build} in {file_path}") + return True + +def process_readme_changelog(readme_path, version): + """Process the README changelog section for the release.""" + with open(readme_path, 'r') as f: + content = f.read() + + # Look for the snapshot section + snapshot_pattern = rf'### Snapshot\s*\n' + match = re.search(snapshot_pattern, content) + + if not match: + print(f"Error: README.md does not contain a '### Snapshot' section") + return False, None + + # Find the content of the snapshot section + start_pos = match.end() + + # Find the next ## section or end of file + next_section_pattern = r'\n##(#?) ' + next_match = re.search(next_section_pattern, content[start_pos:]) + + if next_match: + end_pos = start_pos + next_match.start() + section_content = content[start_pos:end_pos].strip() + else: + section_content = content[start_pos:].strip() + + # Remove the "-snapshot" from the header + new_header = f"## {version}" + updated_content = re.sub(snapshot_pattern, "## Snapshot\n\n\n" + new_header + '\n\n', content) + + # Write the updated README + with open(readme_path, 'w') as f: + f.write(updated_content) + + print(f"โœ… Updated README.md: converted '## Snapshot' to '## {version}'") + + return True, section_content + +def get_base_version(version): + """Return the base version (e.g., 4.0.0 from 4.0.0-rc2)""" + return version.split('-')[0] + +def is_rc_version(version_str): + """Return True if the version string ends with -rc or -rcN.""" + return bool(re.match(r"^\d+\.\d+\.\d+-rc(\d+)?$", version_str)) + +def generate_release_notes(version, changelog_content): + """Generate complete release notes file.""" + release_notes = f"""## CF CLI Java Plugin {version} + +Plugin for profiling Java applications and getting heap and thread-dumps. + +## Changes + +{changelog_content} + +## Installation + +### Installation via CF Community Repository + +Make sure you have the CF Community plugin repository configured or add it via: +```bash +cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org +``` + +Trigger installation of the plugin via: +```bash +cf install-plugin -r CF-Community "java" +``` + +### Manual Installation + +Download this specific release ({version}) and install manually: + +```bash +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/{version}/cf-cli-java-plugin-linux-amd64 +``` + +Or download the latest release: + +```bash +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-amd64 +``` + +**Note:** On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` on the plugin binary. +On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. + +You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. +""" + + with open("release_notes.md", 'w') as f: + f.write(release_notes) + +def main(): + if len(sys.argv) != 4: + print("Usage: update_version.py ") + print("Example: update_version.py 4 1 0") + sys.exit(1) + + try: + major = int(sys.argv[1]) + minor = int(sys.argv[2]) + build = int(sys.argv[3]) + except ValueError: + print("Error: Version numbers must be integers") + sys.exit(1) + + version = f"{major}.{minor}.{build}" + version_arg = f"{major}.{minor}.{build}" if (major + minor + build) != 0 else sys.argv[1] + # Accept any -rc suffix, e.g. 4.0.0-rc, 4.0.0-rc1, 4.0.0-rc2 + if is_rc_version(sys.argv[1]): + base_version = get_base_version(sys.argv[1]) + go_file = Path("cf_cli_java_plugin.go") + readme_file = Path("README.md") + changelog_file = Path("release_changelog.txt") + if not readme_file.exists(): + print(f"Error: {readme_file} not found") + sys.exit(1) + with open(readme_file, 'r') as f: + content = f.read() + # Find the section for the base version + base_pattern = rf'## {re.escape(base_version)}\s*\n' + match = re.search(base_pattern, content) + if not match: + print(f"Error: README.md does not contain a '## {base_version}' section for RC release") + sys.exit(1) + start_pos = match.end() + next_match = re.search(r'\n## ', content[start_pos:]) + if next_match: + end_pos = start_pos + next_match.start() + section_content = content[start_pos:end_pos].strip() + else: + section_content = content[start_pos:].strip() + with open(changelog_file, 'w') as f: + f.write(section_content) + + # Generate full release notes for RC + generate_release_notes(sys.argv[1], section_content) + + print(f"โœ… RC release: Changelog for {base_version} saved to {changelog_file}") + print(f"โœ… RC release: Full release notes saved to release_notes.md") + sys.exit(0) + + go_file = Path("cf_cli_java_plugin.go") + readme_file = Path("README.md") + changelog_file = Path("release_changelog.txt") + + # Update Go version + success = update_version_in_go_file(go_file, major, minor, build) + if not success: + sys.exit(1) + + # Process README changelog + success, changelog_content = process_readme_changelog(readme_file, version) + if not success: + sys.exit(1) + + # Write changelog content to a file for the workflow to use + with open(changelog_file, 'w') as f: + f.write(changelog_content) + + # Generate full release notes + generate_release_notes(version, changelog_content) + + print(f"โœ… Version updated successfully to {version}") + print(f"โœ… Changelog content saved to {changelog_file}") + print(f"โœ… Full release notes saved to release_notes.md") + +if __name__ == "__main__": + main() diff --git a/.gitignore b/.gitignore index d16c028..83d268c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,29 @@ counterfeiter # Built binaries build/ +pkg/ # built project binary (go build) cf-java-plugin + +# Testing directory - sensitive config and test results +test/test_config.yml +test/*.hprof +test/*.jfr +test/test_results/ +test/test_reports/ +test/__pycache__/ +test/.pytest_cache/ +test/snapshots/ + +# JFR files +*.jfr + +# Heap dump files +*.hprof + +# Build artifacts +dist + +# go +pkg \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..37bc648 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,74 @@ +# golangci-lint configuration file +# Run with: golangci-lint run + +version: "2" + +run: + timeout: 5m + +linters: + enable: + # Core linters (enabled by default but explicitly listed) + - errcheck # Check for unchecked errors + - govet # Report suspicious constructs + - ineffassign # Detect ineffectual assignment + - staticcheck # Comprehensive static analysis + - unused # Find unused code + + # Architecture and interface linters (key for your request) + - interfacebloat # Detect interfaces with too many methods + - unparam # Report unused function parameters + + # Code quality linters + - asciicheck # Check for non-ASCII identifiers + - bidichk # Check for dangerous unicode sequences + - bodyclose # Check HTTP response body is closed + - contextcheck # Check context usage + - dupl # Check for code duplication + - durationcheck # Check duration multiplication + - errname # Check error naming conventions + - errorlint # Check error wrapping + - exhaustive # Check enum switch exhaustiveness + - goconst # Find repeated strings that could be constants + - godox # Find TODO, FIXME, etc. comments + - goprintffuncname # Check printf-style function names + - misspell # Check for misspellings + - nakedret # Check naked returns + - nilerr # Check nil error returns + - nolintlint # Check nolint directives + - predeclared # Check for shadowed predeclared identifiers + - rowserrcheck # Check SQL rows.Err + - sqlclosecheck # Check SQL Close calls + - unconvert # Check unnecessary type conversions + - wastedassign # Check wasted assignments + - whitespace # Check for extra whitespace + - gocritic + + + disable: + # Disabled as requested + - gochecknoglobals # Ignore global variables (as requested) + + # Disabled for being too strict or problematic + - testpackage # Too strict - requires separate test packages + - paralleltest # Not always applicable + - exhaustruct # Too strict - requires all struct fields + - varnamelen # Variable name length can be subjective + - wrapcheck # Error wrapping can be excessive + - nlreturn # Newline return rules too strict + - wsl # Whitespace linter too opinionated + - gosmopolitan # Locale-specific, not needed + - nonamedreturns # Named returns can be useful + - tagliatelle # Struct tag formatting can be subjective + - maintidx # Maintainability index can be subjective + - godot # Check comments end with period + - lll # Check line length + - ireturn # Interface return types are sometimes necessary + # ignore for now + - nestif + - gocognit + - gocyclo # Check cyclomatic complexity + - cyclop # Check cyclomatic complexity + - funlen # Check function length + - gosec # Security-focused linter + - revive # Fast, configurable, extensible linter diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..22e7517 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,13 @@ +{ + "default": true, + "MD013": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + "MD033": false, + "MD041": false, + "MD046": { + "style": "fenced" + } +} diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..1530974 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,8 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: java-memory-assistant +Upstream-Contact: Tim Gerlach +Source: https://github.com/SAP/cf-cli-java-plugin + +Files: * +Copyright: 2024 SAP SE or an SAP affiliate company and Cloud Foundry Command Line Java plugin contributors +License: Apache-2.0 \ No newline at end of file diff --git a/.vscode/README.md b/.vscode/README.md new file mode 100644 index 0000000..5f71c2e --- /dev/null +++ b/.vscode/README.md @@ -0,0 +1,193 @@ +# VS Code Development Setup + +This directory contains a comprehensive VS Code configuration for developing and testing the CF Java Plugin test suite. + +## Quick Start + +1. **Open the workspace**: Use the workspace file in the root directory: + + ```bash + code ../cf-java-plugin.code-workspace + ``` + +2. **Install recommended extensions**: VS Code will prompt you to install recommended extensions. + +3. **Setup environment**: Run the setup task from the Command Palette: + - `Ctrl/Cmd + Shift + P` โ†’ "Tasks: Run Task" โ†’ "Setup Virtual Environment" + +## Features + +### ๐Ÿš€ Launch Configurations (F5 or Debug Panel) + +- **Debug Current Test File** - Debug the currently open test file +- **Debug Current Test Method** - Debug a specific test method (prompts for class/method) +- **Debug Custom Filter** - Debug tests matching a custom filter pattern +- **Run All Tests** - Run the entire test suite +- **Run Basic Commands Tests** - Run only basic command tests +- **Run JFR Tests** - Run only JFR (Java Flight Recorder) tests +- **Run Async-profiler Tests** - Run only async-profiler tests (SapMachine) +- **Run Integration Tests** - Run full integration tests +- **Run Heap Tests** - Run all heap-related tests +- **Run Profiling Tests** - Run all profiling tests (JFR + async-profiler) +- **Interactive Test Runner** - Launch the interactive test runner + +### โšก Tasks (Ctrl/Cmd + Shift + P โ†’ "Tasks: Run Task") + +#### Test Execution + +- **Run All Tests** - Execute all tests +- **Run Current Test File** - Run the currently open test file +- **Run Basic Commands Tests** - Basic command functionality +- **Run JFR Tests** - Java Flight Recorder tests +- **Run Async-profiler Tests** - Async-profiler tests +- **Run Integration Tests** - Full integration tests +- **Run Heap Tests (Pattern)** - Tests matching "heap" +- **Run Profiling Tests (Pattern)** - Tests matching "jfr or asprof" +- **Run Tests in Parallel** - Parallel test execution +- **Generate HTML Test Report** - Create HTML test report + +#### Development Tools + +- **Setup Virtual Environment** - Initialize/setup the Python environment +- **Clean Test Artifacts** - Clean up test files and artifacts +- **Interactive Test Runner** - Launch interactive test selector +- **Install/Update Dependencies** - Update Python packages + +### ๐Ÿ”ง Integrated Settings + +- **Python Environment**: Automatic virtual environment detection (`./venv/bin/python`) +- **Test Discovery**: Automatic pytest test discovery +- **Formatting**: Black formatter with 120-character line length +- **Linting**: Flake8 with custom rules +- **Type Checking**: Basic type checking enabled +- **Import Organization**: Automatic import sorting on save + +### ๐Ÿ“ Code Snippets + +Type these prefixes and press Tab for instant code generation: + +- **`cftest`** - Basic CF Java test method +- **`cfheap`** - Heap dump test template +- **`cfjfr`** - JFR test template +- **`cfasprof`** - Async-profiler test template +- **`cftestclass`** - Test class template +- **`cfimport`** - Import test framework +- **`cfmulti`** - Multi-step workflow test +- **`cfsleep`** - Time.sleep with comment +- **`cfcleanup`** - Test cleanup code + +## Test Organization & Filtering + +### By File + +```bash +pytest test_basic_commands.py -v # Basic commands +pytest test_jfr.py -v # JFR tests +pytest test_asprof.py -v # Async-profiler tests +pytest test_cf_java_plugin.py -v # Integration tests +``` + +### By Test Class + +```bash +pytest test_basic_commands.py::TestHeapDump -v # Only heap dump tests +pytest test_jfr.py::TestJFRBasic -v # Basic JFR functionality +pytest test_asprof.py::TestAsprofProfiles -v # Async-profiler profiles +``` + +### By Pattern + +```bash +pytest -k "heap" -v # All heap-related tests +pytest -k "jfr or asprof" -v # All profiling tests +``` + +### By Markers + +```bash +pytest -m "sapmachine21" -v # SapMachine-specific tests +``` + +## Debugging Tips + +1. **Set Breakpoints**: Click in the gutter or press F9 +2. **Step Through**: Use F10 (step over) and F11 (step into) +3. **Inspect Variables**: Hover over variables or use the Variables panel +4. **Debug Console**: Use the Debug Console for live evaluation +5. **Conditional Breakpoints**: Right-click on breakpoint for conditions + +## Test Execution Patterns + +### Quick Development Cycle + +1. Edit test file +2. Press F5 โ†’ "Debug Current Test File" +3. Fix issues and repeat + +### Focused Testing + +1. Use custom filter: F5 โ†’ "Debug Custom Filter" +2. Enter pattern like "heap and download" +3. Debug only matching tests + +## File Organization + +```text +test/ +โ”œโ”€โ”€ .vscode/ # VS Code configuration +โ”‚ โ”œโ”€โ”€ launch.json # Debug configurations +โ”‚ โ”œโ”€โ”€ tasks.json # Build/test tasks +โ”‚ โ”œโ”€โ”€ settings.json # Workspace settings +โ”‚ โ”œโ”€โ”€ extensions.json # Recommended extensions +โ”‚ โ””โ”€โ”€ python.code-snippets # Code snippets +โ”œโ”€โ”€ framework/ # Test framework +โ”œโ”€โ”€ test_*.py # Test modules +โ”œโ”€โ”€ requirements.txt # Dependencies +โ”œโ”€โ”€ setup.sh # Environment setup script +โ””โ”€โ”€ test_runner.py # Interactive test runner +``` + +## Keyboard Shortcuts + +- **F5** - Start debugging +- **Ctrl/Cmd + F5** - Run without debugging +- **Shift + F5** - Stop debugging +- **F9** - Toggle breakpoint +- **F10** - Step over +- **F11** - Step into +- **Ctrl/Cmd + Shift + P** - Command palette +- **Ctrl/Cmd + `** - Open terminal + +## Troubleshooting + +### Python Environment Issues + +1. Ensure virtual environment is created: Run "Setup Virtual Environment" task +2. Check Python interpreter: Bottom left corner should show `./venv/bin/python` +3. Reload window: Ctrl/Cmd + Shift + P โ†’ "Developer: Reload Window" + +### Test Discovery Issues + +1. Save all files (tests auto-discover on save) +2. Check PYTHONPATH in terminal +3. Verify test files follow `test_*.py` naming + +### Extension Issues + +1. Install recommended extensions when prompted +2. Check Extensions panel for any issues +3. Restart VS Code if needed + +## Advanced Features + +### Parallel Testing + +Use the "Run Tests in Parallel" task for faster execution on multi-core systems. + +### HTML Reports + +Generate comprehensive HTML test reports with the "Generate HTML Test Report" task. + +### Interactive Runner + +Launch `test_runner.py` for menu-driven test selection and execution. diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..30a60ad --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,24 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.debugpy", + "ms-python.pylance", + "ms-python.black-formatter", + "ms-python.flake8", + "ms-python.pylint", + "ms-python.isort", + "charliermarsh.ruff", + "redhat.vscode-yaml", + "ms-vscode.test-adapter-converter", + "littlefoxteam.vscode-python-test-adapter", + "ms-vscode.vscode-json", + "esbenp.prettier-vscode", + "ms-vsliveshare.vsliveshare", + "github.copilot", + "github.copilot-chat", + "njpwerner.autodocstring", + "golang.go", + "ms-vscode.makefile-tools", + "tamasfe.even-better-toml" + ] +} \ No newline at end of file diff --git a/.vscode/keybindings.json b/.vscode/keybindings.json new file mode 100644 index 0000000..6b6b37f --- /dev/null +++ b/.vscode/keybindings.json @@ -0,0 +1,36 @@ +[ + { + "key": "ctrl+shift+t", + "command": "workbench.action.tasks.runTask", + "args": "Run Current Test File" + }, + { + "key": "ctrl+shift+a", + "command": "workbench.action.tasks.runTask", + "args": "Run All Tests" + }, + { + "key": "ctrl+shift+c", + "command": "workbench.action.tasks.runTask", + "args": "Clean Test Artifacts" + }, + { + "key": "ctrl+shift+r", + "command": "workbench.action.tasks.runTask", + "args": "Interactive Test Runner" + }, + { + "key": "f6", + "command": "workbench.action.debug.start", + "args": { + "name": "Debug Current Test File" + } + }, + { + "key": "shift+f6", + "command": "workbench.action.debug.start", + "args": { + "name": "Debug Custom Filter" + } + } +] diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bbf284e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,244 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Current Test File", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "${file}", + "-v", + "--tb=short" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + }, + "justMyCode": false + }, + { + "name": "Debug Current Test Method", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "${file}::${input:testClass}::${input:testMethod}", + "-v", + "--tb=long", + "-s" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + }, + "justMyCode": false + }, + { + "name": "Run All Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-v", + "--tb=short" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + }, + "justMyCode": false + }, + { + "name": "Run Basic Commands Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "test_basic_commands.py", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run JFR Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "test_jfr.py", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run Async-profiler Tests (SapMachine)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "test_asprof.py", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run Integration Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "test_cf_java_plugin.py", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run Snapshot Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-k", + "snapshot", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run Heap Tests (Pattern)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-k", + "heap", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Run Profiling Tests (Pattern)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-k", + "jfr or asprof", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Update Snapshots", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-k", + "snapshot", + "--snapshot-update", + "-v" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Interactive Test Runner", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/test/test_runner.py", + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + } + }, + { + "name": "Debug Custom Filter", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-k", + "${input:testFilter}", + "-v", + "--tb=long", + "-s" + ], + "python": "${workspaceFolder}/test/venv/bin/python", + "cwd": "${workspaceFolder}/test", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/test" + }, + "justMyCode": false + } + ], + "inputs": [ + { + "id": "testClass", + "description": "Test class name (e.g., TestHeapDump)", + "default": "TestHeapDump", + "type": "promptString" + }, + { + "id": "testMethod", + "description": "Test method name (e.g., test_basic_download)", + "default": "test_basic_download", + "type": "promptString" + }, + { + "id": "testFilter", + "description": "Custom test filter (e.g., 'heap and download', 'jfr or asprof')", + "default": "heap", + "type": "promptString" + } + ] +} \ No newline at end of file diff --git a/.vscode/python.code-snippets b/.vscode/python.code-snippets new file mode 100644 index 0000000..732dbc4 --- /dev/null +++ b/.vscode/python.code-snippets @@ -0,0 +1,127 @@ +{ + "CF Java Test Method": { + "prefix": "cftest", + "body": [ + "@test(\"${1:all}\")", + "def test_${2:name}(self, t, app):", + " \"\"\"${3:Test description}.\"\"\"", + " t.${4:command}() \\", + " .should_succeed() \\", + " .should_contain(\"${5:expected_text}\")", + "$0" + ], + "description": "Create a CF Java Plugin test method" + }, + "CF Java Heap Dump Test": { + "prefix": "cfheap", + "body": [ + "@test(\"${1:all}\")", + "def test_heap_dump_${2:scenario}(self, t, app):", + " \"\"\"Test heap dump ${3:description}.\"\"\"", + " t.heap_dump(\"${4:--local-dir .}\") \\", + " .should_succeed() \\", + " .should_create_file(f\"{app}-heapdump-*.hprof\") \\", + " .should_create_no_remote_files()", + "$0" + ], + "description": "Create a heap dump test" + }, + "CF Java JFR Test": { + "prefix": "cfjfr", + "body": [ + "@test(\"${1:all}\")", + "def test_jfr_${2:scenario}(self, t, app):", + " \"\"\"Test JFR ${3:description}.\"\"\"", + " # Start recording", + " t.jfr_start(${4:}).should_succeed()", + " ", + " time.sleep(${5:1})", + " ", + " # Stop and verify", + " t.jfr_stop(\"--local-dir .\") \\", + " .should_succeed() \\", + " .should_create_file(f\"{app}-jfr-*.jfr\")", + "$0" + ], + "description": "Create a JFR test" + }, + "CF Java Async-profiler Test": { + "prefix": "cfasprof", + "body": [ + "@test(\"sapmachine21\")", + "def test_asprof_${1:scenario}(self, t, app):", + " \"\"\"Test async-profiler ${2:description}.\"\"\"", + " # Start profiling", + " t.asprof_start(\"${3:cpu}\").should_succeed()", + " ", + " time.sleep(${4:1})", + " ", + " # Stop and verify", + " t.asprof_stop(\"--local-dir .\") \\", + " .should_succeed() \\", + " .should_create_file(f\"{app}-asprof-*.jfr\")", + "$0" + ], + "description": "Create an async-profiler test" + }, + "CF Java Test Class": { + "prefix": "cftestclass", + "body": [ + "class Test${1:ClassName}(TestBase):", + " \"\"\"${2:Test class description}.\"\"\"", + " ", + " @test(\"${3:all}\")", + " def test_${4:method_name}(self, t, app):", + " \"\"\"${5:Test method description}.\"\"\"", + " ${0:pass}", + "" + ], + "description": "Create a CF Java Plugin test class" + }, + "Import CF Java Framework": { + "prefix": "cfimport", + "body": [ + "import time", + "from framework.runner import TestBase", + "from framework.decorators import test", + "$0" + ], + "description": "Import CF Java Plugin test framework" + }, + "CF Java Time Sleep": { + "prefix": "cfsleep", + "body": [ + "time.sleep(${1:1}) # Wait for ${2:operation} to complete" + ], + "description": "Add a time.sleep with comment" + }, + "CF Java Cleanup": { + "prefix": "cfcleanup", + "body": [ + "# Clean up", + "t.${1:jfr_stop}(\"--no-download\").should_succeed()" + ], + "description": "Add cleanup code for tests" + }, + "CF Java Multi-Step Test": { + "prefix": "cfmulti", + "body": [ + "@test(\"${1:all}\")", + "def test_${2:name}_workflow(self, t, app):", + " \"\"\"Test ${3:description} complete workflow.\"\"\"", + " # Step 1: ${4:Start operation}", + " t.${5:command}().should_succeed()", + " ", + " # Step 2: ${6:Verify state}", + " time.sleep(${7:1})", + " t.${8:status}().should_succeed().should_contain(\"${9:expected}\")", + " ", + " # Step 3: ${10:Complete operation}", + " t.${11:stop}(\"${12:--local-dir .}\") \\", + " .should_succeed() \\", + " .should_create_file(\"${13:*.jfr}\")", + "$0" + ], + "description": "Create a multi-step workflow test" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..918f577 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,140 @@ +{ + // Python interpreter and environment - adjusted for root folder + "python.defaultInterpreterPath": "./test/venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.terminal.activateEnvInCurrentTerminal": true, + // Testing configuration - adjusted paths for root folder + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "./test", + "-v", + "--tb=short" + ], + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.testing.pytestPath": "./test/venv/bin/python", + "python.testing.cwd": "${workspaceFolder}/test", + // Enhanced Python language support + "python.analysis.extraPaths": [ + "./test/framework", + "./test", + "./test/apps" + ], + "python.autoComplete.extraPaths": [ + "./test/framework", + "./test", + "./test/apps" + ], + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.analysis.completeFunctionParens": true, + "python.analysis.autoSearchPaths": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.stubPath": "./test", + "python.analysis.include": [ + "./test" + ], + // Linting and formatting + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.flake8Args": [ + "--max-line-length=120", + "--ignore=E203,W503" + ], + "python.linting.flake8Path": "./test/venv/bin/flake8", + "python.formatting.provider": "black", + "python.formatting.blackPath": "./test/venv/bin/black", + "python.formatting.blackArgs": [ + "--line-length=120" + ], + // Editor settings + "editor.formatOnSave": true, + "editor.rulers": [ + 120 + ], + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.tabSize": 4, + "editor.insertSpaces": true, + // File associations + "files.associations": { + "*.yml": "yaml", + "*.yaml": "yaml", + "*.go": "go", + "Makefile": "makefile", + "*.pyi": "python", + "test_*.py": "python", + "conftest.py": "python" + }, + // File exclusions for better performance + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/*.pyo": true, + "test/.pytest_cache": true, + "test/venv": true, + "*.hprof": true, + "*.jfr": true, + "**/.DS_Store": true, + "build/": true + }, + // Search exclusions + "search.exclude": { + "**/venv": true, + "test/venv": true, + "**/__pycache__": true, + "test/.pytest_cache": true, + "**/*.hprof": true, + "**/*.jfr": true, + "build/": true + }, + // Environment variables for integrated terminal + "terminal.integrated.env.osx": { + "PYTHONPATH": "${workspaceFolder}/test:${workspaceFolder}/test/framework" + }, + "terminal.integrated.env.linux": { + "PYTHONPATH": "${workspaceFolder}/test:${workspaceFolder}/test/framework" + }, + "terminal.integrated.env.windows": { + "PYTHONPATH": "${workspaceFolder}/test;${workspaceFolder}/test/framework" + }, + // Go language support for main project + "go.gopath": "${workspaceFolder}", + "go.goroot": "", + "go.formatTool": "gofumpt", + "go.lintTool": "golangci-lint", + "go.lintOnSave": "package", + // YAML schema validation + "yaml.schemas": { + "./test/test_config.yml.example": [ + "test/test_config.yml" + ] + }, + // IntelliSense settings + "editor.quickSuggestions": { + "other": "on", + "comments": "off", + "strings": "on" + }, + "editor.parameterHints.enabled": true, + "editor.suggestOnTriggerCharacters": true, + "editor.wordBasedSuggestions": "matchingDocuments", + // Python-specific IntelliSense enhancements + "python.jediEnabled": false, + "python.languageServer": "Pylance", + "python.analysis.indexing": true, + "python.analysis.userFileIndexingLimit": 2000, + "python.analysis.packageIndexDepths": [ + { + "name": "", + "depth": 2, + "includeAllSymbols": true + } + ], + // Additional Pylance settings for better IntelliSense + "python.analysis.logLevel": "Information", + "python.analysis.symbolsHierarchyDepthLimit": 10, + "python.analysis.importFormat": "relative" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..561421f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,386 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run All Tests", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-v", + "--tb=short" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": { + "owner": "pytest", + "fileLocation": [ + "relative", + "${workspaceFolder}/test" + ], + "pattern": [ + { + "regexp": "^(.*?):(\\d+): (.*)$", + "file": 1, + "line": 2, + "message": 3 + } + ] + } + }, + { + "label": "Run Current Test File", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "${fileBasename}", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run Basic Commands Tests", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "test_basic_commands.py", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run JFR Tests", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "test_jfr.py", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run Async-profiler Tests", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "test_asprof.py", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run Integration Tests", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "test_cf_java_plugin.py", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run Heap Tests (Pattern)", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-k", + "heap", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Run Profiling Tests (Pattern)", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-k", + "jfr or asprof", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Setup Virtual Environment", + "type": "shell", + "command": "./test/setup.sh", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + }, + { + "label": "Clean Test Artifacts", + "type": "shell", + "command": "bash", + "args": [ + "-c", + "rm -rf .pytest_cache __pycache__ framework/__pycache__ test_report.html .test_success_cache.json && find . -name '*.hprof' -delete 2>/dev/null || true && find . -name '*.jfr' -delete 2>/dev/null || true" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + }, + { + "label": "Interactive Test Runner", + "type": "shell", + "command": "./test/venv/bin/python", + "args": [ + "test_runner.py" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "shared" + } + }, + { + "label": "Run Tests in Parallel", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-v", + "--tb=short", + "-n", + "auto" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Generate HTML Test Report", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-v", + "--html=test_report.html", + "--self-contained-html" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$python" + ] + }, + { + "label": "Install/Update Dependencies", + "type": "shell", + "command": "./test/venv/bin/pip", + "args": [ + "install", + "-r", + "requirements.txt", + "--upgrade" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + }, + { + "label": "Build Go Plugin", + "type": "shell", + "command": "make", + "args": [ + "build" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$go" + ] + }, + { + "label": "Run Tests with Fail-Fast", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-x", + "--tb=line", + "--capture=no", + "--showlocals", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "Run Tests with HTML Report and Fail-Fast", + "type": "shell", + "command": "./test/venv/bin/pytest", + "args": [ + "-x", + "--tb=line", + "--capture=no", + "--showlocals", + "--html=test_report.html", + "--self-contained-html", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/test" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d1b81..bd1fc35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing -When contributing to this repository, please first discuss the change you wish to make via issue before making a change. +When contributing to this repository, please first discuss the change you wish to make via issue before making a +change. We restrict the scope of this plugin to keep it maintainable. We have a [code of conduct](#code-of-conduct), please follow it in all your interactions with the project. @@ -10,54 +11,49 @@ You are welcome to contribute code to the Cloud Foundry CLI Java Plugin in order There are three important things to know: -1. You must be aware of the Apache License (which describes contributions) and agree to the Contributors License Agreement (CLA). - This is common practice in all major Open Source projects. - To make this process as simple as possible, we are using the [CLA assistant](https://cla-assistant.io/) for individual contributions. - CLA assistant is an open source tool that integrates with GitHub very well and enables a one-click-experience for accepting the CLA. - For company contributors, [special rules apply](#company-contributors). -2. We set ourselves [requirements regarding code style and quality](#pull-request-process), and we kindly ask you to do the same with PRs. +1. You must be aware of the Apache License (which describes contributions) and agree to the Contributors License + Agreement (CLA). This is common practice in all major Open Source projects. + To make this process as simple as possible, we are using the [CLA assistant](https://cla-assistant.io/) for + individual contributions. + CLA assistant is an open source tool that integrates with GitHub very well and enables a one-click-experience + for accepting the CLA. + For company contributors, special rules apply. +2. We set ourselves requirements regarding code style and quality, and we kindly ask you to do the same with PRs. 3. Not all proposed contributions can be accepted. Some features may, for example, just fit a separate plugin better. - The code must fit the overall direction of Cloud Foundry CLI Java Plugin and really improve it, so there should be some "bang for the byte". - For most bug fixes this is a given, but it would be advisable to first discus new major features with the maintainers by opening an issue on the project. + The code must fit the overall direction of Cloud Foundry CLI Java Plugin and really improve it, so there should + be some "bang for the byte". + For most bug fixes this is a given, but it would be advisable to first discus new major features with the + maintainers by opening an issue on the project. ### Pull Request Process This a checklist of things to keep in your mind when opening pull requests for this project. -0. Before pushing anything, validate your pull request with `go test` -1. Make sure you have signed the [Contributor License Agreement](#contributor-license-agreement) +1. Make sure you have accepted the [Developer Certificate of Origin](#developer-certificate-of-origin-dco) 2. Make sure any added dependency is licensed under Apache v2.0 license 3. Strive for very high unit-test coverage and favor testing productive code over mocks (mock in depth wherever possible) 4. Update the README.md with details of changes to the options -Pull requests will be tested and validated by maintainers. In case small changes are needed (e.g., correcting typos), the maintainers may fix those issues themselves. +Pull requests will be tested and validated by maintainers. In case small changes are needed (e.g., correcting typos), +the maintainers may fix those issues themselves. In case of larger issues, you may be asked to apply modifications to your changes before the Pull Request can be merged. -### Contributor License Agreement +### Developer Certificate of Origin (DCO) -When you contribute (code, documentation, or anything else), you have to be aware that your contribution is covered by the same [Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0) that is applied to the Cloud Foundry CLI Java Plugin itself. -Also, you need to agree to the Individual Contributor License Agreement, which can be [found here](https://gist.github.com/CLAassistant/bd1ea8ec8aa0357414e8). -(This applies to all contributors, including those contributing on behalf of a company). -If you agree to its content, you simply have to click on the link posted by the CLA assistant as a comment to the pull request. -Click it to check the CLA, then accept it on the following screen if you agree to it. -CLA assistant will save this decision for upcoming contributions and will notify you if there is any change to the CLA in the meantime. +Due to legal reasons, contributors will be asked to accept a DCO before they submit the first pull request to this +projects, this happens in an automated fashion during the submission process. SAP uses +[the standard DCO text of the Linux Foundation](https://developercertificate.org/). -#### Company Contributors +## Contributing with AI-generated code -If employees of a company contribute code, in **addition** to the individual agreement above, there needs to be one company agreement submitted. -This is mainly for the protection of the contributing employees. +As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including +open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our +open-source projects there a certain requirements that need to be reflected and adhered to when making contributions. -A company representative authorized to do so needs to download, fill, and print -the [Corporate Contributor License Agreement](docs/SAP%20Corporate%20Contributor%20License%20Agreement.pdf) form. Then either: - -- Scan it and e-mail it to [opensource@sap.com](mailto:opensource@sap.com) -- Fax it to: +49 6227 78-45813 -- Send it by traditional letter to: *Industry Standards & Open Source Team, Dietmar-Hopp-Allee 16, 69190 Walldorf, Germany* - -The form contains a list of employees who are authorized to contribute on behalf of your company. -When this list changes, please let us know. +Please see our [guideline for AI-generated code contributions to SAP Open Source Software Projects](CONTRIBUTING_USING_GENAI.md) +for these requirements. ## Code of Conduct diff --git a/CONTRIBUTING_USING_GENAI.md b/CONTRIBUTING_USING_GENAI.md new file mode 100644 index 0000000..85e57a8 --- /dev/null +++ b/CONTRIBUTING_USING_GENAI.md @@ -0,0 +1,32 @@ +# Guideline for AI-generated code contributions to SAP Open Source Software Projects + +As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including +open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our +open-source projects there are certain requirements that need to be reflected and adhered to when making +contributions. + +When using AI-generated code contributions in OSS Projects, their usage needs to align with Open-Source Software +values and legal requirements. We have established these essential guidelines to help contributors navigate the +complexities of using AI tools while maintaining compliance with open-source licenses and the broader +[Open-Source Definition](https://opensource.org/osd). + +AI-generated code or content can be contributed to SAP Open Source Software projects if the following conditions +are met: + +1. **Compliance with AI Tool Terms and Conditions**: Contributors must ensure that the AI tool's terms and + conditions do not impose any restrictions on the tool's output that conflict with the project's open-source + license or intellectual property policies. This includes ensuring that the AI-generated content adheres to the + [Open-Source Definition](https://opensource.org/osd). +2. **Filtering Similar Suggestions**: Contributors must use features provided by AI tools to suppress responses that + are similar to third-party materials or flag similarities. We only accept contributions from AI tools with such + filtering options. If the AI tool flags any similarities, contributors must review and ensure compliance with the + licensing terms of such materials before including them in the project. +3. **Management of Third-Party Materials**: If the AI tool's output includes pre-existing copyrighted materials, + including open-source code authored or owned by third parties, contributors must verify that they have the + necessary permissions from the original owners. This typically involves ensuring that there is an open-source + license or public domain declaration that is compatible with the project's licensing policies. Contributors must + also provide appropriate notice and attribution for these third-party materials, along with relevant information + about the applicable license terms. +4. **Employer Policies Compliance**: If AI-generated content is contributed in the context of employment, + contributors must also adhere to their employer's policies. This ensures that all contributions are made with + proper authorization and respect for relevant corporate guidelines. diff --git a/LICENSE b/LICENSE index 9d89a94..5f145b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,3 @@ - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -179,7 +178,7 @@ APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -187,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2017 SAP SE + Copyright 2017-21 SAP SE or an SAP affiliate company and Cloud Foundry Command Line Java plugin contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..4ed90b9 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,208 @@ +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, +AND DISTRIBUTION + + 1. Definitions. + + + +"License" shall mean the terms and conditions for use, reproduction, and distribution +as defined by Sections 1 through 9 of this document. + + + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + + + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct +or indirect, to cause the direction or management of such entity, whether +by contract or otherwise, or (ii) ownership of fifty percent (50%) or more +of the outstanding shares, or (iii) beneficial ownership of such entity. + + + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions +granted by this License. + + + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + + + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + + + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that +is included in or attached to the work (an example is provided in the Appendix +below). + + + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative +Works shall not include works that remain separable from, or merely link (or +bind by name) to the interfaces of, the Work and Derivative Works thereof. + + + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative +Works thereof, that is intentionally submitted to Licensor for inclusion in +the Work by the copyright owner or by an individual or Legal Entity authorized +to submit on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication +sent to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor +for the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + + + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently incorporated +within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable copyright license to reproduce, prepare +Derivative Works of, publicly display, publicly perform, sublicense, and distribute +the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent +license to make, have made, use, offer to sell, sell, import, and otherwise +transfer the Work, where such license applies only to those patent claims +licensable by such Contributor that are necessarily infringed by their Contribution(s) +alone or by combination of their Contribution(s) with the Work to which such +Contribution(s) was submitted. If You institute patent litigation against +any entity (including a cross-claim or counterclaim in a lawsuit) alleging +that the Work or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses granted to You +under this License for that Work shall terminate as of the date such litigation +is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and +in Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source +form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy +of the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part +of the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works +that You distribute, alongside or as an addendum to the NOTICE text from the +Work, provided that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, +or distribution of Your modifications, or for any such Derivative Works as +a whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without +any additional terms or conditions. Notwithstanding the above, nothing herein +shall supersede or modify the terms of any separate license agreement you +may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to +in writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR +A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness +of using or redistributing the Work and assume any risks associated with Your +exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether +in tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to +in writing, shall any Contributor be liable to You for damages, including +any direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability +to use the Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other commercial +damages or losses), even if such Contributor has been advised of the possibility +of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work +or Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such obligations, +You may act only on Your own behalf and on Your sole responsibility, not on +behalf of any other Contributor, and only if You agree to indemnify, defend, +and hold each Contributor harmless for any liability incurred by, or claims +asserted against, such Contributor by reason of your accepting any such warranty +or additional liability. END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own identifying +information. (Don't include the brackets!) The text should be enclosed in +the appropriate comment syntax for the file format. We also recommend that +a file or class name and description of purpose be included on the same "printed +page" as the copyright notice for easier identification within third-party +archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed 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. diff --git a/Makefile b/Makefile index 55efb73..4346876 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,6 @@ compile: cf_cli_java_plugin.go go build -o build/cf-cli-java-plugin cf_cli_java_plugin.go compile-all: cf_cli_java_plugin.go - ginkgo -p GOOS=linux GOARCH=386 go build -o build/cf-cli-java-plugin-linux32 cf_cli_java_plugin.go GOOS=linux GOARCH=amd64 go build -o build/cf-cli-java-plugin-linux64 cf_cli_java_plugin.go GOOS=darwin GOARCH=amd64 go build -o build/cf-cli-java-plugin-osx cf_cli_java_plugin.go @@ -21,7 +20,7 @@ install: compile remove remove: $(objects) ifeq ($(JAVA_PLUGIN_INSTALLED),) - cf uninstall-plugin JavaPlugin || true + cf uninstall-plugin java || true endif vclean: remove clean \ No newline at end of file diff --git a/README.md b/README.md index c6d29ee..d21a8bf 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,454 @@ +[![REUSE status](https://api.reuse.software/badge/github.com/SAP/cf-cli-java-plugin)](https://api.reuse.software/info/github.com/SAP/cf-cli-java-plugin) +[![Build and Snapshot Release](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/build-and-snapshot.yml) +[![PR Validation](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/SAP/cf-cli-java-plugin/actions/workflows/pr-validation.yml) + # Cloud Foundry Command Line Java plugin -This plugin for the [Cloud Foundry Command Line](https://github.com/cloudfoundry/cli) provides convenience utilities to work with Java applications deployed on Cloud Foundry. +This plugin for the [Cloud Foundry Command Line](https://github.com/cloudfoundry/cli) provides convenience utilities to +work with Java applications deployed on Cloud Foundry. + +Currently, it allows you to: -Currently, it allows to: -* Trigger and retrieve a heap dump from an instance of a Cloud Foundry Java application -* Trigger and retrieve a thread dump from an instance of a Cloud Foundry Java application +- Trigger and retrieve a heap dump and a thread dump from an instance of a Cloud Foundry Java application +- To run jcmd remotely on your application +- To start, stop and retrieve JFR and [async-profiler](https://github.com/jvm-profiling-tools/async-profiler) + ([SapMachine](https://sapmachine.io) only) profiles from your application ## Installation ### Installation via CF Community Repository -Make sure you have the CF Community plugin repository configured or add it via (```cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org```) +Make sure you have the CF Community plugin repository configured (or add it via +`cf add-plugin-repo CF-Community http://plugins.cloudfoundry.org`) Trigger installation of the plugin via -``` -cf install-plugin -r CF-Community "java-plugin" + +```sh +cf install-plugin java ``` +The releases in the community repository are older than the actual releases on GitHub, that you can install manually, so +we recommend the manual installation. + ### Manual Installation -Download the binary file for your target OS from the [latest release](https://github.com/SAP/cf-cli-java-plugin/releases/latest). -If you've already installed the plugin and are updating it, you must first execute the `cf uninstall-plugin JavaPlugin` command. +Download the latest release from [GitHub](https://github.com/SAP/cf-cli-java-plugin/releases/latest). + +To install a new version of the plugin, run the following: + +```sh +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/latest/download/cf-cli-java-plugin-linux-amd64 +``` + +You can verify that the plugin is successfully installed by looking for `java` in the output of `cf plugins`. -Install the plugin with `cf install-plugin [cf-cli-java-plugin]` (replace `[cf-cli-java-plugin]` with the actual binary name you will use, which depends on the OS you are running). +### Manual Installation of Snapshot Release -You can verify that the plugin is successfully installed by looking for `JavaPlugin` in the output of `cf plugins`. +Download the current snapshot release from [GitHub](https://github.com/SAP/cf-cli-java-plugin/releases/tag/snapshot). +This is intended for experimentation and might fail. -### Permission Issues +To install a new version of the plugin, run the following: -On Linux and macOS, if you get a permission error, run `chmod +x [cf-cli-java-plugin]` (replace `[cf-cli-java-plugin]` with the actual binary name you will use, which depends on the OS you are running) on the plugin binary. -On Windows, the plugin will refuse to install unless the binary has the `.exe` file extension. +```sh +# on Mac arm64 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-macos-arm64 +# on Windows x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-windows-amd64 +# on Linux x86 +cf install-plugin https://github.com/SAP/cf-cli-java-plugin/releases/download/snapshot/cf-cli-java-plugin-linux-amd64 +``` ## Usage +### Prerequisites + +#### JDK Tools + +This plugin internally uses `jmap` for OpenJDK-like Java virtual machines. When using the +[Cloud Foundry Java Buildpack](https://github.com/cloudfoundry/java-buildpack), `jmap` is no longer shipped by default +in order to meet the legal obligations of the Cloud Foundry Foundation. To ensure that `jmap` is available in the +container of your application, you have to explicitly request a full JDK in your application manifest via the +`JBP_CONFIG_OPEN_JDK_JRE` environment variable. This could be done like this: + +```yaml +--- +applications: + - name: + memory: 1G + path: + buildpack: https://github.com/cloudfoundry/java-buildpack + env: + JBP_CONFIG_OPEN_JDK_JRE: + '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 11.+ } + }' + JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']" +``` + +`-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints` is used to improve profiling accuracy and has no known negative +performance impacts. + +Please note that this requires the use of an online buildpack (configured in the `buildpack` property). When system +buildpacks are used, staging will fail with cache issues, because the system buildpacks donโ€™t have the JDK cached. +Please also note that this is not to be considered a recommendation to use a full JDK. It's just one option to get the +tools required for the use of this plugin when you need it, e.g., for troubleshooting. The `version` property is +optional and can be used to request a specific Java version. + +#### SSH Access + +As it is built directly on `cf ssh`, the `cf java` plugin can work only with Cloud Foundry applications that have +`cf ssh` enabled. To check if your app fulfills the requirements, you can find out by running the +`cf ssh-enabled [app-name]` command. If not enabled yet, run `cf enable-ssh [app-name]`. + +**Note:** You must restart your app after enabling SSH access. + +In case a proxy server is used, ensure that `cf ssh` is configured accordingly. Refer to the +[official documentation](https://docs.cloudfoundry.org/cf-cli/http-proxy.html#v3-ssh-socks5) of the Cloud Foundry +Command Line for more information. If `cf java` is having issues connecting to your app, chances are the problem is in +the networking issues encountered by `cf ssh`. To verify, run your `cf java` command in "dry-run" mode by adding the +`-n` flag and try to execute the command line that `cf java` gives you back. If it fails, the issue is not in `cf java`, +but in whatever makes `cf ssh` fail. + +### Examples + +Getting a heap-dump: + +```sh +> cf java heap-dump $APP_NAME +-> ./$APP_NAME-heapdump-$RANDOM.hprof +``` + +Getting a thread-dump: + +```sh +> cf java thread-dump $APP_NAME +... +Full thread dump OpenJDK 64-Bit Server VM ... +... +``` + +Creating a CPU-time profile via async-profiler: + +```sh +> cf java asprof-start-cpu $APP_NAME +Profiling started +# wait some time to gather data +> cf java asprof-stop $APP_NAME +-> ./$APP_NAME-asprof-$RANDOM.jfr +``` + +Running arbitrary JCMD commands, like `VM.uptime`: + +```sh +> cf java jcmd $APP_NAME -a VM.uptime +Connected to remote JVM +JVM response code = 0 +$TIME s +``` + +#### Variable Replacements for JCMD and Asprof Commands + +When using `jcmd` and `asprof` commands with the `--args` parameter, the following variables are automatically replaced +in your command strings: + +- `@FSPATH`: A writable directory path on the remote container (always set, typically `/tmp/jcmd` or `/tmp/asprof`) +- `@ARGS`: The command arguments you provided via `--args` +- `@APP_NAME`: The name of your Cloud Foundry application +- `@FILE_NAME`: Generated filename for file operations (includes full path with UUID) + +Example usage: + +```sh +# Create a heap dump in the available directory +cf java jcmd $APP_NAME --args 'GC.heap_dump @FSPATH/my_heap.hprof' + +# Use an absolute path instead +cf java jcmd $APP_NAME --args "GC.heap_dump /tmp/absolute_heap.hprof" + +# Access the application name in your command +cf java jcmd $APP_NAME --args 'echo "Processing app: @APP_NAME"' +``` + +**Note**: Variables use the `@` prefix to avoid shell expansion issues. The plugin automatically creates the `@FSPATH` +directory and downloads any files created there to your local directory (unless `--no-download` is used). + +### Commands + +The following is a list of all available commands (some of the SapMachine specific), generated via `cf java --help`: +
+
 NAME:
-   java - Obtain a heap dump or thread dump from a running, Diego-enabled, SSH-enabled Java application
+   java - Obtain a heap-dump, thread-dump or profile from a running, SSH-enabled Java application.
 
 USAGE:
-   cf java [heap-dump|thread-dump] APP_NAME
+   cf java COMMAND APP_NAME [options]
+
+     heap-dump
+        Generate a heap dump from a running Java application
+
+     thread-dump
+        Generate a thread dump from a running Java application
+
+     vm-info
+        Print information about the Java Virtual Machine running a Java
+        application
+
+     jcmd (supports --args)
+        Run a JCMD command on a running Java application via --args, downloads
+        and deletes all files that are created in the current folder, use
+        '--no-download' to prevent this. Environment variables available:
+        @FSPATH (writable directory path, always set), @ARGS (command
+        arguments), @APP_NAME (application name), @FILE_NAME (generated filename
+        for file operations without UUID), and @STATIC_FILE_NAME (without UUID).
+        Use single quotes around --args to prevent shell expansion.
+
+     jfr-start
+        Start a Java Flight Recorder default recording on a running Java
+        application (stores in the container-dir)
+
+     jfr-start-profile
+        Start a Java Flight Recorder profile recording on a running Java
+        application (stores in the container-dir))
+
+     jfr-start-gc (recent SapMachine only)
+        Start a Java Flight Recorder GC recording on a running Java application
+        (stores in the container-dir)
+
+     jfr-start-gc-details (recent SapMachine only)
+        Start a Java Flight Recorder detailed GC recording on a running Java
+        application (stores in the container-dir)
+
+     jfr-stop
+        Stop a Java Flight Recorder recording on a running Java application
+
+     jfr-dump
+        Dump a Java Flight Recorder recording on a running Java application
+        without stopping it
+
+     jfr-status
+        Check the running Java Flight Recorder recording on a running Java
+        application
+
+     vm-version
+        Print the version of the Java Virtual Machine running a Java application
+
+     vm-vitals
+        Print vital statistics about the Java Virtual Machine running a Java
+        application
+
+     asprof (recent SapMachine only, supports --args)
+        Run async-profiler commands passed to asprof via --args, copies files in
+        the current folder. Don't use in combination with asprof-* commands.
+        Downloads and deletes all files that are created in the current folder,
+        if not using 'start' asprof command, use '--no-download' to prevent
+        this. Environment variables available: @FSPATH (writable directory path,
+        always set), @ARGS (command arguments), @APP_NAME (application name),
+        @FILE_NAME (generated filename for file operations), and
+        @STATIC_FILE_NAME (without UUID). Use single quotes around --args to
+        prevent shell expansion.
+
+     asprof-start-cpu (recent SapMachine only)
+        Start an async-profiler CPU-time profile recording on a running Java
+        application
+
+     asprof-start-wall (recent SapMachine only)
+        Start an async-profiler wall-clock profile recording on a running Java
+        application
+
+     asprof-start-alloc (recent SapMachine only)
+        Start an async-profiler allocation profile recording on a running Java
+        application
+
+     asprof-start-lock (recent SapMachine only)
+        Start an async-profiler lock profile recording on a running Java
+        application
+
+     asprof-stop (recent SapMachine only)
+        Stop an async-profiler profile recording on a running Java application
+
+     asprof-status (recent SapMachine only)
+        Get the status of async-profiler on a running Java application
 
 OPTIONS:
    -app-instance-index       -i [index], select to which instance of the app to connect
+   -args                     -a, Miscellaneous arguments to pass to the command (if supported) in the
+                               container, be aware to end it with a space if it is a simple option. For
+                               commands that create arbitrary files (jcmd, asprof), the environment
+                               variables @FSPATH, @ARGS, @APP_NAME, @FILE_NAME, and @STATIC_FILE_NAME are
+                               available in --args to reference the working directory path, arguments,
+                               application name, and generated file name respectively.
+   -container-dir            -cd, the directory path in the container that the heap dump/JFR/... file will be
+                                saved to
    -dry-run                  -n, just output to command line what would be executed
-   -keep                     -k, keep the heap dump in the container; by default the heap dump will be deleted from the container's filesystem after been downloaded
+   -keep                     -k, keep the heap dump in the container; by default the heap dump/JFR/... will
+                               be deleted from the container's filesystem after being downloaded
+   -local-dir                -ld, the local directory path that the dump/JFR/... file will be saved to,
+                                defaults to the current directory
+   -no-download              -nd, don't download the heap dump/JFR/... file to local, only keep it in the
+                                container, implies '--keep'
+   -verbose                  -v, enable verbose output for the plugin
+
 
-The heap dump or thread dump (depending on what you execute) will be outputted to `std-out`. -You may want to redirect the command's output to file, e.g., by executing: -`cf java heap-dump [my_app] -i [my_instance_index] > heap-dump.hprof` +The heap dumps and profiles will be copied to a local file if `-local-dir` is specified as a full folder path. Without +providing `-local-dir` the heap dump will only be created in the container and not transferred. To save disk space of +the application container, the files are automatically deleted unless the `-keep` option is set. -The `-k` flag is invalid when invoking `cf java thread-dump`. -(Unlike with heap dumps, the JVM does not need to output the threaddump to file before streaming it out.) +Providing `-container-dir` is optional. If specified the plugin will create the heap dump or profile at the given file +path in the application container. Without providing this parameter, the file will be created either at `/tmp` or at the +file path of a file system service if attached to the container. -## Limitations +```shell +cf java [heap-dump|stop-jfr|stop-asprof] [my-app] -local-dir /local/path [-container-dir /var/fspath] +``` + +Everything else, like thread dumps, will be output to `std-out`. You may want to redirect the command's output to file, +e.g., by executing: -As it is built directly on `cf ssh`, the `cf java` plugin can work only with Diego applications that have `cf ssh` enabled. -To check if your app fulfills the requirements, you can find out by running the `cf ssh-enabled [app-name]` command. +```shell +cf java thread-dump [my_app] -i [my_instance_index] > heap-dump.hprof +``` + +The `-k` flag is invalid when invoking non file producing commands. (Unlike with heap dumps, the JVM does not need to +output the thread dump to file before streaming it out.) + +## Limitations -Also, `cf ssh` is *very* picky with proxies. -If `cf java` is having issues connecting to your app, chances are the problem is in the networking issues encountered by `cf ssh`. -To verify, run your `cf java` command in "dry-run" mode by adding the `-n` flag and try to execute the command line that `cf java` gives you back. -If it fails, the issue is not in `cf java`, but in whatever makes `cf ssh` fail. +The capability of creating heap dumps and profiles is also limited by the filesystem available to the container. The +`cf java heap-dump`, `cf java asprof-stop` and `cf java jfr-stop` commands trigger a write to the file system, read the +content of the file over the SSH connection, and then remove the file from the container's file system (unless you have +the `-k` flag set). The amount of filesystem space available to a container is set for the entire Cloud Foundry +landscape with a global configuration. The size of a heap dump is roughly linear with the allocated memory of the heap +and the size of the profile is related to the length of the recording. So, it could be that, in case of large heaps, +long profiling durations or the filesystem having too much stuff in it, there is not enough space on the filesystem for +creating the file. In that case, the creation of the heap dump or profile recording and thus the command will fail. -The capability of creating heap dumps is also limited by the filesystem available to the container. -The `cf java heap-dump` command triggers the heap dump to file system, read the content of the file over the SSH connection, and then remove the heap dump file from the container's file system (unless you have the `-k` flag set). -The amount of filesystem space available to a container is set for the entire Cloud Foundry landscape with a global configuration. -The size of a heap dump is roughly linear with the allocated memory of the heap. -So, it could be that, in case of large heaps or the filesystem having too much stuff in it, there is not enough space on the filesystem for creating the heap dump. -In that case, the creation of the heap dump and thus the command will fail. +From the perspective of integration in workflows and overall shell-friendliness, the `cf java` plugin suffers from some +shortcomings in the current `cf-cli` plugin framework: -From the perspective of integration in workflows and overall shell-friendliness, the `cf java` plugin suffers from some shortcomings in the current `cf-cli` plugin framework: -* There is no distinction between `stdout` and `stderr` output from the underlying `cf ssh` command (see [this issue on the `cf-cli` project](https://github.com/cloudfoundry/cli/issues/1074)) - * The `cf java` will however exit with status code `1` when the underpinning `cf ssh` command fails - * If split between `stdout` and `stderr` is needed, you can run the `cf java` plugin in dry-run mode (`--dry-run` flag) and execute its output instead -* The plugin is not current capability of storing output directly to file (see [this issue on the `cf-cli` project](https://github.com/cloudfoundry/cli/issues/1069)) - * The upstream change needed to fix this issue has been scheduled at Pivotal; when they provide the new API we need, we'll update the `cf java` command to save output to file. +- There is no distinction between `stdout` and `stderr` output from the underlying `cf ssh` command (see + [this issue on the `cf-cli` project](https://github.com/cloudfoundry/cli/issues/1074)) + - The `cf java` will however (mostly) exit with status code `1` when the underpinning `cf ssh` command fails + - If split between `stdout` and `stderr` is needed, you can run the `cf java` plugin in dry-run mode (`--dry-run` + flag) and execute its output instead ## Side-effects on the running instance -Executing a thread dump via the `cf java` command does not have much of an overhead on the affected JVM. -(Unless you have **a lot** of threads, that is.) +Storing dumps or profile recordings to the filesystem may lead to to not enough space on the filesystem been available +for other tasks (e.g., temp files). In that case, the application in the container may suffer unexpected errors. + +### Thread-Dumps + +Executing a thread dump via the `cf java` command does not have much of an overhead on the affected JVM. (Unless you +have **a lot** of threads, that is.) + +### Heap-Dumps + +Heap dumps, on the other hand, have to be treated with a little more care. First of all, triggering the heap dump of a +JVM makes the latter execute in most cases a full garbage collection, which will cause your JVM to become unresponsive +for the duration. How much time is needed to execute the heap dump, depends on the size of the heap (the bigger, the +slower), the algorithm used and, above all, whether your container is swapping memory to disk or not (swap is _bad_ for +the JVM). Since Cloud Foundry allows for over-commit in its cells, it is possible that a container would begin swapping +when executing a full garbage collection. (To be fair, it could be swapping even _before_ the garbage collection begins, +but let's not knit-pick here.) So, it is theoretically possible that execuing a heap dump on a JVM in poor status of +health will make it go even worse. + +### Profiles + +Profiles might cause overhead depending on the configuration, but the default configurations typically have a limited +overhead. + +## Development + +### Quick Start + +```bash +# Setup environment and build +./setup-dev-env.sh +make build + +# Run all quality checks and tests +./scripts/lint-all.sh ci + +# Auto-fix formatting before commit +./scripts/lint-all.sh fix +``` + +### Testing + +**Python Tests**: Modern pytest-based test suite. + +```bash +cd test && ./setup.sh && ./test.py all +``` + +### Test Suite Resumption + +The Python test runner in `test/` supports resuming tests from any point using the `--start-with` option: + +```bash +./test.py --start-with TestClass::test_method all # Start with a specific test (inclusive) +``` + +This is useful for long test suites or after interruptions. See `test/README.md` for more details. + +### Code Quality + +Centralized linting scripts: + +```bash +./scripts/lint-all.sh check # Quality check +./scripts/lint-all.sh fix # Auto-fix formatting +./scripts/lint-all.sh ci # CI validation +``` + +### CI/CD + +- Multi-platform builds (Linux, macOS, Windows) +- Automated linting and testing on PRs +- Pre-commit hooks with auto-formatting + +## Support, Feedback, Contributing + +This project is open to feature requests/suggestions, bug reports etc. via +[GitHub issues](https://github.com/SAP/cf-cli-java-plugin/issues). Contribution and feedback are encouraged and always +welcome. Just be aware that this plugin is limited in scope to keep it maintainable. For more information about how to +contribute, the project structure, as well as additional contribution information, see our +[Contribution Guidelines](CONTRIBUTING.md). + +## Security / Disclosure + +If you find any bug that may be a security problem, please follow our instructions at +[in our security policy](https://github.com/SAP/cf-cli-java-plugin/security/policy) on how to report it. Please do not +create GitHub issues for security-related doubts or problems. + +## Changelog + +## Snapshot + + +## 4.0.2 + +### 4.0.2 + +- Don't use CliConnection at all, just call `cf ssh` directly + +### 4.0.1 -Heap dumps, on the other hand, have to be treated with a little more care. -First of all, triggering the heap dump of a JVM makes the latter execute in most cases a full garbage collection, which will cause your JVM to become unresponsive for the duration. -How much time is needed to execute the heap dump, depends on the size of the heap (the bigger, the slower), the algorithm used and, above all, whether your container is swapping memory to disk or not (swap is *bad* for the JVM). -Since Cloud Foundry allows for over-commit in its cells, it is possible that a container would begin swapping when executing a full garbage collection. -(To be fair, it could be swapping even *before* the garbage collection begins, but let's not knit-pick here.) -So, it is theoretically possible that execuing a heap dump on a JVM in poor status of health will make it go even worse. +- Fix thread-dump command -Secondly, as the JVMs output heap dumps to the filesystem, creating a heap dump may lead to to not enough space on the filesystem been available for other tasks (e.g., temp files). -In that case, the application in the container may suffer unexpected errors. +### 4.0.0 -## Tests and Mocking +- Create a proper test suite +- Fix many bugs discovered during testing +- Profiling and JCMD related features -The tests are written using [Ginkgo](https://onsi.github.io/ginkgo/) with [Gomega](https://onsi.github.io/gomega/) for the BDD structure, and [Counterfeiter](https://github.com/maxbrunsfeld/counterfeiter) for the mocking generation. -Unless modifications to the helper interfaces `cmd.CommandExecutor` and `uuid.UUIDGenerator` are needed, there should be no need to regenerate the mocks. +## License -To run the tests, go to the root of the repository and simply run `gingko` (you may need to install Ginkgo first, e.g., `go get github.com/onsi/ginkgo/ginkgo` puts the executable under `$GOPATH/bin`). +Copyright 2017 - 2025 SAP SE or an SAP affiliate company and contributors. Please see our LICENSE for copyright and +license information. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6a1ca31 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,198 @@ +# CI/CD and Testing Integration Summary + +## ๐ŸŽฏ Overview + +The CF Java Plugin now includes comprehensive CI/CD integration with automated testing, linting, +and quality assurance for both Go and Python codebases. + +## ๐Ÿ—๏ธ CI/CD Pipeline + +### GitHub Actions Workflows + +1. **Build and Snapshot Release** (`.github/workflows/build-and-snapshot.yml`) + - **Triggers**: Push to main/master, PRs, weekly schedule, manual dispatch + - **Jobs**: + - Python test suite validation (if available) + - Multi-platform Go builds (Linux, macOS, Windows) + - Automated snapshot releases + +2. **Pull Request Validation** (`.github/workflows/pr-validation.yml`) + - **Triggers**: All pull requests to main/master + - **Validation Steps**: + - Go formatting (`go fmt`) and linting (`go vet`) + - Python code quality (flake8, black, isort) + - Markdown linting and formatting + - Python test execution + - Plugin build verification + +### Smart Python Detection + +The CI automatically detects if the Python test suite exists by checking for: + +- `test/requirements.txt` +- `test/setup.sh` + +If found, runs Python linting validation. **Note: Python test execution is temporarily disabled in CI.** + +## ๐Ÿ”’ Pre-commit Hooks + +### Installation + +```bash +./setup-dev-env.sh # One-time setup +``` + +### What It Checks + +- โœ… Go code formatting (`go fmt`) +- โœ… Go static analysis (`go vet`) +- โœ… Python linting (flake8) - if test suite exists +- โœ… Python formatting (black) - auto-fixes issues +- โœ… Import sorting (isort) - auto-fixes issues +- โœ… Python syntax validation +- โœ… Markdown linting (markdownlint) - checks git-tracked files + +### Hook Behavior + +- **Auto-fixes**: Python formatting and import sorting +- **Blocks commits**: On critical linting issues +- **Warnings**: For non-critical issues or missing Python suite + +## ๐Ÿงช Python Test Suite Integration + +### Linting Standards + +- **[flake8](https://flake8.pycqa.org/)**: Line length 120, ignores E203,W503 +- **[black](https://black.readthedocs.io/)**: Line length 120, compatible with flake8 +- **[isort](https://pycqa.github.io/isort/)**: Black-compatible profile for import sorting +- **[markdownlint](https://github.com/DavidAnson/markdownlint)**: Automated markdown formatting + (120 char limit, git-tracked files only) + +### Manual Usage + +```bash +./scripts/lint-go.sh check # Check Go code formatting and static analysis +./scripts/lint-go.sh fix # Auto-fix Go code issues +./scripts/lint-python.sh check # Check Python code quality +./scripts/lint-python.sh fix # Auto-fix Python code issues +./scripts/lint-markdown.sh check # Check formatting +./scripts/lint-markdown.sh fix # Auto-fix issues +./scripts/lint-all.sh check # Check all (Go, Python, Markdown) +``` + +### Test Execution + +```bash +cd test +./setup.sh # Setup environment +./test.py all # Run all tests +``` + +**CI Status**: Python tests are currently disabled in CI workflows but can be run locally. + +### Coverage Reporting + +- Generated in XML format for Codecov integration +- Covers the `framework` module +- Includes terminal output for local development + +## ๐Ÿ› ๏ธ Development Workflow + +### First-time Setup + +```bash +git clone +cd cf-cli-java-plugin +./setup-dev-env.sh +``` + +### Daily Development + +```bash +# Make changes +code cf-java-plugin.code-workspace + +# Commit (hooks run automatically) +git add . +git commit -m "Feature: Add new functionality" + +# Push (triggers CI) +git push origin feature-branch + +# Create PR (triggers validation) +``` + +### Manual Testing + +```bash +# Test pre-commit hooks +.git/hooks/pre-commit + +# Test VS Code configuration +./test-vscode-config.sh + +# Run specific tests +cd test && pytest test_jfr.py -v +``` + +## ๐Ÿ“Š Quality Metrics + +### Go Code Quality + +- Formatting enforcement via `go fmt` +- Static analysis via `go vet` + +### Python Code Quality + +- Style compliance: flake8 (PEP 8 + custom rules) +- Formatting: black (consistent style) +- Import organization: isort (proper import ordering) + +### Markdown Code Quality + +- Style compliance: markdownlint (120 char limit, git-tracked files only) +- Automated formatting with relaxed rules for compatibility + +## ๐Ÿ” GitHub Secrets Configuration + +For running Python tests in CI that require Cloud Foundry credentials, configure these GitHub repository secrets: + +### Required Secrets + +| Secret Name | Description | Example | +| ------------- | -------------------------- | --------------------------------------- | +| `CF_API` | Cloud Foundry API endpoint | `https://api.cf.eu12.hana.ondemand.com` | +| `CF_USERNAME` | Cloud Foundry username | `your-username` | +| `CF_PASSWORD` | Cloud Foundry password | `your-password` | +| `CF_ORG` | Cloud Foundry organization | `sapmachine-testing` | +| `CF_SPACE` | Cloud Foundry space | `dev` | + +### Setting Up Secrets + +1. **Navigate to Repository Settings**: + - Go to your GitHub repository + - Click "Settings" โ†’ "Secrets and variables" โ†’ "Actions" + +2. **Add New Repository Secret**: + - Click "New repository secret" + - Enter the secret name (e.g., `CF_USERNAME`) + - Enter the secret value + - Click "Add secret" + +3. **Repeat for all required secrets** + +### Environment Variable Usage + +The Python test framework automatically uses these environment variables: + +- Falls back to `test_config.yml` if environment variables are not set +- Supports both file-based and environment-based configuration +- CI workflows pass secrets as environment variables to test processes + +### Security Best Practices + +- โœ… **Never commit credentials** to source code +- โœ… **Use repository secrets** for sensitive data +- โœ… **Limit secret access** to necessary workflows only +- โœ… **Rotate credentials** regularly +- โœ… **Use organization secrets** for shared credentials across repositories diff --git a/cf-java-plugin.code-workspace b/cf-java-plugin.code-workspace new file mode 100644 index 0000000..87dbc9f --- /dev/null +++ b/cf-java-plugin.code-workspace @@ -0,0 +1,80 @@ +{ + "folders": [ + { + "name": "CF Java Plugin", + "path": "." + } + ], + "settings": { + // Python settings for testing + "python.defaultInterpreterPath": "./test/venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "./test", + "-v" + ], + "python.testing.cwd": "./test", + // Go settings for main plugin + "go.gopath": "${workspaceFolder}", + "go.useLanguageServer": true, + "go.formatTool": "goimports", + "go.lintTool": "golint", + "go.buildOnSave": "package", + "go.vetOnSave": "package", + "go.coverOnSave": false, + "go.useCodeSnippetsOnFunctionSuggest": true, + // File associations + "files.associations": { + "*.yml": "yaml", + "*.yaml": "yaml", + "*.go": "go", + "Makefile": "makefile", + "*.py": "python", + }, + // File exclusions for better performance + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/*.pyo": true, + "test/.pytest_cache": true, + "test/venv": true, + "*.hprof": true, + "*.jfr": true, + "**/.DS_Store": true, + "build/": true + }, + // Search exclusions + "search.exclude": { + "test/venv": true, + "**/__pycache__": true, + "test/.pytest_cache": true, + "**/*.hprof": true, + "**/*.jfr": true, + "build/": true + }, + // Editor settings + "editor.formatOnSave": true, + "editor.rulers": [ + 120 + ], + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "extensions": { + "recommendations": [ + "ms-python.python", + "ms-python.debugpy", + "ms-python.pylance", + "ms-python.black-formatter", + "ms-python.flake8", + "golang.go", + "redhat.vscode-yaml", + "ms-vscode.test-adapter-converter", + "ms-vscode.vscode-json", + "github.copilot", + "github.copilot-chat", + "ms-vscode.makefile-tools" + ] + } +} \ No newline at end of file diff --git a/cf_cli_java_plugin.go b/cf_cli_java_plugin.go index 3f094f6..6348bd6 100644 --- a/cf_cli_java_plugin.go +++ b/cf_cli_java_plugin.go @@ -1,18 +1,18 @@ /* - * Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved. + * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved. * This file is licensed under the Apache Software License, v. 2 except as noted * otherwise in the LICENSE file at the root of the repository. */ +// Package main implements a CF CLI plugin for Java applications, providing commands +// for heap dumps, thread dumps, profiling, and other Java diagnostics. package main import ( - "github.com/SAP/cf-cli-java-plugin/cmd" - "github.com/SAP/cf-cli-java-plugin/uuid" - "errors" "fmt" "os" + "os/exec" "strconv" "strings" @@ -20,15 +20,27 @@ import ( "code.cloudfoundry.org/cli/cf/trace" "code.cloudfoundry.org/cli/plugin" - guuid "github.com/satori/go.uuid" + "cf.plugin.ref/requires/utils" + "github.com/simonleung8/flags" ) -// The JavaPlugin is a cf cli plugin that supports taking heap and thread dumps on demand +// Assert that JavaPlugin implements plugin.Plugin. +var _ plugin.Plugin = (*JavaPlugin)(nil) + +// JavaPlugin is a CF CLI plugin that supports taking heap and thread dumps on demand type JavaPlugin struct { + verbose bool } -// InvalidUsageError errors mean that the arguments passed in input to the command are invalid +// logVerbosef logs a message with a format string if verbose mode is enabled +func (c *JavaPlugin) logVerbosef(format string, args ...any) { + if c.verbose { + fmt.Printf("[VERBOSE] "+format+"\n", args...) + } +} + +// InvalidUsageError indicates that the arguments passed as input to the command are invalid type InvalidUsageError struct { message string } @@ -37,62 +49,215 @@ func (e InvalidUsageError) Error() string { return e.message } -type commandExecutorImpl struct { - cliConnection plugin.CliConnection +// Options holds all command-line options for the Java plugin +type Options struct { + AppInstanceIndex int + Keep bool + NoDownload bool + DryRun bool + Verbose bool + ContainerDir string + LocalDir string + Args string } -func (c commandExecutorImpl) Execute(args []string) ([]string, error) { - output, err := c.cliConnection.CliCommand(args...) +// FlagDefinition holds metadata for a command-line flag +type FlagDefinition struct { + Name string + ShortName string + Usage string + Description string // Longer description for help text + Type string + DefaultInt int +} - return output, err +// flagDefinitions contains all flag definitions in a centralized location +var flagDefinitions = []FlagDefinition{ + { + Name: "app-instance-index", + ShortName: "i", + Usage: "application `instance` to connect to", + Description: "select to which instance of the app to connect", + Type: "int", + DefaultInt: 0, + }, + { + Name: "keep", + ShortName: "k", + Usage: "whether to `keep` the heap-dump/JFR/... files on the container of the application instance after having downloaded it locally", + Description: "keep the heap dump in the container; by default the heap dump/JFR/... will be deleted from the container's filesystem after being downloaded", + Type: "bool", + }, + { + Name: "no-download", + ShortName: "nd", + Usage: "do not download the heap-dump/JFR/... file to the local machine", + Description: "don't download the heap dump/JFR/... file to local, only keep it in the container, implies '--keep'", + Type: "bool", + }, + { + Name: "dry-run", + ShortName: "n", + Usage: "triggers the `dry-run` mode to show only the cf-ssh command that would have been executed", + Description: "just output to command line what would be executed", + Type: "bool", + }, + { + Name: "verbose", + ShortName: "v", + Usage: "enable verbose output for the plugin", + Description: "enable verbose output for the plugin", + Type: "bool", + }, + { + Name: "container-dir", + ShortName: "cd", + Usage: "specify the folder path where the dump/JFR/... file should be stored in the container", + Description: "the directory path in the container that the heap dump/JFR/... file will be saved to", + Type: "string", + }, + { + Name: "local-dir", + ShortName: "ld", + Usage: "specify the folder where the dump/JFR/... file will be downloaded to, dump file will not be copied to local if this parameter was not set", + Description: "the local directory path that the dump/JFR/... file will be saved to, defaults to the current directory", + Type: "string", + }, + { + Name: "args", + ShortName: "a", + Usage: "Miscellaneous arguments to pass to the command in the container, be aware to end it with a space if it is a simple option", + Description: "Miscellaneous arguments to pass to the command (if supported) in the container, be aware to end it with a space if it is a simple option. For commands that create arbitrary files (jcmd, asprof), the environment variables @FSPATH, @ARGS, @APP_NAME, @FILE_NAME, and @STATIC_FILE_NAME are available in --args to reference the working directory path, arguments, application name, and generated file name respectively.", + Type: "string", + }, } -type uuidGeneratorImpl struct { +func (c *JavaPlugin) createOptionsParser() flags.FlagContext { + commandFlags := flags.New() + + // Create flags from centralized definitions + for _, flagDef := range flagDefinitions { + switch flagDef.Type { + case "int": + commandFlags.NewIntFlagWithDefault(flagDef.Name, flagDef.ShortName, flagDef.Usage, flagDef.DefaultInt) + case "bool": + commandFlags.NewBoolFlag(flagDef.Name, flagDef.ShortName, flagDef.Usage) + case "string": + commandFlags.NewStringFlag(flagDef.Name, flagDef.ShortName, flagDef.Usage) + } + } + + return commandFlags } -func (u uuidGeneratorImpl) Generate() string { - return guuid.NewV4().String() +// parseOptions creates and parses command-line flags, returning the Options struct +func (c *JavaPlugin) parseOptions(args []string) (*Options, []string, error) { + commandFlags := c.createOptionsParser() + parseErr := commandFlags.Parse(args...) + if parseErr != nil { + return nil, nil, parseErr + } + + options := &Options{ + AppInstanceIndex: commandFlags.Int("app-instance-index"), + Keep: commandFlags.IsSet("keep"), + NoDownload: commandFlags.IsSet("no-download"), + DryRun: commandFlags.IsSet("dry-run"), + Verbose: commandFlags.IsSet("verbose"), + ContainerDir: commandFlags.String("container-dir"), + LocalDir: commandFlags.String("local-dir"), + Args: commandFlags.String("args"), + } + + return options, commandFlags.Args(), nil +} + +// generateOptionsMapFromFlags creates the options map for plugin metadata +func (c *JavaPlugin) generateOptionsMapFromFlags() map[string]string { + options := make(map[string]string) + + // Generate options from the centralized flag definitions + for _, flagDef := range flagDefinitions { + // Create the prefix for the flag (short name with appropriate formatting) + prefix := "-" + flagDef.ShortName + if flagDef.Name == "app-instance-index" { + prefix += " [index]" + } + prefix += ", " + + // Use the Description field for detailed help text + options[flagDef.Name] = utils.WrapTextWithPrefix(flagDef.Description, prefix, 80, 27) + } + + return options } const ( - // JavaDetectionCommand is the prologue command to detect on the Garden container if it contains a Java app. Visible for tests - JavaDetectionCommand = "if ! pgrep -x \"java\" > /dev/null; then echo \"No 'java' process found running. Are you sure this is a Java app?\" >&2; exit 1; fi;" - heapDumpCommand = "heap-dump" - threadDumpCommand = "thread-dump" + // JavaDetectionCommand is the prologue command to detect if the Garden container contains a Java app. + JavaDetectionCommand = "if ! pgrep -x \"java\" > /dev/null; then echo \"No 'java' process found running. Are you sure this is a Java app?\" >&2; exit 1; fi" + CheckNoCurrentJFRRecordingCommand = `OUTPUT=$($JCMD_COMMAND $(pidof java) JFR.check 2>&1); if [[ ! "$OUTPUT" == *"No available recording"* ]]; then echo "JFR recording already running. Stop it before starting a new recording."; exit 1; fi;` + FilterJCMDRemoteMessage = `filter_jcmd_remote_message() { + if command -v grep >/dev/null 2>&1; then + grep -v -e "Connected to remote JVM" -e "JVM response code = 0" + else + cat # fallback: just pass through the input unchanged + fi +};` ) // Run must be implemented by any plugin because it is part of the // plugin interface defined by the core CLI. // -// Run(....) is the entry point when the core CLI is invoking a command defined +// Run(...) is the entry point when the core CLI is invoking a command defined // by a plugin. The first parameter, plugin.CliConnection, is a struct that can -// be used to invoke cli commands. The second paramter, args, is a slice of +// be used to invoke CLI commands. The second parameter, args, is a slice of // strings. args[0] will be the name of the command, and will be followed by -// any additional arguments a cli user typed in. +// any additional arguments a CLI user typed in. // -// Any error handling should be handled with the plugin itself (this means printing -// user facing errors). The CLI will exit 0 if the plugin exits 0 and will exit +// Any error handling should be handled within the plugin itself (this means printing +// user-facing errors). The CLI will exit 0 if the plugin exits 0 and will exit // 1 should the plugin exit nonzero. func (c *JavaPlugin) Run(cliConnection plugin.CliConnection, args []string) { - _, err := c.DoRun(&commandExecutorImpl{cliConnection: cliConnection}, &uuidGeneratorImpl{}, args) + // Check if verbose flag is in args for early logging + for _, arg := range args { + if arg == "-v" || arg == "--verbose" { + c.verbose = true + break + } + } + + c.logVerbosef("Run called with args: %v", args) + + _, err := c.DoRun(cliConnection, args) if err != nil { + c.logVerbosef("Error occurred: %v", err) os.Exit(1) } + c.logVerbosef("Run completed successfully") } -// DoRun is an internal method that we use to wrap the cmd package with CommandExecutor for test purposes -func (c *JavaPlugin) DoRun(commandExecutor cmd.CommandExecutor, uuidGenerator uuid.UUIDGenerator, args []string) (string, error) { +// DoRun is an internal method used to wrap the cmd package with CommandExecutor for test purposes +func (c *JavaPlugin) DoRun(cliConnection plugin.CliConnection, args []string) (string, error) { traceLogger := trace.NewLogger(os.Stdout, true, os.Getenv("CF_TRACE"), "") ui := terminal.NewUI(os.Stdin, os.Stdout, terminal.NewTeePrinter(os.Stdout), traceLogger) - output, err := c.execute(commandExecutor, uuidGenerator, args) + c.logVerbosef("DoRun called with args: %v", args) + + output, err := c.execute(cliConnection, args) if err != nil { + if err.Error() == "unexpected EOF" { + return output, err + } ui.Failed(err.Error()) - if _, invalidUsageErr := err.(*InvalidUsageError); invalidUsageErr { + var invalidUsageErr *InvalidUsageError + if errors.As(err, &invalidUsageErr) { fmt.Println() fmt.Println() - commandExecutor.Execute([]string{"help", "java"}) + err := exec.Command("cf", "help", "java").Run() + if err != nil { + ui.Failed("Failed to show help") + } } } else if output != "" { ui.Say(output) @@ -101,7 +266,338 @@ func (c *JavaPlugin) DoRun(commandExecutor cmd.CommandExecutor, uuidGenerator uu return output, err } -func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator uuid.UUIDGenerator, args []string) (string, error) { +type Command struct { + Name string + Description string + OnlyOnRecentSapMachine bool + // Required tools, checked and $TOOL_COMMAND set in the remote command + // jcmd is special: it uses asprof if available + RequiredTools []string + GenerateFiles bool + NeedsFileName bool + // Use @ prefix to avoid shell expansion issues, replaced directly in Go code + // use @FILE_NAME to get the generated file name with a random UUID, + // @STATIC_FILE_NAME without, and @FSPATH to get the path where the file is stored (for GenerateArbitraryFiles commands) + SSHCommand string + FilePattern string + FileExtension string + FileLabel string + FileNamePart string + // Run the command in a subfolder of the container + GenerateArbitraryFiles bool + GenerateArbitraryFilesFolderName string +} + +// HasMiscArgs checks whether the SSHCommand contains @ARGS +func (c *Command) HasMiscArgs() bool { + return strings.Contains(c.SSHCommand, "@ARGS") +} + +// replaceVariables replaces @-prefixed variables in the command with actual values. +// Returns the processed command string and an error if validation fails. +func (c *JavaPlugin) replaceVariables(command, appName, fspath, fileName, staticFileName, args string) (string, error) { + // Validate: @ARGS cannot contain itself, other variables cannot contain any @ variables + if strings.Contains(args, "@ARGS") { + return "", fmt.Errorf("invalid variable reference: @ARGS cannot contain itself") + } + for varName, value := range map[string]string{"@APP_NAME": appName, "@FSPATH": fspath, "@FILE_NAME": fileName, "@STATIC_FILE_NAME": staticFileName} { + if strings.Contains(value, "@") { + return "", fmt.Errorf("invalid variable reference: %s cannot contain @ variables", varName) + } + } + + // First, replace variables within @ARGS value itself + processedArgs := args + processedArgs = strings.ReplaceAll(processedArgs, "@APP_NAME", appName) + processedArgs = strings.ReplaceAll(processedArgs, "@FSPATH", fspath) + processedArgs = strings.ReplaceAll(processedArgs, "@FILE_NAME", fileName) + processedArgs = strings.ReplaceAll(processedArgs, "@STATIC_FILE_NAME", staticFileName) + + // Then replace all variables in the command template + result := command + result = strings.ReplaceAll(result, "@APP_NAME", appName) + result = strings.ReplaceAll(result, "@FSPATH", fspath) + result = strings.ReplaceAll(result, "@FILE_NAME", fileName) + result = strings.ReplaceAll(result, "@STATIC_FILE_NAME", staticFileName) + result = strings.ReplaceAll(result, "@ARGS", processedArgs) + + return result, nil +} + +var commands = []Command{ + { + Name: "heap-dump", + Description: "Generate a heap dump from a running Java application", + GenerateFiles: true, + FileExtension: ".hprof", + /* + If there is not enough space on the filesystem to write the dump, jmap will create a file + with size 0, output something about not enough space left on the device, and exit with status code 0. + Because YOLO. + + Also: if the heap dump file already exists, jmap will output something about the file already + existing and exit with status code 0. At least it is consistent. + + OpenJDK: Wrap everything in an if statement in case jmap is available + */ + SSHCommand: `if [ -f @FILE_NAME ]; then echo >&2 'Heap dump @FILE_NAME already exists'; exit 1; fi +JMAP_COMMAND=$(find -executable -name jmap | head -1 | tr -d [:space:]) +# SAP JVM: Wrap everything in an if statement in case jvmmon is available +JVMMON_COMMAND=$(find -executable -name jvmmon | head -1 | tr -d [:space:]) +# if we have neither jmap nor jvmmon, we cannot generate a heap dump and should exit with an error +if [ -z "${JMAP_COMMAND}" ] && [ -z "${JVMMON_COMMAND}" ]; then + echo >&2 "jvmmon or jmap are required for generating heap dump, you can modify your application manifest.yaml on the 'JBP_CONFIG_OPEN_JDK_JRE' environment variable. This could be done like this: + --- + applications: + - name: + memory: 1G + path: + buildpack: https://github.com/cloudfoundry/java-buildpack + env: + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }' + + " + exit 1 +fi +if [ -n "${JMAP_COMMAND}" ]; then +OUTPUT=$( ${JMAP_COMMAND} -dump:format=b,file=@FILE_NAME $(pidof java) ) || STATUS_CODE=$? +if [ ! -s @FILE_NAME ]; then echo >&2 ${OUTPUT}; exit 1; fi +if [ ${STATUS_CODE:-0} -gt 0 ]; then echo >&2 ${OUTPUT}; exit ${STATUS_CODE}; fi +elif [ -n "${JVMMON_COMMAND}" ]; then +echo -e 'change command line flag flags=-XX:HeapDumpOnDemandPath=@FSPATH\ndump heap' > setHeapDumpOnDemandPath.sh +OUTPUT=$( ${JVMMON_COMMAND} -pid $(pidof java) -cmd "setHeapDumpOnDemandPath.sh" ) || STATUS_CODE=$? +sleep 5 # Writing the heap dump is triggered asynchronously -> give the JVM some time to create the file +HEAP_DUMP_NAME=$(find @FSPATH -name 'java_pid*.hprof' -printf '%T@ %p\0' | sort -zk 1nr | sed -z 's/^[^ ]* //' | tr '\0' '\n' | head -n 1) +SIZE=-1; OLD_SIZE=$(stat -c '%s' "${HEAP_DUMP_NAME}"); while [ ${SIZE} != ${OLD_SIZE} ]; do OLD_SIZE=${SIZE}; sleep 3; SIZE=$(stat -c '%s' "${HEAP_DUMP_NAME}"); done +if [ ! -s "${HEAP_DUMP_NAME}" ]; then echo >&2 ${OUTPUT}; exit 1; fi +if [ ${STATUS_CODE:-0} -gt 0 ]; then echo >&2 ${OUTPUT}; exit ${STATUS_CODE}; fi +fi`, + FileLabel: "heap dump", + FileNamePart: "heapdump", + }, + { + Name: "thread-dump", + Description: "Generate a thread dump from a running Java application", + GenerateFiles: false, + SSHCommand: `JSTACK_COMMAND=$(find -executable -name jstack | head -1); + JVMMON_COMMAND=$(find -executable -name jvmmon | head -1) + if [ -z "${JVMMON_COMMAND}" ] && [ -z "${JSTACK_COMMAND}" ]; then + echo >&2 "jstack or jvmmon are required for generating heap dump, you can modify your application manifest.yaml on the 'JBP_CONFIG_OPEN_JDK_JRE' environment variable. This could be done like this: + --- + applications: + - name: + memory: 1G + path: + buildpack: https://github.com/cloudfoundry/java-buildpack + env: + JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { repository_root: "https://java-buildpack.cloudfoundry.org/openjdk-jdk/jammy/x86_64", version: 21.+ } }' + + " + exit 1 + fi + if [ -n \"${JSTACK_COMMAND}\" ]; then ${JSTACK_COMMAND} $(pidof java); exit 0; fi; + if [ -n \"${JVMMON_COMMAND}\" ]; then ${JVMMON_COMMAND} -pid $(pidof java) -c \"print stacktrace\"; fi`, + }, + { + Name: "vm-info", + Description: "Print information about the Java Virtual Machine running a Java application", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.info | filter_jcmd_remote_message`, + }, + { + Name: "jcmd", + Description: "Run a JCMD command on a running Java application via --args, downloads and deletes all files that are created in the current folder, use '--no-download' to prevent this. Environment variables available: @FSPATH (writable directory path, always set), @ARGS (command arguments), @APP_NAME (application name), @FILE_NAME (generated filename for file operations without UUID), and @STATIC_FILE_NAME (without UUID). Use single quotes around --args to prevent shell expansion.", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + GenerateArbitraryFiles: true, + GenerateArbitraryFilesFolderName: "jcmd", + SSHCommand: `$JCMD_COMMAND $(pidof java) @ARGS`, + }, + { + Name: "jfr-start", + Description: "Start a Java Flight Recorder default recording on a running Java application (stores in the container-dir)", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + `$JCMD_COMMAND $(pidof java) JFR.start settings=default.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; + echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "jfr-start-profile", + Description: "Start a Java Flight Recorder profile recording on a running Java application (stores in the container-dir))", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + `$JCMD_COMMAND $(pidof java) JFR.start settings=profile.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; + echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "jfr-start-gc", + Description: "Start a Java Flight Recorder GC recording on a running Java application (stores in the container-dir)", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + OnlyOnRecentSapMachine: true, + NeedsFileName: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + `$JCMD_COMMAND $(pidof java) JFR.start settings=gc.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; + echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "jfr-start-gc-details", + Description: "Start a Java Flight Recorder detailed GC recording on a running Java application (stores in the container-dir)", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + OnlyOnRecentSapMachine: true, + NeedsFileName: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + CheckNoCurrentJFRRecordingCommand + + `$JCMD_COMMAND $(pidof java) JFR.start settings=gc_details.jfc filename=@FILE_NAME name=JFR | filter_jcmd_remote_message; + echo "Use 'cf java jfr-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "jfr-stop", + Description: "Stop a Java Flight Recorder recording on a running Java application", + RequiredTools: []string{"jcmd"}, + GenerateFiles: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.stop name=JFR | filter_jcmd_remote_message); + echo "$output"; echo ""; filename=$(echo "$output" | grep /.*.jfr --only-matching); + if [ -z "$filename" ]; then echo "No JFR recording created"; exit 1; fi; + if [ ! -f "$filename" ]; then echo "JFR recording $filename does not exist"; exit 1; fi; + if [ ! -s "$filename" ]; then echo "JFR recording $filename is empty"; exit 1; fi; + mv "$filename" @FILE_NAME; + echo "JFR recording copied to @FILE_NAME"`, + }, + { + Name: "jfr-dump", + Description: "Dump a Java Flight Recorder recording on a running Java application without stopping it", + RequiredTools: []string{"jcmd"}, + GenerateFiles: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "jfr", + SSHCommand: FilterJCMDRemoteMessage + ` output=$($JCMD_COMMAND $(pidof java) JFR.dump name=JFR | filter_jcmd_remote_message); + echo "$output"; echo ""; filename=$(echo "$output" | grep /.*.jfr --only-matching); + if [ -z "$filename" ]; then echo "No JFR recording created"; exit 1; fi; + if [ ! -f "$filename" ]; then echo "JFR recording $filename does not exist"; exit 1; fi; + if [ ! -s "$filename" ]; then echo "JFR recording $filename is empty"; exit 1; fi; + cp "$filename" @FILE_NAME; + echo "JFR recording copied to @FILE_NAME"; + echo "Use 'cf java jfr-stop @APP_NAME' to stop the recording and copy the final JFR file to the local folder"`, + }, + { + Name: "jfr-status", + Description: "Check the running Java Flight Recorder recording on a running Java application", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) JFR.check | filter_jcmd_remote_message`, + }, + { + Name: "vm-version", + Description: "Print the version of the Java Virtual Machine running a Java application", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.version | filter_jcmd_remote_message`, + }, + { + Name: "vm-vitals", + Description: "Print vital statistics about the Java Virtual Machine running a Java application", + RequiredTools: []string{"jcmd"}, + GenerateFiles: false, + SSHCommand: FilterJCMDRemoteMessage + `$JCMD_COMMAND $(pidof java) VM.vitals | filter_jcmd_remote_message`, + }, + { + Name: "asprof", + Description: "Run async-profiler commands passed to asprof via --args, copies files in the current folder. Don't use in combination with asprof-* commands. Downloads and deletes all files that are created in the current folder, if not using 'start' asprof command, use '--no-download' to prevent this. Environment variables available: @FSPATH (writable directory path, always set), @ARGS (command arguments), @APP_NAME (application name), @FILE_NAME (generated filename for file operations), and @STATIC_FILE_NAME (without UUID). Use single quotes around --args to prevent shell expansion.", + OnlyOnRecentSapMachine: true, + RequiredTools: []string{"asprof"}, + GenerateFiles: false, + GenerateArbitraryFiles: true, + GenerateArbitraryFilesFolderName: "asprof", + SSHCommand: `$ASPROF_COMMAND $(pidof java) @ARGS`, + }, + { + Name: "asprof-start-cpu", + Description: "Start an async-profiler CPU-time profile recording on a running Java application", + OnlyOnRecentSapMachine: true, + RequiredTools: []string{"asprof"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileNamePart: "asprof", + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e cpu -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "asprof-start-wall", + Description: "Start an async-profiler wall-clock profile recording on a running Java application", + OnlyOnRecentSapMachine: true, + RequiredTools: []string{"asprof"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileNamePart: "asprof", + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e wall -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "asprof-start-alloc", + Description: "Start an async-profiler allocation profile recording on a running Java application", + OnlyOnRecentSapMachine: true, + RequiredTools: []string{"asprof"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileNamePart: "asprof", + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e alloc -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "asprof-start-lock", + Description: "Start an async-profiler lock profile recording on a running Java application", + OnlyOnRecentSapMachine: true, + RequiredTools: []string{"asprof"}, + GenerateFiles: false, + NeedsFileName: true, + FileExtension: ".jfr", + FileNamePart: "asprof", + SSHCommand: `$ASPROF_COMMAND start $(pidof java) -e lock -f @FILE_NAME; echo "Use 'cf java asprof-stop @APP_NAME' to copy the file to the local folder"`, + }, + { + Name: "asprof-stop", + Description: "Stop an async-profiler profile recording on a running Java application", + RequiredTools: []string{"asprof"}, + OnlyOnRecentSapMachine: true, + GenerateFiles: true, + FileExtension: ".jfr", + FileLabel: "JFR recording", + FileNamePart: "asprof", + SSHCommand: `$ASPROF_COMMAND stop $(pidof java)`, + }, + { + Name: "asprof-status", + Description: "Get the status of async-profiler on a running Java application", + RequiredTools: []string{"asprof"}, + OnlyOnRecentSapMachine: true, + GenerateFiles: false, + SSHCommand: `$ASPROF_COMMAND status $(pidof java)`, + }, +} + +func (c *JavaPlugin) execute(_ plugin.CliConnection, args []string) (string, error) { if len(args) == 0 { return "", &InvalidUsageError{message: "No command provided"} } @@ -113,138 +609,385 @@ func (c *JavaPlugin) execute(commandExecutor cmd.CommandExecutor, uuidGenerator case "java": break default: - return "", &InvalidUsageError{message: fmt.Sprintf("Unexpected command name '%s' (expected : 'java')", args[0])} + return "", &InvalidUsageError{message: fmt.Sprintf("Unexpected command Name '%s' (expected : 'java')", args[0])} } if os.Getenv("CF_TRACE") == "true" { - return "", errors.New("The environment variable CF_TRACE is set to true. This prevents download of the dump from succeeding") + return "", errors.New("the environment variable CF_TRACE is set to true. This prevents download of the dump from succeeding") } - commandFlags := flags.New() - - commandFlags.NewIntFlagWithDefault("app-instance-index", "i", "application `instance` to connect to", -1) - commandFlags.NewBoolFlag("keep", "k", "whether to `keep` the heap/thread-dump on the container of the application instance after having downloaded it locally") - commandFlags.NewBoolFlag("dry-run", "n", "triggers the `dry-run` mode to show only the cf-ssh command that would have been executed") - - parseErr := commandFlags.Parse(args[1:]...) + options, arguments, parseErr := c.parseOptions(args[1:]) if parseErr != nil { return "", &InvalidUsageError{message: fmt.Sprintf("Error while parsing command arguments: %v", parseErr)} } - applicationInstance := commandFlags.Int("app-instance-index") - keepAfterDownload := commandFlags.IsSet("keep") + fileFlags := []string{"container-dir", "local-dir", "keep", "no-download"} + + c.logVerbosef("Starting command execution") + c.logVerbosef("Command arguments: %v", args) + + noDownload := options.NoDownload + keepAfterDownload := options.Keep || noDownload + + c.logVerbosef("Application instance: %d", options.AppInstanceIndex) + c.logVerbosef("No download: %t", noDownload) + c.logVerbosef("Keep after download: %t", keepAfterDownload) + + remoteDir := options.ContainerDir + // strip trailing slashes from remoteDir + remoteDir = strings.TrimRight(remoteDir, "/") + localDir := options.LocalDir + if localDir == "" { + localDir = "." + } + + c.logVerbosef("Remote directory: %s", remoteDir) + c.logVerbosef("Local directory: %s", localDir) - arguments := commandFlags.Args() argumentLen := len(arguments) if argumentLen < 1 { - return "", &InvalidUsageError{message: fmt.Sprintf("No command provided")} + return "", &InvalidUsageError{message: "No command provided"} } - command := arguments[0] - switch command { - case heapDumpCommand: - break - case threadDumpCommand: - if commandFlags.IsSet("keep") { - return "", &InvalidUsageError{message: fmt.Sprintf("The flag %q is not supported for thread-dumps", "keep")} + commandName := arguments[0] + c.logVerbosef("Command name: %s", commandName) + + index := -1 + for i, command := range commands { + if command.Name == commandName { + index = i + break } - break - default: - return "", &InvalidUsageError{message: fmt.Sprintf("Unrecognized command %q: supported commands are 'heap-dump' and 'thread-dump' (see cf help)", command)} + } + if index == -1 { + avCommands := make([]string, 0, len(commands)) + for _, command := range commands { + avCommands = append(avCommands, command.Name) + } + matches := utils.FuzzySearch(commandName, avCommands, 3) + return "", &InvalidUsageError{message: fmt.Sprintf("Unrecognized command %q, did you mean: %s?", commandName, utils.JoinWithOr(matches))} } + command := commands[index] + c.logVerbosef("Found command: %s - %s", command.Name, command.Description) + if !command.GenerateFiles && !command.GenerateArbitraryFiles { + c.logVerbosef("Command does not generate files, checking for invalid file flags") + for _, flag := range fileFlags { + if (flag == "container-dir" && options.ContainerDir != "") || + (flag == "local-dir" && options.LocalDir != "") || + (flag == "keep" && options.Keep) || + (flag == "no-download" && options.NoDownload) { + c.logVerbosef("Invalid flag %q detected for command %s", flag, command.Name) + return "", &InvalidUsageError{message: fmt.Sprintf("The flag %q is not supported for %s", flag, command.Name)} + } + } + } + if command.Name == "asprof" { + trimmedMiscArgs := strings.TrimLeft(options.Args, " ") + if len(trimmedMiscArgs) > 6 && trimmedMiscArgs[:6] == "start " { + noDownload = true + c.logVerbosef("asprof start command detected, setting noDownload to true") + } else { + noDownload = trimmedMiscArgs == "start" + if noDownload { + c.logVerbosef("asprof start command detected, setting noDownload to true") + } + } + } + if !command.HasMiscArgs() && options.Args != "" { + c.logVerbosef("Command %s does not support --args flag", command.Name) + return "", &InvalidUsageError{message: fmt.Sprintf("The flag %q is not supported for %s", "args", command.Name)} + } if argumentLen == 1 { - return "", &InvalidUsageError{message: fmt.Sprintf("No application name provided")} + return "", &InvalidUsageError{message: "No application name provided"} } else if argumentLen > 2 { return "", &InvalidUsageError{message: fmt.Sprintf("Too many arguments provided: %v", strings.Join(arguments[2:], ", "))} } applicationName := arguments[1] + c.logVerbosef("Application name: %s", applicationName) cfSSHArguments := []string{"ssh", applicationName} - if applicationInstance > 0 { - cfSSHArguments = append(cfSSHArguments, "--app-instance-index", strconv.Itoa(applicationInstance)) + if options.AppInstanceIndex > 0 { + cfSSHArguments = append(cfSSHArguments, "--app-instance-index", strconv.Itoa(options.AppInstanceIndex)) + } + if options.AppInstanceIndex < 0 { + // indexes can't be negative, so fail with an error + return "", &InvalidUsageError{message: fmt.Sprintf("Invalid application instance index %d, must be >= 0", options.AppInstanceIndex)} } - var remoteCommandTokens = []string{} + c.logVerbosef("CF SSH arguments: %v", cfSSHArguments) - switch command { - case heapDumpCommand: - heapdumpFileName := "/tmp/heapdump-" + uuidGenerator.Generate() + ".hprof" - remoteCommandTokens = append(remoteCommandTokens, JavaDetectionCommand+"$(find -executable -name jmap | head -1) -dump:format=b,file="+heapdumpFileName+" $(pidof java) > /dev/null", "cat "+heapdumpFileName) - if !keepAfterDownload { - remoteCommandTokens = append(remoteCommandTokens, "rm -f "+heapdumpFileName) + supported, err := utils.CheckRequiredTools(applicationName) + + if err != nil || !supported { + return "required tools checking failed", err + } + + c.logVerbosef("Required tools check passed") + + remoteCommandTokens := []string{JavaDetectionCommand} + + c.logVerbosef("Building remote command tokens") + c.logVerbosef("Java detection command: %s", JavaDetectionCommand) + + for _, requiredTool := range command.RequiredTools { + c.logVerbosef("Setting up required tool: %s", requiredTool) + uppercase := strings.ToUpper(requiredTool) + toolCommand := fmt.Sprintf(`%[1]s_TOOL_PATH=$(find -executable -name %[2]s | head -1 | tr -d [:space:]); if [ -z "$%[1]s_TOOL_PATH" ]; then echo "%[2]s not found"; exit 1; fi; %[1]s_COMMAND=$(realpath "$%[1]s_TOOL_PATH")`, uppercase, requiredTool) + if requiredTool == "jcmd" { + // add code that first checks whether asprof is present and if so use `asprof jcmd` instead of `jcmd` + remoteCommandTokens = append(remoteCommandTokens, toolCommand, "ASPROF_COMMAND=$(realpath $(find -executable -name asprof | head -1 | tr -d [:space:])); if [ -n \"${ASPROF_COMMAND}\" ]; then JCMD_COMMAND=\"${ASPROF_COMMAND} jcmd\"; fi") + c.logVerbosef("Added jcmd with asprof fallback") + } else { + remoteCommandTokens = append(remoteCommandTokens, toolCommand) + c.logVerbosef("Added tool command for %s", requiredTool) + } + } + fileName := "" + staticFileName := "" + fspath := remoteDir + + // Initialize fspath and fileName for commands that need them + if command.GenerateFiles || command.NeedsFileName || command.GenerateArbitraryFiles { + c.logVerbosef("Command requires file generation") + fspath, err = utils.GetAvailablePath(applicationName, remoteDir) + if err != nil { + return "", fmt.Errorf("failed to get available path: %w", err) } - case threadDumpCommand: - remoteCommandTokens = append(remoteCommandTokens, JavaDetectionCommand+"$(find -executable -name jstack | head -1) $(pidof java)") + if fspath == "" { + return "", fmt.Errorf("no available path found for file generation") + } + c.logVerbosef("Available path: %s", fspath) + + if command.GenerateArbitraryFiles { + fspath = fspath + "/" + command.GenerateArbitraryFilesFolderName + c.logVerbosef("Updated path for arbitrary files: %s", fspath) + } + + fileName = fspath + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + command.FileExtension + staticFileName = fspath + "/" + applicationName + command.FileNamePart + command.FileExtension + c.logVerbosef("Generated filename: %s", fileName) + c.logVerbosef("Generated static filename without UUID: %s", staticFileName) + } + + commandText := command.SSHCommand + // Perform variable replacements directly in Go code + var err2 error + commandText, err2 = c.replaceVariables(commandText, applicationName, fspath, fileName, staticFileName, options.Args) + if err2 != nil { + return "", fmt.Errorf("variable replacement failed: %w", err2) } + // For arbitrary files commands, insert mkdir and cd before the main command + if command.GenerateArbitraryFiles { + remoteCommandTokens = append(remoteCommandTokens, "mkdir -p "+fspath, "cd "+fspath, commandText) + c.logVerbosef("Added directory creation and navigation before command execution") + } else { + remoteCommandTokens = append(remoteCommandTokens, commandText) + } + + c.logVerbosef("Command text after replacements: %s", commandText) + c.logVerbosef("Full remote command tokens: %v", remoteCommandTokens) + cfSSHArguments = append(cfSSHArguments, "--command") remoteCommand := strings.Join(remoteCommandTokens, "; ") - if commandFlags.IsSet("dry-run") { + c.logVerbosef("Final remote command: %s", remoteCommand) + + if options.DryRun { + c.logVerbosef("Dry-run mode enabled, returning command without execution") // When printing out the entire command line for separate execution, we wrap the remote command in single quotes // to prevent the shell processing it from running it in local cfSSHArguments = append(cfSSHArguments, "'"+remoteCommand+"'") return "cf " + strings.Join(cfSSHArguments, " "), nil } - cfSSHArguments = append(cfSSHArguments, remoteCommand) + fullCommand := append([]string{}, cfSSHArguments...) + fullCommand = append(fullCommand, remoteCommand) + c.logVerbosef("Executing command: %v", fullCommand) + + cmdArgs := append([]string{"cf"}, fullCommand...) + c.logVerbosef("Executing command: %v", cmdArgs) + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + outputBytes, err := cmd.CombinedOutput() + output := strings.TrimRight(string(outputBytes), "\n") + if err != nil { + if err.Error() == "unexpected EOF" { + return "", fmt.Errorf("Command failed") + } + if len(output) == 0 { + return "", fmt.Errorf("Command execution failed: %w", err) + } + return "", fmt.Errorf("Command execution failed: %w\nOutput: %s", err, output) + } + + if command.GenerateFiles { + c.logVerbosef("Processing file generation and download") + + var finalFile string + var err error + switch command.FileExtension { + case ".hprof": + c.logVerbosef("Finding heap dump file") + finalFile, err = utils.FindHeapDumpFile(cfSSHArguments, fileName, fspath) + case ".jfr": + c.logVerbosef("Finding JFR file") + finalFile, err = utils.FindJFRFile(cfSSHArguments, fileName, fspath) + default: + return "", &InvalidUsageError{message: fmt.Sprintf("Unsupported file extension %q", command.FileExtension)} + } + if err == nil && finalFile != "" { + fileName = finalFile + c.logVerbosef("Found file: %s", finalFile) + fmt.Println("Successfully created " + command.FileLabel + " in application container at: " + fileName) + } else if !noDownload { + c.logVerbosef("Failed to find file, error: %v", err) + fmt.Println("Failed to find " + command.FileLabel + " in application container") + return "", err + } + + if noDownload { + fmt.Println("No download requested, skipping file download") + return output, nil + } - output, err := commandExecutor.Execute(cfSSHArguments) + localFileFullPath := localDir + "/" + applicationName + "-" + command.FileNamePart + "-" + utils.GenerateUUID() + command.FileExtension + c.logVerbosef("Downloading file to: %s", localFileFullPath) + err = utils.CopyOverCat(cfSSHArguments, fileName, localFileFullPath) + if err == nil { + c.logVerbosef("File download completed successfully") + fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file saved to: " + localFileFullPath) + } else { + c.logVerbosef("File download failed: %v", err) + return "", err + } + if !keepAfterDownload { + c.logVerbosef("Deleting remote file") + err = utils.DeleteRemoteFile(cfSSHArguments, fileName) + if err != nil { + c.logVerbosef("Failed to delete remote file: %v", err) + return "", err + } + c.logVerbosef("Remote file deleted successfully") + fmt.Println(utils.ToSentenceCase(command.FileLabel) + " file deleted in application container") + } else { + c.logVerbosef("Keeping remote file as requested") + } + } + if command.GenerateArbitraryFiles && !noDownload { + c.logVerbosef("Processing arbitrary files download: %s", fspath) + c.logVerbosef("cfSSHArguments: %v", cfSSHArguments) + // download all files in the generic folder + files, err := utils.ListFiles(cfSSHArguments, fspath) + for i, file := range files { + c.logVerbosef("File %d: %s", i+1, file) + } + if err != nil { + c.logVerbosef("Failed to list files: %v", err) + return "", err + } + c.logVerbosef("Found %d files to download", len(files)) + if len(files) != 0 { + for _, file := range files { + c.logVerbosef("Downloading file: %s", file) + localFileFullPath := localDir + "/" + file + err = utils.CopyOverCat(cfSSHArguments, fspath+"/"+file, localFileFullPath) + if err == nil { + c.logVerbosef("File %s downloaded successfully", file) + fmt.Printf("File %s saved to: %s\n", file, localFileFullPath) + } else { + c.logVerbosef("Failed to download file %s: %v", file, err) + return "", err + } + } + + if !keepAfterDownload { + c.logVerbosef("Deleting remote file folder") + err = utils.DeleteRemoteFile(cfSSHArguments, fspath) + if err != nil { + c.logVerbosef("Failed to delete remote folder: %v", err) + return "", err + } + c.logVerbosef("Remote folder deleted successfully") + fmt.Println("File folder deleted in application container") + } else { + c.logVerbosef("Keeping remote files as requested") + } + } else { + c.logVerbosef("No files found to download") + } + } // We keep this around to make the compiler happy, but commandExecutor.Execute will cause an os.Exit - return strings.Join(output, "\n"), err + c.logVerbosef("Command execution completed successfully") + return output, err } // GetMetadata must be implemented as part of the plugin interface // defined by the core CLI. // // GetMetadata() returns a PluginMetadata struct. The first field, Name, -// determines the name of the plugin which should generally be without spaces. -// If there are spaces in the name a user will need to properly quote the name -// during uninstall otherwise the name will be treated as seperate arguments. +// determines the name of the plugin, which should generally be without spaces. +// If there are spaces in the name, a user will need to properly quote the name +// during uninstall; otherwise, the name will be treated as separate arguments. // The second value is a slice of Command structs. Our slice only contains one -// Command Struct, but could contain any number of them. The first field Name +// Command struct, but could contain any number of them. The first field Name // defines the command `cf heapdump` once installed into the CLI. The // second field, HelpText, is used by the core CLI to display help information // to the user in the core commands `cf help`, `cf`, or `cf -h`. func (c *JavaPlugin) GetMetadata() plugin.PluginMetadata { + usageText := "cf java COMMAND APP_NAME [options]" + for _, command := range commands { + usageText += "\n\n " + command.Name + if command.OnlyOnRecentSapMachine || command.HasMiscArgs() { + usageText += " (" + if command.OnlyOnRecentSapMachine { + usageText += "recent SapMachine only" + } + if command.HasMiscArgs() { + if command.OnlyOnRecentSapMachine { + usageText += ", " + } + usageText += "supports --args" + } + usageText += ")" + } + // Wrap the description with proper indentation + wrappedDescription := utils.WrapTextWithPrefix(command.Description, " ", 80, 0) + usageText += "\n" + wrappedDescription + } return plugin.PluginMetadata{ - Name: "JavaPlugin", + Name: "java", Version: plugin.VersionType{ - Major: 1, + Major: 4, Minor: 0, - Build: 0, + Build: 2, }, MinCliVersion: plugin.VersionType{ - Major: 6, - Minor: 7, - Build: 0, + Major: 4, + Minor: 0, + Build: 2, }, Commands: []plugin.Command{ { Name: "java", - HelpText: "Obtain a heap-dump or thread-dump from a running, Diego-enabled, SSH-enabled Java application.", + HelpText: "Obtain a heap-dump, thread-dump or profile from a running, SSH-enabled Java application.", // UsageDetails is optional // It is used to show help of usage of each command UsageDetails: plugin.Usage{ - Usage: "cf java [" + heapDumpCommand + "|" + threadDumpCommand + "] APP_NAME", - Options: map[string]string{ - "app-instance-index": "-i [index], select to which instance of the app to connect", - "keep": "-k, keep the heap dump in the container; by default the heap dump will be deleted from the container's filesystem after been downloaded", - "dry-run": "-n, just output to command line what would be executed", - }, + Usage: usageText, + Options: c.generateOptionsMapFromFlags(), }, }, }, } } -// Unlike most Go programs, the `Main()` function will not be used to run all of the -// commands provided in your plugin. Main will be used to initialize the plugin +// Unlike most Go programs, the `main()` function will not be used to run all of the +// commands provided in your plugin. main will be used to initialize the plugin // process, as well as any dependencies you might require for your // plugin. func main() { diff --git a/cf_cli_java_plugin_suite_test.go b/cf_cli_java_plugin_suite_test.go deleted file mode 100644 index a8f7e63..0000000 --- a/cf_cli_java_plugin_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main_test - -import ( - ginkgo "github.com/onsi/ginkgo" - gomega "github.com/onsi/gomega" - - "testing" -) - -func TestCfJavaPlugin(t *testing.T) { - gomega.RegisterFailHandler(ginkgo.Fail) - ginkgo.RunSpecs(t, "CfCliJavaPlugin Suite") -} diff --git a/cf_cli_java_plugin_test.go b/cf_cli_java_plugin_test.go deleted file mode 100644 index eb0946b..0000000 --- a/cf_cli_java_plugin_test.go +++ /dev/null @@ -1,374 +0,0 @@ -package main_test - -import ( - "strings" - - . "github.com/SAP/cf-cli-java-plugin" - . "github.com/SAP/cf-cli-java-plugin/cmd/fakes" - . "github.com/SAP/cf-cli-java-plugin/uuid/fakes" - - io_helpers "code.cloudfoundry.org/cli/util/testhelpers/io" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -type commandOutput struct { - out string - err error -} - -func captureOutput(closure func() (string, error)) (string, error, string) { - cliOutputChan := make(chan []string) - defer close(cliOutputChan) - - cmdOutputChan := make(chan *commandOutput) - defer close(cmdOutputChan) - - go func() { - cliOutput := io_helpers.CaptureOutput(func() { - output, err := closure() - cmdOutputChan <- &commandOutput{out: output, err: err} - }) - cliOutputChan <- cliOutput - }() - - var cliOutput []string - var cmdOutput *commandOutput - - Eventually(cmdOutputChan, 5).Should(Receive(&cmdOutput)) - - Eventually(cliOutputChan).Should(Receive(&cliOutput)) - - cliOutputString := strings.Join(cliOutput, "|") - - return cmdOutput.out, cmdOutput.err, cliOutputString -} - -var _ = Describe("CfJavaPlugin", func() { - - Describe("Run", func() { - - var ( - subject *JavaPlugin - commandExecutor *FakeCommandExecutor - uuidGenerator *FakeUUIDGenerator - ) - - BeforeEach(func() { - subject = &JavaPlugin{} - commandExecutor = new(FakeCommandExecutor) - uuidGenerator = new(FakeUUIDGenerator) - - uuidGenerator.GenerateReturns("abcd-123456") - }) - - Context("when invoked without arguments", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("No command provided")) - Expect(cliOutput).To(ContainSubstring("No command provided")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("when invoked with too many arguments", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app", "ciao"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("Too many arguments provided: ciao")) - Expect(cliOutput).To(ContainSubstring("Too many arguments provided: ciao")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("when invoked with an unknown command", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "UNKNOWN_COMMAND"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("Unrecognized command \"UNKNOWN_COMMAND\": supported commands are 'heap-dump' and 'thread-dump'")) - Expect(cliOutput).To(ContainSubstring("Unrecognized command \"UNKNOWN_COMMAND\": supported commands are 'heap-dump' and 'thread-dump'")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("when invoked to generate a heap-dump", func() { - - Context("without application name", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("No application name provided")) - Expect(cliOutput).To(ContainSubstring("No application name provided")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("with too many arguments", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app", "my_file", "ciao"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("Too many arguments provided: my_file, ciao")) - Expect(cliOutput).To(ContainSubstring("Too many arguments provided: my_file, ciao")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("with just the app name", func() { - - It("invokes cf ssh with the basic commands", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err).To(BeNil()) - Expect(cliOutput).To(Equal("")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"ssh", "my_app", "--command", JavaDetectionCommand + "$(find -executable -name jmap | head -1) -dump:format=b,file=/tmp/heapdump-abcd-123456.hprof $(pidof java) > /dev/null; cat /tmp/heapdump-abcd-123456.hprof; rm -f /tmp/heapdump-abcd-123456.hprof"})) - }) - - }) - - Context("for a container with index > 0", func() { - - It("invokes cf ssh with the basic commands", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app", "-i", "4"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err).To(BeNil()) - Expect(cliOutput).To(Equal("")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"ssh", "my_app", "--app-instance-index", "4", "--command", JavaDetectionCommand + "$(find -executable -name jmap | head -1) -dump:format=b,file=/tmp/heapdump-abcd-123456.hprof $(pidof java) > /dev/null; cat /tmp/heapdump-abcd-123456.hprof; rm -f /tmp/heapdump-abcd-123456.hprof"})) - }) - - }) - - Context("with the --keep flag", func() { - - It("keeps the heap-dump on the container", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app", "-i", "4", "-k"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err).To(BeNil()) - Expect(cliOutput).To(Equal("")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"ssh", "my_app", "--app-instance-index", "4", "--command", JavaDetectionCommand + "$(find -executable -name jmap | head -1) -dump:format=b,file=/tmp/heapdump-abcd-123456.hprof $(pidof java) > /dev/null; cat /tmp/heapdump-abcd-123456.hprof"})) - }) - - }) - - Context("with the --dry-run flag", func() { - - It("prints out the command line without executing the command", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "heap-dump", "my_app", "-i", "4", "-k", "-n"}) - return output, err - }) - - Expect(output).To(Equal("cf ssh my_app --app-instance-index 4 --command '" + JavaDetectionCommand + "$(find -executable -name jmap | head -1) -dump:format=b,file=/tmp/heapdump-abcd-123456.hprof $(pidof java) > /dev/null; cat /tmp/heapdump-abcd-123456.hprof'")) - Expect(err).To(BeNil()) - Expect(cliOutput).To(ContainSubstring("cf ssh my_app --app-instance-index 4 --command '" + JavaDetectionCommand + "$(find -executable -name jmap | head -1) -dump:format=b,file=/tmp/heapdump-abcd-123456.hprof $(pidof java) > /dev/null; cat /tmp/heapdump-abcd-123456.hprof'")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(0)) - }) - - }) - - }) - - Context("when invoked to generate a thread-dump", func() { - - Context("without application name", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("No application name provided")) - Expect(cliOutput).To(ContainSubstring("No application name provided")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("with too many arguments", func() { - - It("outputs an error and does not invoke cf ssh", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump", "my_app", "my_file", "ciao"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("Too many arguments provided: my_file, ciao")) - Expect(cliOutput).To(ContainSubstring("Too many arguments provided: my_file, ciao")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("with just the app name", func() { - - It("invokes cf ssh with the basic commands", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump", "my_app"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err).To(BeNil()) - Expect(cliOutput).To(Equal("")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"ssh", "my_app", "--command", JavaDetectionCommand + "$(find -executable -name jstack | head -1) $(pidof java)"})) - }) - - }) - - Context("for a container with index > 0", func() { - - It("invokes cf ssh with the basic commands", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump", "my_app", "-i", "4"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err).To(BeNil()) - Expect(cliOutput).To(Equal("")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"ssh", "my_app", "--app-instance-index", "4", "--command", JavaDetectionCommand + "$(find -executable -name jstack | head -1) $(pidof java)"})) - }) - - }) - - Context("with the --keep flag", func() { - - It("fails", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump", "my_app", "-i", "4", "-k"}) - return output, err - }) - - Expect(output).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("The flag \"keep\" is not supported for thread-dumps")) - Expect(cliOutput).To(ContainSubstring("The flag \"keep\" is not supported for thread-dumps")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(1)) - Expect(commandExecutor.ExecuteArgsForCall(0)).To(Equal([]string{"help", "java"})) - }) - - }) - - Context("with the --dry-run flag", func() { - - It("prints out the command line without executing the command", func(done Done) { - defer close(done) - - output, err, cliOutput := captureOutput(func() (string, error) { - output, err := subject.DoRun(commandExecutor, uuidGenerator, []string{"java", "thread-dump", "my_app", "-i", "4", "-n"}) - return output, err - }) - - Expect(output).To(Equal("cf ssh my_app --app-instance-index 4 --command '" + JavaDetectionCommand + "$(find -executable -name jstack | head -1) $(pidof java)'")) - Expect(err).To(BeNil()) - Expect(cliOutput).To(ContainSubstring("cf ssh my_app --app-instance-index 4 --command '" + JavaDetectionCommand + "$(find -executable -name jstack | head -1) $(pidof java)'")) - - Expect(commandExecutor.ExecuteCallCount()).To(Equal(0)) - }) - - }) - - }) - - }) - -}) diff --git a/cmd/cmd.go b/cmd/cmd.go index f3ea252..9455529 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1,13 +1,13 @@ /* - * Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved. + * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved. * This file is licensed under the Apache Software License, v. 2 except as noted * otherwise in the LICENSE file at the root of the repository. */ package cmd -// CommandExecutor is an interface that encapsulates the execution of further cf cli commands. -// By "hiding" the cli command execution in this interface, we can mock the command cli execution in tests. +// CommandExecutor is an interface that encapsulates the execution of further CF CLI commands. +// By "hiding" the CLI command execution in this interface, we can mock the command CLI execution in tests. type CommandExecutor interface { Execute(args []string) ([]string, error) } diff --git a/cmd/fakes/fake_command_executor.go b/cmd/fakes/fake_command_executor.go deleted file mode 100644 index 0971882..0000000 --- a/cmd/fakes/fake_command_executor.go +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved. - * This file is licensed under the Apache Software License, v. 2 except as noted - * otherwise in the LICENSE file at the root of the repository. - */ - -// This file was generated by counterfeiter -package fakes - -import ( - "sync" - - "github.com/SAP/cf-cli-java-plugin/cmd" -) - -type FakeCommandExecutor struct { - ExecuteStub func(args []string) ([]string, error) - executeMutex sync.RWMutex - executeArgsForCall []struct { - args []string - } - executeReturns struct { - result1 []string - result2 error - } - invocations map[string][][]interface{} - invocationsMutex sync.RWMutex -} - -func (fake *FakeCommandExecutor) Execute(args []string) ([]string, error) { - var argsCopy []string - if args != nil { - argsCopy = make([]string, len(args)) - copy(argsCopy, args) - } - fake.executeMutex.Lock() - fake.executeArgsForCall = append(fake.executeArgsForCall, struct { - args []string - }{argsCopy}) - fake.recordInvocation("Execute", []interface{}{argsCopy}) - fake.executeMutex.Unlock() - if fake.ExecuteStub != nil { - return fake.ExecuteStub(args) - } - return fake.executeReturns.result1, fake.executeReturns.result2 -} - -func (fake *FakeCommandExecutor) ExecuteCallCount() int { - fake.executeMutex.RLock() - defer fake.executeMutex.RUnlock() - return len(fake.executeArgsForCall) -} - -func (fake *FakeCommandExecutor) ExecuteArgsForCall(i int) []string { - fake.executeMutex.RLock() - defer fake.executeMutex.RUnlock() - return fake.executeArgsForCall[i].args -} - -func (fake *FakeCommandExecutor) ExecuteReturns(result1 []string, result2 error) { - fake.ExecuteStub = nil - fake.executeReturns = struct { - result1 []string - result2 error - }{result1, result2} -} - -func (fake *FakeCommandExecutor) Invocations() map[string][][]interface{} { - fake.invocationsMutex.RLock() - defer fake.invocationsMutex.RUnlock() - fake.executeMutex.RLock() - defer fake.executeMutex.RUnlock() - return fake.invocations -} - -func (fake *FakeCommandExecutor) recordInvocation(key string, args []interface{}) { - fake.invocationsMutex.Lock() - defer fake.invocationsMutex.Unlock() - if fake.invocations == nil { - fake.invocations = map[string][][]interface{}{} - } - if fake.invocations[key] == nil { - fake.invocations[key] = [][]interface{}{} - } - fake.invocations[key] = append(fake.invocations[key], args) -} - -var _ cmd.CommandExecutor = new(FakeCommandExecutor) diff --git a/docs/SAP Corporate Contributor License Agreement.pdf b/docs/SAP Corporate Contributor License Agreement.pdf deleted file mode 100644 index 9d0ff6f..0000000 Binary files a/docs/SAP Corporate Contributor License Agreement.pdf and /dev/null differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f59a9f --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module cf.plugin.ref/requires + +go 1.24.3 + +toolchain go1.24.4 + +require ( + code.cloudfoundry.org/cli v0.0.0-20250623142502-fb19e7a825ee + github.com/lithammer/fuzzysearch v1.1.8 + github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a +) + +require ( + code.cloudfoundry.org/bytefmt v0.42.0 // indirect + code.cloudfoundry.org/jsonry v1.1.4 // indirect + code.cloudfoundry.org/tlsconfig v0.29.0 // indirect + github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/charlievieth/fs v0.0.3 // indirect + github.com/cloudfoundry/bosh-cli v6.4.1+incompatible // indirect + github.com/cloudfoundry/bosh-utils v0.0.397 // indirect + github.com/cppforlife/go-patch v0.1.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jessevdk/go-flags v1.6.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/lunixbochs/vtclean v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5f2e5ab --- /dev/null +++ b/go.sum @@ -0,0 +1,326 @@ +code.cloudfoundry.org/bytefmt v0.42.0 h1:GqDCAcz234i1Si0LC3utNDkGFWwRRUYiCERJcdx2kb0= +code.cloudfoundry.org/bytefmt v0.42.0/go.mod h1:1ezR2pHZGy8PBIYo1eWD++lb8NVbVfmFQcuupNEuNpw= +code.cloudfoundry.org/cli v0.0.0-20250623142502-fb19e7a825ee h1:XhdD86Gi7VszUz0WFU7BSo8+bkJQed8sPA2IE3wazSk= +code.cloudfoundry.org/cli v0.0.0-20250623142502-fb19e7a825ee/go.mod h1:QONrN/KpDyZSKNLKRy1G2n1xSN4KfZNaCB2geYa2p+s= +code.cloudfoundry.org/cli-plugin-repo v0.0.0-20200304195157-af98c4be9b85 h1:jaHWw9opYjKPrDT19uydBBWSxl+g5F4Hv030fqMsalo= +code.cloudfoundry.org/cli-plugin-repo v0.0.0-20200304195157-af98c4be9b85/go.mod h1:R1EiyOAr7lW0l/YkZNqItUNZ01Q/dYUfbTn4X4Z+82M= +code.cloudfoundry.org/clock v1.40.0 h1:D4gzY9oMhqLK+KtzKL55Fya8eq1G/Xbet1NQGGEIqhk= +code.cloudfoundry.org/clock v1.40.0/go.mod h1:+RR6TI/Vi/D6Ob4viE+K2fazZyEdOPfepS4YL1fo4Bo= +code.cloudfoundry.org/go-log-cache/v2 v2.0.7 h1:yR/JjQ/RscO1n4xVAT9HDYcpx5ET/3Cq2/RhpJml6ZU= +code.cloudfoundry.org/go-log-cache/v2 v2.0.7/go.mod h1:6KQe2FeeaqRheD5vCvpyTa80YoJojB/r21E54mT97Mc= +code.cloudfoundry.org/go-loggregator/v9 v9.2.1 h1:S6Lgg5UJbhh2bt2TGQxs6R00CF8PrUA3GFPYDxy56Fk= +code.cloudfoundry.org/go-loggregator/v9 v9.2.1/go.mod h1:FTFFruqGeOhVCDFvyLgl8EV8YW63NNwRzLhxJcporu8= +code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTgeiff9vxdrfdqxibzpWjxLnuXDI5m6z3GJAk= +code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI= +code.cloudfoundry.org/jsonry v1.1.4 h1:P9N7IlH1/4aRCLcXLgLFj1hkcBmV7muijJzY+K6U4hE= +code.cloudfoundry.org/jsonry v1.1.4/go.mod h1:6aKilShQP7w/Ez76h1El2/n9y2OkHuU56nKSBB9Gp0A= +code.cloudfoundry.org/tlsconfig v0.29.0 h1:t9//PpF7fNJi4mfmkzWd1F4fHpqREQUsQMIe4sj6AKU= +code.cloudfoundry.org/tlsconfig v0.29.0/go.mod h1:YDHTq7ZNy8W26SinApuFKxlhg78xN28g86so8kRD6/Q= +code.cloudfoundry.org/ykk v0.0.0-20170424192843-e4df4ce2fd4d h1:M+zXqtXJqcsmpL76aU0tdl1ho23eYa4axYoM4gD62UA= +code.cloudfoundry.org/ykk v0.0.0-20170424192843-e4df4ce2fd4d/go.mod h1:YUJiVOr5xl0N/RjMxM1tHmgSpBbi5UM+KoVR5AoejO0= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 h1:koK7z0nSsRiRiBWwa+E714Puh+DO+ZRdIyAXiXzL+lg= +github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 h1:y4B3+GPxKlrigF1ha5FFErxK+sr6sWxQovRMzwMhejo= +github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/charlievieth/fs v0.0.3 h1:3lZQXTj4PbE81CVPwALSn+JoyCNXkZgORHN6h2XHGlg= +github.com/charlievieth/fs v0.0.3/go.mod h1:hD4sRzto1Hw8zCua76tNVKZxaeZZr1RiKftjAJQRLLo= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudfoundry/bosh-cli v6.4.1+incompatible h1:n5/+NIF9QxvGINOrjh6DmO+GTen78MoCj5+LU9L8bR4= +github.com/cloudfoundry/bosh-cli v6.4.1+incompatible/go.mod h1:rzIB+e1sn7wQL/TJ54bl/FemPKRhXby5BIMS3tLuWFM= +github.com/cloudfoundry/bosh-utils v0.0.397 h1:1zs2vFN6P1eefDZ2u68j8PARbv/IKNJJQKWeeN/1B4g= +github.com/cloudfoundry/bosh-utils v0.0.397/go.mod h1:FPZV+W2FecYFy2N5iWeDFYQvtkPbgrVf0uIg1Xdwk+E= +github.com/cppforlife/go-patch v0.1.0 h1:I0fT+gFTSW4xWwvaTaUUVjr9xxjNXJ4naGc01BeQjwY= +github.com/cppforlife/go-patch v0.1.0/go.mod h1:67a7aIi94FHDZdoeGSJRRFDp66l9MhaAG1yGxpUoFD8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.20.0/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sabhiram/go-gitignore v0.0.0-20171017070213-362f9845770f h1:FQZgA673tRGrrXIP/OPMO69g81ow4XsKlN/DLH8pSic= +github.com/sabhiram/go-gitignore v0.0.0-20171017070213-362f9845770f/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= +github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a h1:3qgm+2S7MtAhH6xop4yeX/P5QGr+Ss9d+CLErszoCCs= +github.com/simonleung8/flags v0.0.0-20170704170018-8020ed7bcf1a/go.mod h1:lfYEax1IvoGfNjgwTUYQXhLUry2sOHGH+3S7+imSCSI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/square/certstrap v1.3.0 h1:N9P0ZRA+DjT8pq5fGDj0z3FjafRKnBDypP0QHpMlaAk= +github.com/square/certstrap v1.3.0/go.mod h1:wGZo9eE1B7WX2GKBn0htJ+B3OuRl2UsdCFySNooy9hU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tedsuo/rata v1.0.1-0.20170830210128-07d200713958 h1:mueRRuRjR35dEOkHdhpoRcruNgBz0ohG659HxxmcAwA= +github.com/tedsuo/rata v1.0.1-0.20170830210128-07d200713958/go.mod h1:X47ELzhOoLbfFIY0Cql9P6yo3Cdwf2CMX3FVZxRzJPc= +github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec h1:Klu98tQ9Z1t23gvC7p7sCmvxkZxLhBHLNyrUPsWsYFg= +github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec/go.mod h1:wPlfmglZmRWMYv/qJy3P+fK/UnoQB5ISk4txfNd9tDo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.step.sm/crypto v0.66.0 h1:9TW6BEguOtcS9NIjja9bDQ+j8OjhenU/F6lJfHjbXNU= +go.step.sm/crypto v0.66.0/go.mod h1:anqGyvO/Px05D1mznHq4/a9wwP1I1DmMZvk+TWX5Dzo= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.11-0.20220316014157-77aa08bb151a/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.28 h1:n1tBJnnK2r7g9OW2btFH91V92STTUevLXYFb8gy9EMk= +gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.4.0-0.dev/go.mod h1:vlRD9XErLMGT+mDuofSr0mMMquscM/1nQqtRSsh6m70= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..dc1f658 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,168 @@ +# Linting Scripts Documentation + +This directory contains centralized linting and code quality scripts for the CF Java Plugin project. + +## Scripts Overview + +### `lint-python.sh` + +Python-specific linting and formatting script. + +**Usage:** + +```bash +./scripts/lint-python.sh [check|fix|ci] +``` + +**Modes:** + +- `check` (default): Check code quality without making changes +- `fix`: Auto-fix formatting and import sorting issues +- `ci`: Strict checking for CI environments + +**Tools used:** + +- `flake8`: Code linting (line length, style issues) +- `black`: Code formatting +- `isort`: Import sorting + +### `lint-go.sh` + +Go-specific linting and testing script. + +**Usage:** + +```bash +./scripts/lint-go.sh [check|test|ci] +``` + +**Modes:** + +- `check` (default): Run linting checks only +- `ci`: Run all checks for CI environments (lint + dependencies) + +**Tools used:** + +- `gofumpt`: Stricter Go code formatting (fallback to `go fmt`) +- `go vet`: Static analysis +- `golangci-lint`: Comprehensive linting (detects unused interfaces, code smells, etc.) + +**Line Length Management:** + +The project enforces a 120-character line length limit via the `lll` linter. Note that Go +formatters (`gofumpt`/`go fmt`) do not automatically wrap long lines - this is by design +in the Go community. Manual line breaking is required for lines exceeding the limit. + +### `lint-markdown.sh` + +Markdown-specific linting and formatting script. + +**Usage:** + +```bash +./scripts/lint-markdown.sh [check|fix|ci] +``` + +**Modes:** + +- `check` (default): Check markdown quality without making changes +- `fix`: Auto-fix formatting issues +- `ci`: Strict checking for CI environments + +**Tools used:** + +- `markdownlint-cli`: Markdown linting (structure, style, consistency) +- `prettier`: Markdown formatting + +### `lint-all.sh` + +Comprehensive script that runs both Go and Python linting. + +**Usage:** + +```bash +./scripts/lint-all.sh [check|fix|ci] +``` + +**Features:** + +- Runs Go linting first, then Python (if test suite exists) +- Provides unified exit codes and summary +- Color-coded output with status indicators + +### `update-readme-help.py` + +Automatically updates README.md with current plugin help text. + +**Usage:** + +```bash +./scripts/update-readme-help.py +``` + +**Features:** + +- Extracts help text using `cf java help` +- Updates help section in README.md +- Stages changes for git commit +- Integrated into pre-commit hooks + +**Requirements:** CF CLI and CF Java plugin must be installed. + +## Tool Requirements + +### Go Linting Tools + +- **golangci-lint**: Install with `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` +- **go**: Go compiler and tools (comes with Go installation) + +### Python Linting Tools + +- **flake8**: Install with `pip install flake8` +- **black**: Install with `pip install black` +- **isort**: Install with `pip install isort` + +### Markdown Linting Tools + +- **markdownlint-cli**: Install with `npm install -g markdownlint-cli` + +### Installation + +To install all linting tools at once, run: + +```bash +./setup-dev-env.sh +``` + +This will install all required linters and development tools. + +## Integration Points + +### Pre-commit Hooks + +- Uses `lint-go.sh check` for Go code +- Uses `lint-python.sh fix` for Python code (auto-fixes issues) +- Uses `update-readme-help.py` to keep README help text current + +### GitHub Actions CI + +- **Build & Snapshot**: Uses `ci` mode for strict checking +- **PR Validation**: Uses `ci` mode for comprehensive validation +- **Release**: Uses `check` and `test` modes + +### Development Workflow + +- **Local development**: Use `check` mode for quick validation +- **Before commit**: Use `fix` mode to auto-resolve formatting issues +- **CI/CD**: Uses `ci` mode for strict validation + +## Configuration + +All linting tools are configured via: + +- `.golangci.yml`: golangci-lint configuration (enables all linters except gochecknoglobals) +- `test/pyproject.toml`: Python tool configurations +- `test/requirements.txt`: Python tool dependencies +- Project-level files: Go module and dependencies + +Virtual environments and build artifacts are automatically excluded from all linting operations. diff --git a/scripts/lint-all.sh b/scripts/lint-all.sh new file mode 100755 index 0000000..99b2789 --- /dev/null +++ b/scripts/lint-all.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Comprehensive linting script for CF Java Plugin +# Usage: ./scripts/lint-all.sh [check|fix|ci] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_status() { + echo -e "${GREEN}โœ…${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}โš ๏ธ${NC} $1" +} + +print_error() { + echo -e "${RED}โŒ${NC} $1" +} + +print_info() { + echo -e "${BLUE}โ„น๏ธ${NC} $1" +} + +print_header() { + echo -e "\n${BLUE}================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}================================${NC}\n" +} + +MODE="${1:-check}" + +# Change to project root +cd "$PROJECT_ROOT" + +print_header "CF Java Plugin - Code Quality Check" + +# Track overall success +OVERALL_SUCCESS=true + +# Run Go linting +print_header "Go Code Quality" +if "$SCRIPT_DIR/lint-go.sh" "$MODE"; then + print_status "Go linting passed" +else + print_error "Go linting failed" + OVERALL_SUCCESS=false +fi + +# Run Python linting (if test suite exists) +print_header "Python Code Quality" +if [ -f "test/requirements.txt" ]; then + if "$SCRIPT_DIR/lint-python.sh" "$MODE"; then + print_status "Python linting passed" + else + print_error "Python linting failed" + OVERALL_SUCCESS=false + fi +else + print_warning "Python test suite not found - skipping Python linting" +fi + +# Run Markdown linting +print_header "Markdown Code Quality" +if "$SCRIPT_DIR/lint-markdown.sh" "$MODE"; then + print_status "Markdown linting passed" +else + print_error "Markdown linting failed" + OVERALL_SUCCESS=false +fi + +# Final summary +print_header "Summary" +if [ "$OVERALL_SUCCESS" = true ]; then + print_status "All code quality checks passed!" + echo -e "\n๐Ÿš€ ${GREEN}Ready for commit/deployment!${NC}\n" + exit 0 +else + print_error "Some code quality checks failed!" + echo -e "\nโŒ ${RED}Please fix the issues before committing.${NC}\n" + exit 1 +fi diff --git a/scripts/lint-go.sh b/scripts/lint-go.sh new file mode 100755 index 0000000..31cb5ec --- /dev/null +++ b/scripts/lint-go.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +# Go linting and testing script for CF Java Plugin +# Usage: ./scripts/lint-go.sh [check|test|ci] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_status() { + echo -e "${GREEN}โœ…${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}โš ๏ธ${NC} $1" +} + +print_error() { + echo -e "${RED}โŒ${NC} $1" +} + +print_info() { + echo -e "${BLUE}โ„น๏ธ${NC} $1" +} + +# Change to project root +cd "$PROJECT_ROOT" + +# Check if this is a Go project +if [ ! -f "go.mod" ]; then + print_error "Not a Go project (go.mod not found)" + exit 1 +fi + +MODE="${1:-check}" + +case "$MODE" in + "check") + print_info "Running Go code quality checks..." + + echo "๐Ÿ” Running gofumpt..." + if command -v gofumpt >/dev/null 2>&1; then + # Get only Git-tracked Go files + GO_FILES=$(git ls-files '*.go') + if [ -n "$GO_FILES" ]; then + if ! echo "$GO_FILES" | xargs gofumpt -l -w; then + print_error "Go formatting issues found with gofumpt" + exit 1 + fi + print_status "gofumpt formatting check passed on Git-tracked files" + else + print_warning "No Git-tracked Go files found" + fi + else + echo "๐Ÿ” Running go fmt..." + if ! go fmt ./...; then + print_error "Go formatting issues found. Run 'go fmt ./...' to fix." + exit 1 + fi + print_status "Go formatting check passed" + print_info "For better formatting, install gofumpt: go install mvdan.cc/gofumpt@latest" + fi + + echo "๐Ÿ” Running go vet..." + if ! go vet .; then + print_error "Go vet issues found" + exit 1 + fi + print_status "Go vet check passed" + + echo "๐Ÿ” Running golangci-lint..." + if command -v golangci-lint >/dev/null 2>&1; then + if (! golangci-lint run --timeout=3m *.go || ! golangci-lint run utils/*.go); then + print_error "golangci-lint issues found" + exit 1 + fi + else + print_warning "golangci-lint not found, skipping comprehensive linting" + print_info "Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + fi + + print_status "All Go linting checks passed!" + ;; + + "ci") + print_info "Running CI checks for Go..." + + echo "๐Ÿ” Installing dependencies..." + go mod tidy -e || true + + echo "๐Ÿ” Running gofumpt..." + if command -v gofumpt >/dev/null 2>&1; then + if ! gofumpt -l -w *.go cmd/ utils/; then + print_error "Go formatting issues found with gofumpt" + exit 1 + fi + else + echo "๐Ÿ” Running go fmt..." + if ! go fmt ./...; then + print_error "Go formatting issues found" + exit 1 + fi + fi + + echo "๐Ÿ” Running go vet..." + if ! go vet .; then + print_error "Go vet issues found" + exit 1 + fi + + print_status "All CI checks passed for Go!" + ;; + + *) + echo "Usage: $0 [check|ci]" + echo "" + echo "Modes:" + echo " check - Run linting checks only (default)" + echo " ci - Run all checks for CI environments" + exit 1 + ;; +esac diff --git a/scripts/lint-markdown.sh b/scripts/lint-markdown.sh new file mode 100755 index 0000000..3b9885d --- /dev/null +++ b/scripts/lint-markdown.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +MODE="${1:-check}" + +cd "$PROJECT_ROOT" + +# Install markdownlint-cli if not available +if ! command -v markdownlint &> /dev/null; then + echo "Installing markdownlint-cli..." + npm install -g markdownlint-cli +fi + +# Install prettier if not available +if ! command -v npx &> /dev/null; then + echo "Error: npx is required for prettier. Please install Node.js" + exit 1 +fi + +# Get only git-tracked markdown files +MARKDOWN_FILES=$(git ls-files "*.md" | tr '\n' ' ') + +if [ -z "$MARKDOWN_FILES" ]; then + echo "No markdown files found in git repository" + exit 0 +fi + +case "$MODE" in + "check" | "ci") + echo "๐Ÿ” Checking Markdown files..." + echo "Files to check: $MARKDOWN_FILES" + markdownlint $MARKDOWN_FILES + echo "โœ… Markdown files are properly formatted" + ;; + "fix") + echo "๐Ÿ”ง Fixing Markdown files..." + echo "Files to fix: $MARKDOWN_FILES" + + # Function to preserve
 sections during prettier formatting
+        format_markdown_with_pre_protection() {
+            local file="$1"
+            local temp_file="${file}.tmp"
+            local pre_markers_file="${file}.pre_markers"
+            
+            # Create a unique marker for each 
 block
+            python3 "$temp_file" "$pre_markers_file" << EOF
+import sys
+import re
+import json
+
+file_path = "$file"
+temp_file = sys.argv[1] if len(sys.argv) > 1 else "${file}.tmp"
+markers_file = sys.argv[2] if len(sys.argv) > 2 else "${file}.pre_markers"
+
+with open(file_path, 'r') as f:
+    content = f.read()
+
+# Find all 
...
blocks and replace with markers +pre_blocks = {} +counter = 0 + +def replace_pre(match): + global counter + marker = f"__PRETTIER_PRE_BLOCK_{counter}__" + pre_blocks[marker] = match.group(0) + counter += 1 + return marker + +# Replace
 blocks with markers
+modified_content = re.sub(r'
.*?
', replace_pre, content, flags=re.DOTALL) + +# Write modified content to temp file +with open(temp_file, 'w') as f: + f.write(modified_content) + +# Save markers mapping +with open(markers_file, 'w') as f: + json.dump(pre_blocks, f) +EOF + + # Run prettier on the modified file + npx prettier --parser markdown --prose-wrap always --print-width 120 --write "$temp_file" + + # Restore
 blocks
+            python3 "$temp_file" "$pre_markers_file" "$file" << EOF
+import sys
+import json
+
+temp_file = sys.argv[1] if len(sys.argv) > 1 else "${file}.tmp"
+markers_file = sys.argv[2] if len(sys.argv) > 2 else "${file}.pre_markers"
+original_file = sys.argv[3] if len(sys.argv) > 3 else "$file"
+
+with open(temp_file, 'r') as f:
+    content = f.read()
+
+with open(markers_file, 'r') as f:
+    pre_blocks = json.load(f)
+
+# Restore 
 blocks
+for marker, pre_block in pre_blocks.items():
+    content = content.replace(marker, pre_block)
+
+# Write back to original file
+with open(original_file, 'w') as f:
+    f.write(content)
+EOF
+            
+            # Clean up temp files
+            rm -f "$temp_file" "$pre_markers_file"
+        }
+        
+        # Format each markdown file with 
 protection
+        for file in $MARKDOWN_FILES; do
+            echo "  Formatting $file with prettier (preserving 
 sections)..."
+            format_markdown_with_pre_protection "$file"
+        done
+        
+        # Then run markdownlint to fix any remaining issues
+        echo "Running markdownlint --fix..."
+        markdownlint $MARKDOWN_FILES --fix
+        echo "โœ… Markdown files have been fixed"
+        ;;
+    *)
+        echo "Usage: $0 [check|fix|ci]"
+        exit 1
+        ;;
+esac
diff --git a/scripts/lint-python.sh b/scripts/lint-python.sh
new file mode 100755
index 0000000..85ef0e9
--- /dev/null
+++ b/scripts/lint-python.sh
@@ -0,0 +1,138 @@
+#!/bin/bash
+
+# Python linting script for CF Java Plugin
+# Usage: ./scripts/lint-python.sh [check|fix|ci]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+TESTING_DIR="$PROJECT_ROOT/test"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+print_status() {
+    echo -e "${GREEN}โœ…${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}โš ๏ธ${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}โŒ${NC} $1"
+}
+
+print_info() {
+    echo -e "${BLUE}โ„น๏ธ${NC} $1"
+}
+
+# Check if Python test suite exists
+if [ ! -f "$TESTING_DIR/requirements.txt" ] || [ ! -f "$TESTING_DIR/pyproject.toml" ]; then
+    print_warning "Python test suite not found - skipping Python linting"
+    exit 0
+fi
+
+# Change to testing directory
+cd "$TESTING_DIR"
+
+# Check if virtual environment exists
+if [ ! -f "venv/bin/python" ]; then
+    print_error "Python virtual environment not found. Run './setup.sh' first."
+    exit 1
+fi
+
+# Activate virtual environment
+source venv/bin/activate
+
+MODE="${1:-check}"
+
+case "$MODE" in
+    "check")
+        print_info "Running Python linting checks..."
+        
+        echo "๐Ÿ” Running flake8..."
+        if ! flake8 --max-line-length=120 --ignore=E203,W503,E402 --exclude=venv,__pycache__,.git .; then
+            print_error "Flake8 found linting issues"
+            exit 1
+        fi
+        print_status "Flake8 passed"
+        
+        echo "๐Ÿ” Checking black formatting..."
+        if ! black --line-length=120 --check .; then
+            print_error "Black found formatting issues"
+            exit 1
+        fi
+        print_status "Black formatting check passed"
+        
+        echo "๐Ÿ” Checking import sorting..."
+        if ! isort --check-only --profile=black .; then
+            print_error "Isort found import sorting issues"
+            exit 1
+        fi
+        print_status "Import sorting check passed"
+        
+        print_status "All Python linting checks passed!"
+        ;;
+        
+    "fix")
+        print_info "Fixing Python code formatting..."
+        
+        echo "๐Ÿ”ง Running black formatter..."
+        black --line-length=120 .
+        print_status "Black formatting applied"
+        
+        echo "๐Ÿ”ง Sorting imports..."
+        isort --profile=black .
+        print_status "Import sorting applied"
+        
+        echo "๐Ÿ” Running flake8 check..."
+        if ! flake8 --max-line-length=120 --ignore=E203,W503,E402 --exclude=venv,__pycache__,.git .; then
+            print_warning "Flake8 still reports issues after auto-fixing"
+            print_info "Manual fixes may be required"
+            exit 1
+        fi
+        
+        print_status "Python code formatting fixed!"
+        ;;
+        
+    "ci")
+        print_info "Running CI linting checks..."
+        
+        # For CI, we want to be strict and not auto-fix
+        echo "๐Ÿ” Running flake8..."
+        flake8 --max-line-length=120 --ignore=E203,W503,E402 --exclude=venv,__pycache__,.git . || {
+            print_error "Flake8 linting failed"
+            exit 1
+        }
+        
+        echo "๐Ÿ” Checking black formatting..."
+        black --line-length=120 --check . || {
+            print_error "Black formatting check failed"
+            exit 1
+        }
+        
+        echo "๐Ÿ” Checking import sorting..."
+        isort --check-only --profile=black . || {
+            print_error "Import sorting check failed"
+            exit 1
+        }
+        
+        print_status "All CI linting checks passed!"
+        ;;
+        
+    *)
+        echo "Usage: $0 [check|fix|ci]"
+        echo ""
+        echo "Modes:"
+        echo "  check  - Check code quality without making changes (default)"
+        echo "  fix    - Auto-fix formatting and import sorting issues"
+        echo "  ci     - Strict checking for CI environments"
+        exit 1
+        ;;
+esac
diff --git a/scripts/update-readme-help.py b/scripts/update-readme-help.py
new file mode 100755
index 0000000..30de1a1
--- /dev/null
+++ b/scripts/update-readme-help.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""
+Script to update README.md with current plugin help text.
+Usage: ./scripts/update-readme-help.py
+"""
+
+import subprocess
+import sys
+import os
+import tempfile
+import shutil
+from pathlib import Path
+
+
+class Colors:
+    """ANSI color codes for terminal output."""
+    RED = '\033[0;31m'
+    GREEN = '\033[0;32m'
+    YELLOW = '\033[1;33m'
+    NC = '\033[0m'  # No Color
+
+
+def print_status(message: str) -> None:
+    """Print a success message with green checkmark."""
+    print(f"{Colors.GREEN}โœ…{Colors.NC} {message}")
+
+
+def print_warning(message: str) -> None:
+    """Print a warning message with yellow warning sign."""
+    print(f"{Colors.YELLOW}โš ๏ธ{Colors.NC} {message}")
+
+
+def print_error(message: str) -> None:
+    """Print an error message with red X."""
+    print(f"{Colors.RED}โŒ{Colors.NC} {message}")
+
+
+def check_repository_root() -> None:
+    """Check if we're in the correct repository root directory."""
+    if not Path("cf_cli_java_plugin.go").exists():
+        print_error("Not in CF Java Plugin root directory")
+        print("Please run this script from the repository root")
+        sys.exit(1)
+    
+    if not Path("README.md").exists():
+        print_error("README.md not found")
+        sys.exit(1)
+
+
+def ensure_plugin_installed() -> None:
+    """Ensure the java plugin is installed in cf CLI."""
+    print("๐Ÿ” Checking if cf java plugin is installed...")
+    
+    try:
+        # Check if the java plugin is installed
+        result = subprocess.run(
+            ["cf", "plugins"],
+            capture_output=True,
+            text=True,
+            check=True
+        )
+        
+        if "java" not in result.stdout:
+            print_error("CF Java plugin is not installed")
+            print("Please install the plugin first with:")
+            print("  cf install-plugin ")
+            sys.exit(1)
+        else:
+            print_status("CF Java plugin is installed")
+            
+    except subprocess.CalledProcessError as e:
+        print_error("Failed to check cf plugins")
+        print("Make sure the cf CLI is installed and configured")
+        print(f"Error: {e}")
+        sys.exit(1)
+
+
+def get_plugin_help() -> str:
+    """Extract help text from the plugin, skipping first 3 lines."""
+    print("๐Ÿ“ Extracting help text from plugin...")
+    
+    try:
+        # Use 'cf java help' to get the help text
+        result = subprocess.run(
+            ["cf", "java", "help"],
+            capture_output=True,
+            text=True
+        )
+        
+        # Combine stdout and stderr since plugin might write to stderr
+        output = result.stdout + result.stderr
+        
+        # Skip first 3 lines as requested
+        lines = output.splitlines()
+        help_text = '\n'.join(lines[3:]) if len(lines) > 3 else output
+        
+        # Validate that we got reasonable help text
+        if not help_text.strip():
+            print_error("Failed to get help text from plugin")
+            sys.exit(1)
+        
+        # Check if it looks like actual help text (should contain USAGE or similar)
+        if "USAGE:" not in help_text and "Commands:" not in help_text:
+            print_warning("Help text doesn't look like expected format")
+            print(f"Got: {help_text[:100]}...")
+            
+        return help_text
+        
+    except (subprocess.CalledProcessError, FileNotFoundError) as e:
+        print_error(f"Failed to run 'cf java help': {e}")
+        print("Make sure the cf CLI is installed and the java plugin is installed")
+        sys.exit(1)
+
+
+def update_readme_help(help_text: str) -> bool:
+    """Update README.md with the new help text."""
+    readme_path = Path("README.md")
+    
+    print("๐Ÿ”„ Updating README.md...")
+    
+    with open(readme_path, 'r', encoding='utf-8') as f:
+        lines = f.readlines()
+    
+    # Create temporary file
+    with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as temp_file:
+        temp_path = temp_file.name
+        
+        in_help_section = False
+        help_updated = False
+        found_pre = False
+        
+        for i, line in enumerate(lines):
+            # Look for 
 tag after a line mentioning cf java --help
+            if not in_help_section and '
' in line.strip():
+                # Check if previous few lines mention "cf java"
+                context_start = max(0, i - 3)
+                context = ''.join(lines[context_start:i])
+                if 'cf java' in context:
+                    found_pre = True
+                    in_help_section = True
+                    temp_file.write(line)
+                    temp_file.write(help_text + '\n')
+                    help_updated = True
+                else:
+                    temp_file.write(line)
+            # Look for the end of help section
+            elif in_help_section and '
' in line: + in_help_section = False + temp_file.write(line) + # Write lines that are not in the help section + elif not in_help_section: + temp_file.write(line) + # Skip lines that are inside the help section (old help text) + + if help_updated: + # Replace the original file + shutil.move(temp_path, readme_path) + print_status("README.md help text updated successfully") + + # Stage changes if in git repository + try: + subprocess.run( + ["git", "rev-parse", "--git-dir"], + capture_output=True, + check=True + ) + subprocess.run(["git", "add", "README.md"], check=True) + print_status("Changes staged for commit") + except subprocess.CalledProcessError: + # Not in a git repository or git command failed + pass + + return True + else: + # Clean up temp file + os.unlink(temp_path) + print_warning("Help section not found in README.md") + print("Expected to find a
 tag following a mention of 'cf java'")
+        return False
+
+
+def main() -> None:
+    """Main function to orchestrate the README update process."""
+    try:
+        check_repository_root()
+        ensure_plugin_installed()
+        help_text = get_plugin_help()
+        
+        if update_readme_help(help_text):
+            print("\n๐ŸŽ‰ README help text update complete!")
+        else:
+            sys.exit(1)
+            
+    except KeyboardInterrupt:
+        print_error("\nOperation cancelled by user")
+        sys.exit(1)
+    except Exception as e:
+        print_error(f"Unexpected error: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/setup-dev-env.sh b/setup-dev-env.sh
new file mode 100755
index 0000000..a55d4c0
--- /dev/null
+++ b/setup-dev-env.sh
@@ -0,0 +1,160 @@
+#!/bin/bash
+
+# Setup script for CF Java Plugin development environment
+# Installs pre-commit hooks and validates the development setup
+
+echo "๐Ÿš€ Setting up CF Java Plugin development environment"
+echo "====================================================="
+
+# Check if we're in the right directory
+if [ ! -f "cf_cli_java_plugin.go" ]; then
+    echo "โŒ Error: Not in the CF Java Plugin root directory"
+    exit 1
+fi
+
+echo "โœ… In correct project directory"
+
+# Install pre-commit hook
+echo "๐Ÿ“ฆ Installing pre-commit hooks..."
+if [ ! -f ".git/hooks/pre-commit" ]; then
+    echo "โŒ Error: Pre-commit hook file not found"
+    echo "This script should be run from the repository root where .git/hooks/pre-commit exists"
+    exit 1
+fi
+
+chmod +x .git/hooks/pre-commit
+echo "โœ… Pre-commit hooks installed"
+
+# Setup Go environment
+echo "๐Ÿ”ง Checking Go environment..."
+if ! command -v go &> /dev/null; then
+    echo "โŒ Go is not installed. Please install Go 1.23.5 or later."
+    exit 1
+fi
+
+GO_VERSION=$(go version | grep -o 'go[0-9]\+\.[0-9]\+' | head -1)
+echo "โœ… Go version: $GO_VERSION"
+
+# Install Go dependencies
+echo "๐Ÿ“ฆ Installing Go dependencies..."
+go mod tidy
+echo "โœ… Go dependencies installed"
+
+# Install linting tools
+echo "๐Ÿ” Installing linting tools..."
+
+# Install golangci-lint
+if ! command -v golangci-lint &> /dev/null; then
+    echo "Installing golangci-lint..."
+    go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+    echo "โœ… golangci-lint installed"
+else
+    echo "โœ… golangci-lint already installed"
+fi
+
+# Install gofumpt for stricter formatting
+if ! command -v gofumpt &> /dev/null; then
+    echo "Installing gofumpt..."
+    go install mvdan.cc/gofumpt@latest
+    echo "โœ… gofumpt installed"
+else
+    echo "โœ… gofumpt already installed"
+fi
+
+# Install markdownlint (if npm is available)
+if command -v npm &> /dev/null; then
+    if ! command -v markdownlint &> /dev/null; then
+        echo "Installing markdownlint-cli..."
+        npm install -g markdownlint-cli
+        echo "โœ… markdownlint-cli installed"
+    else
+        echo "โœ… markdownlint-cli already installed"
+    fi
+else
+    echo "โš ๏ธ  npm not found - skipping markdownlint installation"
+    echo "   Install Node.js and npm to enable markdown linting"
+fi
+
+echo "โœ… Linting tools setup complete"
+
+# Setup Python environment (if test suite exists)
+if [ -f "test/requirements.txt" ]; then
+    echo "๐Ÿ Setting up Python test environment..."
+    cd test
+    
+    if [ ! -d "venv" ]; then
+        echo "Creating Python virtual environment..."
+        python3 -m venv venv
+    fi
+    
+    source venv/bin/activate
+    pip3 install --upgrade pip
+    pip3 install -r requirements.txt
+    echo "โœ… Python test environment ready"
+    cd ..
+else
+    echo "โš ๏ธ  Python test suite not found - skipping Python setup"
+fi
+
+# VS Code setup validation
+if [ -f "cf-java-plugin.code-workspace" ]; then
+    echo "โœ… VS Code workspace configuration found"
+    if [ -f "./test-vscode-config.sh" ]; then
+        echo "๐Ÿ”ง Running VS Code configuration test..."
+        ./test-vscode-config.sh
+    fi
+else
+    echo "โš ๏ธ  VS Code workspace configuration not found"
+fi
+
+# Test the pre-commit hook
+echo ""
+echo "๐Ÿงช Testing pre-commit hook..."
+echo "This will run all checks without committing..."
+if .git/hooks/pre-commit; then
+    echo "โœ… Pre-commit hook test passed"
+else
+    echo "โŒ Pre-commit hook test failed"
+    echo "Please fix the issues before proceeding"
+    exit 1
+fi
+
+echo ""
+echo "๐ŸŽ‰ Development Environment Setup Complete!"
+echo "=========================================="
+echo ""
+echo "๐Ÿ“‹ What's configured:"
+echo "  โœ… Pre-commit hooks (run on every git commit)"
+echo "  โœ… Go development environment"
+echo "  โœ… Linting tools (golangci-lint, markdownlint)"
+if [ -f "test/requirements.txt" ]; then
+    echo "  โœ… Python test suite environment"
+else
+    echo "  โš ๏ธ  Python test suite (not found)"
+fi
+if [ -f "cf-java-plugin.code-workspace" ]; then
+    echo "  โœ… VS Code workspace with debugging support"
+fi
+
+echo "Setup Python Testing Environment:"
+(cd test && ./test.sh setup)
+
+echo ""
+echo "๐Ÿš€ Quick Start:"
+echo "  โ€ข Build plugin:        make build"
+if [ -f "test/requirements.txt" ]; then
+    echo "  โ€ข Run Python tests:    cd test && ./test.sh all"
+    echo "  โ€ข VS Code debugging:   code cf-java-plugin.code-workspace"
+fi
+echo "  โ€ข Manual hook test:    .git/hooks/pre-commit"
+echo ""
+echo "๐Ÿ“š Documentation:"
+echo "  โ€ข Main README:         README.md"
+if [ -f "test/README.md" ]; then
+    echo "  โ€ข Test documentation:  test/README.md"
+fi
+if [ -f ".vscode/README.md" ]; then
+    echo "  โ€ข VS Code guide:       .vscode/README.md"
+fi
+echo ""
+echo "Happy coding! ๐ŸŽฏ"
diff --git a/test/.gitignore b/test/.gitignore
new file mode 100644
index 0000000..1e248fa
--- /dev/null
+++ b/test/.gitignore
@@ -0,0 +1,70 @@
+# Configuration files - contain sensitive credentials
+test_config.yml
+config.yml
+*.config.yml
+
+# Test results and reports
+test_results/
+test_reports/
+test_output/
+*.xml
+*.json
+pytest_cache/
+.pytest_cache/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Snapshot testing - output snapshots contain sensitive data
+snapshots/
+*.snapshot
+
+# Downloaded files from tests
+*.hprof
+*.jfr
+*.log
+
+# Temporary test files and directories
+temp_*
+tmp_*
+.temp/
+.tmp/
+
+# IDE and editor files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Virtual environments
+venv/
+env/
+.venv/
+.env/
+
+# Coverage reports
+.coverage
+htmlcov/
+coverage.xml
+*.cover
+.hypothesis/
+
+# Jupyter Notebook checkpoints
+.ipynb_checkpoints
+
+# pytest
+test_report.html
+
+# go
+pkg
\ No newline at end of file
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000..6caf964
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,186 @@
+# CF Java Plugin Test Suite
+
+A modern, efficient testing framework for the CF Java Plugin using Python and pytest.
+
+## Quick Start
+
+```bash
+# Setup
+./test.py setup
+
+# Run tests
+./test.py all           # Run all tests
+./test.py basic         # Basic commands
+./test.py jfr           # JFR tests
+./test.py asprof        # Async-profiler (SapMachine)
+./test.py profiling     # All profiling tests
+
+# Common options
+./test.py --failed all                        # Re-run failed tests
+./test.py --html basic                        # Generate HTML report
+./test.py --parallel all                      # Parallel execution
+./test.py --fail-fast all                     # Stop on first failure
+./test.py --no-initial-restart all            # Skip app restarts (faster)
+./test.py --stats all                         # Enable CF command statistics
+./test.py --start-with TestClass::test_method all  # Start with a specific test (inclusive)
+```
+
+## Possible Problems
+
+The following error might occur when connected to the SAP internal network:
+
+```sh
+ ssh: handshake failed: read tcp 10.16.73.196:64531->18.157.52.48:2222: read: connection reset by peer
+```
+
+Just connect directly to the internet without the VPN.
+
+## State of Testing
+
+- `heap-dump` is thoroughly tested, including all flags, so that less has to be tested for the other commands.
+
+## Test Discovery
+
+Use the `list` command to explore available tests:
+
+```bash
+# Show all tests with class prefixes (ready to copy/paste)
+./test.py list
+
+# Show only method names without class prefixes
+./test.py list --short
+
+# Show with line numbers and docstrings
+./test.py list --verbose
+
+# Show only application names used in tests
+./test.py list --apps-only
+```
+
+Example output:
+
+```text
+๐Ÿ“ test_asprof.py
+  ๐Ÿ“‹ TestAsprofBasic - Basic async-profiler functionality.
+    ๐ŸŽฏ App: sapmachine21
+      โ€ข TestAsprofBasic::test_status_no_profiling
+      โ€ข TestAsprofBasic::test_cpu_profiling
+```
+
+## Test Files
+
+- **`test_basic_commands.py`** - Core commands (heap-dump, vm-info, thread-dump, etc.)
+- **`test_jfr.py`** - Java Flight Recorder profiling tests
+- **`test_asprof.py`** - Async-profiler tests (SapMachine only)
+- **`test_cf_java_plugin.py`** - Integration and workflow tests
+- **`test_disk_full.py`** - Tests for disk full scenarios (e.g., heap dump with no space left)
+- **`test_jre21.py`** - JRE21/non-SapMachine21-specific tests (e.g., heap dump, thread dump, etc.)
+
+## Test Selection & Execution
+
+### Run Specific Tests
+
+```bash
+# Copy test name from `./test.py list` and run directly
+./test.py run TestAsprofBasic::test_cpu_profiling
+
+# Run by test class
+./test.py run test_asprof.py::TestAsprofBasic
+
+# Run by file
+./test.py run test_basic_commands.py
+
+# Search by pattern
+./test.py run test_cpu_profiling
+```
+
+### Test Resumption
+
+After interruption or failure, the CLI shows actionable suggestions:
+
+```bash
+โŒ Tests failed
+๐Ÿ’ก Use --failed to re-run only failed tests
+๐Ÿ’ก Use --start-with TestClass::test_method to resume from a specific test (inclusive)
+```
+
+## Application Dependencies
+
+Tests are organized by application requirements:
+
+- **`all`** - Tests that run on any Java application (sapmachine21)
+- **`sapmachine21`** - Tests specific to SapMachine (async-profiler support)
+
+## Key Features
+
+### CF Command Statistics
+
+```bash
+./test.py --stats all   # Track all CF commands with performance insights
+```
+
+### Environment Variables
+
+```bash
+export RESTART_APPS="never"           # Skip app restarts (faster)
+export CF_COMMAND_STATS="true"        # Global command tracking
+```
+
+### Fast Development Mode
+
+```bash
+# Skip app restarts for faster test iterations
+./test.py --no-initial-restart basic
+
+# Stop immediately on first failure
+./test.py --fail-fast all
+
+# Combine for fastest feedback
+./test.py --no-initial-restart --fail-fast basic
+```
+
+### Parallel Testing
+
+Tests are automatically grouped by app to prevent interference:
+
+```bash
+./test.py --parallel all    # Safe parallel execution
+```
+
+### HTML Reports
+
+```bash
+./test.py --html all        # Generate detailed HTML test report
+```
+
+## Development
+
+```bash
+./test.py setup         # Setup environment
+./test.py clean         # Clean artifacts
+```
+
+## Test Framework
+
+The framework uses a decorator-based approach:
+
+```python
+from framework.decorators import test
+from framework.runner import TestBase
+
+class TestExample(TestBase):
+    @test  # or @test(ine21")
+    def test_heap_dump_basic(self, t, app):
+        t.heap_dump("--local-dir .") \
+            .should_succeed() \
+            .should_create_file(f"{app}-heapdump-*.hprof")
+```
+
+## Tips
+
+1. **Start with `./test.py list`** to see all available tests
+2. **Use `--apps-only`** to see which applications are needed
+3. **Copy test names directly** from the list output to run specific tests
+4. **Use `--failed`** to quickly re-run only failed tests after fixing issues
+5. **Use `--parallel`** for faster execution of large test suites
+6. **Use `--html`** to get detailed reports with logs and timing information
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..af66a07
--- /dev/null
+++ b/test/__init__.py
@@ -0,0 +1,8 @@
+"""
+CF CLI Java Plugin Test Suite
+
+This package contains comprehensive tests for the CF CLI Java Plugin,
+including basic commands, profiling tools, heap snapshots, and disk space simulation.
+"""
+
+__version__ = "1.0.0"
diff --git a/test/apps/jre21/manifest.yml b/test/apps/jre21/manifest.yml
new file mode 100644
index 0000000..de42ea0
--- /dev/null
+++ b/test/apps/jre21/manifest.yml
@@ -0,0 +1,12 @@
+---
+applications:
+- name: jre21
+  random-route: true
+  path: test.jar
+  memory: 1024M
+  buildpacks:
+  - https://github.com/cloudfoundry/java-buildpack.git
+  env:
+    TARGET_RUNTIME: tomcat
+    JBP_CONFIG_COMPONENTS: '{jres: ["JavaBuildpack::Jre::OpenJdkJRE"]}'
+    JBP_CONFIG_OPEN_JDK_JRE: '{ jre: { version: 21.+ } }'
\ No newline at end of file
diff --git a/test/apps/jre21/test.jar b/test/apps/jre21/test.jar
new file mode 100644
index 0000000..c998b29
Binary files /dev/null and b/test/apps/jre21/test.jar differ
diff --git a/test/apps/sapmachine21/manifest.yml b/test/apps/sapmachine21/manifest.yml
new file mode 100644
index 0000000..ed9cff5
--- /dev/null
+++ b/test/apps/sapmachine21/manifest.yml
@@ -0,0 +1,13 @@
+---
+applications:
+- name: sapmachine21
+  random-route: true
+  path: test.jar
+  memory: 512M
+  buildpacks: 
+  - sap_java_buildpack_jakarta
+  env:
+    TARGET_RUNTIME: tomcat
+    JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK']"
+    JBP_CONFIG_SAP_MACHINE_JDK : "{ version: 21.+ }"
+    JBP_CONFIG_JAVA_OPTS: "[java_opts: '-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints']"
diff --git a/test/apps/sapmachine21/test.jar b/test/apps/sapmachine21/test.jar
new file mode 100644
index 0000000..c998b29
Binary files /dev/null and b/test/apps/sapmachine21/test.jar differ
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..1f26211
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,413 @@
+"""
+Pytest configuration and hooks for CF Java Plugin testing.
+"""
+
+import os
+import signal
+import sys
+
+import pytest
+
+# Add the test directory to Python path for absolute imports
+test_dir = os.path.dirname(os.path.abspath(__file__))
+if test_dir not in sys.path:
+    sys.path.insert(0, test_dir)
+
+# noqa: E402
+from framework.runner import CFJavaTestSession
+
+# Global test session instance
+test_session = None
+
+# Track HTML report configuration
+html_report_enabled = False
+html_report_path = None
+
+# Track failures for handling interruptions
+_test_failures = []
+_interrupt_count = 0  # Track number of interrupts for graduated response
+_active_test = None  # Track currently running test for better interrupt messages
+
+# Track apps that need restart on failure (regardless of no_restart=True)
+_apps_need_restart_on_failure = set()
+
+
+def pytest_addoption(parser):
+    """Add custom command line options."""
+    parser.addoption(
+        "--no-initial-restart",
+        action="store_true",
+        default=False,
+        help="Skip restarting all apps at the start of the test suite",
+    )
+
+
+# Set up signal handlers to improve interrupt behavior
+def handle_interrupt(signum, frame):
+    """Custom signal handler for SIGINT to ensure failures are reported."""
+    global _interrupt_count
+
+    _interrupt_count += 1
+
+    # Print a message about the interrupt
+    if _interrupt_count == 1:
+        print("\n๐Ÿ›‘ Test execution interrupted by user (Ctrl+C)")
+        if _active_test:
+            print(f"   Currently running test: {_active_test}")
+
+        # Let Python's default handler take over after our custom handling
+        # This will raise KeyboardInterrupt in the main thread
+        signal.default_int_handler(signum, frame)
+    else:
+        # Second Ctrl+C - force immediate exit
+        print("\n๐Ÿ›‘ Second interrupt detected - forcing immediate termination")
+
+        # Attempt to clean up resources
+        try:
+            if "test_session" in globals() and test_session:
+                print("   Attempting cleanup of test resources...")
+                try:
+                    test_session.teardown_session()
+                    print("   โœ… Test session cleaned up successfully")
+                except Exception as e:
+                    print(f"   โš ๏ธ Failed to clean up test session: {e}")
+        except Exception:
+            print("   โš ๏ธ Error during cleanup - continuing to force exit")
+            pass
+
+        # Display helpful message before exit
+        print("\n๐Ÿ’ก To debug what was happening:")
+        print("   1. Run the specific test with verbose output: ./test.py run  -v")
+        print("   2. Or use fail-fast mode: ./test.py --failed -x")
+
+        # Force immediate exit - extreme case
+        os.exit(130)  # 130 is the standard exit code for SIGINT
+
+
+# Register our custom interrupt handler
+signal.signal(signal.SIGINT, handle_interrupt)
+
+
+def pytest_xdist_make_scheduler(config, log):
+    """Configure pytest-xdist scheduler to group tests by app name.
+
+    This ensures that tests for the same app never run in parallel,
+    preventing interference between test cases on the same application.
+    """
+    # Import here to avoid dependency issues when xdist is not available
+    try:
+        from xdist.scheduler import LoadScopeScheduling
+
+        class AppGroupedScheduling(LoadScopeScheduling):
+            """Custom scheduler that groups tests by app parameter."""
+
+            def _split_scope(self, nodeid):
+                """Split scope to group by app name from test parameters."""
+                # Extract app name from test node ID
+                # Format: test_file.py::TestClass::test_method[app_name]
+                if "[" in nodeid and "]" in nodeid:
+                    # Extract the parameter part (e.g., "sapmachine21")
+                    param_part = nodeid.split("[")[-1].rstrip("]")
+                    # Use the app name as the scope to group tests
+                    return param_part
+                # Fallback to default behavior for tests without parameters
+                return super()._split_scope(nodeid)
+
+        return AppGroupedScheduling(config, log)
+    except ImportError:
+        # If xdist is not available, return None to use default scheduling
+        return None
+
+
+def pytest_configure(config):
+    """Configure pytest session."""
+    global test_session, html_report_enabled, html_report_path
+    test_session = CFJavaTestSession()
+
+    # Set the global session in runner module to avoid duplicate sessions
+    from framework.runner import set_global_test_session
+
+    set_global_test_session(test_session)
+
+    # Check if HTML reporting is enabled
+    html_report_path = config.getoption("--html", default=None)
+    html_report_enabled = html_report_path is not None
+
+    if html_report_enabled:
+        print(f"๐Ÿ“Š Live HTML reporting enabled: {html_report_path}")
+
+    # Check if parallel execution is requested
+    if config.getoption("-n", default=None) or config.getoption("--numprocesses", default=None):
+        print("๐Ÿš€ Parallel execution configured with app-based grouping")
+        print("   Tests for the same app will run on the same worker to prevent interference")
+
+
+def pytest_runtest_protocol(item, nextitem):
+    """Hook for the test execution protocol."""
+    # Let pytest handle execution normally without extra verbose output
+    return None
+
+
+def pytest_sessionstart(session):
+    """Start of test session."""
+    if test_session and not getattr(test_session, "_initialized", False):
+        try:
+            test_session.setup_session()
+        except Exception as e:
+            print(f"Warning: Failed to setup test session: {e}")
+            print("Tests will continue but may fail without proper CF setup.")
+
+    # Handle initial app restart unless --no-initial-restart is specified
+    if not session.config.getoption("--no-initial-restart"):
+        _restart_all_apps_at_start()
+
+
+def _restart_all_apps_at_start():
+    """Restart all apps at the start of the test suite."""
+    if test_session and test_session._cf_logged_in:
+        try:
+            print("๐Ÿ”„ INITIAL RESTART: Restarting all apps at test suite start...")
+            # Use the same restart mode as configured
+            restart_mode = os.environ.get("RESTART_APPS", "smart_parallel").lower()
+
+            # Use a safer restart approach that won't hang
+            if restart_mode in ["smart"]:
+                # For smart mode, check if restart is actually needed
+                success = test_session.cf_manager.restart_apps_if_needed()
+            elif restart_mode == "smart_parallel":
+                success = test_session.cf_manager.restart_apps_if_needed_parallel()
+            elif restart_mode == "parallel":
+                success = test_session.cf_manager.restart_apps_parallel()
+            elif restart_mode == "always":
+                success = test_session.cf_manager.restart_apps()
+            elif restart_mode != "never":
+                # Default to smart mode for safety
+                success = test_session.cf_manager.restart_apps_if_needed()
+            else:
+                return
+            if success:
+                print("โœ… INITIAL RESTART: All apps restarted successfully")
+            else:
+                print("โš ๏ธ INITIAL RESTART: Some apps may not have restarted properly")
+
+        except Exception as e:
+            print(f"โš ๏ธ INITIAL RESTART: Failed to restart apps at start: {e}")
+            # Continue with tests even if restart fails
+            pass
+
+
+def pytest_sessionfinish(session, exitstatus):
+    """End of test session."""
+    if test_session:
+        try:
+            test_session.teardown_session()
+        except Exception as e:
+            print(f"Warning: Failed to teardown test session: {e}")
+
+
+def pytest_runtest_setup(item):
+    """Setup before each test."""
+    global _active_test
+    # Track the currently running test for better interrupt handling
+    _active_test = item.nodeid
+
+
+def pytest_collection_modifyitems(config, items):
+    """Modify collected test items based on decorators and filters, and clean up display names."""
+    filtered_items = []
+
+    for item in items:
+        test_func = item.function
+
+        # Check if test should be skipped
+        if hasattr(test_func, "_skip") and test_func._skip:
+            reason = getattr(test_func, "_skip_reason", "Skipped by decorator")
+            item.add_marker(pytest.mark.skip(reason=reason))
+            continue
+
+        # Clean up the node ID to remove decorator source location
+        if hasattr(item, "nodeid") and "<- framework/decorators.py" in item.nodeid:
+            item.nodeid = item.nodeid.replace(" <- framework/decorators.py", "")
+
+        # Also clean up the item name if it has the decorator reference
+        if hasattr(item, "name") and "<- framework/decorators.py" in item.name:
+            item.name = item.name.replace(" <- framework/decorators.py", "")
+
+        filtered_items.append(item)
+
+    items[:] = filtered_items
+
+
+def pytest_runtest_logreport(report):
+    """Clean up test reports to remove decorator source locations and track failures."""
+    global _active_test
+
+    # Clean up node IDs
+    if hasattr(report, "nodeid") and report.nodeid:
+        if "<- framework/decorators.py" in report.nodeid:
+            report.nodeid = report.nodeid.replace(" <- framework/decorators.py", "")
+
+    # Track failures for interruption handling
+    if report.when == "call" and report.failed:
+        _test_failures.append(report.nodeid)
+
+    # Track test completion to clear the active test reference
+    if report.when == "teardown":
+        if _active_test == report.nodeid:
+            _active_test = None
+
+
+def pytest_terminal_summary(terminalreporter, exitstatus, config):
+    """Enhanced terminal summary with HTML report info and live reporting cleanup."""
+    # Original functionality: customize terminal output to remove decorator references
+    # Enhanced: Add HTML report information and handle KeyboardInterrupt
+
+    # Special handling for keyboard interruption - ensure summary is shown
+    # Display HTML report information if enabled
+    if html_report_enabled and html_report_path:
+        if os.path.exists(html_report_path):
+            abs_path = os.path.abspath(html_report_path)
+            print(f"\n๐Ÿ“Š HTML Report: file://{abs_path}")
+            print("   Open this file in your browser to view detailed results")
+        else:
+            print(f"\nโš ๏ธ  HTML report not found at: {html_report_path}")
+
+    # Display failure summary advice
+    if exitstatus != 0:
+        print("\n๐Ÿ’ก Tip: Use './test.py all --failed' to re-run only failed tests")
+        print("   Or './test.py run ' to run a specific test")
+
+
+def pytest_runtest_logstart(nodeid, location):
+    """Hook called at the start of running each test."""
+    # Clean up the nodeid for live display
+    if "<- framework/decorators.py" in nodeid:
+        # Unfortunately we can't modify nodeid here as it's read-only
+        # This is a limitation of pytest's architecture
+        pass
+
+
+@pytest.fixture(scope="session")
+def cf_session():
+    """Pytest fixture to access the CF test session."""
+    global test_session
+    if test_session is None:
+        test_session = CFJavaTestSession()
+        test_session.setup_session()
+    return test_session
+
+
+@pytest.fixture(autouse=True)
+def cleanup_tmp_after_test(request):
+    """Cleanup all remote files and folders created during the test after each test, and on interruption."""
+    _cleanup_remote_files_on_interrupt()
+
+
+# Also clean up on interruption (SIGINT)
+def _cleanup_remote_files_on_interrupt():
+    if test_session:
+        try:
+            for app in test_session.get_apps_with_tracked_files():
+                remote_paths = test_session.get_and_clear_created_remote_files(app)
+                for remote_path in remote_paths:
+                    os.system(f"cf ssh {app} -c 'rm -rf {remote_path}' > /dev/null 2>&1")
+        except Exception:
+            pass
+
+
+_original_sigint_handler = signal.getsignal(signal.SIGINT)
+
+
+def _sigint_handler(signum, frame):
+    _cleanup_remote_files_on_interrupt()
+    if callable(_original_sigint_handler):
+        _original_sigint_handler(signum, frame)
+
+
+signal.signal(signal.SIGINT, _sigint_handler)
+
+
+@pytest.fixture(autouse=True)
+def cleanup_remote_tmp_before_test(request):
+    """Clean up /tmp on the remote app container before every test."""
+    if test_session:
+        try:
+            # Get all apps involved in this test (parameterized or not)
+            apps = []
+            # Try to extract app parameter from test function arguments
+            if hasattr(request, "param"):
+                apps = [request.param]
+            elif hasattr(request, "node") and hasattr(request.node, "callspec"):
+                # For parameterized tests
+                callspec = getattr(request.node, "callspec", None)
+                if callspec and "app" in callspec.params:
+                    apps = [callspec.params["app"]]
+            # Fallback: get all tracked apps if none found
+            if not apps:
+                try:
+                    apps = test_session.get_apps_with_tracked_files()
+                except Exception:
+                    apps = []
+            # Clean /tmp for each app
+            for app in apps:
+                try:
+                    # Use cf ssh to clean /tmp, ignore errors
+                    os.system(f"cf ssh {app} -c 'rm -rf /tmp/*' > /dev/null 2>&1")
+                except Exception:
+                    pass
+        except Exception:
+            pass
+
+
+def pytest_runtest_teardown(item, nextitem):
+    """Teardown after each test - handle restart on failure."""
+    # Check if this test failed and needs app restart
+    if hasattr(item, "_test_failed") and item._test_failed:
+        # Extract app name from test parameters
+        app_name = _extract_app_name_from_test(item)
+        if app_name and test_session and test_session._cf_logged_in:
+            try:
+                print(f"๐Ÿ”„ FAILURE RESTART: Test failed, restarting app {app_name}...")
+                success = test_session.cf_manager.restart_single_app(app_name)
+                if success:
+                    print(f"โœ… FAILURE RESTART: App {app_name} restarted successfully after test failure")
+                else:
+                    print(f"โš ๏ธ FAILURE RESTART: Failed to restart app {app_name} after test failure")
+            except Exception as e:
+                print(f"โš ๏ธ FAILURE RESTART: Error restarting app {app_name} after test failure: {e}")
+                # Continue with test execution even if restart fails
+                pass
+
+
+def _extract_app_name_from_test(item):
+    """Extract app name from test item parameters."""
+    try:
+        # Check for parameterized test with app parameter
+        if hasattr(item, "callspec") and item.callspec:
+            params = item.callspec.params
+            if "app" in params:
+                return params["app"]
+
+        # Try to extract from node ID for parameterized tests
+        # Format: test_file.py::TestClass::test_method[app_name]
+        if "[" in item.nodeid and "]" in item.nodeid:
+            param_part = item.nodeid.split("[")[-1].rstrip("]")
+            # Simple heuristic: if it doesn't contain spaces or special chars, likely an app name
+            if param_part and " " not in param_part and "," not in param_part:
+                return param_part
+
+        return None
+    except Exception:
+        return None
+
+
+@pytest.hookimpl(tryfirst=True, hookwrapper=True)
+def pytest_runtest_makereport(item, call):
+    """Create test report and track failures for restart logic."""
+    # Execute all other hooks to get the report
+    outcome = yield
+    rep = outcome.get_result()
+
+    # Mark the item if test failed during the call phase
+    if call.when == "call" and rep.failed:
+        item._test_failed = True
diff --git a/test/framework/__init__.py b/test/framework/__init__.py
new file mode 100644
index 0000000..2886960
--- /dev/null
+++ b/test/framework/__init__.py
@@ -0,0 +1,37 @@
+"""
+Framework for CF Java Plugin testing.
+
+This package provides a comprehensive testing framework for the CF Java Plugin,
+including test runners, assertions, DSL, and utilities.
+"""
+
+# Core testing infrastructure
+from .core import CFConfig, CFJavaTestRunner, CFManager, FluentAssertions
+
+# Test decorators and markers
+from .decorators import test
+
+# Fluent DSL for test writing
+from .dsl import CFJavaTest, test_cf_java
+
+# Main test runner and base classes
+from .runner import CFJavaTestSession, TestBase, create_test_class, get_test_session, test_with_apps
+
+__all__ = [
+    # Core components
+    "CFJavaTestRunner",
+    "CFManager",
+    "FluentAssertions",
+    "CFConfig",
+    # Decorators
+    "test",
+    # DSL
+    "CFJavaTest",
+    "test_cf_java",
+    # Runner
+    "CFJavaTestSession",
+    "TestBase",
+    "test_with_apps",
+    "create_test_class",
+    "get_test_session",
+]
diff --git a/test/framework/core.py b/test/framework/core.py
new file mode 100644
index 0000000..1b5ab3c
--- /dev/null
+++ b/test/framework/core.py
@@ -0,0 +1,1904 @@
+"""
+Core test framework for CF Java Plugin black box testing.
+Provides a clean DSL for writing readable tests.
+"""
+
+import getpass
+import glob
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import threading
+import time
+from datetime import datetime
+from typing import Any, Dict, List, Union
+
+import yaml
+
+
+class GlobalCFCommandStats:
+    """Global singleton for tracking CF command statistics across all test instances and processes."""
+
+    _instance = None
+    _lock = threading.Lock()
+    _stats_file = None
+
+    def __new__(cls):
+        if cls._instance is None:
+            with cls._lock:
+                if cls._instance is None:
+                    cls._instance = super().__new__(cls)
+                    cls._instance._initialized = False
+        return cls._instance
+
+    def __init__(self):
+        if not self._initialized:
+            self.cf_command_stats = []
+            self.stats_mode = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+            # Use a fixed temp file name for this pytest run to ensure all instances use the same file
+            if GlobalCFCommandStats._stats_file is None:
+                import tempfile
+
+                # Use a fixed name based on the pytest run to ensure all sessions share the same file
+                temp_dir = tempfile.gettempdir()
+                import getpass
+
+                username = getpass.getuser()
+                GlobalCFCommandStats._stats_file = os.path.join(temp_dir, f"cf_stats_pytest_{username}.json")
+            self._stats_file = GlobalCFCommandStats._stats_file
+            self._load_stats_from_file()
+            self._initialized = True
+
+    def _load_stats_from_file(self):
+        """Load statistics from persistent file."""
+        try:
+            if self._stats_file and os.path.exists(self._stats_file):
+                import json
+
+                with open(self._stats_file, "r") as f:
+                    data = json.load(f)
+                    self.cf_command_stats = data.get("stats", [])
+        except Exception:
+            # If loading fails, start with empty stats
+            self.cf_command_stats = []
+
+    def _save_stats_to_file(self):
+        """Save statistics to persistent file."""
+        try:
+            if self._stats_file:
+                import json
+
+                data = {"stats": self.cf_command_stats}
+                with open(self._stats_file, "w") as f:
+                    json.dump(data, f)
+        except Exception:
+            # If saving fails, continue silently
+            pass
+
+    def add_command_stat(self, command: str, duration: float, success: bool):
+        """Add a CF command statistic to the global tracker."""
+        # Always check current environment variable value (don't rely on cached self.stats_mode)
+        stats_enabled = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+        if not stats_enabled:
+            return
+
+        with self._lock:
+            # Load latest stats from file (in case other processes added stats)
+            self._load_stats_from_file()
+
+            # Add new stat
+            self.cf_command_stats.append(
+                {"command": command, "duration": duration, "success": success, "timestamp": time.time()}
+            )
+
+            # Save updated stats to file
+            self._save_stats_to_file()
+
+    def get_stats(self) -> List[Dict]:
+        """Get all CF command statistics."""
+        # Always load from file to get latest stats
+        self._load_stats_from_file()
+        return self.cf_command_stats.copy()
+
+    def clear_stats(self):
+        """Clear all statistics (useful for testing)."""
+        with self._lock:
+            self.cf_command_stats.clear()
+            self._save_stats_to_file()
+
+    @classmethod
+    def cleanup_temp_files(cls):
+        """Clean up temporary stats files (call at end of test run)."""
+        if cls._stats_file and os.path.exists(cls._stats_file):
+            try:
+                os.unlink(cls._stats_file)
+            except Exception:
+                pass
+            cls._stats_file = None
+
+    def has_stats(self) -> bool:
+        """Check if any statistics have been recorded."""
+        # Load from file to get latest count
+        self._load_stats_from_file()
+        return len(self.cf_command_stats) > 0
+
+    def print_summary(self):
+        """Print a summary of all CF command statistics."""
+        # Always check current environment variable value
+        stats_enabled = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+
+        # Load latest stats from file
+        self._load_stats_from_file()
+
+        # Only print if stats mode is enabled AND we have commands to show
+        if not stats_enabled or not self.cf_command_stats:
+            return
+
+        print("\n" + "=" * 80)
+        print("CF COMMAND STATISTICS SUMMARY (GLOBAL)")
+        print("=" * 80)
+
+        total_commands = len(self.cf_command_stats)
+        total_time = sum(stat["duration"] for stat in self.cf_command_stats)
+        successful_commands = sum(1 for stat in self.cf_command_stats if stat["success"])
+        failed_commands = total_commands - successful_commands
+
+        print(f"Total CF commands executed: {total_commands}")
+        print(f"Total execution time: {total_time:.2f}s")
+        print(f"Successful commands: {successful_commands}")
+        print(f"Failed commands: {failed_commands}")
+        print(
+            f"Average command time: {total_time / total_commands:.2f}s"
+            if total_commands > 0
+            else "Average command time: 0.00s"
+        )
+
+        # Show slowest commands
+        if self.cf_command_stats:
+            slowest = sorted(self.cf_command_stats, key=lambda x: x["duration"], reverse=True)[:5]
+            print("\nSlowest commands:")
+            for i, stat in enumerate(slowest, 1):
+                status = "โœ“" if stat["success"] else "โœ—"
+                print(f"  {i}. {status} {stat['command']} | {stat['duration']:.2f}s")
+
+        # Print detailed table of all commands
+        if self.cf_command_stats:
+            print(f"\n{'DETAILED COMMAND TABLE':^80}")
+            print("-" * 80)
+
+            # Table headers
+            header = f"{'#':<3} {'Status':<6} {'Duration':<10} {'Timestamp':<19} {'Command':<36}"
+            print(header)
+            print("-" * 80)
+
+            # Sort by execution order (timestamp)
+            sorted_stats = sorted(self.cf_command_stats, key=lambda x: x["timestamp"])
+
+            for i, stat in enumerate(sorted_stats, 1):
+                status = "โœ“" if stat["success"] else "โœ—"
+                duration_str = f"{stat['duration']:.2f}s"
+
+                # Format timestamp (convert from float to datetime)
+                timestamp_dt = datetime.fromtimestamp(stat["timestamp"])
+                timestamp_str = timestamp_dt.strftime("%H:%M:%S")
+
+                # Truncate command if too long
+                command = stat["command"]
+                if len(command) > 36:
+                    command = command[:33] + "..."
+
+                row = f"{i:<3} {status:<6} {duration_str:<10} {timestamp_str:<19} {command:<36}"
+                print(row)
+
+        print("=" * 80)
+
+
+class CFConfig:
+    """Configuration for the test suite."""
+
+    def __init__(self, config_file: str = "test_config.yml"):
+        self.config_file = config_file
+        self.config = self._load_config()
+
+    def _load_config(self) -> Dict[str, Any]:
+        """Load configuration from YAML file, with environment variable overrides."""
+        try:
+            with open(self.config_file, "r") as f:
+                config = yaml.safe_load(f)
+        except FileNotFoundError:
+            config = self._default_config()
+
+        # Ensure required sections exist
+        if config is None:
+            config = {}
+
+        if "cf" not in config:
+            config["cf"] = {}
+
+        # Override with environment variables if they exist
+        config["cf"]["api_endpoint"] = os.environ.get("CF_API", config["cf"].get("api_endpoint", ""))
+        config["cf"]["username"] = os.environ.get("CF_USERNAME", config["cf"].get("username", ""))
+        config["cf"]["password"] = os.environ.get("CF_PASSWORD", config["cf"].get("password", ""))
+        config["cf"]["org"] = os.environ.get("CF_ORG", config["cf"].get("org", ""))
+        config["cf"]["space"] = os.environ.get("CF_SPACE", config["cf"].get("space", ""))
+
+        # Ensure apps section exists
+        if "apps" not in config:
+            config["apps"] = self._auto_detect_apps()
+
+        # Ensure timeouts section exists
+        if "timeouts" not in config:
+            config["timeouts"] = {"app_start": 300, "command": 60}
+
+        return config
+
+    def _default_config(self) -> Dict[str, Any]:
+        """Default configuration if file doesn't exist."""
+        return {
+            "cf": {
+                "api_endpoint": os.environ.get("CF_API", "https://api.cf.eu12.hana.ondemand.com"),
+                "username": os.environ.get("CF_USERNAME", ""),
+                "password": os.environ.get("CF_PASSWORD", ""),
+                "org": os.environ.get("CF_ORG", "sapmachine-testing"),
+                "space": os.environ.get("CF_SPACE", "dev"),
+            },
+            "apps": self._auto_detect_apps(),
+            "timeouts": {"app_start": 300, "command": 60},
+        }
+
+    def _auto_detect_apps(self) -> Dict[str, str]:
+        """Auto-detect apps by scanning the testing apps folder."""
+        apps = {}
+
+        # Look for app directories in common locations
+        possible_paths = [
+            os.path.join(os.getcwd(), "apps"),  # From testing dir
+            os.path.join(os.getcwd(), "..", "testing", "apps"),  # From framework dir
+            os.path.join(os.path.dirname(__file__), "..", "apps"),  # Relative to this file
+            os.path.join(os.path.dirname(__file__), "..", "..", "testing", "apps"),  # Up two levels
+        ]
+
+        for base_path in possible_paths:
+            if os.path.exists(base_path) and os.path.isdir(base_path):
+                for item in os.listdir(base_path):
+                    app_dir = os.path.join(base_path, item)
+                    if os.path.isdir(app_dir):
+                        # Check if it looks like a CF app (has manifest.yml or similar)
+                        app_files = [
+                            "manifest.yml",
+                            "manifest.yaml",
+                            "Dockerfile",
+                            "pom.xml",
+                            "build.gradle",
+                            "package.json",
+                        ]
+                        if any(os.path.exists(os.path.join(app_dir, f)) for f in app_files):
+                            apps[item] = item
+                if apps:  # Found apps, use this path
+                    break
+        return apps
+
+    @property
+    def username(self) -> str:
+        return self.config["cf"]["username"]
+
+    @property
+    def password(self) -> str:
+        return self.config["cf"]["password"]
+
+    @property
+    def api_endpoint(self) -> str:
+        return self.config["cf"]["api_endpoint"]
+
+    @property
+    def org(self) -> str:
+        return self.config["cf"]["org"]
+
+    @property
+    def space(self) -> str:
+        return self.config["cf"]["space"]
+
+    @property
+    def apps(self) -> Dict[str, str]:
+        return self.config["apps"]
+
+    def get_detected_apps_info(self) -> str:
+        """Get information about detected apps for debugging."""
+        apps = self.apps
+        if not apps:
+            return "No apps detected"
+
+        info = f"Detected {len(apps)} apps:\n"
+        for app_key, app_name in apps.items():
+            info += f"  - {app_key}: {app_name}\n"
+        return info.rstrip()
+
+
+class CommandResult:
+    """Represents the result of a command execution."""
+
+    def __init__(self, returncode: int, stdout: str, stderr: str, command: str):
+        self.returncode = returncode
+        self.stdout = stdout
+        self.stderr = stderr
+        self.command = command
+        self.output = stdout + stderr  # Combined output
+
+    @property
+    def success(self) -> bool:
+        return self.returncode == 0
+
+    @property
+    def failed(self) -> bool:
+        return self.returncode != 0
+
+    def __str__(self) -> str:
+        return (
+            f"CommandResult(cmd='{self.command}', rc={self.returncode}, "
+            f"stdout_len={len(self.stdout)}, stderr_len={len(self.stderr)})"
+        )
+
+
+class TestContext:
+    """Context for a single test execution."""
+
+    def __init__(self, app_name: str, temp_dir: str):
+        self.app_name = app_name
+        self.temp_dir = temp_dir
+        self.original_cwd = os.getcwd()
+        self.files_before = set()
+        self.files_after = set()
+
+    def __enter__(self):
+        os.chdir(self.temp_dir)
+        self.files_before = set(os.listdir("."))
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.files_after = set(os.listdir("."))
+        os.chdir(self.original_cwd)
+
+    @property
+    def new_files(self) -> set:
+        """Files created during test execution."""
+        return self.files_after - self.files_before
+
+    @property
+    def deleted_files(self) -> set:
+        """Files deleted during test execution."""
+        return self.files_before - self.files_after
+
+
+class CFJavaTestRunner:
+    """Main test runner with a clean DSL for CF Java Plugin testing."""
+
+    def __init__(self, config: CFConfig):
+        self.config = config
+        self.temp_dirs = []
+        # Use global stats tracker instead of local instance stats
+        self.global_stats = GlobalCFCommandStats()
+        self.stats_mode = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+
+    def _is_cf_command(self, cmd: str) -> bool:
+        """Check if a command is a CF CLI command."""
+        cmd_stripped = cmd.strip()
+        return cmd_stripped.startswith("cf ") or cmd_stripped.startswith("CF ")
+
+    def _redact_sensitive_info(self, cmd: str) -> str:
+        """Redact sensitive information from commands for logging."""
+        # Redact login commands
+        if "cf login" in cmd:
+            # Replace username and password with placeholders
+            import re
+
+            # Pattern to match cf login with -u and -p flags
+            pattern = r"cf login -u [^\s]+ -p \'[^\']+\'"
+            if re.search(pattern, cmd):
+                redacted = re.sub(r"(-u) [^\s]+", r"\1 [REDACTED]", cmd)
+                redacted = re.sub(r"(-p) \'[^\']+\'", r"\1 [REDACTED]", redacted)
+                return redacted
+        return cmd
+
+    def _log_cf_command_stats(self, cmd: str, duration: float, success: bool):
+        """Log CF command statistics."""
+        # Check if stats mode is enabled
+        stats_enabled = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+
+        if not self.stats_mode and not stats_enabled:
+            return
+
+        # Extract just the CF command part (remove cd and other shell operations)
+        cf_part = cmd
+        if "&&" in cmd:
+            parts = cmd.split("&&")
+            for part in parts:
+                part = part.strip()
+                if self._is_cf_command(part):
+                    cf_part = part
+                    break
+
+        # Redact sensitive information for logging
+        cf_part_redacted = self._redact_sensitive_info(cf_part)
+
+        status = "โœ“" if success else "โœ—"
+        print(f"[CF_STATS] {status} {cf_part_redacted} | {duration:.2f}s")
+
+        # Only store in global stats if environment variable is enabled
+        if stats_enabled:
+            # Store in global stats tracker
+            self.global_stats.add_command_stat(cf_part_redacted, duration, success)
+
+    def print_cf_command_summary(self):
+        """Print a summary of all CF command statistics (delegates to global stats)."""
+        self.global_stats.print_summary()
+
+    def run_command(self, cmd: Union[str, List[str]], timeout: int = 60, app_name: str = None) -> CommandResult:
+        """Execute a command and return the result."""
+        if isinstance(cmd, list):
+            # Handle sequence of commands
+            results = []
+            for single_cmd in cmd:
+                if single_cmd.startswith("sleep "):
+                    sleep_time = float(single_cmd.split()[1])
+                    time.sleep(sleep_time)
+                    continue
+                result = self._execute_single_command(single_cmd, timeout, app_name)
+                results.append(result)
+                if result.failed:
+                    return result  # Return first failure
+            return results[-1]  # Return last result if all succeeded
+        else:
+            return self._execute_single_command(cmd, timeout, app_name)
+
+    def _execute_single_command(self, cmd: str, timeout: int, app_name: str = None) -> CommandResult:
+        """Execute a single command."""
+        if app_name:
+            cmd = cmd.replace("$APP_NAME", app_name)
+
+        # Track timing for CF commands
+        is_cf_cmd = self._is_cf_command(cmd)
+        start_time = time.time() if is_cf_cmd else 0
+
+        try:
+            process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
+            result = CommandResult(
+                returncode=process.returncode, stdout=process.stdout, stderr=process.stderr, command=cmd
+            )
+
+            # Log CF command stats
+            if is_cf_cmd and start_time > 0:
+                duration = time.time() - start_time
+                self._log_cf_command_stats(cmd, duration, result.success)
+
+            return result
+
+        except subprocess.TimeoutExpired:
+            result = CommandResult(
+                returncode=-1, stdout="", stderr=f"Command timed out after {timeout} seconds", command=cmd
+            )
+
+            # Log timeout for CF command
+            if is_cf_cmd and start_time > 0:
+                duration = time.time() - start_time
+                self._log_cf_command_stats(cmd, duration, False)
+
+            return result
+
+        except KeyboardInterrupt:
+            # Handle CTRL-C gracefully
+            if is_cf_cmd:
+                print(f"๐Ÿ›‘ CF COMMAND CANCELLED: {cmd} (CTRL-C)")
+
+            result = CommandResult(returncode=-1, stdout="", stderr="Command cancelled by user (CTRL-C)", command=cmd)
+
+            # Log cancellation for CF command
+            if is_cf_cmd and start_time > 0:
+                duration = time.time() - start_time
+                self._log_cf_command_stats(cmd, duration, False)
+
+            # Re-raise to allow calling code to handle
+            raise
+
+    def create_test_context(self, app_name: str) -> TestContext:
+        """Create a temporary directory context for test execution."""
+        temp_dir = tempfile.mkdtemp(prefix=f"cf_java_test_{app_name}_")
+        self.temp_dirs.append(temp_dir)
+        return TestContext(app_name, temp_dir)
+
+    def cleanup(self):
+        """Clean up temporary directories."""
+        # Clean up temporary directories
+        for temp_dir in self.temp_dirs:
+            if os.path.exists(temp_dir):
+                shutil.rmtree(temp_dir)
+        self.temp_dirs.clear()
+
+    def check_file_exists(self, pattern: str) -> bool:
+        """Check if a file matching the pattern exists."""
+        matches = glob.glob(pattern)
+        return len(matches) > 0
+
+    def get_matching_files(self, pattern: str) -> List[str]:
+        """Get all files matching the pattern."""
+        return glob.glob(pattern)
+
+    def check_remote_files(self, app_name: str, expected_files: List[str] = None) -> List[str]:
+        """Check files in the remote app directory."""
+        result = self.run_command(f"cf ssh {app_name} -c 'ls'", app_name=app_name)
+        if result.failed:
+            return []
+
+        remote_files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
+
+        if expected_files is not None:
+            unexpected = set(remote_files) - set(expected_files)
+            missing = set(expected_files) - set(remote_files)
+            if unexpected or missing:
+                raise AssertionError(f"Remote files mismatch. Unexpected: {unexpected}, Missing: {missing}")
+
+        return remote_files
+
+    def check_jfr_events(self, file_pattern: str, event_type: str, min_count: int) -> bool:
+        """Check JFR file contains minimum number of events."""
+        files = self.get_matching_files(file_pattern)
+        if not files:
+            return False
+
+        for file in files:
+            result = self.run_command(f"jfr summary {file}")
+            if result.success:
+                # Parse output to find event count
+                lines = result.stdout.split("\n")
+                for line in lines:
+                    if event_type in line:
+                        # Extract count from line (assuming format like "ExecutionSample: 123")
+                        match = re.search(r":\s*(\d+)", line)
+                        if match and int(match.group(1)) >= min_count:
+                            return True
+        return False
+
+    def capture_remote_file_state(self, app_name: str) -> Dict[str, List[str]]:
+        """
+        Capture the complete file state in key remote directories,
+        ignoring JVM artifacts like /tmp/hsperfdata_vcap.
+        """
+        state = {}
+        # Directories to monitor for file changes
+        directories = {"tmp": "/tmp", "home": "$HOME", "app": "$HOME/app"}
+        for name, directory in directories.items():
+            # Use simple ls command to get directory contents
+            cmd = f"cf ssh {app_name} -c 'ls -1 {directory} 2>/dev/null || echo NO_DIRECTORY'"
+            result = self.run_command(cmd, app_name=app_name, timeout=15)
+            if result.success:
+                output = result.stdout.strip()
+                if output == "NO_DIRECTORY" or not output:
+                    state[name] = []
+                else:
+                    files = [f.strip() for f in output.split("\n") if f.strip()]
+                    # Filter out JVM perfdata directory from /tmp
+                    if name == "tmp":
+                        files = [f for f in files if f != "hsperfdata_vcap"]
+                    state[name] = files
+            else:
+                # If command fails, record empty state
+                state[name] = []
+        return state
+
+    def compare_remote_file_states(
+        self, before: Dict[str, List[str]], after: Dict[str, List[str]]
+    ) -> Dict[str, List[str]]:
+        """Compare two remote file states and return new files."""
+        new_files = {}
+
+        for directory in before.keys():
+            before_set = set(before.get(directory, []))
+            after_set = set(after.get(directory, []))
+
+            # Find files that were added
+            added_files = after_set - before_set
+            if added_files:
+                new_files[directory] = list(added_files)
+
+        return new_files
+
+
+class FluentAssertions:
+    """Assertion helpers for test validation."""
+
+    @staticmethod
+    def output_contains(result: CommandResult, text: str):
+        """Assert that command output contains specific text."""
+        if text not in result.output:
+            raise AssertionError(f"Expected output to contain '{text}', but got:\n{result.output}")
+
+    @staticmethod
+    def output_matches(result: CommandResult, pattern: str):
+        """Assert that command output matches regex pattern."""
+        if not re.search(pattern, result.output, re.MULTILINE | re.DOTALL):
+            raise AssertionError(f"Expected output to match pattern '{pattern}', but got:\n{result.output}")
+
+    @staticmethod
+    def command_succeeds(result: CommandResult):
+        """Assert that command succeeded."""
+        if result.failed:
+            raise AssertionError(
+                f"Expected command to succeed, but it failed with code {result.returncode}:\n{result.stderr}"
+            )
+
+    @staticmethod
+    def command_fails(result: CommandResult):
+        """Assert that command failed."""
+        if result.success:
+            raise AssertionError(f"Expected command to fail, but it succeeded:\n{result.stdout}")
+
+    @staticmethod
+    def has_file(pattern: str):
+        """Assert that a file matching pattern exists."""
+        files = glob.glob(pattern)
+        if not files:
+            raise AssertionError(f"Expected file matching '{pattern}' to exist, but none found")
+
+    @staticmethod
+    def has_no_files(pattern: str = "*"):
+        """Assert that no files matching pattern exist."""
+        files = glob.glob(pattern)
+        if files:
+            raise AssertionError(f"Expected no files matching '{pattern}', but found: {files}")
+
+    @staticmethod
+    def line_count_at_least(result: CommandResult, min_lines: int):
+        """Assert that output has at least specified number of lines."""
+        lines = result.output.split("\n")
+        actual_lines = len([line for line in lines if line.strip()])
+        if actual_lines < min_lines:
+            raise AssertionError(f"Expected at least {min_lines} lines, but got {actual_lines}")
+
+    @staticmethod
+    def jfr_has_events(file_pattern: str, event_type: str, min_count: int):
+        """Assert that JFR file contains minimum number of events using JFRSummaryParser."""
+        files = glob.glob(file_pattern)
+        if not files:
+            raise AssertionError(f"No JFR files found matching '{file_pattern}'")
+
+        for file in files:
+            try:
+                parser = JFRSummaryParser(file)
+                summary = parser.parse_summary()
+                matching_events = [e for e in summary["events"] if event_type in e["name"] or e["name"] in event_type]
+                for event in matching_events:
+                    if event["count"] >= min_count:
+                        return  # Success
+            except Exception as ex:
+                raise AssertionError(f"Failed to parse JFR summary for {file}: {ex}")
+
+        # On error, show JFR summary with only events that have counts > 0
+        error_msg = f"JFR file does not contain at least {min_count} {event_type} events"
+        if files:
+            file = files[0]
+            try:
+                parser = JFRSummaryParser(file)
+                summary = parser.parse_summary()
+                events_with_counts = [e for e in summary["events"] if e["count"] > 0]
+                if events_with_counts:
+                    try:
+                        table = parser.format_events_table(min_count=0, highlight_pattern=event_type)
+                        error_msg += f"\n\nJFR Summary for {file} (events with count > 0):\n{table}"
+                    except Exception:
+                        # Fallback to simple format
+                        error_msg += f"\n\nJFR Summary for {file} (events with count > 0):\n"
+                        for event in events_with_counts:
+                            marker = "โ†’" if (event_type in event["name"] or event["name"] in event_type) else " "
+                            error_msg += f"  {marker} {event['name']}: {event['count']:,}\n"
+                matching_events = [e for e in events_with_counts if event_type in e["name"] or e["name"] in event_type]
+                if matching_events:
+                    event_name, actual_count = matching_events[0]["name"], matching_events[0]["count"]
+                    error_msg += (
+                        f"\n\n๐Ÿ’ก Note: '{event_name}' was found with {actual_count:,} events (needed {min_count:,})"
+                    )
+                else:
+                    error_msg += f"\n\n๐Ÿ’ก Note: No events matching '{event_type}' were found in the JFR file"
+            except Exception as ex:
+                error_msg += f"\n\nFailed to parse JFR summary for {file}: {ex}"
+        raise AssertionError(error_msg)
+
+
+class JFRSummaryParser:
+    """Utility class for parsing JFR summary output."""
+
+    def __init__(self, jfr_file_path: str):
+        self.jfr_file_path = jfr_file_path
+        self._summary_data = None
+
+    def parse_summary(self) -> Dict[str, Any]:
+        """Parse JFR summary and return structured data."""
+        if self._summary_data is not None:
+            return self._summary_data
+
+        result = subprocess.run(["jfr", "summary", self.jfr_file_path], capture_output=True, text=True)
+        if result.returncode != 0:
+            raise ValueError(f"Failed to get JFR summary for {self.jfr_file_path}: {result.stderr}")
+
+        lines = result.stdout.split("\n")
+
+        # Parse metadata
+        metadata = {}
+        events = []
+
+        in_event_table = False
+        for line in lines:
+            line_stripped = line.strip()
+
+            # Parse metadata before the event table
+            if not in_event_table:
+                if line_stripped.startswith("Start:"):
+                    metadata["start"] = line_stripped.split(":", 1)[1].strip()
+                elif line_stripped.startswith("Version:"):
+                    metadata["version"] = line_stripped.split(":", 1)[1].strip()
+                elif line_stripped.startswith("VM Arguments:"):
+                    metadata["vm_arguments"] = line_stripped.split(":", 1)[1].strip()
+                elif line_stripped.startswith("Chunks:"):
+                    try:
+                        metadata["chunks"] = int(line_stripped.split(":", 1)[1].strip())
+                    except ValueError:
+                        pass
+                elif line_stripped.startswith("Duration:"):
+                    duration_str = line_stripped.split(":", 1)[1].strip()
+                    metadata["duration"] = duration_str
+                    # Parse duration to seconds if possible
+                    try:
+                        if "s" in duration_str:
+                            duration_num = float(duration_str.replace("s", "").strip())
+                            metadata["duration_seconds"] = duration_num
+                    except ValueError:
+                        pass
+
+            # Look for the actual separator line (all equals signs)
+            if line_stripped.startswith("=") and "=" * 10 in line_stripped and len(line_stripped) > 30:
+                in_event_table = True
+                continue
+
+            # Skip empty lines and metadata before the table
+            if not in_event_table or not line_stripped:
+                continue
+
+            # Skip metadata lines that can appear after the separator
+            if line_stripped.startswith(("Start:", "Version:", "VM Arguments:", "Chunks:", "Duration:")):
+                continue
+
+            # Skip the header line ("Event Type    Count    Size (bytes)")
+            if "Event Type" in line_stripped and "Count" in line_stripped:
+                continue
+
+            # Parse event lines (format: "jdk.EventName    count    size")
+            parts = line_stripped.split()
+            if len(parts) >= 2:
+                try:
+                    event_name = parts[0]
+                    count = int(parts[1])
+                    size_bytes = int(parts[2]) if len(parts) >= 3 else 0
+
+                    # Only include events that look like JFR event names
+                    if "." in event_name or event_name.startswith(("jdk", "jfr")):
+                        events.append({"name": event_name, "count": count, "size_bytes": size_bytes})
+                except (ValueError, IndexError):
+                    # Skip lines that don't match the expected format
+                    continue
+
+        self._summary_data = {
+            "metadata": metadata,
+            "events": events,
+            "total_events": sum(event["count"] for event in events),
+            "total_size_bytes": sum(event["size_bytes"] for event in events),
+        }
+
+        return self._summary_data
+
+    def get_events_with_count_gt(self, min_count: int) -> List[Dict[str, Any]]:
+        """Get events with count greater than specified minimum."""
+        summary = self.parse_summary()
+        return [event for event in summary["events"] if event["count"] > min_count]
+
+    def find_events_matching(self, pattern: str) -> List[Dict[str, Any]]:
+        """Find events whose names contain the given pattern."""
+        summary = self.parse_summary()
+        return [event for event in summary["events"] if pattern in event["name"]]
+
+    def get_total_event_count(self) -> int:
+        """Get total number of events across all types."""
+        summary = self.parse_summary()
+        return summary["total_events"]
+
+    def get_duration_seconds(self) -> float:
+        """Get recording duration in seconds, or 0 if not available."""
+        summary = self.parse_summary()
+        return summary["metadata"].get("duration_seconds", 0.0)
+
+    def has_minimum_events(self, min_total_events: int) -> bool:
+        """Check if JFR has at least the specified number of total events."""
+        return self.get_total_event_count() >= min_total_events
+
+    def has_minimum_duration(self, min_duration_seconds: float) -> bool:
+        """Check if JFR recording has at least the specified duration."""
+        return self.get_duration_seconds() >= min_duration_seconds
+
+    def format_events_table(self, min_count: int = 0, highlight_pattern: str = None) -> str:
+        """Format events as a beautiful table, optionally filtering and highlighting."""
+        events_to_show = self.get_events_with_count_gt(min_count)
+
+        if not events_to_show:
+            return "No events found with the specified criteria."
+
+        # Sort by count descending
+        events_to_show.sort(key=lambda x: x["count"], reverse=True)
+
+        try:
+            from tabulate import tabulate
+
+            # Prepare table data with highlighting for pattern
+            table_data = []
+            for event in events_to_show:
+                event_name = event["name"]
+                count = event["count"]
+
+                # Highlight if pattern matches
+                if highlight_pattern and (highlight_pattern in event_name or event_name in highlight_pattern):
+                    event_display = f"โ†’ {event_name}"  # Mark with arrow
+                else:
+                    event_display = f"  {event_name}"
+
+                # Format count with thousand separators
+                count_display = f"{count:,}"
+                table_data.append([event_display, count_display])
+
+            # Create the table
+            return tabulate(
+                table_data, headers=["Event Type", "Count"], tablefmt="rounded_grid", stralign="left", numalign="right"
+            )
+
+        except ImportError:
+            # Fallback to simple format if tabulate is not available
+            result = "Event Type                           Count\n"
+            result += "-" * 50 + "\n"
+            for event in events_to_show:
+                event_name = event["name"]
+                count = event["count"]
+                marker = (
+                    "โ†’"
+                    if (highlight_pattern and (highlight_pattern in event_name or event_name in highlight_pattern))
+                    else " "
+                )
+                result += f"{marker} {event_name:<30} {count:>10,}\n"
+
+            return result
+
+
+class CFManager:
+    """Manages CF operations like login, app deployment, etc."""
+
+    # File-based state tracking (replaces class-level state)
+    _login_state_file = None
+    _restart_state_file = None
+    _lock = threading.Lock()
+
+    @classmethod
+    def _get_state_files(cls):
+        """Get the paths for state files, creating them if needed."""
+        if cls._login_state_file is None or cls._restart_state_file is None:
+            temp_dir = tempfile.gettempdir()
+            username = getpass.getuser()
+            cls._login_state_file = os.path.join(temp_dir, f"cf_login_state_{username}.json")
+            cls._restart_state_file = os.path.join(temp_dir, f"cf_restart_state_{username}.json")
+        return cls._login_state_file, cls._restart_state_file
+
+    @classmethod
+    def _load_login_state(cls) -> Dict:
+        """Load login state from persistent file."""
+        login_file, _ = cls._get_state_files()
+        try:
+            if os.path.exists(login_file):
+                import json
+
+                with open(login_file, "r") as f:
+                    return json.load(f)
+        except Exception:
+            pass
+        # Return default state if loading fails
+        return {"logged_in": False, "login_config": {}, "login_timestamp": 0.0}
+
+    @classmethod
+    def _save_login_state(cls, state: Dict):
+        """Save login state to persistent file."""
+        login_file, _ = cls._get_state_files()
+        try:
+            import json
+
+            with open(login_file, "w") as f:
+                json.dump(state, f)
+        except Exception:
+            pass
+
+    @classmethod
+    def _load_restart_state(cls) -> Dict:
+        """Load deferred restart state from persistent file."""
+        _, restart_file = cls._get_state_files()
+        try:
+            if os.path.exists(restart_file):
+                import json
+
+                with open(restart_file, "r") as f:
+                    data = json.load(f)
+                    # Handle legacy format (list of apps) and convert to new format
+                    if isinstance(data, dict) and "restart_entries" in data:
+                        return data
+                    elif isinstance(data, dict) and "deferred_restart_apps" in data:
+                        # Legacy format - convert to new format with class names
+                        apps = data.get("deferred_restart_apps", [])
+                        return {
+                            "restart_entries": [
+                                {"app": app, "test_class": "Unknown", "reason": "Legacy"} for app in apps
+                            ]
+                        }
+                    else:
+                        # Very old format - just a list
+                        return {"restart_entries": []}
+        except Exception:
+            pass
+        return {"restart_entries": []}
+
+    @classmethod
+    def _save_restart_state(cls, restart_entries: List[Dict]):
+        """Save deferred restart state to persistent file."""
+        _, restart_file = cls._get_state_files()
+        try:
+            import json
+
+            data = {"restart_entries": restart_entries}
+            with open(restart_file, "w") as f:
+                json.dump(data, f)
+        except Exception:
+            pass
+
+    def __init__(self, config: CFConfig):
+        self.config = config
+        self.runner = CFJavaTestRunner(config)
+        self._app_status_cache = {}
+        self._cache_timestamp = 0
+        # Track which apps have been initially restarted in this session
+        self._initially_restarted_apps = set()
+
+    def check_current_cf_target(self) -> Dict[str, str]:
+        """Check current CF target information."""
+        result = self.runner.run_command("cf target", timeout=10)
+        if result.failed:
+            return {}
+
+        target_info = {}
+        lines = result.stdout.split("\n")
+
+        for line in lines:
+            line = line.strip()
+            if line.startswith("api endpoint:"):
+                target_info["api"] = line.split(":", 1)[1].strip()
+            elif line.startswith("user:"):
+                target_info["user"] = line.split(":", 1)[1].strip()
+            elif line.startswith("org:"):
+                target_info["org"] = line.split(":", 1)[1].strip()
+            elif line.startswith("space:"):
+                target_info["space"] = line.split(":", 1)[1].strip()
+
+        return target_info
+
+    def is_logged_in_correctly(self) -> bool:
+        """Check if we're logged in with the correct credentials and target."""
+        target_info = self.check_current_cf_target()
+
+        if not target_info:
+            return False
+
+        # Check all required fields match
+        expected = {
+            "api": self.config.api_endpoint,
+            "user": self.config.username,
+            "org": self.config.org,
+            "space": self.config.space,
+        }
+
+        for key, expected_value in expected.items():
+            current_value = target_info.get(key, "").strip()
+            if current_value != expected_value:
+                # Only print mismatches for debugging, no detailed output
+                return False
+
+        return True
+
+    def login(self) -> bool:
+        """Login to CF only if needed, with file-based state tracking to prevent redundant logins."""
+        import time
+
+        # Create a config signature for comparison
+        current_config = {
+            "username": self.config.username,
+            "password": self.config.password,
+            "api_endpoint": self.config.api_endpoint,
+            "org": self.config.org,
+            "space": self.config.space,
+        }
+
+        with self._lock:
+            # Load current login state from file
+            login_state = self._load_login_state()
+
+            # Check if already logged in with same config
+            if login_state["logged_in"] and login_state["login_config"] == current_config:
+                # Check if login is still valid (not older than 30 minutes)
+                login_timestamp = login_state["login_timestamp"]
+                if isinstance(login_timestamp, (int, float)):
+                    login_age = time.time() - login_timestamp
+                    if login_age < 1800:  # 30 minutes
+                        print("๐Ÿ”— LOGIN: Using existing session (already logged in during this test run)")
+                        return True
+                    else:
+                        print("๐Ÿ”— LOGIN: Previous session expired, re-authenticating...")
+                        login_state["logged_in"] = False
+
+        # Fast check: are we already logged in with correct credentials?
+        if self.is_logged_in_correctly():
+            print("๐Ÿ”— LOGIN: Already logged in with correct credentials")
+            # Update file-based state
+            with self._lock:
+                login_state = {"logged_in": True, "login_config": current_config, "login_timestamp": time.time()}
+                self._save_login_state(login_state)
+            return True
+
+        print("๐Ÿ”— LOGIN: Logging in to CF...")
+        try:
+            cmd = (
+                f"cf login -u {self.config.username} -p '{self.config.password}' "
+                f"-a {self.config.api_endpoint} -o {self.config.org} -s {self.config.space}"
+            )
+            result = self.runner.run_command(cmd, timeout=60)
+
+            if result.success:
+                print("โœ… LOGIN: Successfully logged in to CF")
+                # Update file-based state on successful login
+                with self._lock:
+                    login_state = {"logged_in": True, "login_config": current_config, "login_timestamp": time.time()}
+                    self._save_login_state(login_state)
+                return True
+            else:
+                print(f"โŒ LOGIN: CF login failed: {result.stderr}")
+                return False
+
+        except KeyboardInterrupt:
+            print("๐Ÿ›‘ LOGIN: Login cancelled by CTRL-C")
+            return False
+
+    def deploy_apps(self) -> bool:
+        """Deploy test applications."""
+        success = True
+
+        # Find apps directory using the same logic as auto-detection
+        apps_base_path = None
+        possible_paths = [
+            os.path.join(os.getcwd(), "apps"),  # From testing dir
+            os.path.join(os.getcwd(), "..", "testing", "apps"),  # From framework dir
+            os.path.join(os.path.dirname(__file__), "..", "apps"),  # Relative to this file
+            os.path.join(os.path.dirname(__file__), "..", "..", "testing", "apps"),  # Up two levels
+        ]
+
+        for path in possible_paths:
+            if os.path.exists(path) and os.path.isdir(path):
+                apps_base_path = path
+                break
+
+        if not apps_base_path:
+            print("No apps directory found, cannot deploy apps")
+            return False
+
+        print(f"Using apps directory: {apps_base_path}")
+
+        # Deploy each detected app
+        for app_key, app_name in self.config.apps.items():
+            app_path = os.path.join(apps_base_path, app_key)
+            if os.path.exists(app_path):
+                print(f"Deploying {app_name} from {app_path}")
+                result = self.runner.run_command(f"cd '{app_path}' && cf push --no-start", timeout=120)
+                if result.failed:
+                    print(f"Failed to deploy {app_name}: {result.stderr}")
+                    success = False
+                else:
+                    print(f"Successfully deployed {app_name}")
+            else:
+                print(f"App directory not found: {app_path}")
+                success = False
+
+        return success
+
+    def start_apps(self) -> bool:
+        """Start all test applications."""
+        success = True
+        for app_name in self.config.apps.values():
+            print(f"Starting application: {app_name}")
+            result = self.runner.run_command(
+                f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+            )
+            if result.failed:
+                print(f"Failed to start {app_name}: {result.stderr}")
+                success = False
+        return success
+
+    def start_apps_parallel(self) -> bool:
+        """Start all test applications in parallel."""
+        if not self.config.apps:
+            return True
+
+        app_names = list(self.config.apps.values())
+
+        results = {}
+        threads = []
+
+        def start_single_app(app_name: str):
+            """Start a single app and store the result."""
+            result = self.runner.run_command(
+                f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+            )
+            results[app_name] = result
+
+        # Start all start operations in parallel
+        for app_name in app_names:
+            thread = threading.Thread(target=start_single_app, args=(app_name,))
+            thread.start()
+            threads.append(thread)
+
+        # Wait for all operations to complete
+        for thread in threads:
+            thread.join()
+
+        # Check results and report any failures
+        success = True
+        for app_name, result in results.items():
+            if result.failed:
+                print(f"Failed to start {app_name}: {result.stderr}")
+                success = False
+
+        return success
+
+    def restart_apps(self) -> bool:
+        """Restart all test applications."""
+        success = True
+        for app_name in self.config.apps.values():
+            result = self.runner.run_command(
+                f"cf restart {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+            )
+            if result.failed:
+                print(f"Failed to restart {app_name}: {result.stderr}")
+                success = False
+        return success
+
+    def restart_apps_parallel(self) -> bool:
+        """Restart all test applications in parallel."""
+        if not self.config.apps:
+            return True
+
+        app_names = list(self.config.apps.values())
+
+        results = {}
+        threads = []
+
+        def restart_single_app(app_name: str):
+            """Restart a single app and store the result."""
+            result = self.runner.run_command(
+                f"cf restart {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+            )
+            results[app_name] = result
+
+        # Start all restart operations in parallel
+        for app_name in app_names:
+            thread = threading.Thread(target=restart_single_app, args=(app_name,))
+            thread.start()
+            threads.append(thread)
+
+        # Wait for all operations to complete
+        for thread in threads:
+            thread.join()
+
+        # Check results and report any failures
+        success = True
+        for app_name, result in results.items():
+            if result.failed:
+                print(f"Failed to restart {app_name}: {result.stderr}")
+                success = False
+
+        return success
+
+    def delete_apps(self) -> bool:
+        """Delete test applications."""
+        # Legacy SKIP_DELETE environment variable (for backwards compatibility)
+        if os.environ.get("SKIP_DELETE", "").lower() == "true":
+            print("Skipping app deletion due to SKIP_DELETE=true")
+            return True
+
+        success = True
+        for app_name in self.config.apps.values():
+            print(f"Deleting app: {app_name}")
+            result = self.runner.run_command(f"cf delete {app_name} -f", timeout=60)
+            if result.failed:
+                print(f"Failed to delete {app_name}: {result.stderr}")
+                success = False
+            else:
+                print(f"Successfully deleted {app_name}")
+        return success
+
+    def _clear_app_cache_if_stale(self, max_age_seconds: int = 30):
+        """Clear app status cache if it's too old."""
+        import time
+
+        current_time = time.time()
+        if current_time - self._cache_timestamp > max_age_seconds:
+            self._app_status_cache.clear()
+            self._cache_timestamp = current_time
+
+    def check_app_status(self, app_name: str, use_cache: bool = True) -> str:
+        """Check the status of an application with optional caching."""
+        if use_cache:
+            self._clear_app_cache_if_stale()
+            if app_name in self._app_status_cache:
+                return self._app_status_cache[app_name]
+
+        result = self.runner.run_command(f"cf app {app_name}", timeout=15)
+        if result.failed:
+            status = "unknown"
+        else:
+            # Parse the output to determine status
+            output = result.stdout.lower()
+            if "running" in output:
+                status = "running"
+            elif "stopped" in output:
+                status = "stopped"
+            elif "crashed" in output:
+                status = "crashed"
+            else:
+                status = "unknown"
+
+        if use_cache:
+            self._app_status_cache[app_name] = status
+
+        return status
+
+    def check_all_apps_status(self) -> Dict[str, str]:
+        """Check status of all configured apps efficiently."""
+        # Use cf apps command to get all app statuses at once
+        result = self.runner.run_command("cf apps", timeout=20)
+        statuses = {}
+
+        if result.failed:
+            # Fallback to individual checks
+            for app_name in self.config.apps.values():
+                statuses[app_name] = self.check_app_status(app_name, use_cache=False)
+            return statuses
+
+        # Parse cf apps output
+        lines = result.stdout.split("\n")
+        for line in lines:
+            line = line.strip()
+            if not line or line.startswith("name") or line.startswith("Getting"):
+                continue
+
+            parts = line.split()
+            if len(parts) >= 3:  # name, requested state, processes
+                app_name = parts[0]
+                if app_name in self.config.apps.values():
+                    requested_state = parts[1].lower()  # "started" or "stopped"
+                    processes = parts[2] if len(parts) > 2 else ""  # e.g., "web:1/1" or "web:0/1"
+
+                    # Determine status based on requested state and process info
+                    if requested_state == "stopped":
+                        statuses[app_name] = "stopped"
+                    elif requested_state == "started":
+                        # Check if processes are actually running
+                        # Format is typically "web:1/1" where first number is running instances
+                        if ":" in processes:
+                            process_parts = processes.split(":")
+                            if len(process_parts) > 1:
+                                instance_info = process_parts[1].split("/")
+                                if len(instance_info) >= 2:
+                                    running_instances = instance_info[0]
+                                    try:
+                                        if int(running_instances) > 0:
+                                            statuses[app_name] = "running"
+                                        else:
+                                            statuses[app_name] = "stopped"  # Started but no running instances
+                                    except ValueError:
+                                        statuses[app_name] = "unknown"
+                                else:
+                                    statuses[app_name] = "unknown"
+                            else:
+                                statuses[app_name] = "unknown"
+                        else:
+                            # No process info, assume running if started
+                            statuses[app_name] = "running"
+                    else:
+                        statuses[app_name] = "unknown"
+
+        # Cache the results
+        import time
+
+        self._cache_timestamp = time.time()
+        self._app_status_cache.update(statuses)
+
+        # Fill in any missing apps with individual checks
+        for app_name in self.config.apps.values():
+            if app_name not in statuses:
+                statuses[app_name] = self.check_app_status(app_name, use_cache=False)
+
+        return statuses
+
+    def deploy_apps_if_needed(self) -> bool:
+        """Deploy apps only if they don't exist."""
+        success = True
+
+        # Check which apps already exist efficiently
+        app_exists = self.check_apps_exist()
+
+        # Find apps directory using the same logic as auto-detection
+        apps_base_path = None
+        possible_paths = [
+            os.path.join(os.getcwd(), "apps"),  # From testing dir
+            os.path.join(os.getcwd(), "..", "testing", "apps"),  # From framework dir
+            os.path.join(os.path.dirname(__file__), "..", "apps"),  # Relative to this file
+            os.path.join(os.path.dirname(__file__), "..", "..", "testing", "apps"),  # Up two levels
+        ]
+
+        for path in possible_paths:
+            if os.path.exists(path) and os.path.isdir(path):
+                apps_base_path = path
+                break
+
+        if not apps_base_path:
+            print("No apps directory found, cannot deploy apps")
+            return False
+
+        # Check and deploy each app if needed
+        apps_to_deploy = []
+        for app_key, app_name in self.config.apps.items():
+            # Check if app already exists
+            if not app_exists.get(app_name, False):
+                app_path = os.path.join(apps_base_path, app_key)
+                if os.path.exists(app_path):
+                    apps_to_deploy.append((app_key, app_name))
+                    print(f"๐Ÿš€ DEPLOY IF NEEDED: {app_name} needs deployment")
+                else:
+                    print(f"โŒ DEPLOY IF NEEDED: App directory not found: {app_path}")
+                    success = False
+            else:
+                print(f"โœ… DEPLOY IF NEEDED: {app_name} already exists, skipping deployment")
+
+        if apps_to_deploy:
+            print(f"Deploying {len(apps_to_deploy)} apps...")
+            for app_key, app_name in apps_to_deploy:
+                app_path = os.path.join(apps_base_path, app_key)
+                print(f"Deploying {app_name} from {app_path}")
+                result = self.runner.run_command(f"cd '{app_path}' && cf push --no-start", timeout=120)
+                if result.failed:
+                    print(f"Failed to deploy {app_name}: {result.stderr}")
+                    success = False
+                else:
+                    print(f"Successfully deployed {app_name}")
+
+        return success
+
+    def start_apps_if_needed(self) -> bool:
+        """Start apps only if they're not already running."""
+        success = True
+
+        # Get all app statuses at once for efficiency
+        app_statuses = self.check_all_apps_status()
+
+        apps_to_start = []
+        for app_name in self.config.apps.values():
+            status = app_statuses.get(app_name, "unknown")
+            if status != "running":
+                apps_to_start.append(app_name)
+                print(f"๐Ÿš€ START IF NEEDED: {app_name} is {status} โ†’ will start")
+            else:
+                print(f"โœ… START IF NEEDED: {app_name} is already running")
+
+        if apps_to_start:
+            print(f"๐Ÿš€ START IF NEEDED: Starting {len(apps_to_start)} apps...")
+            for app_name in apps_to_start:
+                print(f"๐Ÿš€ START IF NEEDED: Starting {app_name}")
+                result = self.runner.run_command(
+                    f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                )
+                if result.failed:
+                    print(f"โŒ START IF NEEDED FAILED: {app_name}: {result.stderr}")
+                    success = False
+                else:
+                    print(f"โœ… START IF NEEDED SUCCESS: {app_name}")
+        else:
+            print("โœ… START IF NEEDED: No apps need starting - all are already running")
+
+        return success
+
+    def start_apps_if_needed_parallel(self) -> bool:
+        """Start apps in parallel only if they're not already running."""
+        # Get all app statuses at once for efficiency
+        app_statuses = self.check_all_apps_status()
+
+        apps_to_start = []
+        for app_name in self.config.apps.values():
+            status = app_statuses.get(app_name, "unknown")
+            if status != "running":
+                apps_to_start.append(app_name)
+                print(f"๐Ÿš€ PARALLEL START IF NEEDED: {app_name} is {status} โ†’ will start")
+            else:
+                print(f"โœ… PARALLEL START IF NEEDED: {app_name} is already running")
+
+        if not apps_to_start:
+            print("โœ… PARALLEL START IF NEEDED: No apps need starting - all are already running")
+            return True
+
+        results = {}
+        threads = []
+
+        def start_single_app(app_name: str):
+            """Start a single app and store the result."""
+            try:
+                print(f"๐Ÿš€ PARALLEL START IF NEEDED: Starting start for {app_name}")
+                result = self.runner.run_command(
+                    f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                )
+                results[app_name] = result
+                if result.failed:
+                    print(f"โŒ PARALLEL START IF NEEDED FAILED: {app_name}: {result.stderr}")
+                else:
+                    print(f"โœ… PARALLEL START IF NEEDED SUCCESS: {app_name}")
+            except KeyboardInterrupt:
+                print(f"๐Ÿ›‘ PARALLEL START IF NEEDED CANCELLED: {app_name} (CTRL-C)")
+                results[app_name] = CommandResult(-1, "", "Cancelled by user", f"cf start {app_name}")
+
+        # Start all start operations in parallel
+        for app_name in apps_to_start:
+            thread = threading.Thread(target=start_single_app, args=(app_name,))
+            thread.start()
+            threads.append(thread)
+
+        # Wait for all operations to complete
+        for thread in threads:
+            thread.join()
+
+        # Check results and report any failures
+        success = True
+        for app_name, result in results.items():
+            if result.failed:
+                print(f"โŒ PARALLEL START IF NEEDED FAILED: {app_name}: {result.stderr}")
+                success = False
+
+        if success:
+            print(f"โœ… PARALLEL START IF NEEDED: All {len(apps_to_start)} operations completed successfully")
+
+        return success
+
+    def check_apps_exist(self) -> Dict[str, bool]:
+        """Check which apps exist in CF."""
+        result = self.runner.run_command("cf apps", timeout=20)
+        app_exists = {}
+
+        if result.failed:
+            # If cf apps fails, assume all apps don't exist
+            for app_name in self.config.apps.values():
+                app_exists[app_name] = False
+            return app_exists
+
+        # Parse cf apps output to see which apps exist
+        lines = result.stdout.split("\n")
+        existing_apps = set()
+
+        for line in lines:
+            line = line.strip()
+            if not line or line.startswith("name") or line.startswith("Getting"):
+                continue
+
+            parts = line.split()
+            if len(parts) >= 1:
+                app_name = parts[0]
+                existing_apps.add(app_name)
+
+        # Check each configured app
+        for app_name in self.config.apps.values():
+            app_exists[app_name] = app_name in existing_apps
+
+        return app_exists
+
+    def restart_apps_if_needed(self) -> bool:
+        """Restart apps only if they're not running or have crashed."""
+        print("๐Ÿง  SMART RESTART: Checking which apps need restart...")
+        success = True
+
+        # Get all app statuses at once for efficiency
+        app_statuses = self.check_all_apps_status()
+
+        apps_to_restart = []
+        apps_to_start = []
+
+        for app_name in self.config.apps.values():
+            status = app_statuses.get(app_name, "unknown")
+
+            if status == "running":
+                apps_to_restart.append(app_name)
+                print(f"๐Ÿ”„ SMART RESTART: {app_name} is running โ†’ will restart")
+            elif status in ["stopped", "crashed"]:
+                apps_to_start.append(app_name)
+                print(f"๐Ÿš€ SMART RESTART: {app_name} is {status} โ†’ will start")
+            else:
+                apps_to_restart.append(app_name)  # Unknown status, try restart
+                print(f"โ“ SMART RESTART: {app_name} status unknown โ†’ will restart")
+
+        if apps_to_restart:
+            print(f"๐Ÿ”„ SMART RESTART: Restarting {len(apps_to_restart)} running apps...")
+            for app_name in apps_to_restart:
+                print(f"๐Ÿ”„ SMART RESTART: Restarting {app_name}")
+                result = self.runner.run_command(
+                    f"cf restart {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                )
+                if result.failed:
+                    print(f"โŒ SMART RESTART FAILED: {app_name}: {result.stderr}")
+                    success = False
+                else:
+                    print(f"โœ… SMART RESTART SUCCESS: {app_name}")
+
+        if apps_to_start:
+            print(f"๐Ÿš€ SMART RESTART: Starting {len(apps_to_start)} stopped apps...")
+            for app_name in apps_to_start:
+                print(f"๐Ÿš€ SMART RESTART: Starting {app_name}")
+                result = self.runner.run_command(
+                    f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                )
+                if result.failed:
+                    print(f"โŒ SMART START FAILED: {app_name}: {result.stderr}")
+                    success = False
+                else:
+                    print(f"โœ… SMART START SUCCESS: {app_name}")
+
+        if not apps_to_restart and not apps_to_start:
+            print("โœ… SMART RESTART: No apps need restart - all are already running")
+
+        return success
+
+    def restart_apps_if_needed_parallel(self) -> bool:
+        """Restart apps in parallel only if they're not running or have crashed."""
+        print("๐Ÿš€ SMART PARALLEL RESTART: Checking which apps need restart...")
+
+        try:
+            # Get all app statuses at once for efficiency
+            app_statuses = self.check_all_apps_status()
+
+            apps_to_restart = []
+            apps_to_start = []
+
+            for app_name in self.config.apps.values():
+                status = app_statuses.get(app_name, "unknown")
+
+                if status == "running":
+                    apps_to_restart.append(app_name)
+                    print(f"๐Ÿ”„ SMART PARALLEL: {app_name} is running โ†’ will restart")
+                elif status in ["stopped", "crashed"]:
+                    apps_to_start.append(app_name)
+                    print(f"๐Ÿš€ SMART PARALLEL: {app_name} is {status} โ†’ will start")
+                else:
+                    apps_to_restart.append(app_name)  # Unknown status, try restart
+                    print(f"โ“ SMART PARALLEL: {app_name} status unknown โ†’ will restart")
+
+            if not apps_to_restart and not apps_to_start:
+                print("โœ… SMART PARALLEL: No apps need restart - all are already running")
+                return True
+
+            total_ops = len(apps_to_restart) + len(apps_to_start)
+            print(f"๐Ÿš€ SMART PARALLEL: Starting {total_ops} operations in parallel...")
+
+            results = {}
+            threads = []
+
+            def restart_single_app(app_name: str):
+                """Restart a single app and store the result."""
+                try:
+                    print(f"๐Ÿ”„ SMART PARALLEL: Starting restart for {app_name}")
+                    result = self.runner.run_command(
+                        f"cf restart {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                    )
+                    results[app_name] = ("restart", result)
+                    if result.failed:
+                        print(f"โŒ SMART PARALLEL RESTART FAILED: {app_name}: {result.stderr}")
+                    else:
+                        print(f"โœ… SMART PARALLEL RESTART SUCCESS: {app_name}")
+                except KeyboardInterrupt:
+                    print(f"๐Ÿ›‘ SMART PARALLEL RESTART CANCELLED: {app_name} (CTRL-C)")
+                    results[app_name] = (
+                        "restart",
+                        CommandResult(-1, "", "Cancelled by user", f"cf restart {app_name}"),
+                    )
+
+            def start_single_app(app_name: str):
+                """Start a single app and store the result."""
+                try:
+                    print(f"๐Ÿš€ SMART PARALLEL: Starting start for {app_name}")
+                    result = self.runner.run_command(
+                        f"cf start {app_name}", timeout=self.config.config["timeouts"]["app_start"]
+                    )
+                    results[app_name] = ("start", result)
+                    if result.failed:
+                        print(f"โŒ SMART PARALLEL START FAILED: {app_name}: {result.stderr}")
+                    else:
+                        print(f"โœ… SMART PARALLEL START SUCCESS: {app_name}")
+                except KeyboardInterrupt:
+                    print(f"๐Ÿ›‘ SMART PARALLEL START CANCELLED: {app_name} (CTRL-C)")
+                    results[app_name] = ("start", CommandResult(-1, "", "Cancelled by user", f"cf start {app_name}"))
+
+            # Start all restart operations in parallel
+            if apps_to_restart:
+                for app_name in apps_to_restart:
+                    thread = threading.Thread(target=restart_single_app, args=(app_name,))
+                    thread.start()
+                    threads.append(thread)
+
+            # Start all start operations in parallel
+            if apps_to_start:
+                for app_name in apps_to_start:
+                    thread = threading.Thread(target=start_single_app, args=(app_name,))
+                    thread.start()
+                    threads.append(thread)
+
+            # Wait for all operations to complete
+            for thread in threads:
+                thread.join()
+
+            # Check results and report any failures
+            success = True
+            cancelled_count = 0
+            for app_name, (operation, result) in results.items():
+                if result.failed:
+                    success = False
+                    if "Cancelled by user" in result.stderr:
+                        cancelled_count += 1
+
+            if cancelled_count > 0:
+                print(f"๐Ÿ›‘ SMART PARALLEL RESTART: {cancelled_count} operations cancelled by user")
+
+            return success
+        except Exception as e:
+            print(f"โŒ SMART PARALLEL RESTART: Failed to restart apps: {e}")
+            return False
+
+    @classmethod
+    def reset_global_login_state(cls):
+        """Reset the global login state (for testing)."""
+        with cls._lock:
+            default_state = {"logged_in": False, "login_config": {}, "login_timestamp": 0.0}
+            cls._save_login_state(default_state)
+        print("๐Ÿ”— LOGIN: Global login state reset")
+
+    @classmethod
+    def get_global_login_info(cls) -> str:
+        """Get information about the current global login state."""
+        state = cls._load_login_state()
+        if state["logged_in"]:
+            import time
+
+            timestamp = state["login_timestamp"]
+            config = state["login_config"]
+
+            if isinstance(timestamp, (int, float)) and isinstance(config, dict):
+                login_age = time.time() - timestamp
+                return (
+                    f"Logged in as {config.get('username', 'unknown')} @ "
+                    f"{config.get('api_endpoint', 'unknown')} for {login_age:.0f}s"
+                )
+            else:
+                return "Logged in (invalid state data)"
+        else:
+            return "Not logged in"
+
+    @classmethod
+    def add_deferred_restart_app(cls, app_name: str, test_class: str = "Unknown", reason: str = "no_restart=True"):
+        """Add an app to the deferred restart list (due to no_restart=True test)."""
+        with cls._lock:
+            restart_data = cls._load_restart_state()
+            restart_entries = restart_data.get("restart_entries", [])
+
+            # Check if app is already in the list for this test class
+            existing_entry = next(
+                (entry for entry in restart_entries if entry["app"] == app_name and entry["test_class"] == test_class),
+                None,
+            )
+
+            if not existing_entry:
+                restart_entries.append(
+                    {"app": app_name, "test_class": test_class, "reason": reason, "timestamp": time.time()}
+                )
+                cls._save_restart_state(restart_entries)
+
+        print(f"๐Ÿšซโžก๏ธ DEFERRED RESTART: Tracking {app_name} for later restart (from {test_class})")
+
+    @classmethod
+    def get_deferred_restart_apps(cls) -> set:
+        """Get the set of apps that need deferred restarts."""
+        restart_data = cls._load_restart_state()
+        restart_entries = restart_data.get("restart_entries", [])
+        return set(entry["app"] for entry in restart_entries)
+
+    @classmethod
+    def get_deferred_restart_details(cls) -> List[Dict]:
+        """Get detailed information about deferred restarts including test class names."""
+        restart_data = cls._load_restart_state()
+        return restart_data.get("restart_entries", [])
+
+    @classmethod
+    def clear_deferred_restart_apps(cls):
+        """Clear the deferred restart apps list."""
+        with cls._lock:
+            restart_data = cls._load_restart_state()
+            restart_entries = restart_data.get("restart_entries", [])
+            if restart_entries:
+                apps_list = [entry["app"] for entry in restart_entries]
+                cls._save_restart_state([])
+                print(f"๐Ÿงน DEFERRED RESTART: Cleared deferred restart list: {apps_list}")
+            else:
+                print("๐Ÿงน DEFERRED RESTART: No apps in deferred restart list")
+
+    @classmethod
+    def has_deferred_restarts(cls) -> bool:
+        """Check if there are any apps pending deferred restart."""
+        restart_data = cls._load_restart_state()
+        restart_entries = restart_data.get("restart_entries", [])
+        return bool(restart_entries)
+
+    def process_deferred_restarts(self, restart_mode: str = "smart_parallel") -> bool:
+        """Process any deferred restarts before proceeding with the current test."""
+        with self._lock:
+            restart_data = self._load_restart_state()
+            restart_entries = restart_data.get("restart_entries", [])
+            if not restart_entries:
+                return True
+
+            apps_to_restart = [entry["app"] for entry in restart_entries]
+            test_classes = [entry["test_class"] for entry in restart_entries]
+            print(
+                f"๐Ÿ”„โžก๏ธ DEFERRED RESTART: Processing deferred restarts for apps: {apps_to_restart}"
+                f"(from test classes: {set(test_classes)})"
+            )
+
+            # Clear the deferred list before attempting restarts
+            self._save_restart_state([])
+
+        # Perform the actual restart based on mode
+        try:
+            if restart_mode == "smart_parallel":
+                print("๐Ÿš€ DEFERRED RESTART: Using smart parallel restart")
+                return self.restart_apps_if_needed_parallel()
+            elif restart_mode == "smart":
+                print("๐Ÿง  DEFERRED RESTART: Using smart restart")
+                return self.restart_apps_if_needed()
+            elif restart_mode == "parallel":
+                print("๐Ÿ”„ DEFERRED RESTART: Using parallel restart")
+                return self.restart_apps_parallel()
+            elif restart_mode == "always":
+                print("๐Ÿ”„ DEFERRED RESTART: Using always restart")
+                return self.restart_apps()
+            else:
+                print("๐Ÿš€ DEFERRED RESTART: Using default smart parallel restart")
+                return self.restart_apps_if_needed_parallel()
+        except Exception as e:
+            print(f"โŒ DEFERRED RESTART: Failed to process deferred restarts: {e}")
+            return False
+
+    @classmethod
+    def cleanup_state_files(cls):
+        """Clean up temporary state files (call at end of test run)."""
+        login_file, restart_file = cls._get_state_files()
+
+        # Clean up login state file
+        if login_file and os.path.exists(login_file):
+            try:
+                os.unlink(login_file)
+            except Exception:
+                pass
+
+        # Clean up restart state file
+        if restart_file and os.path.exists(restart_file):
+            try:
+                os.unlink(restart_file)
+            except Exception:
+                pass
+
+        # Reset file paths so they'll be recreated if needed
+        cls._login_state_file = None
+        cls._restart_state_file = None
+
+    def restart_single_app(self, app_name: str) -> bool:
+        """Restart a single specific application."""
+        print(f"๐Ÿ”„ SINGLE APP RESTART: Restarting {app_name}")
+        result = self.runner.run_command(f"cf restart {app_name}", timeout=self.config.config["timeouts"]["app_start"])
+        if result.failed:
+            print(f"โš ๏ธ SINGLE APP RESTART: Failed to restart {app_name}: {result.stderr}")
+            return False
+        print(f"โœ… SINGLE APP RESTART: Successfully restarted {app_name}")
+        return True
+
+    def restart_single_app_if_needed(self, app_name: str) -> bool:
+        """Restart a single app only if it's not running or unhealthy."""
+        print(f"๐Ÿ”„ SMART SINGLE RESTART: Checking if {app_name} needs restart")
+
+        # Check if app is running and healthy
+        result = self.runner.run_command("cf apps")
+        if result.failed:
+            print("โš ๏ธ SMART SINGLE RESTART: Failed to check app status")
+            return False
+
+        # Parse the apps output to check status
+        lines = result.stdout.strip().split("\n")
+        app_found = False
+        needs_restart = True
+
+        for line in lines:
+            if app_name in line:
+                app_found = True
+                # Check if app is running (look for "started" state)
+                if "started" in line.lower():
+                    print(f"โœ… SMART SINGLE RESTART: {app_name} is already running, no restart needed")
+                    needs_restart = False
+                else:
+                    print(f"๐Ÿ”„ SMART SINGLE RESTART: {app_name} is not running, restart needed")
+                break
+
+        if not app_found:
+            print(f"โš ๏ธ SMART SINGLE RESTART: {app_name} not found in app list")
+            return False
+
+        if needs_restart:
+            return self.restart_single_app(app_name)
+
+        return True
+
+    def needs_initial_restart(self, app_name: str) -> bool:
+        """Check if an app needs initial restart for this session."""
+        restart_behavior = os.environ.get("RESTART_APPS", "smart_parallel").lower()
+
+        # If restart is disabled, never restart
+        if restart_behavior == "never":
+            return False
+
+        # Check if app has already been initially restarted in this session
+        return app_name not in self._initially_restarted_apps
+
+    def mark_app_initially_restarted(self, app_name: str):
+        """Mark an app as having been initially restarted in this session."""
+        self._initially_restarted_apps.add(app_name)
+        print(f"๐Ÿ“ SESSION TRACKING: Marked {app_name} as initially restarted")
+
+    def restart_single_app_with_initial_check(self, app_name: str) -> bool:
+        """Restart a single app, handling initial restart logic."""
+        if self.needs_initial_restart(app_name):
+            print(f"๐Ÿ”„ INITIAL RESTART: First use of {app_name} in this session - performing initial restart")
+            success = self.restart_single_app(app_name)
+            if success:
+                self.mark_app_initially_restarted(app_name)
+            return success
+        else:
+            print(f"โœ… INITIAL RESTART: {app_name} already restarted in this session, performing smart restart")
+            return self.restart_single_app_if_needed(app_name)
+
+    def restart_single_app_if_needed_with_initial_check(self, app_name: str) -> bool:
+        """Smart restart a single app, handling initial restart logic."""
+        if self.needs_initial_restart(app_name):
+            print(f"๐Ÿ”„ INITIAL SMART RESTART: First use of {app_name} in this session - ensuring it's running")
+            # For initial restart, we want to ensure the app is definitely restarted
+            # even if it appears to be running, to guarantee fresh state
+            success = self.restart_single_app(app_name)
+            if success:
+                self.mark_app_initially_restarted(app_name)
+            return success
+        else:
+            print(f"โœ… INITIAL SMART RESTART: {app_name} already restarted in this session, performing smart check")
+            return self.restart_single_app_if_needed(app_name)
diff --git a/test/framework/decorators.py b/test/framework/decorators.py
new file mode 100644
index 0000000..0d4a385
--- /dev/null
+++ b/test/framework/decorators.py
@@ -0,0 +1,269 @@
+"""
+Test decorators and annotations for CF Java Plugin testing.
+"""
+
+import fnmatch
+import json
+import os
+import sys
+from pathlib import Path
+from typing import List
+
+import pytest
+
+
+def test(*apps, no_restart=False):
+    """Test decorator.
+
+    Usage:
+        @test()
+        @test(no_restart=True)  # Skip app restart after test
+
+    Args:
+        *apps: App names to test on, defaults to "sapmachine21"
+        no_restart: If True, skip app restart after test
+    """
+
+    # Determine which apps to test
+    if "all" in apps:
+        test_apps = get_available_apps()
+    elif not apps:
+        # If no apps specified, default to sapmachine21
+        test_apps = ["sapmachine21"]
+    else:
+        # Use the provided apps directly
+        test_apps = list(apps)
+
+    print(f"๐Ÿ” TEST DECORATOR: Running tests for apps: {test_apps}  ")
+
+    def decorator(test_func):
+        # Create a wrapper that matches pytest's expected signature
+        def wrapper(self, app):  # pytest provides these parameters
+            # Check if test should be skipped due to previous success
+            if should_skip_successful_test(test_func.__name__, app):
+                pytest.skip(f"Skipping previously successful test: {test_func.__name__}[{app}]")
+
+            # Check test selection patterns
+            selection_patterns = get_test_selection_patterns()
+            if selection_patterns and not match_test_patterns(test_func.__name__, selection_patterns):
+                pytest.skip(f"Test {test_func.__name__} not in selection patterns: {selection_patterns}")
+
+            # Environment filtering (TESTS variable)
+            if test_filter := os.environ.get("TESTS", "").strip():
+                patterns = [p.strip() for p in test_filter.split(",")]
+                if not any(p in test_func.__name__ for p in patterns):
+                    pytest.skip(f"Filtered by TESTS={test_filter}")
+
+            # Execute test with DSL - import here to avoid circular imports
+            from .dsl import test_cf_java
+
+            # Track cleanup needs
+            should_restart = True
+            cleanup_files = []
+            test_passed = False
+
+            try:
+                # Execute test with DSL
+                with self.runner.create_test_context(app) as ctx:
+                    # Track files before test execution
+                    import glob
+
+                    initial_files = set(glob.glob("*"))
+
+                    # Create the DSL instance (this becomes the 't' parameter)
+                    t = test_cf_java(self.runner, ctx, test_func.__name__)
+
+                    # Call test function with standard parameters
+                    test_func(self, t, app)
+
+                    # Determine if restart is needed
+                    # --restart flag forces restart, otherwise use decorator parameter
+                    force_restart = should_force_restart()
+                    should_restart = force_restart or not no_restart
+
+                    # Find files created during test
+                    final_files = set(glob.glob("*"))
+                    cleanup_files = list(final_files - initial_files)
+
+                    # Mark test as passed if we get here
+                    test_passed = True
+
+            except Exception as e:
+                # Emit error details before restart
+                print(f"โŒ TEST FAILED: {test_func.__name__}[{app}] - {type(e).__name__}: {str(e)}")
+
+                # Always restart on test failure
+                should_restart = True
+                raise
+            finally:
+                # Mark test as successful if it passed
+                if test_passed:
+                    mark_test_successful(test_func.__name__, app)
+
+                # Clean up created files and folders
+                if cleanup_files:
+                    import shutil
+                    import time
+
+                    for item in cleanup_files:
+                        try:
+                            if os.path.isfile(item):
+                                os.remove(item)
+                                print(f"Cleaned up file: {item}")
+                            elif os.path.isdir(item):
+                                shutil.rmtree(item)
+                                print(f"Cleaned up directory: {item}")
+                        except Exception as cleanup_error:
+                            print(f"Warning: Could not clean up {item}: {cleanup_error}")
+
+                    # Wait one second after cleanup
+                    time.sleep(1)
+
+                # Handle app restart if needed
+                if should_restart and hasattr(self, "runner"):
+                    try:
+                        print(f"๐Ÿ”„ TEST DECORATOR: Test requires restart for {app}")
+                        # Check if we have a CF manager for restart operations
+                        if hasattr(self, "session") and hasattr(self.session, "cf_manager"):
+                            # Use the session's CF manager for restart
+                            cf_manager = self.session.cf_manager
+                            restart_mode = os.environ.get("RESTART_APPS", "smart_parallel").lower()
+
+                            # First, process any deferred restarts from previous no_restart=True tests
+                            # Only do this when we're actually restarting (should_restart=True)
+                            if cf_manager.has_deferred_restarts():
+                                print(f"๐Ÿ”„โžก๏ธ TEST DECORATOR: Processing deferred restarts before {app}")
+                                if not cf_manager.process_deferred_restarts(restart_mode):
+                                    print(f"โš ๏ธ TEST DECORATOR: Deferred restart failed for {app}")
+
+                            # Now perform the normal restart for just this specific app
+                            if restart_mode == "smart_parallel" or restart_mode == "smart":
+                                print(f"๐Ÿง  TEST DECORATOR: Using smart restart for {app} only")
+                                if not cf_manager.restart_single_app_if_needed(app):
+                                    print(f"โš ๏ธ TEST DECORATOR: Smart restart failed for {app}")
+                            elif restart_mode == "parallel" or restart_mode == "always":
+                                print(f"๐Ÿ”„ TEST DECORATOR: Using direct restart for {app} only")
+                                if not cf_manager.restart_single_app(app):
+                                    print(f"โš ๏ธ TEST DECORATOR: Direct restart failed for {app}")
+                            else:
+                                print(f"๐Ÿง  TEST DECORATOR: Using default smart restart for {app} only")
+                                if not cf_manager.restart_single_app_if_needed(app):
+                                    print(f"โš ๏ธ TEST DECORATOR: Default restart failed for {app}")
+                        else:
+                            print(f"โš ๏ธ TEST DECORATOR: No CF manager available for restart of {app}")
+                    except Exception as restart_error:
+                        print(f"โŒ TEST DECORATOR: Could not restart app {app}: {restart_error}")
+                else:
+                    if not should_restart:
+                        print(f"๐Ÿšซ TEST DECORATOR: Skipping restart for {app} (no_restart=True)")
+                        # Only add to deferred restart list if there are more tests coming
+                        # If this is the last test in the session, don't bother deferring
+                        if hasattr(self, "session") and hasattr(self.session, "cf_manager"):
+                            # For now, always skip adding to deferred restart to prevent end-of-session restarts
+                            print(
+                                f"๐Ÿšซ TEST DECORATOR: Not adding {app} to deferred restart list"
+                                "to prevent unnecessary restarts"
+                            )
+                        else:
+                            print("โš ๏ธ TEST DECORATOR: Cannot track deferred restart - no CF manager available")
+                    else:
+                        print(f"โš ๏ธ TEST DECORATOR: No runner available for restart of {app}")
+
+        # Preserve ALL original function metadata before applying parametrize
+        wrapper.__name__ = test_func.__name__
+        wrapper.__doc__ = test_func.__doc__
+        wrapper.__qualname__ = getattr(test_func, "__qualname__", test_func.__name__)
+        wrapper.__module__ = test_func.__module__
+        wrapper.__annotations__ = getattr(test_func, "__annotations__", {})
+
+        # Apply parametrize decorator
+        parametrized_wrapper = pytest.mark.parametrize("app", test_apps, ids=lambda app: f"{app}")(wrapper)
+
+        # Preserve metadata on the final result as well
+        parametrized_wrapper.__name__ = test_func.__name__
+        parametrized_wrapper.__doc__ = test_func.__doc__
+        parametrized_wrapper.__qualname__ = getattr(test_func, "__qualname__", test_func.__name__)
+        parametrized_wrapper.__module__ = test_func.__module__
+        parametrized_wrapper.__annotations__ = getattr(test_func, "__annotations__", {})
+
+        return parametrized_wrapper
+
+    return decorator
+
+
+def get_available_apps() -> List[str]:
+    """Get a list of available apps for testing, based on the apps folder"""
+    return [app.name for app in Path("apps").iterdir() if app.is_dir() and not app.name.startswith(".")]
+
+
+# Test tracking and selection utilities
+SUCCESS_CACHE_FILE = ".test_success_cache.json"
+
+
+def load_success_cache():
+    """Load successful test cache from file."""
+    try:
+        if os.path.exists(SUCCESS_CACHE_FILE):
+            with open(SUCCESS_CACHE_FILE, "r") as f:
+                return json.load(f)
+    except Exception:
+        pass
+    return {}
+
+
+def save_success_cache(cache):
+    """Save successful test cache to file."""
+    try:
+        with open(SUCCESS_CACHE_FILE, "w") as f:
+            json.dump(cache, f, indent=2)
+    except Exception:
+        pass
+
+
+def should_skip_successful_test(test_name, app):
+    """Check if we should skip a test that was previously successful."""
+    # Check for --skip-successful flag
+    if "--skip-successful" not in sys.argv:
+        return False
+
+    cache = load_success_cache()
+    test_key = f"{test_name}[{app}]"
+    return test_key in cache
+
+
+def mark_test_successful(test_name, app):
+    """Mark a test as successful in the cache."""
+    cache = load_success_cache()
+    test_key = f"{test_name}[{app}]"
+    cache[test_key] = True
+    save_success_cache(cache)
+
+
+def match_test_patterns(test_name, patterns):
+    """Check if test name matches any of the glob patterns."""
+    if not patterns:
+        return True
+
+    for pattern in patterns:
+        if fnmatch.fnmatch(test_name, pattern):
+            return True
+    return False
+
+
+def get_test_selection_patterns():
+    """Get test selection patterns from command line."""
+    # Look for --select-tests argument
+    args = sys.argv
+    try:
+        idx = args.index("--select-tests")
+        if idx + 1 < len(args):
+            patterns_str = args[idx + 1]
+            return [p.strip() for p in patterns_str.split(",")]
+    except ValueError:
+        pass
+    return []
+
+
+def should_force_restart():
+    """Check if --restart flag is present to force restart after every test."""
+    return "--restart" in sys.argv
diff --git a/test/framework/dsl.py b/test/framework/dsl.py
new file mode 100644
index 0000000..1f4c56d
--- /dev/null
+++ b/test/framework/dsl.py
@@ -0,0 +1,575 @@
+"""
+Fluent DSL for CF Java Plugin testing.
+Provides a clean, readable interface for test assertions.
+"""
+
+import glob
+import re
+import time
+from typing import TYPE_CHECKING, Dict, List, Optional
+
+from .core import CFJavaTestRunner, CommandResult, TestContext
+
+if TYPE_CHECKING:
+    from .file_validators import FileType
+
+
+def is_ssh_auth_error(output: str) -> tuple[bool, Optional[str]]:
+    """
+    Check if the given output contains SSH authentication errors.
+
+    Returns:
+        tuple: (is_auth_error: bool, detected_error: Optional[str])
+    """
+    ssh_auth_errors = [
+        "Error getting one time auth code: Error getting SSH code: Error requesting one time code from server:",
+        "Error getting one time auth code",
+        "Error getting SSH code",
+        "Authentication failed",
+        "SSH authentication failed",
+        "Error opening SSH connection: ssh: handshake failed",
+    ]
+
+    for error_pattern in ssh_auth_errors:
+        if error_pattern in output:
+            return True, error_pattern
+
+    return False, None
+
+
+class FluentAssertion:
+    """Fluent assertion interface for test results."""
+
+    def __init__(self, result: CommandResult, context: TestContext, runner: CFJavaTestRunner):
+        self.result = result
+        self.context = context
+        self.runner = runner
+        self._remote_files_before: Optional[Dict] = None
+        self._test_name: Optional[str] = None
+        self._command_name: Optional[str] = None
+
+    # Command execution assertions
+    def should_succeed(self) -> "FluentAssertion":
+        """Assert that the command succeeded."""
+        if self.result.failed:
+            # Check for SSH auth errors that should trigger re-login and restart
+            output_to_check = self.result.stderr + " " + self.result.stdout
+            ssh_error_detected, detected_error = is_ssh_auth_error(output_to_check)
+            print("๐Ÿ” Checking for SSH auth errors in command output...")
+            print("output_to_check:", output_to_check)
+            print("ssh_error_detected:", ssh_error_detected)
+            if ssh_error_detected:
+                import pytest
+
+                print(f"๐Ÿ”„ SSH AUTH ERROR DETECTED: {detected_error}")
+                print("๐Ÿ”„ SSH AUTH ERROR DETECTED: Attempting re-login and app restart")
+
+                # Try to recover by re-logging in and restarting the app
+                try:
+                    # Force re-login by clearing login state
+                    from .core import CFManager
+
+                    CFManager.reset_global_login_state()
+
+                    time.sleep(5)
+
+                    # Re-login
+                    cf_manager = CFManager(self.runner.config)
+                    login_success = cf_manager.login()
+
+                    if login_success:
+                        print("โœ… SSH AUTH RECOVERY: Successfully re-logged in")
+
+                        # Restart the app
+                        if hasattr(self.context, "app_name") and self.context.app_name:
+                            restart_success = cf_manager.restart_single_app(self.context.app_name)
+                            if restart_success:
+                                print(f"โœ… SSH AUTH RECOVERY: Successfully restarted {self.context.app_name}")
+
+                                # Re-run the original command
+                                print(f"๐Ÿ”„ SSH AUTH RECOVERY: Retrying original command: {self.result.command}")
+                                retry_result = self.runner.run_command(
+                                    self.result.command, app_name=self.context.app_name
+                                )
+
+                                # Replace the result with the retry result
+                                self.result = retry_result
+
+                                # Check if retry succeeded
+                                if not retry_result.failed:
+                                    print("โœ… SSH AUTH RECOVERY: Command succeeded after recovery")
+                                    return self
+                                else:
+                                    print(
+                                        "โŒ SSH AUTH RECOVERY: Command still failed after recovery: "
+                                        f"{retry_result.stderr}"
+                                    )
+                            else:
+                                print(f"โŒ SSH AUTH RECOVERY: Failed to restart {self.context.app_name}")
+                        else:
+                            print("โŒ SSH AUTH RECOVERY: No app name available for restart")
+                    else:
+                        print("โŒ SSH AUTH RECOVERY: Failed to re-login")
+
+                except Exception as e:
+                    print(f"โŒ SSH AUTH RECOVERY: Exception during recovery: {e}")
+
+                # If we get here, recovery failed or retry failed, so skip the test
+                pytest.skip(f"Test skipped due to SSH auth error (CF platform issue): {detected_error}")
+
+            raise AssertionError(
+                f"Expected command to succeed, but it failed with code {self.result.returncode}:\n"
+                f"Command: {self.result.command}\n"
+                f"Error: {self.result.stderr}\n"
+                f"Output: {self.result.stdout[:1000]}"  # Show first 1000 chars of output
+            )
+        return self
+
+    def should_fail(self) -> "FluentAssertion":
+        """Assert that the command failed."""
+        if self.result.success:
+            raise AssertionError(
+                f"Expected command to fail, but it succeeded:\n"
+                f"Command: {self.result.command}\n"
+                f"Output: {self.result.stdout}"
+            )
+        return self
+
+    # Output content assertions
+    def should_contain(self, text: str, ignore_case: bool = False) -> "FluentAssertion":
+        """Assert that output contains specific text."""
+        output = self.result.output if not ignore_case else self.result.output.lower()
+        text = text if not ignore_case else text.lower()
+        if text not in output:
+            raise AssertionError(
+                f"Expected output to contain '{text}', but it didn't:\n"
+                f"Actual output: {self.result.output[:1000]}..."
+            )
+        return self
+
+    def should_not_contain(self, text: str, ignore_case: bool = False) -> "FluentAssertion":
+        """Assert that output doesn't contain specific text."""
+        output = self.result.output if not ignore_case else self.result.output.lower()
+        text = text if not ignore_case else text.lower()
+        if text in output:
+            raise AssertionError(
+                f"Expected output NOT to contain '{text}', but it did:\n"
+                f"Actual output: {self.result.output[:1000]}..."
+            )
+        return self
+
+    def should_start_with(self, text: str, ignore_case: bool = False) -> "FluentAssertion":
+        """Assert that output starts with specific text."""
+        output = self.result.output if not ignore_case else self.result.output.lower()
+        text = text if not ignore_case else text.lower()
+        if not output.startswith(text):
+            raise AssertionError(
+                f"Expected output to start with '{text}', but it didn't:\n"
+                f"Actual output: {self.result.output[:1000]}..."
+            )
+        return self
+
+    def should_match(self, pattern: str) -> "FluentAssertion":
+        """Assert that output matches regex pattern."""
+        if not re.search(pattern, self.result.output, re.MULTILINE | re.DOTALL):
+            raise AssertionError(
+                f"Expected output to match pattern '{pattern}', but it didn't:\n"
+                f"Actual output: {self.result.output[:1000]}..."
+            )
+        return self
+
+    def should_have_at_least(self, min_lines: int, description: str = "lines") -> "FluentAssertion":
+        """Assert minimum line count."""
+        lines = self.result.output.split("\n")
+        actual_lines = len([line for line in lines if line.strip()])
+        if actual_lines < min_lines:
+            raise AssertionError(f"Expected at least {min_lines} {description}, but got {actual_lines}")
+        return self
+
+    # File assertions
+    def should_create_file(self, pattern: str, validate_as: Optional["FileType"] = None) -> "FluentAssertion":
+        """Assert that a file matching pattern was created locally.
+
+        Args:
+            pattern: Glob pattern to match created files
+            validate_as: Optional file type validation (FileType.HEAP_DUMP, FileType.JFR, etc.)
+        """
+        files = glob.glob(pattern)
+        if not files:
+            all_files = list(self.context.new_files)
+            raise AssertionError(
+                f"Expected file matching '{pattern}' to be created, but none found.\n" f"Files created: {all_files}"
+            )
+
+        # If validation is requested, validate the file
+        if validate_as is not None:
+            from .file_validators import validate_generated_file
+
+            try:
+                validate_generated_file(pattern, validate_as)
+            except Exception as e:
+                raise AssertionError(f"File validation failed: {e}")
+
+        return self
+
+    def should_create_no_files(self) -> "FluentAssertion":
+        """Assert that no local files were created."""
+        if self.context.new_files:
+            raise AssertionError(f"Expected no files to be created, but found: {list(self.context.new_files)}")
+        return self
+
+    def should_not_create_file(self, pattern: str = ".*") -> "FluentAssertion":
+        """Assert that no file matching pattern was created."""
+        files = glob.glob(pattern)
+        if files:
+            raise AssertionError(f"Expected no file matching '{pattern}', but found: {files}")
+        return self
+
+    # Remote file assertions
+    def should_create_no_remote_files(self) -> "FluentAssertion":
+        """Assert that no new files were left on the remote container after the command."""
+        if self._remote_files_before is None:
+            # If no before state was captured, we can't reliably detect new files
+            # This is a limitation - we should warn about this
+            print(
+                "Warning: should_create_no_remote_files() called without before state;"
+                "cannot reliably detect new files"
+            )
+            return self
+        else:
+            # Capture current state and compare
+            after_state = self.runner.capture_remote_file_state(self.context.app_name)
+            new_files = self.runner.compare_remote_file_states(self._remote_files_before, after_state)
+
+            if new_files:
+                # Format the error message nicely
+                error_parts = []
+                for directory, files in new_files.items():
+                    error_parts.append(f"  {directory}: {files}")
+                error_msg = "New files left on remote after command execution:\n" + "\n".join(error_parts)
+                raise AssertionError(error_msg)
+
+        return self
+
+    def _get_recursive_files(self, folder: str) -> List[str]:
+        """Get all files recursively from a remote folder."""
+        # Use find command for recursive file listing
+        cmd = f"cf ssh {self.context.app_name} -c 'find {folder} -type f 2>/dev/null || echo NO_DIRECTORY'"
+        result = self.runner.run_command(cmd, app_name=self.context.app_name, timeout=15)
+
+        if result.success:
+            output = result.stdout.strip()
+            if output == "NO_DIRECTORY" or not output:
+                return []
+            else:
+                # Return full file paths relative to the base folder
+                files = [f.strip() for f in output.split("\n") if f.strip()]
+                return files
+        else:
+            return []
+
+    def should_create_remote_file(
+        self, file_pattern: str = None, file_extension: str = None, folder: str = "/tmp", absolute_path: str = None
+    ) -> "FluentAssertion":
+        """Assert that a remote file exists.
+
+        Can work in two modes:
+        1. Search mode: Searches the specified folder recursively for files matching pattern/extension
+        2. Absolute path mode: Check if a specific absolute file path exists
+
+        Args:
+            file_pattern: Glob pattern to match file names (e.g., "*.jfr", "heap-dump-*")
+            file_extension: File extension to match (e.g., ".jfr", ".hprof")
+            folder: Remote folder to check (default: "/tmp") - ignored if absolute_path is provided
+            absolute_path: Absolute path to a specific file to check for existence
+        """
+        # If absolute_path is provided, check that specific file
+        if absolute_path:
+            cmd = (
+                f'cf ssh {self.context.app_name} -c \'test -f "{absolute_path}" && echo "EXISTS" || echo "NOT_FOUND"\''
+            )
+            result = self.runner.run_command(cmd, app_name=self.context.app_name, timeout=15)
+
+            if result.success and "EXISTS" in result.stdout:
+                return self
+            else:
+                # Try to provide helpful debugging info
+                parent_dir = "/".join(absolute_path.split("/")[:-1]) if "/" in absolute_path else "/"
+
+                # List files in parent directory
+                debug_cmd = (
+                    f'cf ssh {self.context.app_name} -c \'ls -la "{parent_dir}" 2>/dev/null || '
+                    'echo "DIRECTORY_NOT_FOUND"\''
+                )
+                debug_result = self.runner.run_command(debug_cmd, app_name=self.context.app_name, timeout=15)
+
+                error_msg = f"Expected remote file '{absolute_path}' to exist, but it doesn't."
+
+                if debug_result.success and "DIRECTORY_NOT_FOUND" not in debug_result.stdout:
+                    files_in_dir = [line.strip() for line in debug_result.stdout.split("\n") if line.strip()]
+                    error_msg += f"\nFiles in directory '{parent_dir}':\n"
+                    for file_line in files_in_dir[:20]:  # Show first 20 files
+                        error_msg += f"  {file_line}\n"
+                    if len(files_in_dir) > 20:
+                        error_msg += f"  ... and {len(files_in_dir) - 20} more files"
+                else:
+                    error_msg += f"\nParent directory '{parent_dir}' does not exist or is not accessible."
+
+                raise AssertionError(error_msg)
+
+        # Original search mode logic
+        # Check if folder is supported
+        all_folders = {"tmp": "/tmp", "home": "$HOME", "app": "$HOME/app"}
+        if folder not in all_folders.values():
+            raise ValueError(f"Unsupported folder '{folder}'. Supported folders: /tmp, $HOME, $HOME/app")
+
+        # Get all files recursively from the specified folder
+        all_files = self._get_recursive_files(folder)
+
+        # Find matching files based on criteria
+        matching_files = []
+
+        for file_path in all_files:
+            file_name = file_path.split("/")[-1]
+            match = True
+
+            # Check file pattern
+            if file_pattern:
+                import fnmatch
+
+                if not fnmatch.fnmatch(file_name, file_pattern):
+                    match = False
+
+            # Check file extension
+            if file_extension and not file_name.endswith(file_extension):
+                match = False
+
+            if match:
+                matching_files.append(file_path)
+
+        if not matching_files:
+            # Search across all other folders recursively
+            found_elsewhere = {}
+
+            for search_folder in all_folders.values():
+                if search_folder == folder:
+                    continue  # Skip the folder we already searched
+
+                search_files = self._get_recursive_files(search_folder)
+
+                for file_path in search_files:
+                    file_name = file_path.split("/")[-1]
+                    match = True
+
+                    # Apply same criteria checks
+                    if file_pattern:
+                        import fnmatch
+
+                        if not fnmatch.fnmatch(file_name, file_pattern):
+                            match = False
+
+                    if file_extension and not file_name.endswith(file_extension):
+                        match = False
+
+                    if match:
+                        if search_folder not in found_elsewhere:
+                            found_elsewhere[search_folder] = []
+                        found_elsewhere[search_folder].append(file_path)  # Store full path for subfolders
+
+            # Build helpful error message
+            criteria = []
+            if file_pattern:
+                criteria.append(f"pattern='{file_pattern}'")
+            if file_extension:
+                criteria.append(f"extension='{file_extension}'")
+
+            error_msg = (
+                f"Expected remote file matching criteria [{', '.join(criteria)}] in folder '{folder}'"
+                " (searched recursively)"
+            )
+
+            if found_elsewhere:
+                error_msg += ", but found matching files in other folders:\n"
+                for other_folder, files in found_elsewhere.items():
+                    error_msg += f"  {other_folder}: {files}\n"
+                error_msg += f"Tip: Use folder='{list(found_elsewhere.keys())[0]}' to check the correct folder."
+            else:
+                # Show summary of what files exist
+                total_files = len(all_files)
+                if total_files > 0:
+                    file_names = [f.split("/")[-1] for f in all_files]
+                    if total_files <= 30:
+                        error_msg += f", but found no matching files anywhere.\nFiles in {folder}: {file_names}"
+                    else:
+                        error_msg += (
+                            f", but found no matching files anywhere.\nFiles in {folder}: "
+                            f"{file_names[:30]}... (showing 30 of {total_files} files)"
+                        )
+                else:
+                    error_msg += f", but found no files in {folder}."
+
+                # Also show summary from other folders for debugging
+                other_files_summary = []
+                for search_folder in all_folders.values():
+                    if search_folder != folder:
+                        search_files = self._get_recursive_files(search_folder)
+                        if search_files:
+                            count = len(search_files)
+                            other_files_summary.append(f"{search_folder}: {count} files")
+                if other_files_summary:
+                    error_msg += f"\nOther folders: {'; '.join(other_files_summary)}"
+
+            raise AssertionError(error_msg)
+
+        return self
+
+    # JFR-specific assertions
+    def jfr_should_have_events(self, event_type: str, min_count: int, file_pattern: str = None) -> "FluentAssertion":
+        """Assert that JFR file contains minimum number of events."""
+        if file_pattern is None:
+            file_pattern = f"{self.context.app_name}-*.jfr"
+
+        # Delegate to the core method to avoid code duplication
+        from .core import FluentAssertions
+
+        FluentAssertions.jfr_has_events(file_pattern, event_type, min_count)
+
+        return self
+
+    def should_contain_valid_thread_dump(self) -> "FluentAssertion":
+        """Assert that output contains valid thread dump information."""
+        from .file_validators import validate_thread_dump_output
+
+        try:
+            validate_thread_dump_output(self.result.output)
+        except Exception as e:
+            raise AssertionError(f"Thread dump validation failed: {e}")
+        return self
+
+    def should_contain_help(self) -> "FluentAssertion":
+        """Assert that output contains help/usage information."""
+        output = self.result.output
+
+        # Check for common help patterns
+        help_indicators = [
+            "NAME:",
+            "USAGE:",
+            "DESCRIPTION:",
+            "OPTIONS:",
+            "EXAMPLES:",
+            "--help",
+            "Usage:",
+            "Commands:",
+            "Flags:",
+            "Arguments:",
+        ]
+
+        found_indicators = [indicator for indicator in help_indicators if indicator in output]
+
+        if len(found_indicators) < 2:
+            raise AssertionError(
+                f"Output does not appear to contain help information. "
+                f"Expected at least 2 help indicators, found {len(found_indicators)}: {found_indicators}. "
+                f"Output: {output[:200]}..."
+            )
+
+        return self
+
+    def should_contain_vitals(self) -> "FluentAssertion":
+        """Assert that output contains VM vitals information in the expected format."""
+        output = self.result.output.strip()
+
+        # Check that output starts with "Vitals:"
+        if not output.startswith("Vitals:"):
+            raise AssertionError(f"VM vitals output should start with 'Vitals:', but starts with: {output[:50]}...")
+
+        # Check for system section header
+        if "------------system------------" not in output:
+            raise AssertionError("VM vitals output should contain '------------system------------' section header")
+
+        # Check for key vitals metrics
+        required_metrics = [
+            "avail: Memory available without swapping",
+            "comm: Committed memory",
+            "crt: Committed-to-Commit-Limit ratio",
+            "swap: Swap space used",
+            "si: Number of pages swapped in",
+            "so: Number of pages pages swapped out",
+            "p: Number of processes",
+        ]
+
+        missing_metrics = [metric for metric in required_metrics if metric not in output]
+        if missing_metrics:
+            raise AssertionError(f"VM vitals output missing required metrics: {missing_metrics}")
+
+        # Check for "Last 60 minutes:" section
+        if "Last 60 minutes:" not in output:
+            raise AssertionError("VM vitals output should contain 'Last 60 minutes:' section")
+
+        return self
+
+    def should_contain_vm_info(self) -> "FluentAssertion":
+        """Assert that output contains VM info information in the expected format."""
+        output = self.result.output
+
+        # Check for JRE version line with OpenJDK Runtime Environment SapMachine
+        jre_pattern = r"#\s*JRE version:.*OpenJDK Runtime Environment.*SapMachine"
+        if not re.search(jre_pattern, output, re.IGNORECASE):
+            raise AssertionError(
+                "VM info output should contain JRE version line with 'OpenJDK Runtime Environment SapMachine'. "
+                f"Expected pattern: '{jre_pattern}'"
+            )
+
+        # Check for SUMMARY section header
+        if "---------------  S U M M A R Y ------------" not in output:
+            raise AssertionError(
+                "VM info output should contain '---------------  S U M M A R Y ------------' section header"
+            )
+
+        # Check for PROCESS section header
+        if "---------------  P R O C E S S  ---------------" not in output:
+            raise AssertionError(
+                "VM info output should contain '---------------  P R O C E S S  ---------------' section header"
+            )
+
+        return self
+
+    def no_files(self) -> "FluentAssertion":
+        """Assert that no local files were created.
+
+        This is a convenience method for commands that should not create any local files.
+        It does NOT check remote files since many commands don't affect remote file state.
+        """
+        self.should_create_no_files()
+        return self
+
+
+class CFJavaTest:
+    """Main DSL entry point for CF Java Plugin testing."""
+
+    def __init__(self, runner: CFJavaTestRunner, context: TestContext, test_name: str = None):
+        self.runner = runner
+        self.context = context
+        self.test_name = test_name
+
+    def run(self, command: str) -> FluentAssertion:
+        """Execute a CF Java command and return assertion object with remote state capture."""
+        # Capture remote file state before command execution
+        before_state = self.runner.capture_remote_file_state(self.context.app_name)
+
+        # Execute the command
+        result = self.runner.run_command(f"cf java {command}", app_name=self.context.app_name)
+
+        # Create assertion with all context
+        assertion = FluentAssertion(result, self.context, self.runner)
+        assertion._test_name = self.test_name
+        assertion._command_name = command.split()[0] if command else "unknown"
+        assertion._remote_files_before = before_state
+
+        return assertion
+
+
+# Factory function for creating test DSL with test name
+def test_cf_java(runner: CFJavaTestRunner, context: TestContext, test_name: str = None) -> CFJavaTest:
+    """Create a test DSL instance with optional test name for snapshot tracking."""
+    return CFJavaTest(runner, context, test_name)
diff --git a/test/framework/file_validators.py b/test/framework/file_validators.py
new file mode 100644
index 0000000..463af4b
--- /dev/null
+++ b/test/framework/file_validators.py
@@ -0,0 +1,240 @@
+"""
+File validation utilities for checking generated files.
+
+This module provides validators to check if generated files look like valid
+heap dumps, JFR files, etc.
+"""
+
+import glob
+import os
+import subprocess
+from enum import Enum
+
+
+class FileType(Enum):
+    """Supported file types for validation."""
+
+    HEAP_DUMP = "heap_dump"
+    JFR = "jfr"
+
+
+class FileValidationError(Exception):
+    """Raised when file validation fails."""
+
+    pass
+
+
+class FileValidator:
+    """Base class for file validators."""
+
+    def __init__(self, file_type: str):
+        self.file_type = file_type
+
+    def validate_local_file(self, pattern: str) -> str:
+        """
+        Validate a local file matches the expected type.
+
+        Args:
+            pattern: Glob pattern to find the file
+
+        Returns:
+            Path to the validated file
+
+        Raises:
+            FileValidationError: If file doesn't exist or doesn't match expected type
+        """
+        files = glob.glob(pattern)
+        if not files:
+            raise FileValidationError(f"No {self.file_type} file found matching pattern: {pattern}")
+
+        file_path = files[0]  # Use first match
+        self._validate_file_content(file_path)
+        return file_path
+
+    def validate_remote_file(self, pattern: str, ssh_result: str) -> None:
+        """
+        Validate a remote file exists and matches expected type.
+
+        Args:
+            pattern: File pattern to check
+            ssh_result: Output from SSH command listing files
+
+        Raises:
+            FileValidationError: If file doesn't exist or validation fails
+        """
+        # This would be implemented for remote validation
+        # For now, just check if the pattern appears in SSH output
+        if pattern.replace("*", "") not in ssh_result:
+            raise FileValidationError(f"No {self.file_type} file found in remote location")
+
+    def _validate_file_content(self, file_path: str) -> None:
+        """Override in subclasses to validate specific file types."""
+        raise NotImplementedError("Subclasses must implement _validate_file_content")
+
+
+class HeapDumpValidator(FileValidator):
+    """Validator for heap dump files (.hprof)."""
+
+    def __init__(self):
+        super().__init__("heap dump")
+
+    def _validate_file_content(self, file_path: str) -> None:
+        """Validate that file looks like a valid heap dump."""
+        # Check file size
+        file_size = os.path.getsize(file_path)
+        if file_size < 1024:
+            raise FileValidationError(f"Heap dump file {file_path} is too small ({file_size} bytes)")
+
+        # Check HPROF header
+        try:
+            with open(file_path, "rb") as f:
+                header = f.read(20)
+                if not header.startswith(b"JAVA PROFILE"):
+                    raise FileValidationError(
+                        f"File {file_path} does not appear to be a valid heap dump " f"(missing HPROF header)"
+                    )
+        except IOError as e:
+            raise FileValidationError(f"Could not read heap dump file {file_path}: {e}")
+
+
+class JFRValidator(FileValidator):
+    """Validator for Java Flight Recorder files (.jfr)."""
+
+    def __init__(self):
+        super().__init__("JFR file")
+
+    def _validate_file_content(self, file_path: str) -> None:
+        """Validate that file looks like a valid JFR file."""
+        # Check file size
+        file_size = os.path.getsize(file_path)
+        if file_size < 512:
+            raise FileValidationError(f"JFR file {file_path} is too small ({file_size} bytes)")
+
+        try:
+            # Use 'jfr summary' command to validate the file
+            result = subprocess.run(["jfr", "summary", file_path], capture_output=True, text=True, timeout=30)
+
+            if result.returncode != 0:
+                raise FileValidationError(f"JFR file {file_path} failed validation with jfr summary: {result.stderr}")
+
+            # Check that summary output is not empty
+            if not result.stdout.strip():
+                raise FileValidationError(f"JFR file {file_path} appears invalid - jfr summary returned empty output")
+
+        except subprocess.TimeoutExpired:
+            raise FileValidationError(f"JFR validation timed out for file {file_path}")
+        except FileNotFoundError:
+            # Fallback to basic binary file check if jfr command is not available
+            try:
+                with open(file_path, "rb") as f:
+                    header = f.read(8)
+
+                    if len(header) < 4:
+                        raise FileValidationError(f"JFR file {file_path} is too small to contain valid header")
+
+                    # Basic check: JFR files are binary and should not be pure text
+                    try:
+                        header.decode("ascii")
+                        raise FileValidationError(f"File {file_path} appears to be text, not a binary JFR file")
+                    except UnicodeDecodeError:
+                        # Good! Binary data as expected for JFR
+                        pass
+            except IOError as e:
+                raise FileValidationError(f"Could not read JFR file {file_path}: {e}")
+        except IOError as e:
+            raise FileValidationError(f"Could not validate JFR file {file_path}: {e}")
+
+
+def validate_thread_dump_output(output: str) -> None:
+    """
+    Validate that output looks like a valid thread dump.
+
+    Args:
+        output: Thread dump output string to validate
+
+    Raises:
+        FileValidationError: If output doesn't look like a valid thread dump
+    """
+    if not output or not output.strip():
+        raise FileValidationError("Thread dump output is empty")
+
+    # Check for required thread dump header
+    if "Full thread dump" not in output:
+        raise FileValidationError("Thread dump output missing 'Full thread dump' header")
+
+    # Check for at least one thread entry
+    if '"' not in output or "java.lang.Thread.State:" not in output:
+        raise FileValidationError("Thread dump output does not contain valid thread information")
+
+    # Count lines to ensure substantial output
+    lines = output.split("\n")
+    non_empty_lines = [line for line in lines if line.strip()]
+    if len(non_empty_lines) < 10:
+        raise FileValidationError(
+            f"Thread dump output too short ({len(non_empty_lines)} non-empty lines), expected at least 5"
+        )
+
+    # Check for common thread dump patterns
+    has_thread_names = any('"' in line and "#" in line for line in lines)  # Thread lines contain quotes and thread IDs
+    has_thread_states = any("java.lang.Thread.State:" in line for line in lines)
+
+    if not has_thread_names:
+        raise FileValidationError("Thread dump output missing thread names with quotes")
+
+    if not has_thread_states:
+        raise FileValidationError("Thread dump output missing thread states")
+
+
+# Factory function to create validators
+_VALIDATORS = {
+    FileType.HEAP_DUMP: HeapDumpValidator,
+    FileType.JFR: JFRValidator,
+}
+
+
+def create_validator(file_type: FileType) -> FileValidator:
+    """
+    Create a validator for the specified file type.
+
+    Args:
+        file_type: Type of file to validate
+
+    Returns:
+        Appropriate validator instance
+
+    Raises:
+        ValueError: If file_type is not supported
+    """
+    if file_type not in _VALIDATORS:
+        supported_types = ", ".join([ft.value for ft in FileType])
+        raise ValueError(f"Unsupported file type: {file_type}. Supported: {supported_types}")
+
+    return _VALIDATORS[file_type]()
+
+
+def validate_generated_file(file_pattern: str, file_type: FileType) -> str:
+    """
+    Convenience function to validate a generated local file.
+
+    Args:
+        file_pattern: Glob pattern to find the file
+        file_type: Expected type of file
+
+    Returns:
+        Path to the validated file
+    """
+    validator = create_validator(file_type)
+    return validator.validate_local_file(file_pattern)
+
+
+def validate_generated_remote_file(file_pattern: str, file_type: FileType, ssh_output: str) -> None:
+    """
+    Convenience function to validate a generated remote file.
+
+    Args:
+        file_pattern: File pattern to check
+        file_type: Expected type of file
+        ssh_output: Output from SSH command
+    """
+    validator = create_validator(file_type)
+    validator.validate_remote_file(file_pattern, ssh_output)
diff --git a/test/framework/runner.py b/test/framework/runner.py
new file mode 100644
index 0000000..b09b473
--- /dev/null
+++ b/test/framework/runner.py
@@ -0,0 +1,270 @@
+"""
+Test runner implementation using pytest for CF Java Plugin testing.
+
+Environment Variables for controlling test behavior:
+- DEPLOY_APPS: Controls app deployment behavior
+  - "always": Always deploy apps, even if they exist
+  - "never": Skip app deployment entirely
+  - "if_needed": Only deploy if apps don't exist (default)
+
+- RESTART_APPS: Controls app restart behavior between tests
+  - "always": Always restart apps between tests
+  - "never": Never restart apps between tests
+  - "smart": Only restart if needed based on app status
+  - "parallel": Use parallel restart for faster execution
+  - "smart_parallel": Use smart parallel restart (default, recommended for speed)
+
+- DELETE_APPS: Controls app cleanup after tests
+  - "true": Delete apps after test session
+  - "false": Keep apps deployed for faster subsequent runs (default)
+
+- CF_COMMAND_STATS: Controls CF command performance tracking
+  - "true": Enable detailed timing and statistics for all CF commands
+  - "false": Disable command timing (default)
+"""
+
+import os
+from typing import Any, Dict, List
+
+import pytest
+
+from .core import CFConfig, CFJavaTestRunner, CFManager, FluentAssertions
+
+
+class CFJavaTestSession:
+    """Manages the test session lifecycle."""
+
+    def __init__(self):
+        self.config = CFConfig()
+        self.cf_manager = CFManager(self.config)
+        self.runner = CFJavaTestRunner(self.config)
+        self.assertions = FluentAssertions()
+        self._initialized = False
+        self._cf_logged_in = False
+
+    def setup_session(self):
+        """Setup the test session."""
+        if self._initialized:
+            return
+
+        # Only print setup message once per session instance
+        if not hasattr(self, "_setup_printed"):
+            print("Setting up CF Java Plugin test session...")
+            self._setup_printed = True
+
+        # Check if we have required CF configuration
+        if not self.config.username or not self.config.password:
+            print("Warning: No CF credentials configured. Skipping CF operations.")
+            print("Set CF_USERNAME and CF_PASSWORD environment variables or update test_config.yml")
+            self._initialized = True
+            return
+
+        # Login to CF
+        try:
+            if self.cf_manager.login():
+                self._cf_logged_in = True
+            else:
+                print("Warning: Failed to login to CF. Skipping app operations.")
+                self._initialized = True
+                return
+        except Exception as e:
+            print(f"Warning: CF login failed with error: {e}. Skipping app operations.")
+            self._initialized = True
+            return
+
+        # Only proceed with app operations if logged in
+        if self._cf_logged_in:
+            # Check if apps should be deployed
+            deploy_behavior = os.environ.get("DEPLOY_APPS", "if_needed").lower()
+
+            if deploy_behavior == "always":
+                print("Deploying applications...")
+                try:
+                    if not self.cf_manager.deploy_apps():
+                        print("Warning: Failed to deploy test applications. Some tests may fail.")
+                except Exception as e:
+                    print(f"Warning: App deployment failed with error: {e}")
+            elif deploy_behavior != "never":  # "if_needed" or default
+                try:
+                    if not self.cf_manager.deploy_apps_if_needed():
+                        print("Warning: Failed to deploy some test applications. Some tests may fail.")
+                except Exception as e:
+                    print(f"Warning: App deployment check failed with error: {e}")
+
+            # Start applications if they're not running
+            try:
+                if not self.cf_manager.start_apps_if_needed():
+                    print("Warning: Failed to start some test applications. Some tests may fail.")
+            except Exception as e:
+                print(f"Warning: App startup failed with error: {e}")
+
+        self._initialized = True
+        print("Test session setup complete.")
+
+    def teardown_session(self):
+        """Teardown the test session."""
+        # Always skip deferred restarts at session teardown to prevent unwanted restarts at the end
+        if hasattr(self, "cf_manager") and self.cf_manager and self.cf_manager.has_deferred_restarts():
+            print("๏ฟฝ๐Ÿ“‹ SESSION TEARDOWN: Skipping deferred restarts (never restart at end of test suite)")
+            # Clear the deferred restart list without processing
+            self.cf_manager.clear_deferred_restart_apps()
+
+        # Print CF command statistics before cleanup (always try if stats are enabled)
+        # Check if CF_COMMAND_STATS is enabled and we have any CF commands tracked globally
+        stats_enabled = os.environ.get("CF_COMMAND_STATS", "false").lower() == "true"
+        if stats_enabled:
+            # Import and create GlobalCFCommandStats instance - now with file persistence
+            try:
+                from .core import GlobalCFCommandStats
+
+                global_stats = GlobalCFCommandStats()
+
+                if global_stats.has_stats():
+                    print("\n๐Ÿ” CF_COMMAND_STATS is enabled, printing global command statistics...")
+                    global_stats.print_summary()
+                else:
+                    print("\n๐Ÿ” CF_COMMAND_STATS is enabled, but no CF commands were tracked.")
+
+                # Clean up temporary stats file
+                GlobalCFCommandStats.cleanup_temp_files()
+
+            except Exception as e:
+                print(f"Warning: Failed to print CF command statistics: {e}")
+        elif getattr(self, "_cf_logged_in", False) and hasattr(self, "cf_manager") and self.cf_manager:
+            # Fallback: print stats if we were logged in (original behavior for backward compatibility)
+            try:
+                self.runner.print_cf_command_summary()
+            except Exception as e:
+                print(f"Warning: Failed to print CF command statistics: {e}")
+
+        # Always clean up temporary state files (login and restart tracking)
+        try:
+            from .core import CFManager
+
+            CFManager.cleanup_state_files()
+        except Exception as e:
+            print(f"Warning: Failed to clean up state files: {e}")
+
+        # Clean up temporary directories
+        self.runner.cleanup()
+
+        # Delete applications only if explicitly requested
+        delete_apps = os.environ.get("DELETE_APPS", "false").lower() == "true"
+
+        if getattr(self, "_cf_logged_in", False) and delete_apps:
+            print("Deleting deployed applications...")
+            self.cf_manager.delete_apps()
+
+    def setup_test(self):
+        """Setup before each test."""
+        # Skip app operations if not logged in
+        if not getattr(self, "_cf_logged_in", False):
+            print("Skipping app restart - not logged in to CF")
+            return
+
+        # Check if we should restart apps between tests
+        restart_behavior = os.environ.get("RESTART_APPS", "smart_parallel").lower()
+
+        if restart_behavior == "never":
+            return
+
+        # Skip session-level restarts entirely - let test decorator handle all restart logic
+        # This prevents double restarts and respects no_restart=True test settings
+        print("๐Ÿ”„โญ๏ธ SESSION: Skipping session-level restart - test decorator will handle restart logic")
+        return
+
+    def run_test_for_apps(self, test_func, apps: List[str]):
+        """Run a test function for specified apps."""
+        results = {}
+
+        for app_name in apps:
+            print(f"Running {test_func.__name__} for {app_name}")
+
+            with self.runner.create_test_context(app_name) as context:
+                try:
+                    # Call the test function with app context
+                    test_func(self, app_name, context)
+                    results[app_name] = "PASSED"
+                except Exception as e:
+                    results[app_name] = f"FAILED: {str(e)}"
+                    raise  # Re-raise for pytest to handle
+
+        return results
+
+
+# Global session instance for sharing across modules
+_global_test_session = None
+
+
+def set_global_test_session(session: "CFJavaTestSession"):
+    """Set the global test session."""
+    global _global_test_session
+    _global_test_session = session
+
+
+def get_test_session() -> CFJavaTestSession:
+    """Get the current test session, creating one if needed."""
+    # Return global session if available
+    if _global_test_session is not None:
+        return _global_test_session
+
+    # Fallback: create a new session (but cache it to avoid multiple instances)
+    if not hasattr(get_test_session, "_cached_session"):
+        get_test_session._cached_session = CFJavaTestSession()
+
+        # Try to initialize if not in pytest context
+        import sys
+
+        if not hasattr(sys, "_called_from_test"):
+            try:
+                get_test_session._cached_session.setup_session()
+            except Exception as e:
+                print(f"Warning: Could not initialize test session: {e}")
+
+    return get_test_session._cached_session
+
+
+class TestBase:
+    """Base class for CF Java Plugin tests with helpful methods."""
+
+    @property
+    def session(self) -> CFJavaTestSession:
+        """Get the current test session."""
+        return get_test_session()
+
+    @property
+    def runner(self) -> CFJavaTestRunner:
+        """Get the test runner."""
+        return self.session.runner
+
+    @property
+    def assert_that(self) -> FluentAssertions:
+        """Get assertion helpers."""
+        return self.session.assertions
+
+    def run_cf_java(self, command: str, app_name: str, **kwargs) -> Any:
+        """Run a cf java command with app name substitution."""
+        full_command = f"cf java {command}"
+        return self.runner.run_command(full_command, app_name=app_name, **kwargs)
+
+    def run_commands(self, commands: List[str], app_name: str, **kwargs) -> Any:
+        """Run a sequence of commands."""
+        return self.runner.run_command(commands, app_name=app_name, **kwargs)
+
+
+def test_with_apps(app_names: List[str]):
+    """Parametrize test to run with specified apps."""
+    return pytest.mark.parametrize("app_name", app_names)
+
+
+def create_test_class(test_methods: Dict[str, Any]) -> type:
+    """Dynamically create a test class with specified methods."""
+
+    class DynamicTestClass(TestBase):
+        pass
+
+    # Add test methods to the class
+    for method_name, method_func in test_methods.items():
+        setattr(DynamicTestClass, method_name, method_func)
+
+    return DynamicTestClass
diff --git a/test/pyproject.toml b/test/pyproject.toml
new file mode 100644
index 0000000..63d8c9a
--- /dev/null
+++ b/test/pyproject.toml
@@ -0,0 +1,88 @@
+# Pytest Configuration
+
+[build-system]
+requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "cf-java-plugin-tests"
+description = "Test suite for CF Java Plugin"
+readme = "README.md"
+requires-python = ">=3.8"
+dependencies = [
+    "pytest>=8.4.1",
+    "pyyaml>=6.0",
+    "pytest-xdist>=3.7.0",
+    "pytest-html>=4.1.1",
+    "colorama>=0.4.4",
+    "python-Levenshtein>=0.25.0",
+]
+
+[tool.setuptools]
+packages = ["framework"]
+package-dir = { "" = "." }
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = ["--tb=short", "--strict-markers", "--strict-config", "--color=yes"]
+testpaths = ["."]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+norecursedirs = ["framework", "__pycache__", ".git", "venv"]
+markers = [
+    "all: runs on all Java versions",
+    "sapmachine21: requires SapMachine 21",
+    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+]
+
+[tool.black]
+line-length = 120
+target-version = ['py38']
+include = '\.pyi?$'
+extend-exclude = '''
+/(
+  # directories
+  \.eggs
+  | \.git
+  | \.hg
+  | \.mypy_cache
+  | \.tox
+  | \.venv
+  | venv
+  | _build
+  | buck-out
+  | build
+  | dist
+)/
+'''
+
+[tool.isort]
+profile = "black"
+line_length = 120
+multi_line_output = 3
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+ensure_newline_before_comments = true
+skip_glob = [
+    "venv/*",
+    "*/venv/*",
+    "__pycache__/*",
+    "*/__pycache__/*",
+    ".git/*",
+    "*/.git/*",
+]
+
+[tool.flake8]
+max-line-length = 120
+ignore = ["E203", "W503"]
+exclude = [
+    ".git",
+    "__pycache__",
+    "venv",
+    ".venv",
+    "*.egg-info",
+    "build",
+    "dist",
+]
diff --git a/test/requirements.txt b/test/requirements.txt
new file mode 100644
index 0000000..68e25b5
--- /dev/null
+++ b/test/requirements.txt
@@ -0,0 +1,14 @@
+# Requirements for CF Java Plugin Test Suite
+pytest==8.4.1
+pyyaml>=6.0
+pytest-xdist>=3.7.0  # For parallel test execution
+pytest-html>=4.1.1   # For HTML test reports
+colorama>=0.4.4      # For colored output
+click>=8.1.0         # Command-line interface framework
+python-Levenshtein>=0.25.0  # For edit distance calculations
+tabulate>=0.9.0      # For beautiful table formatting
+
+# Development tools (optional but recommended)
+black>=24.0.0         # Code formatting
+flake8>=7.3.0         # Linting
+isort>=5.13.0         # Import sorting
diff --git a/test/setup.sh b/test/setup.sh
new file mode 100755
index 0000000..4cfb673
--- /dev/null
+++ b/test/setup.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+# CF Java Plugin Test Environment Setup Script
+# Sets up Python virtual environment and dependencies
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+VENV_DIR="$SCRIPT_DIR/venv"
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+print_status() {
+    echo -e "${GREEN}โœ…${NC} $1"
+}
+
+print_info() {
+    echo -e "${BLUE}โ„น๏ธ${NC} $1"
+}
+
+show_help() {
+    echo "CF Java Plugin Test Environment Setup"
+    echo ""
+    echo "Usage: $0 [--help|-h]"
+    echo ""
+    echo "This script sets up the Python virtual environment and installs dependencies"
+    echo "required for running the CF Java Plugin test suite."
+    echo ""
+    echo "Options:"
+    echo "  --help, -h    Show this help message"
+    echo ""
+}
+
+# Parse arguments
+case "${1:-}" in
+    "--help"|"-h")
+        show_help
+        exit 0
+        ;;
+    "")
+        # No arguments, proceed with setup
+        ;;
+    *)
+        echo "โŒ Unknown option: $1"
+        echo ""
+        show_help
+        exit 1
+        ;;
+esac
+
+print_info "Setting up CF Java Plugin test environment..."
+
+# Create virtual environment if it doesn't exist
+if [[ ! -d "$VENV_DIR" ]]; then
+    print_info "Creating Python virtual environment..."
+    python3 -m venv "$VENV_DIR"
+else
+    print_info "Virtual environment already exists"
+fi
+
+# Activate virtual environment
+print_info "Activating virtual environment..."
+source "$VENV_DIR/bin/activate"
+
+# Upgrade pip
+print_info "Upgrading pip..."
+pip install --upgrade pip
+
+# Install dependencies
+print_info "Installing dependencies from requirements.txt..."
+pip install -r "$SCRIPT_DIR/requirements.txt"
+
+print_status "Setup complete!"
+echo ""
+echo "To activate the virtual environment manually:"
+echo "  source $VENV_DIR/bin/activate"
+echo ""
+echo "To run tests:"
+echo "  ./test.py all"
+echo ""
+echo "To clean artifacts:"
+echo "  ./test.py clean"
diff --git a/test/test.py b/test/test.py
new file mode 100755
index 0000000..5e69f95
--- /dev/null
+++ b/test/test.py
@@ -0,0 +1,774 @@
+#!/usr/bin/env python3
+"""
+CF Java Plugin Test Suite - Python CLI.
+
+A modern test runner for the CF Java Plugin test suite.
+"""
+
+import atexit
+import os
+import re
+import signal
+import subprocess
+import sys
+from pathlib import Path
+from typing import Dict, List
+
+# Ensure we're using the virtual environment Python
+SCRIPT_DIR = Path(__file__).parent.absolute()
+VENV_PYTHON = SCRIPT_DIR / "venv" / "bin" / "python"
+
+# If we're not running in the venv, re-exec with venv python
+if not sys.executable.startswith(str(SCRIPT_DIR / "venv")):
+    if VENV_PYTHON.exists():
+        os.execv(str(VENV_PYTHON), [str(VENV_PYTHON)] + sys.argv)
+    else:
+        print("โŒ Virtual environment not found. Run: ./test.py setup")
+        sys.exit(1)
+
+# noqa: E402
+import click
+
+# noqa: E402
+import colorama
+
+# noqa: E402
+from colorama import Fore, Style
+
+# Initialize colorama for cross-platform colored output
+colorama.init(autoreset=True)
+
+# Script directory and paths
+PYTEST = SCRIPT_DIR / "venv" / "bin" / "pytest"
+
+# Color shortcuts
+GREEN = Fore.GREEN + Style.BRIGHT
+YELLOW = Fore.YELLOW + Style.BRIGHT
+RED = Fore.RED + Style.BRIGHT
+BLUE = Fore.BLUE + Style.BRIGHT
+MAGENTA = Fore.MAGENTA + Style.BRIGHT
+CYAN = Fore.CYAN + Style.BRIGHT
+
+# Global state for tracking active tests and process cleanup
+_active_command = None
+_child_processes = set()  # Track child processes for proper cleanup
+_test_failures = set()  # Track test failures for better interrupt reporting
+_interrupt_count = 0  # Track multiple interrupts to handle force termination
+_last_exit_code = 0  # Track last exit code for better reporting
+
+
+def cleanup_on_exit():
+    """Clean up any orphaned processes on exit."""
+    for proc in _child_processes:
+        try:
+            if proc.poll() is None:  # Process is still running
+                proc.terminate()
+        except Exception:
+            pass  # Ignore errors in cleanup
+
+
+# Register cleanup function
+atexit.register(cleanup_on_exit)
+
+
+def handle_keyboard_interrupt(signum, frame):
+    """Handle keyboard interrupt (Ctrl+C) gracefully."""
+    global _interrupt_count
+    _interrupt_count += 1
+    if _interrupt_count == 1:
+        click.echo(f"\n{YELLOW}โš ๏ธ  Interrupting test execution (Ctrl+C)...")
+        click.echo(f"{YELLOW}Press Ctrl+C again to force immediate termination.")
+
+        # Show all previous test failures without headers
+        failed_tests_found = False
+        all_failures = set()
+
+        # Collect failures from pytest cache
+        try:
+            cache_file = SCRIPT_DIR / ".pytest_cache" / "v" / "cache" / "lastfailed"
+            if cache_file.exists():
+                import json
+
+                with open(cache_file, "r") as f:
+                    cached_failures = json.load(f)
+                    all_failures.update(cached_failures.keys())
+                    failed_tests_found = True
+        except Exception as e:
+            click.echo(f"\n{YELLOW}โš ๏ธ  Could not read test failure cache: {e}")
+
+        # Add any failures tracked during this session
+        all_failures.update(_test_failures)
+
+        if all_failures:
+            click.echo()  # Empty line for spacing
+            # Show up to 20 most recent failures
+            failure_list = sorted(list(all_failures))
+            for test in failure_list[:20]:
+                # Clean up test name for better readability
+                clean_test = test.replace(".py::", " โ†’ ").replace("::", " โ†’ ")
+                click.echo(f"{RED}  โœ— {clean_test}")
+
+            if len(failure_list) > 20:
+                remaining = len(failure_list) - 20
+                click.echo(f"{YELLOW}  ... and {remaining} more failed tests")
+
+            click.echo()  # Empty line for spacing
+            click.echo(f"{BLUE}๐Ÿ’ก Use './test.py --failed' to re-run only failed tests")
+        elif failed_tests_found:
+            click.echo(f"\n{GREEN}โœ… No recent test failures found")
+        else:
+            click.echo(f"\n{BLUE}โ„น๏ธ  No test failure information available")
+
+        # Try graceful termination of the active command
+        if _active_command and _active_command.poll() is None:
+            try:
+                click.echo(f"{YELLOW}Attempting to terminate active command...")
+                _active_command.terminate()
+                import time
+
+                time.sleep(0.5)
+            except Exception:
+                pass
+    elif _interrupt_count == 2:
+        click.echo(f"\n{RED}๐Ÿ›‘ Forcing immediate termination...")
+        cleanup_on_exit()
+        click.echo(f"\n{YELLOW}๐Ÿ“‹ To debug failed tests, try:")
+        click.echo(f"{BLUE}  1. ./test.py run  -v              # Run specific test with verbose output")
+        click.echo(
+            f"{BLUE}  2. ./test.py --failed -x                     # Run only failed tests, stop on first failure"
+        )
+        click.echo(f"{BLUE}  3. ./test.py --verbose --html                # Generate HTML report with details")
+        os._exit(130)  # Force exit without running further cleanup handlers
+
+
+# Register signal handler for SIGINT (Ctrl+C)
+signal.signal(signal.SIGINT, handle_keyboard_interrupt)
+
+
+def run_command(command: List[str], **kwargs) -> int:
+    """Run a command and return the exit code."""
+
+    try:
+        # Add --showlocals to pytest to show variable values on failure
+        if command[0].endswith("pytest") and "--showlocals" not in command:
+            # Only add if not already present
+            command.append("--showlocals")
+
+        # Add -v for pytest if not already present to show more details
+        if command[0].endswith("pytest") and "-v" not in command and "--verbose" not in command:
+            command.append("-v")
+
+        # Add --no-header and other options to improve pytest interrupt handling
+        if command[0].endswith("pytest"):
+            # Force showing summary on interruption
+            if "--no-summary" not in command and "-v" in command:
+                command.append("--force-short-summary")
+
+            # Always capture keyboard interruption as a failure
+            command.append("--capture=fd")
+
+            # Make output unbuffered for real-time visibility
+            os.environ["PYTHONUNBUFFERED"] = "1"
+
+        # Ensure subprocess inherits terminal for proper output display
+        if "stdout" not in kwargs:
+            kwargs["stdout"] = None
+        if "stderr" not in kwargs:
+            kwargs["stderr"] = None
+
+        # Prevent shell injection by using a list for the command
+        cmd_str = " ".join(str(c) for c in command)
+        click.echo(f"{BLUE}๐Ÿ”„ Running: {cmd_str}", err=True)
+
+        # Run the command as a subprocess
+        result = subprocess.Popen(command, **kwargs)
+        _active_command = result
+        _child_processes.add(result)
+
+        # Wait for the command to complete
+        return_code = result.wait()
+        _child_processes.discard(result)
+        _active_command = None
+
+        # Extract test failures from pytest cache
+        if command[0].endswith("pytest") and return_code != 0:
+            try:
+                cache_file = SCRIPT_DIR / ".pytest_cache" / "v" / "cache" / "lastfailed"
+                if cache_file.exists():
+                    import json
+
+                    with open(cache_file, "r") as f:
+                        failed_tests = json.load(f)
+                        for test in failed_tests.keys():
+                            _test_failures.add(test)
+            except Exception:
+                # Silently ignore errors in this diagnostic code
+                pass
+
+        # Show additional info based on exit code
+        if return_code != 0:
+            if return_code == 1:
+                click.echo(f"\n{RED}โŒ Tests failed")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --failed to re-run only failed tests")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --verbose for more detailed output")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --start-with  to resume from a specific test")
+            elif return_code == 2:
+                click.echo(f"\n{YELLOW}โš ๏ธ  Test execution interrupted or configuration error")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --failed to re-run only failed tests")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --start-with  to resume from a specific test")
+            elif return_code == 130:  # SIGINT (Ctrl+C)
+                click.echo(f"\n{YELLOW}โš ๏ธ  Test execution interrupted by user (Ctrl+C)")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --failed to re-run only failed tests")
+                click.echo(f"{BLUE}๐Ÿ’ก Use --start-with  to resume from a specific test")
+
+        return return_code
+    except FileNotFoundError as e:
+        click.echo(f"{RED}โŒ Command not found: {e}", err=True)
+        return 1
+    except KeyboardInterrupt:
+        # Let the global signal handler deal with this
+        return 130
+    except Exception as e:
+        click.echo(f"{RED}โŒ Unexpected error: {e}", err=True)
+        import traceback
+
+        traceback.print_exc()
+        return 1
+    finally:
+        # Clean up the active command reference
+        if _active_command in _child_processes:
+            _child_processes.discard(_active_command)
+        _active_command = None
+
+
+def parse_test_file(file_path: Path) -> Dict:
+    """Parse a test file to extract test classes, methods, and app dependencies."""
+    try:
+        with open(file_path, "r") as f:
+            content = f.read()
+
+        classes = {}
+        current_class = None
+        current_method = None
+
+        for line_num, line in enumerate(content.split("\n"), 1):
+            stripped_line = line.strip()
+
+            # Match class definitions
+            class_match = re.match(r"^class (Test\w+)", stripped_line)
+            if class_match:
+                class_name = class_match.group(1)
+                current_class = class_name
+                classes[class_name] = {"methods": [], "line": line_num, "docstring": None}
+                continue
+
+            # Extract class docstring
+            if current_class and classes[current_class]["docstring"] is None:
+                if stripped_line.startswith('"""') and len(stripped_line) > 3:
+                    if stripped_line.endswith('"""') and len(stripped_line) > 6:
+                        # Single line docstring
+                        classes[current_class]["docstring"] = stripped_line[3:-3].strip()
+                    else:
+                        # Multi-line docstring start
+                        classes[current_class]["docstring"] = stripped_line[3:].strip()
+
+            # Match @test decorator and following method
+            if current_class and stripped_line.startswith("@test("):
+                # Extract app name from @test("app_name", ...)
+                test_match = re.search(r'@test\(["\']([^"\']+)["\']', stripped_line)
+                app_name = test_match.group(1) if test_match else "unknown"
+                current_method = {"app": app_name, "options": []}
+
+                # Extract additional options
+                if "no_restart=True" in stripped_line:
+                    current_method["options"].append("no_restart")
+
+                continue
+
+            # Match test method following @test decorator
+            if current_class and current_method and re.match(r"^\s*def (test_\w+)", line):
+                method_match = re.match(r"^\s*def (test_\w+)", line)
+                if method_match:
+                    method_name = method_match.group(1)
+                    current_method["name"] = method_name
+                    current_method["line"] = line_num
+
+                    # Extract method docstring if available
+                    current_method["docstring"] = None
+
+                    classes[current_class]["methods"].append(current_method.copy())
+                    current_method = None
+
+        return {"file": file_path.name, "classes": classes}
+    except Exception as e:
+        return {"file": file_path.name, "classes": {}, "error": str(e)}
+
+
+def get_test_hierarchy() -> Dict:
+    """Get complete test hierarchy with app dependencies."""
+    hierarchy = {}
+    test_files = list(SCRIPT_DIR.glob("test_*.py"))
+
+    for test_file in sorted(test_files):
+        if test_file.name in ["test.py", "test_clean.py"]:  # Skip the runner itself
+            continue
+
+        parsed = parse_test_file(test_file)
+        if parsed["classes"] or "error" in parsed:
+            hierarchy[test_file.name] = parsed
+
+    return hierarchy
+
+
+@click.group(invoke_without_command=True)
+@click.option("--no-initial-restart", is_flag=True, help="Skip initial app restart before running tests")
+@click.option("--failed", is_flag=True, help="Run only previously failed tests")
+@click.option("--html", is_flag=True, help="Generate HTML test report")
+@click.option("--fail-fast", "-x", is_flag=True, help="Stop on first test failure")
+@click.option("--verbose", "-v", is_flag=True, help="Verbose output with detailed information")
+@click.option("--parallel", "-p", is_flag=True, help="Run tests in parallel using multiple CPU cores")
+@click.option("--stats", is_flag=True, help="Enable CF command statistics tracking")
+@click.option("--start-with", metavar="TEST_NAME", help="Start running tests with the specified test (inclusive)")
+@click.pass_context
+def cli(ctx, no_initial_restart, failed, html, fail_fast, verbose, parallel, stats, start_with):
+    """CF Java Plugin Test Suite.
+
+    Run different test suites with various options. Use --help on any command for details.
+    """
+    # Change to script directory
+    os.chdir(SCRIPT_DIR)
+
+    # Store options in context for subcommands
+    ctx.ensure_object(dict)
+    ctx.obj["pytest_args"] = []
+
+    # Set environment variable if --no-initial-restart was specified
+    if no_initial_restart:
+        os.environ["RESTART_APPS"] = "never"
+        click.echo(f"{YELLOW}๐Ÿšซ Skipping initial app restart")
+
+    # Enable CF command statistics if requested
+    if stats:
+        os.environ["CF_COMMAND_STATS"] = "true"
+        click.echo(f"{CYAN}๐Ÿ“Š CF command statistics enabled")
+
+    # Build pytest arguments
+    if failed:
+        ctx.obj["pytest_args"].extend(["--lf"])
+        click.echo(f"{YELLOW}๐Ÿ”„ Running only previously failed tests")
+
+    if start_with:
+        # Use pytest --collect-only -q to get all test nodeids in order
+        import subprocess
+
+        def get_all_pytest_nodeids():
+            try:
+                result = subprocess.run(
+                    [str(PYTEST), "--collect-only", "-q", "--disable-warnings"],
+                    capture_output=True,
+                    text=True,
+                    cwd=SCRIPT_DIR,
+                )
+                if result.returncode != 0:
+                    click.echo(f"{RED}Failed to collect test nodeids via pytest.\n{result.stderr}")
+                    return []
+                # Only keep lines that look like pytest nodeids
+                nodeids = [
+                    line.strip()
+                    for line in result.stdout.splitlines()
+                    if (
+                        "::" in line
+                        and not line.strip().startswith("-")
+                        and not line.strip().startswith("=")
+                        and not line.strip().startswith("|")
+                        and not line.strip().startswith("#")
+                        and not line.strip().startswith("Status")
+                        and not line.strip().startswith("Duration")
+                        and not line.strip().startswith("Timestamp")
+                        and not line.strip().startswith("Command")
+                        and not line.strip().startswith("<")
+                        and len(line.strip()) > 0
+                    )
+                ]
+                return nodeids
+            except Exception as e:
+                click.echo(f"{RED}Error collecting test nodeids: {e}")
+                return []
+
+        all_nodeids = get_all_pytest_nodeids()
+        idx = None
+        for i, nodeid in enumerate(all_nodeids):
+            if start_with in nodeid or nodeid.endswith(start_with):
+                idx = i
+                break
+        if idx is not None:
+            after_nodeids = all_nodeids[idx:]
+            if after_nodeids:
+                # If too many, warn user
+                if len(after_nodeids) > 100:
+                    click.echo(
+                        f"{YELLOW}โš ๏ธ  More than 100 tests from selector, passing as positional args may hit OS limits."
+                        "Consider a more specific selector."
+                    )
+                ctx.obj["pytest_args"].extend(after_nodeids)
+                click.echo(f"{YELLOW}โญ๏ธ  Skipping {idx} tests, starting with: {all_nodeids[idx]}")
+            else:
+                click.echo(f"{RED}No tests found with selector '{start_with}'. Nothing to run.")
+                sys.exit(0)
+        else:
+            click.echo(f"{RED}Could not find test matching selector '{start_with}'. Running all tests.")
+
+    if html:
+        ctx.obj["pytest_args"].extend(["--html=test_report.html", "--self-contained-html"])
+        click.echo(f"{BLUE}๐Ÿ“Š HTML report will be generated: test_report.html")
+
+    if fail_fast:
+        ctx.obj["pytest_args"].extend(["-x", "--tb=short"])
+        click.echo(f"{RED}โšก Fail-fast mode: stopping on first failure")
+
+    if parallel:
+        ctx.obj["pytest_args"].extend(["-n", "auto", "--dist", "worksteal"])
+        click.echo(f"{MAGENTA}๐Ÿš€ Parallel execution enabled")
+
+    # Always add these flags for better developer experience
+    if verbose:
+        ctx.obj["pytest_args"].extend(["--tb=short", "-v", "--showlocals", "-ra"])
+    else:
+        ctx.obj["pytest_args"].extend(["--tb=short", "-v"])
+
+    # If no subcommand provided, show help
+    if ctx.invoked_subcommand is None:
+        click.echo(ctx.get_help())
+
+
+@cli.command("list")
+@click.option("--apps-only", is_flag=True, help="Show only unique app names")
+@click.option("--verbose", "-v", is_flag=True, help="Show method docstrings and line numbers")
+@click.option("--short", is_flag=True, help="Show only method names without class prefix")
+def list_tests(apps_only, verbose, short):
+    """List all tests with their app dependencies in a hierarchical view.
+
+    By default, test methods are prefixed with their class names (e.g., TestClass::test_method)
+    making them ready to copy and paste for use with 'test.py run'. Use --short to disable
+    this behavior and show only method names.
+    """
+    hierarchy = get_test_hierarchy()
+
+    if apps_only:
+        # Collect all unique app names
+        apps = set()
+        for file_data in hierarchy.values():
+            for class_data in file_data["classes"].values():
+                for method in class_data["methods"]:
+                    apps.add(method["app"])
+
+        click.echo(f"{GREEN}๐Ÿ“ฑ Application Names Used in Tests:")
+        for app in sorted(apps):
+            click.echo(f"  โ€ข {app}")
+        return
+
+    click.echo(f"{GREEN}๐Ÿงช Test Suite Hierarchy:")
+    click.echo(f"{BLUE}{'=' * 60}")
+
+    for file_name, file_data in hierarchy.items():
+        if "error" in file_data:
+            click.echo(f"{RED}โŒ {file_name}: {file_data['error']}")
+            continue
+
+        if not file_data["classes"]:
+            continue
+
+        click.echo(f"\n{CYAN}๐Ÿ“ {file_name}")
+
+        for class_name, class_data in file_data["classes"].items():
+            class_doc = class_data.get("docstring", "")
+            if class_doc:
+                click.echo(f"  {MAGENTA}๐Ÿ“‹ {class_name} - {class_doc}")
+            else:
+                click.echo(f"  {MAGENTA}๐Ÿ“‹ {class_name}")
+
+            if verbose:
+                click.echo(f"    {BLUE}(line {class_data['line']})")
+
+            # Group methods by app
+            methods_by_app = {}
+            for method in class_data["methods"]:
+                app = method["app"]
+                if app not in methods_by_app:
+                    methods_by_app[app] = []
+                methods_by_app[app].append(method)
+
+            for app, methods in sorted(methods_by_app.items()):
+                app_color = YELLOW if app == "all" else GREEN if app == "sapmachine21" else CYAN
+                click.echo(f"    {app_color}๐ŸŽฏ App: {app}")
+
+                for method in methods:
+                    options_str = ""
+                    if method["options"]:
+                        options_str = f" ({', '.join(method['options'])})"
+
+                    # Format method name with or without class prefix
+                    if short:
+                        method_display = method["name"]
+                    else:
+                        method_display = f"{class_name}::{method['name']}"
+
+                    if verbose:
+                        click.echo(f"      โ€ข {method_display}{options_str} (line {method['line']})")
+                    else:
+                        click.echo(f"      โ€ข {method_display}{options_str}")
+
+
+@cli.command()
+@click.pass_context
+def basic(ctx):
+    """Run basic command tests."""
+    click.echo(f"{GREEN}Running basic command tests...")
+    return run_command([str(PYTEST), "test_basic_commands.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def jfr(ctx):
+    """Run JFR tests."""
+    click.echo(f"{GREEN}Running JFR tests...")
+    return run_command([str(PYTEST), "test_jfr.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def asprof(ctx):
+    """Run async-profiler tests (SapMachine only)."""
+    click.echo(f"{GREEN}Running async-profiler tests...")
+    return run_command([str(PYTEST), "test_asprof.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def integration(ctx):
+    """Run integration tests."""
+    click.echo(f"{GREEN}Running integration tests...")
+    return run_command([str(PYTEST), "test_cf_java_plugin.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def disk_full(ctx):
+    """Run disk full tests."""
+    click.echo(f"{GREEN}Running disk full tests...")
+    return run_command([str(PYTEST), "test_disk_full.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def jre21(ctx):
+    """Run JRE21-specific tests."""
+    click.echo(f"{GREEN}Running JRE21 tests...")
+    return run_command([str(PYTEST), "test_jre21.py"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def all(ctx):
+    """Run all tests."""
+    click.echo(f"{GREEN}Running all tests...")
+    return run_command([str(PYTEST)] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def heap(ctx):
+    """Run all heap-related tests."""
+    click.echo(f"{GREEN}Running heap-related tests...")
+    return run_command([str(PYTEST), "-k", "heap"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.pass_context
+def profiling(ctx):
+    """Run all profiling tests (JFR + async-profiler)."""
+    click.echo(f"{GREEN}Running profiling tests...")
+    return run_command([str(PYTEST), "-k", "jfr or asprof"] + ctx.obj["pytest_args"])
+
+
+@cli.command()
+@click.argument("selector")
+@click.pass_context
+def run(ctx, selector):
+    """Run specific test by selector.
+
+    SELECTOR can be:
+    - TestClass::test_method (auto-finds file)
+    - test_file.py::TestClass
+    - test_file.py::TestClass::test_method
+    - test_file.py
+    - test_method_name (searches all files)
+
+    Examples:
+        test.py run test_cpu_profiling
+        test.py run TestAsprofBasic::test_cpu_profiling
+        test.py run test_asprof.py::TestAsprofBasic
+    """
+    pytest_args = ctx.obj["pytest_args"].copy()
+
+    # Handle different selector formats
+    if "::" in selector and not selector.endswith(".py"):
+        # Class::method format - need to find the file
+        parts = selector.split("::")
+        class_name = parts[0]
+
+        # Find the file containing this class
+        hierarchy = get_test_hierarchy()
+        found_file = None
+
+        for file_name, file_data in hierarchy.items():
+            if class_name in file_data.get("classes", {}):
+                found_file = file_name
+                break
+
+        if found_file:
+            click.echo(f"{BLUE}๐Ÿ“ Found test in file: {found_file}")
+            full_selector = f"{found_file}::{selector}"
+            pytest_args.append(full_selector)
+        else:
+            # Fall back to using -k for the selector
+            click.echo(f"{YELLOW}โš ๏ธ Could not find file for {selector}, using pattern matching")
+            click.echo(f"{BLUE}๐Ÿ’ก For better test selection, use the full path: test_file.py::{selector}")
+            pytest_args.extend(["-k", selector.replace("::", " and ")])
+    elif "::" in selector:
+        # File::Class::method or File::Class format
+        pytest_args.append(selector)
+    elif selector.endswith(".py"):
+        # File selection
+        pytest_args.append(selector)
+    else:
+        # Search for method name across all files
+        click.echo(f"{BLUE}๐Ÿ“ Searching for tests matching '{selector}' across all files")
+        pytest_args.extend(["-k", selector])
+
+    click.echo(f"{GREEN}Running specific test: {selector}")
+    return run_command([str(PYTEST)] + pytest_args)
+
+
+@cli.command()
+def setup():
+    """Set up the test environment (virtual environment, dependencies)."""
+    import subprocess
+    import sys
+
+    click.echo(f"{GREEN}๐Ÿ”ง Setting up virtual environment...")
+
+    venv_dir = SCRIPT_DIR / "venv"
+
+    if not venv_dir.exists():
+        click.echo("   Creating virtual environment...")
+        subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
+
+    click.echo("   Installing/updating dependencies...")
+    pip_cmd = venv_dir / "bin" / "pip"
+    subprocess.run([str(pip_cmd), "install", "--upgrade", "pip"], check=True)
+    subprocess.run([str(pip_cmd), "install", "-r", str(SCRIPT_DIR / "requirements.txt")], check=True)
+
+    click.echo(f"{GREEN}โœ… Virtual environment setup complete!")
+    click.echo("   To run tests: ./test.py all")
+    return 0
+
+
+@cli.command()
+def clean():
+    """Clean test artifacts and temporary files."""
+    import shutil
+
+    click.echo(f"{GREEN}๐Ÿงน Cleaning test artifacts...")
+
+    # Remove pytest cache
+    for cache_dir in [".pytest_cache", "__pycache__", "framework/__pycache__"]:
+        cache_path = SCRIPT_DIR / cache_dir
+        if cache_path.exists():
+            shutil.rmtree(cache_path)
+
+    # Remove test reports and cache files
+    for pattern in ["test_report.html", ".test_success_cache.json"]:
+        for file_path in SCRIPT_DIR.glob(pattern):
+            file_path.unlink()
+
+    # Remove downloaded files (heap dumps, JFR files, etc.)
+    for pattern in ["*.hprof", "*.jfr"]:
+        for file_path in SCRIPT_DIR.glob(pattern):
+            file_path.unlink()
+
+    click.echo(f"{GREEN}โœ… Cleanup complete!")
+    return 0
+
+
+@cli.command()
+@click.option("--force", "-f", is_flag=True, help="Force shutdown without confirmation")
+def shutdown(force):
+    """Shutdown all running test applications and scale them to zero instances."""
+    import yaml
+
+    click.echo(f"{YELLOW}๐Ÿ›‘ Shutting down all test applications...")
+
+    # Load test configuration to get app names
+    config_file = SCRIPT_DIR / "test_config.yml"
+    if not config_file.exists():
+        click.echo(f"{RED}โŒ Config file not found: {config_file}")
+        return 1
+
+    try:
+        with open(config_file, "r") as f:
+            config = yaml.safe_load(f)
+
+        apps = config.get("apps", {})
+        if not apps:
+            click.echo(f"{YELLOW}โš ๏ธ  No apps found in configuration")
+            return 0
+
+        # Confirm shutdown unless --force is used
+        if not force:
+            app_names = list(apps.keys())
+            click.echo(f"{CYAN}๐Ÿ“‹ Apps to shutdown: {', '.join(app_names)}")
+            if not click.confirm(f"{YELLOW}Are you sure you want to shutdown all test apps?"):
+                click.echo(f"{BLUE}โ„น๏ธ  Shutdown cancelled")
+                return 0
+
+        success_count = 0
+        total_count = len(apps)
+
+        for app_name in apps.keys():
+            try:
+                click.echo(f"{BLUE}๐Ÿ›‘ Stopping {app_name}...")
+
+                # First try to stop the app
+                result = subprocess.run(["cf", "stop", app_name], capture_output=True, text=True, timeout=30)
+
+                if result.returncode == 0:
+                    click.echo(f"{GREEN}โœ… {app_name} stopped")
+                    success_count += 1
+                else:
+                    # App might not exist or already stopped
+                    if "not found" in result.stderr.lower() or "does not exist" in result.stderr.lower():
+                        click.echo(f"{YELLOW}โš ๏ธ  {app_name} does not exist or already stopped")
+                        success_count += 1
+                    else:
+                        click.echo(f"{RED}โŒ Failed to stop {app_name}: {result.stderr.strip()}")
+
+            except subprocess.TimeoutExpired:
+                click.echo(f"{RED}โŒ Timeout stopping {app_name}")
+            except Exception as e:
+                click.echo(f"{RED}โŒ Error stopping {app_name}: {e}")
+
+        if success_count == total_count:
+            click.echo(f"{GREEN}โœ… All {total_count} apps shutdown successfully")
+            return 0
+        else:
+            click.echo(f"{YELLOW}โš ๏ธ  {success_count}/{total_count} apps shutdown successfully")
+            return 1
+
+    except Exception as e:
+        click.echo(f"{RED}โŒ Error during shutdown: {e}")
+        return 1
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/test/test.sh b/test/test.sh
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_asprof.py b/test/test_asprof.py
new file mode 100644
index 0000000..429204e
--- /dev/null
+++ b/test/test_asprof.py
@@ -0,0 +1,344 @@
+"""
+Async-profiler tests (most are SapMachine only).
+"""
+
+import time
+
+from framework.decorators import test
+from framework.runner import TestBase
+
+
+class TestAsprofBasic(TestBase):
+    """Basic async-profiler functionality."""
+
+    # @test(ine11", no_restart=True)
+    # def test_asprof_not_present(self, t, app):
+    #    """Test that async-profiler is not present in JDK 21."""
+    #    t.run(f"asprof {app} --args 'status'").should_fail().should_contain("not found")
+
+    @test(no_restart=True)
+    def test_status_no_profiling(self, t, app):
+        """Test asprof status when no profiling is active."""
+        t.run(f"asprof-status {app}").should_succeed()
+
+    @test(no_restart=True)
+    def test_start_provides_stop_instruction(self, t, app):
+        """Test that asprof-start provides clear stop instructions."""
+        t.run(f"asprof-start-cpu {app}").should_succeed().should_contain(f"Use 'cf java asprof-stop {app}'")
+        time.sleep(1)  # Allow some time for the command to register
+        # Clean up
+        t.run(f"asprof-stop {app} --no-download").should_succeed().should_create_remote_file(
+            "*.jfr"
+        ).should_not_create_file()
+
+    @test(no_restart=True)
+    def test_basic_profile(self, t, app):
+        """Test basic async-profiler profile start and stop."""
+        # Start profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed().should_contain(f"Use 'cf java asprof-stop {app}'").no_files()
+
+        # Clean up
+        t.run(f"asprof-stop {app}").should_succeed().should_create_file("*.jfr").should_create_no_remote_files()
+
+    @test(no_restart=True)
+    def test_dry_run_commands(self, t, app):
+        """Test async-profiler commands dry run functionality."""
+        commands = [
+            "asprof-start-wall",
+            "asprof-start-cpu",
+            "asprof-start-alloc",
+            "asprof-start-lock",
+            "asprof-status",
+            "asprof-stop",
+        ]
+
+        for cmd in commands:
+            t.run(f"{cmd} {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test(no_restart=True)
+    def test_asprof_error_handling(self, t, app):
+        """Test error messages for invalid flags."""
+        t.run(f"asprof-start-cpu {app} --invalid-flag").should_fail().no_files().should_contain("invalid")
+
+
+class TestAsprofProfiles(TestBase):
+    """Different async-profiler profiling modes."""
+
+    @test(no_restart=True)
+    def test_cpu_profiling(self, t, app):
+        """Test CPU profiling with async-profiler."""
+        # Start CPU profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed().should_contain(f"Use 'cf java asprof-stop {app}'").no_files()
+
+        # Check status shows profiling is active
+        t.run(f"asprof-status {app}").should_succeed().no_files().should_match("Profiling is running for")
+
+        # Wait for profiling data
+        time.sleep(1)
+
+        # Stop and verify JFR file contains execution samples
+        t.run(f"asprof-stop {app}").should_succeed().should_create_file(f"{app}-asprof-*.jfr").jfr_should_have_events(
+            "jdk.NativeLibrary", 10
+        )
+
+    @test(no_restart=True)
+    def test_wall_clock_profiling(self, t, app):
+        """Test wall-clock profiling mode."""
+        t.run(f"asprof-start-wall {app}").should_succeed()
+
+        time.sleep(1)
+
+        t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(f"{app}-asprof-*.jfr")
+
+    @test(no_restart=True)
+    def test_allocation_profiling(self, t, app):
+        """Test allocation profiling mode."""
+        t.run(f"asprof-start-alloc {app}").should_succeed()
+
+        time.sleep(1)
+
+        t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(f"{app}-asprof-*.jfr")
+
+    @test(no_restart=True)
+    def test_allocation_profiling_dry_run(self, t, app):
+        """Test allocation profiling dry run."""
+        # This should not create any files, just show the command
+        t.run(f"asprof-start-alloc {app} --dry-run").should_succeed().should_contain("-e alloc").no_files()
+        t.run(f"asprof-status {app}").should_succeed().no_files().should_contain("Profiler is not active")
+        t.run(f"asprof-stop {app}").should_succeed()
+
+    @test(no_restart=True)
+    def test_lock_profiling(self, t, app):
+        """Test lock profiling mode."""
+        t.run(f"asprof-start-lock {app}").should_succeed()
+
+        time.sleep(1)
+
+        t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(f"{app}-asprof-*.jfr")
+
+
+class TestAsprofAdvanced(TestBase):
+    """Advanced async-profiler scenarios."""
+
+    @test(no_restart=True)
+    def test_stop_without_download(self, t, app):
+        """Test stopping profiling without downloading results."""
+        # Start profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed()
+
+        time.sleep(1)
+
+        # Stop without download
+        t.run(f"asprof-stop {app} --no-download").should_succeed().should_not_create_file("*.jfr")
+
+    @test(no_restart=True)
+    def test_keep_remote_file(self, t, app):
+        """Test keeping profiling file on remote after download."""
+        # Start profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed()
+
+        time.sleep(1)
+
+        # Stop with keep flag
+        t.run(f"asprof-stop {app} --local-dir . --keep").should_succeed().should_create_file(
+            f"{app}-asprof-*.jfr"
+        ).should_create_remote_file(file_extension=".jfr")
+
+    @test(no_restart=True)
+    def test_workflow_with_multiple_checks(self, t, app):
+        """Test complete workflow with comprehensive checks."""
+        # Test each step of the profiling workflow
+
+        # Start profiling - check success and basic output
+        t.run(f"asprof-start-cpu {app}").should_succeed().should_contain("Profiling started").no_files()
+
+        time.sleep(1)
+
+        # Check status - verify profiling is active
+        t.run(f"asprof-status {app}").should_succeed().should_contain("Profiling is running for").no_files()
+
+        # Stop profiling - check completion message
+        t.run(f"asprof-stop {app} --no-download").should_succeed().should_contain(
+            "--- Execution profile ---"
+        ).should_create_no_files().should_create_remote_file("*.jfr")
+
+
+class TestAsprofLifecycle(TestBase):
+    """Complete async-profiler workflow tests."""
+
+    @test(no_restart=True)
+    def test_full_cpu_profiling_workflow(self, t, app):
+        """Test complete CPU profiling workflow with validation."""
+        # 1. Verify no profiling initially
+        t.run(f"asprof-status {app}").should_succeed()
+
+        # 2. Start CPU profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed().should_contain("asprof-stop").no_files()
+
+        # 3. Verify profiling is active
+        t.run(f"asprof-status {app}").should_succeed().no_files().should_contain("Profiling is running for")
+
+        # 4. Let it run for enough time to collect data
+        time.sleep(2)
+
+        # 5. Stop and download with validation
+        t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(
+            f"{app}-asprof-*.jfr"
+        ).jfr_should_have_events("jdk.NativeLibrary", 5)
+
+        # 6. Verify profiling has stopped
+        t.run(f"asprof-status {app}").should_succeed().no_files().should_contain("Profiler is not active")
+
+    @test(no_restart=True)
+    def test_multiple_profiling_sessions(self, t, app):
+        """Test running multiple profiling sessions in sequence."""
+        profiling_modes = ["cpu", "wall", "alloc"]
+
+        for mode in profiling_modes:
+            # Start profiling
+            t.run(f"asprof-start-{mode} {app}").should_succeed()
+
+            time.sleep(1)
+
+            # Stop and verify file creation
+            t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(f"{app}-asprof-*.jfr")
+
+
+class TestAsprofCommand(TestBase):
+    """Tests for the general asprof command with --args (distinct from asprof-* commands)."""
+
+    @test(no_restart=True)
+    def test_asprof_help_command(self, t, app):
+        """Test asprof help command via --args."""
+        t.run(f"asprof {app} --args '--help'").should_succeed().should_contain("profiler").no_files()
+
+    @test(no_restart=True)
+    def test_asprof_version_command(self, t, app):
+        """Test asprof version command."""
+        t.run(f"asprof {app} --args '--version'").should_succeed().should_start_with("Async-profiler ").no_files()
+
+    @test(no_restart=True)
+    def test_asprof_status_via_args(self, t, app):
+        """Test asprof status via --args (different from asprof-status command)."""
+        t.run(f"asprof {app} --args 'status'").should_succeed().no_files().should_contain("Profiler is not active")
+
+    @test(no_restart=True)
+    def test_asprof_start_auto_no_download(self, t, app):
+        """Test that asprof start commands automatically set no-download."""
+        t.run(f"asprof {app} --args 'start -e cpu -f /tmp/asprof/bla.jfr'").should_succeed().no_files()
+
+        time.sleep(1)
+
+        t.run(f"asprof {app} --args 'stop'").should_succeed().should_create_file(
+            "*.jfr"
+        ).should_create_no_remote_files()
+
+    @test(no_restart=True)
+    def test_asprof_with_custom_output_file(self, t, app):
+        """Test asprof with custom output file using @FSPATH."""
+        # Start profiling with custom file in the asprof folder
+        t.run(f"asprof {app} --args 'start -e cpu -f @FSPATH/custom-profile.jfr'").should_succeed().no_files()
+
+        time.sleep(1)
+
+        # Stop and download
+        t.run(f"asprof {app} --args 'stop' --local-dir .").should_succeed().should_create_file("custom-profile.jfr")
+
+    @test(no_restart=True)
+    def test_asprof_collect_multiple_files(self, t, app):
+        """Test that asprof collects multiple files from the asprof folder."""
+        # Create multiple files via asprof
+        t.run(f"asprof {app} --args 'start -e cpu -f @FSPATH/cpu.jfr'").should_succeed()
+        time.sleep(1)
+        t.run(f"asprof {app} --args 'stop'").should_succeed()
+
+        # Start another profiling session with different file
+        t.run(f"asprof {app} --args 'start -e alloc -f @FSPATH/alloc.jfr'").should_succeed()
+        time.sleep(1)
+        t.run(f"asprof {app} --args 'stop' --local-dir .").should_succeed().should_create_file(
+            "cpu.jfr"
+        ).should_create_file("alloc.jfr")
+
+    @test()
+    def test_asprof_keep_remote_files(self, t, app):
+        """Test keeping remote files with asprof."""
+        # Generate a file and keep it
+        t.run(f"asprof {app} --args 'start -e cpu -f @FSPATH/keep-test.jfr'").should_succeed()
+        time.sleep(1)
+        t.run(f"asprof {app} --args 'stop' --keep --local-dir .").should_succeed().should_create_file(
+            "keep-test.jfr"
+        ).should_create_remote_file("keep-test.jfr")
+
+    @test(no_restart=True)
+    def test_asprof_invalid_args_flag_for_non_args_commands(self, t, app):
+        """Test that --args flag is rejected for commands that don't support it."""
+        # asprof-start commands don't use @ARGS, so --args should be rejected
+        t.run(f"asprof-start-cpu {app} --args 'test'").should_fail().should_contain(
+            "not supported for asprof-start-cpu"
+        )
+
+
+class TestAsprofEdgeCases(TestBase):
+    """Edge cases and error conditions for async-profiler."""
+
+    @test()
+    def test_asprof_start_commands_file_flags_validation(self, t, app):
+        """Test that asprof-start commands reject inappropriate file flags."""
+        # asprof-start commands have GenerateFiles=false, so some file flags should be rejected
+        for flag in ["--keep", "--no-download"]:
+            t.run(f"asprof-start-cpu {app} {flag}").should_fail().should_contain("not supported for asprof-start-cpu")
+
+    # @test()
+    # def test_asprof_stop_requires_prior_start(self, t, app):
+    #    """Test asprof-stop behavior when no profiling is active."""
+    #    t.run(f"asprof-stop {app}").should_fail().should_contain("[ERROR] Profiler has not started").no_files()
+
+    @test()
+    def test_asprof_different_event_types(self, t, app):
+        """Test CPU event type via asprof command."""
+        # Test CPU event type
+        t.run(f"asprof {app} --args 'start -e cpu'").should_succeed()
+        time.sleep(0.5)
+        t.run(f"asprof {app} --args 'stop'").should_succeed()
+
+    @test(no_restart=True)
+    def test_asprof_output_formats(self, t, app):
+        """Test JFR output format with asprof."""
+        t.run(f"asprof {app} --args 'start -e cpu -o jfr -f @FSPATH/profile.jfr'").should_succeed()
+        time.sleep(0.5)
+        t.run(f"asprof {app} --args 'stop' --local-dir .").should_succeed().should_create_file("profile.jfr")
+
+    @test(no_restart=True)
+    def test_asprof_recursive_args_validation(self, t, app):
+        """Test that @ARGS cannot contain itself in asprof."""
+        # This should fail due to the validation in replaceVariables
+        t.run(f"asprof {app} --args 'echo @ARGS'").should_fail()
+
+    @test(no_restart=True)
+    def test_asprof_profiling_duration_and_interval(self, t, app):
+        """Test asprof with duration parameter."""
+        # Test duration parameter
+        t.run(f"asprof {app} --args 'start -e cpu -d 2 -f @FSPATH/duration.jfr'").should_succeed()
+        time.sleep(3)  # Wait for profiling to complete
+        t.run(f"asprof {app} --args 'status'").should_succeed()  # Should show no active profiling
+        t.run(f"asprof {app} --args 'stop' --local-dir .").should_succeed().should_create_file("duration.jfr")
+
+    @test(no_restart=True)
+    def test_asprof_list_command(self, t, app):
+        # List should show available files
+        t.run(f"asprof {app} --args 'list'").should_succeed().no_files().should_contain("Basic events:")
+
+
+class TestAsprofAdvancedFeatures(TestBase):
+    """Advanced async-profiler features and workflows."""
+
+    @test(no_restart=True)
+    def test_asprof_flamegraph_generation(self, t, app):
+        """Test flamegraph generation with asprof."""
+        t.run(f"asprof {app} --args 'start -e cpu'").should_succeed()
+        time.sleep(1)
+
+        # Generate flamegraph directly
+        t.run(
+            f"asprof {app} --args 'stop -o collapsed -f @FSPATH/flamegraph.html' --local-dir ."
+        ).should_succeed().should_create_file("flamegraph.html")
diff --git a/test/test_basic_commands.py b/test/test_basic_commands.py
new file mode 100644
index 0000000..187192c
--- /dev/null
+++ b/test/test_basic_commands.py
@@ -0,0 +1,377 @@
+"""
+Basic CF Java Plugin command tests.
+
+Run with:
+    pytest test_basic_commands.py -v                    # All basic commands
+    pytest test_basic_commands.py::TestHeapDump -v     # Only heap dump tests
+    pytest test_basic_commands.py::TestVMCommands -v   # Only VM commands
+
+    ./test.py basic # Run all basic tests
+    ./test.py run heap-dump # Run only heap dump tests
+    # ...
+"""
+
+import os
+import shutil
+import tempfile
+
+from framework.decorators import test
+from framework.runner import TestBase
+
+
+class TestHeapDump(TestBase):
+    """Test suite for heap dump functionality."""
+
+    @test(no_restart=True)
+    def test_basic_download(self, t, app):
+        """Test basic heap dump with local download."""
+        t.run(f"heap-dump {app}").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        ).should_create_no_remote_files()
+
+    @test(no_restart=True)
+    def test_keep_remote_file(self, t, app):
+        """Test heap dump with --keep flag to preserve remote file."""
+        t.run(f"heap-dump {app} --keep").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        ).should_create_remote_file(
+            "*.hprof"
+        )  # Just look for any .hprof file
+
+    @test(no_restart=True)
+    def test_no_download(self, t, app):
+        """Test heap dump without downloading - file stays remote."""
+        t.run(f"heap-dump {app} --no-download").should_succeed().should_create_no_files().should_create_remote_file(
+            "*.hprof"
+        ).should_contain("Successfully created heap dump").should_contain("No download requested")
+
+    @test(no_restart=True)
+    def test_custom_container_dir(self, t, app):
+        """Test heap dump with custom container directory."""
+        t.run(f"heap-dump {app} --container-dir /home/vcap/app").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        ).should_create_no_remote_files()
+
+    @test(no_restart=True)
+    def test_custom_local_dir(self, t, app):
+        """Test heap dump with custom local directory."""
+        # Create a temporary directory for the download
+        temp_dir = tempfile.mkdtemp()
+        try:
+            t.run(f"heap-dump {app} --local-dir {temp_dir}").should_succeed()
+            # Verify file exists in the custom directory
+            import glob
+
+            files = glob.glob(f"{temp_dir}/{app}-heapdump-*.hprof")
+            assert len(files) > 0, f"No heap dump files found in {temp_dir}"
+        finally:
+            # Clean up
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    @test(no_restart=True)
+    def test_verbose_output(self, t, app):
+        """Test heap dump with verbose output."""
+        # Verbose output should contain more detailed information
+        t.run(f"heap-dump {app} --verbose").should_succeed().should_contain("[VERBOSE]")
+
+    @test(no_restart=True)
+    def test_combined_flags(self, t, app):
+        """Test heap dump with multiple flags combined."""
+        t.run(f"heap-dump {app} --keep --local-dir . --verbose").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        ).should_create_remote_file("*.hprof")
+
+    @test(no_restart=True)
+    def test_dry_run(self, t, app):
+        """Test heap dump dry run shows SSH command."""
+        t.run(f"heap-dump {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test(no_restart=True)
+    def test_dry_run_variable_replacement(self, t, app):
+        """Test that @ variables are properly replaced in dry-run mode."""
+        result = t.run(f"heap-dump {app} --dry-run").should_succeed()
+        # Ensure no @ variables remain in the output
+        result.should_not_contain("@FSPATH")
+        result.should_not_contain("@APP_NAME")
+        result.should_not_contain("@FILE_NAME")
+        result.should_not_contain("@ARGS")
+        # Should contain the actual app name
+        result.should_contain(app)
+
+    @test(no_restart=True)
+    def test_no_download_twice(self, t, app):
+        """Test error handling when heap dump file already exists on remote."""
+        t.run(f"heap-dump {app} --no-download").should_succeed()
+        t.run(f"heap-dump {app} --no-download").should_succeed()
+
+    @test(no_restart=True)
+    def test_app_instance_selection(self, t, app):
+        """Test heap dump with specific app instance index."""
+        # Note: This test is valid even with a single instance app
+        # as specifying index 0 should work the same as not specifying
+
+        # Try with explicit instance 0 (should succeed even if only one instance)
+        t.run(f"heap-dump {app} --app-instance-index 0 --local-dir .").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        )
+
+    @test(no_restart=True)
+    def test_heap_dump_shorthand_flags(self, t, app):
+        """Test heap dump with shorthand flags."""
+        # Test with shorthand flags -k (keep) and -ld (local-dir)
+        t.run(f"heap-dump {app} -k -ld .").should_succeed().should_create_file(
+            f"{app}-heapdump-*.hprof"
+        ).should_create_remote_file("*.hprof")
+
+    @test(no_restart=True)
+    def test_invalid_flag(self, t, app):
+        """Test heap dump with an invalid/unknown flag."""
+        t.run(f"heap-dump {app} --not-a-real-flag").should_fail().should_contain(
+            "Error while parsing command arguments: Invalid flag: --not-a-real-flag"
+        )
+
+    @test(no_restart=True)
+    def test_help_output(self, t, app):
+        """Test heap dump help/usage output."""
+        # the help only works for the main command
+        t.run(f"heap-dump {app} --help").should_succeed().should_contain_help()
+
+    @test(no_restart=True)
+    def test_nonexistent_local_dir(self, t, app):
+        """Test heap dump with a non-existent local directory."""
+        import uuid
+
+        bad_dir = f"/tmp/does-not-exist-{uuid.uuid4()}"
+        t.run(f"heap-dump {app} --local-dir {bad_dir}").should_fail().should_contain("Error creating local file at")
+
+    @test(no_restart=True)
+    def test_unwritable_local_dir(self, t, app):
+        """Test heap dump with an unwritable local directory."""
+        with tempfile.TemporaryDirectory() as temp_dir:
+            os.chmod(temp_dir, 0o400)  # Read-only
+            try:
+                t.run(f"heap-dump {app} --local-dir {temp_dir}").should_fail().should_contain(
+                    "Error creating local file at"
+                )
+            finally:
+                os.chmod(temp_dir, 0o700)
+
+    @test(no_restart=True)
+    def test_negative_app_instance_index(self, t, app):
+        # Test negative index
+        t.run(f"heap-dump {app} --app-instance-index -1").should_fail().should_contain(
+            "Invalid application instance index -1, must be >= 0"
+        )
+
+    @test(no_restart=True)
+    def test_invalid_app_instance_index(self, t, app):
+        t.run(f"heap-dump {app} --app-instance-index abc").should_fail().should_contain(
+            "Error while parsing command arguments: Value for flag 'app-instance-index' must be an integer"
+        )
+
+    @test(no_restart=True)
+    def test_wrong_app_instance_index(self, t, app):
+        """Test heap dump with wrong app instance index."""
+        t.run(f"heap-dump {app} --app-instance-index 1").should_fail().should_contain(
+            "Command execution failed: The specified application instance does not exist"
+        )
+
+
+class TestGeneralCommands(TestBase):
+    """Test suite for general command functionality."""
+
+    @test(no_restart=True)
+    def test_invalid_command_error(self, t, app):
+        """Test that invalid commands fail with appropriate error message."""
+        t.run("invalid-command-xyz").should_fail().should_contain(
+            'Unrecognized command "invalid-command-xyz", did you mean:'
+        ).no_files()
+
+
+class TestThreadDump(TestBase):
+    """Test suite for thread dump functionality."""
+
+    @test(no_restart=True)
+    def test_thread_dump_format(self, t, app):
+        """Test thread dump output format with proper validation."""
+        t.run(f"thread-dump {app}").should_succeed().should_contain_valid_thread_dump().should_contain(
+            "http-nio-8080-Acceptor"
+        ).no_files()
+
+    @test(no_restart=True)
+    def test_dry_run(self, t, app):
+        """Test thread dump dry run shows SSH command."""
+        t.run(f"thread-dump {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test(no_restart=True)
+    def test_thread_dump_basic_success(self, t, app):
+        """Test thread dump basic functionality."""
+        t.run(f"thread-dump {app}").should_succeed().no_files().should_contain_valid_thread_dump()
+
+    @test(no_restart=True)
+    def test_thread_dump_keep_flag_error(self, t, app):
+        """Test that thread-dump rejects --keep flag."""
+        t.run(f"thread-dump {app} --keep").should_fail().should_contain("not supported for thread-dump")
+
+
+class TestVMCommands(TestBase):
+    """Test suite for VM information commands."""
+
+    @test(no_restart=True)
+    def test_vm_info_comprehensive(self, t, app):
+        """Test VM info provides comprehensive system information."""
+        t.run(f"vm-info {app}").should_succeed().should_contain_vm_info().should_have_at_least(1000, "lines").no_files()
+
+    @test(no_restart=True)
+    def test_vm_info_invalid_flag(self, t, app):
+        """Test VM info with invalid flag."""
+        t.run(f"vm-info {app} --not-a-real-flag").should_fail().should_contain(
+            "Error while parsing command arguments: Invalid flag: --not-a-real-flag"
+        )
+
+    @test(no_restart=True)
+    def test_vm_info_help_output(self, t, app):
+        """Test VM info help/usage output."""
+        t.run(f"vm-info {app} --help").should_succeed().should_contain_help()
+
+    @test(no_restart=True)
+    def test_vm_info_dry_run(self, t, app):
+        """Test VM info dry run shows SSH command."""
+        t.run(f"vm-info {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test(no_restart=True)
+    def test_vm_vitals_dry_run(self, t, app):
+        """Test VM vitals dry run shows SSH command."""
+        t.run(f"vm-vitals {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test(no_restart=True)
+    def test_vm_vitals_basic(self, t, app):
+        """Test VM vitals provides vital statistics."""
+        t.run(f"vm-vitals {app}").should_succeed().no_files()
+
+    @test(no_restart=True)
+    def test_vm_vitals_content(self, t, app):
+        """Test VM vitals output contains expected vital statistics."""
+        t.run(f"vm-vitals {app}").should_succeed().should_contain_vitals()
+
+    @test(no_restart=True)
+    def test_vm_version(self, t, app):
+        """Test VM version output format validation."""
+        t.run(f"vm-version {app}").should_succeed().should_contain(
+            "OpenJDK 64-Bit Server VM version 21"
+        ).should_contain("JDK").should_have_at_least(2, "lines")
+
+    @test(no_restart=True)
+    def test_vm_commands_with_file_flags(self, t, app):
+        """Test that VM commands handle file-related flags appropriately."""
+        # VM info should work with --dry-run
+        t.run(f"vm-info {app} --dry-run").should_succeed().should_contain("cf ssh")
+
+        # VM commands don't generate files, so --keep should either be ignored or error
+        t.run(f"vm-info {app} --keep").should_fail()
+
+
+class TestVariableReplacements(TestBase):
+    """Test suite for variable replacements in commands."""
+
+    @test(no_restart=True)
+    def test_fspath_validation(self, t, app):
+        """Test that FSPATH environment variable is properly set and usable."""
+        # Run a command that uses FSPATH and verify it works
+        t.run(
+            f'jcmd {app} --args \'"FSPATH is: @FSPATH" && test -d "@FSPATH" &&'
+            'echo "FSPATH directory exists"\' --dry-run'
+        ).should_succeed().should_contain("FSPATH is: /tmp/jcmd").should_contain("FSPATH directory exists")
+
+    @test(no_restart=True)
+    def test_variable_replacement_functionality(self, t, app):
+        """Test that variable replacements work correctly in dry-run mode."""
+        # Use dry-run to see that variables are properly replaced
+        (
+            t.run(f"jcmd {app} --args 'echo test @FSPATH @APP_NAME' --dry-run")
+            .should_succeed()
+            .should_not_contain("@FSPATH")
+            .should_not_contain("@ARGS")
+            .should_not_contain("@APP_NAME")
+        )
+
+    @test(no_restart=True)
+    def test_variable_replacement_with_disallowed_recursion(self, t, app):
+        """Test that @-variables do not allow recursive replacements."""
+        t.run(f"jcmd {app} --args 'echo @ARGS'").should_fail()
+
+
+class TestJCmdCommands(TestBase):
+    """Test suite for JCMD functionality."""
+
+    @test(no_restart=True)
+    def test_heap_dump_without_download(self, t, app):
+        """Test JCMD heap dump without local download."""
+        t.run(f"jcmd {app} --args 'GC.heap_dump my_dump.hprof'").should_succeed().should_create_remote_file(
+            "my_dump.hprof", folder="$HOME/app"
+        ).should_not_create_file()
+
+    @test(no_restart=True)
+    def test_heap_dump_with_fspath(self, t, app):
+        """Test JCMD heap dump with local download using FSPATH."""
+        t.run(f"jcmd {app} --args 'GC.heap_dump @FSPATH/my_dump.hprof'").should_succeed().should_create_file(
+            "my_dump.hprof"
+        )
+
+    @test(no_restart=True)
+    def test_heap_dump_absolute_path(self, t, app):
+        """Test JCMD heap dump with absolute path (without using FSPATH)."""
+        t.run(
+            f"jcmd {app} --args 'GC.heap_dump /tmp/my_absolute_dump.hprof'"
+        ).should_succeed().should_not_create_file().should_create_remote_file(
+            absolute_path="/tmp/my_absolute_dump.hprof"
+        )
+
+    @test(no_restart=True)
+    def test_heap_dump_no_download(self, t, app):
+        """Test JCMD heap dump without download."""
+        t.run(
+            f"jcmd {app} --args 'GC.heap_dump @FSPATH/my_dump.hprof' --no-download"
+        ).should_succeed().should_not_create_file("my_dump.hprof")
+
+    @test(no_restart=True)  # VM uptime is read-only
+    def test_vm_uptime(self, t, app):
+        """Test JCMD VM uptime command."""
+        t.run(f"jcmd {app} --args 'VM.uptime'").should_succeed().should_match(
+            r"\d+\.\d+\s+s"
+        )  # Should show uptime in seconds
+
+    @test(no_restart=True)
+    def test_relative_path_with_fspath(self, t, app):
+        """Test JCMD with relative path combined with FSPATH."""
+        t.run(
+            f"jcmd {app} --args 'GC.heap_dump @FSPATH/../relative_dump.hprof'"
+        ).should_succeed().should_not_create_file().should_create_remote_file("relative_dump.hprof")
+
+    @test(no_restart=True)
+    def test_jcmd_recursive_args_error(self, t, app):
+        """Test that JCMD prevents recursive @ARGS usage."""
+        t.run(f"jcmd {app} --args 'echo @ARGS'").should_fail()
+
+    @test(no_restart=True)
+    def test_jcmd_invalid_command_error(self, t, app):
+        """Test that JCMD fails gracefully with an invalid command."""
+        t.run(f"jcmd {app} --args 'invalid-command'").should_fail().should_contain(
+            "java.lang.IllegalArgumentException: Unknown diagnostic command"
+        )
+
+    @test(no_restart=True)
+    def test_sapmachine_uses_asprof_jcmd(self, t, app):
+        """Test that SapMachine uses asprof-jcmd instead of regular jcmd."""
+        (
+            t.run(f"jcmd {app} --args 'help \\\"$JCMD_COMMAND\\\"'")
+            .should_contain("asprof jcmd")
+            .no_files()
+            .should_succeed()
+        )
+
+
+if __name__ == "__main__":
+    import pytest
+
+    pytest.main([__file__, "-v", "--tb=short"])
diff --git a/test/test_cf_java_plugin.py b/test/test_cf_java_plugin.py
new file mode 100644
index 0000000..fc03e90
--- /dev/null
+++ b/test/test_cf_java_plugin.py
@@ -0,0 +1,112 @@
+"""
+Integration and cross-cutting tests for CF Java Plugin.
+
+This file contains integration tests and scenarios that span multiple commands.
+For focused command testing, see:
+- test_basic_commands.py: Basic CF Java commands
+- test_jfr.py: JFR functionality
+- test_asprof.py: Async-profiler (SapMachine)
+
+Run with:
+    pytest test_cf_java_plugin.py -v               # Integration tests
+    pytest test_cf_java_plugin.py::TestWorkflows -v # Complete workflows
+"""
+
+import time
+
+from framework.decorators import test
+from framework.runner import TestBase
+
+
+class TestDryRunConsistency(TestBase):
+    """Test that all commands support --dry-run consistently."""
+
+    @test()
+    def test_all_commands_support_dry_run(self, t, app):
+        """Test that all major commands support --dry-run flag."""
+        commands = [
+            "heap-dump",
+            "thread-dump",
+            "vm-info",
+            "vm-version",
+            "vm-vitals",
+            "jfr-start",
+            "jfr-status",
+            "jfr-stop",
+            "jfr-dump",
+            "asprof-start-wall",
+            "asprof-start-cpu",
+            "asprof-start-alloc",
+            "asprof-start-lock",
+            "asprof-status",
+            "asprof-stop",
+        ]
+
+        for cmd in commands:
+            t.run(f"{cmd} {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+
+class TestWorkflows(TestBase):
+    """Integration tests for complete workflows."""
+
+    @test()
+    def test_diagnostic_data_collection_workflow(self, t, app):
+        """Test collecting comprehensive diagnostic data."""
+        # 1. Collect VM information
+        t.run(f"vm-info {app}").should_succeed().should_contain_vm_info()
+        # 2. Get thread state
+        t.run(f"thread-dump {app}").should_succeed().should_contain_valid_thread_dump()
+
+        # 3. Capture memory state
+        t.run(f"heap-dump {app} --local-dir .").should_succeed().should_create_file(f"{app}-heapdump-*.hprof")
+
+        # 4. Start performance recording
+        t.run(f"jfr-start {app}").should_succeed()
+
+        time.sleep(2)
+
+        # 5. Capture performance data
+        t.run(f"jfr-stop {app} --local-dir .").should_succeed().should_create_file(f"{app}-jfr-*.jfr")
+
+    @test()
+    def test_performance_analysis_workflow(self, t, app):
+        """Test performance analysis workflow with async-profiler."""
+        # 1. Baseline: Get VM vitals
+        t.run(f"vm-vitals {app}").should_succeed().should_contain_vitals()
+
+        # 2. Start CPU profiling
+        t.run(f"asprof-start-cpu {app}").should_succeed().no_files()
+
+        # 3. Let application run under profiling
+        time.sleep(2)
+
+        # 4. Capture profiling data
+        t.run(f"asprof-stop {app} --local-dir .").should_succeed().should_create_file(
+            f"{app}-asprof-*.jfr"
+        ).jfr_should_have_events("jdk.NativeLibrary", 5)
+
+        # 5. Follow up with memory analysis
+        t.run(f"heap-dump {app} --local-dir .").should_succeed().should_create_file(f"{app}-heapdump-*.hprof")
+
+    @test()
+    def test_concurrent_operations_safety(self, t, app):
+        """Test that concurrent operations don't interfere."""
+        # Start JFR recordingx
+        t.run(f"jfr-start {app}").should_succeed()
+
+        # Other operations should work while JFR is recording
+        t.run(f"vm-info {app}").should_succeed().should_contain_vm_info()
+        t.run(f"thread-dump {app}").should_succeed().should_contain_valid_thread_dump()
+        t.run(f"vm-vitals {app}").should_succeed().should_contain_vitals()
+
+        # JFR should still be recording
+        t.run(f"jfr-status {app}").should_succeed().should_contain("name=JFR maxsize=250.0MB (running)")
+
+        # Clean up
+        t.run(f"jfr-stop {app} --no-download").should_succeed()
+
+
+if __name__ == "__main__":
+    import pytest
+
+    pytest.main([__file__, "-v", "--tb=short"])
diff --git a/test/test_config.yml.example b/test/test_config.yml.example
new file mode 100644
index 0000000..22f6e2a
--- /dev/null
+++ b/test/test_config.yml.example
@@ -0,0 +1,20 @@
+"""
+Configuration file for CF Java Plugin testing.
+
+Copy this file to test_config.yml and fill in your credentials.
+Environment variables (CF_API, CF_USERNAME, CF_PASSWORD, CF_ORG, CF_SPACE) 
+take precedence over values in this file.
+"""
+
+# CF Configuration
+cf:
+  api_endpoint: "https://api.cf.eu12.hana.ondemand.com"  # Or set CF_API environment variable
+  username: "your-username"  # Or set CF_USERNAME environment variable
+  password: "your-password"  # Or set CF_PASSWORD environment variable
+  org: "sapmachine-testing"  # Or set CF_ORG environment variable
+  space: "dev"               # Or set CF_SPACE environment variable
+
+# Timeouts in seconds
+timeouts:
+  app_start: 300
+  command: 60
diff --git a/test/test_disk_full.py b/test/test_disk_full.py
new file mode 100644
index 0000000..fcac56b
--- /dev/null
+++ b/test/test_disk_full.py
@@ -0,0 +1,54 @@
+"""
+JFR (Java Flight Recorder) tests.
+
+Run with:
+    pytest test_disk_full.py -v                           # All JFR tests
+"""
+
+import time
+
+from framework.decorators import test
+from framework.runner import TestBase, get_test_session
+
+
+class DiskFullContext:
+    """Fills the disk to the brim for testing purposes."""
+
+    def __init__(self, app):
+        self.app = app
+        self.runner = get_test_session().runner
+
+    def __enter__(self):
+        # well dd doesn't work, so we use good
+        self.runner.run_command(f"cf ssh {self.app} -c 'yes >> $HOME/fill_disk.txt'")
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        # Clean up the dummy data
+        self.runner.run_command(f"cf ssh {self.app} -c 'rm $HOME/fill_disk.txt'")
+
+
+class TestDiskFull(TestBase):
+    """Tests for disk full scenarios."""
+
+    @test(no_restart=True)
+    def test_heap_dump(self, t, app):
+        """Test JFR functionality with disk full simulation."""
+        with DiskFullContext(app):
+            t.run(f"heap-dump {app}").should_fail().should_contain("No space left on device").no_files()
+
+    @test(no_restart=True)
+    def test_jfr(self, t, app):
+        """Test JFR start with disk full simulation."""
+        with DiskFullContext(app):
+            t.run(f"jfr-start {app}")
+            time.sleep(2)
+            t.run(f"jfr-stop {app}").should_fail().no_files()
+
+    @test(no_restart=True)
+    def test_asprof(self, t, app):
+        """Test ASProfile with disk full simulation."""
+        with DiskFullContext(app):
+            t.run(f"asprof-start-wall {app}")
+            time.sleep(2)
+            t.run(f"asprof-stop {app}").should_fail().no_files()
diff --git a/test/test_jfr.py b/test/test_jfr.py
new file mode 100644
index 0000000..02bbfa7
--- /dev/null
+++ b/test/test_jfr.py
@@ -0,0 +1,88 @@
+"""
+JFR (Java Flight Recorder) tests.
+
+Run with:
+    pytest test_jfr.py -v                           # All JFR tests
+    pytest test_jfr.py::TestJFRBasic -v            # Basic JFR functionality
+    pytest test_jfr.py::TestJFRProfiles -v         # Profile-specific tests
+    pytest test_jfr.py::TestJFRLifecycle -v        # Complete workflows
+"""
+
+import time
+
+from framework.decorators import test
+from framework.runner import TestBase
+
+
+class TestJFRBasic(TestBase):
+    """Basic JFR functionality tests."""
+
+    @test()
+    def test_status_no_recording(self, t, app):
+        """Test JFR status when no recording is active."""
+        t.run(f"jfr-status {app}").should_succeed().should_match(
+            r"No available recordings\.\s*Use jcmd \d+ JFR\.start to start a recording\."
+        ).no_files()
+
+    @test()
+    def test_status_with_active_recording(self, t, app):
+        """Test JFR status shows active recording information."""
+        # Start recording
+        t.run(f"jfr-start {app}").should_succeed().no_files()
+
+        # Check status shows recording
+        t.run(f"jfr-status {app}").should_succeed().should_contain("name=JFR maxsize=250.0MB (running)").no_files()
+
+        # Clean up
+        t.run(f"jfr-stop {app} --no-download").should_succeed().should_create_remote_file(
+            "*.jfr"
+        ).should_create_no_files()
+
+    @test()
+    def test_jfr_dump(self, t, app):
+        """Test JFR dump functionality."""
+        # Start recording
+        t.run(f"jfr-start {app}").should_succeed().should_create_remote_file("*.jfr").should_create_no_files()
+
+        # Wait a bit to ensure recording has data
+        time.sleep(2)
+
+        # Dump the recording
+        t.run(f"jfr-dump {app}").should_succeed().should_create_file("*.jfr").should_create_no_remote_files()
+
+        t.run(f"jfr-status {app}").should_succeed().should_contain("Recording ").no_files()
+
+        # Clean up
+        t.run(f"jfr-stop {app} --no-download").should_succeed().should_create_remote_file(
+            "*.jfr"
+        ).should_create_no_files()
+
+    @test()
+    def test_concurrent_recordings_prevention(self, t, app):
+        """Test that concurrent JFR recordings are prevented."""
+        # Start first recording
+        t.run(f"jfr-start {app}").should_succeed().should_contain(f"Use 'cf java jfr-stop {app}'")
+
+        # Attempt to start second recording should fail
+        t.run(f"jfr-start {app}").should_fail().should_contain("JFR recording already running")
+
+        # Clean up - stop the first recording
+        t.run(f"jfr-stop {app} --no-download").should_succeed()
+
+    @test()
+    def test_gc_profile(self, t, app):
+        """Test JFR GC profile (SapMachine only)."""
+        t.run(f"jfr-start-gc {app}").should_succeed().no_files()
+        t.run(f"jfr-stop {app} --no-download").should_succeed().should_create_remote_file("*.jfr")
+
+    @test()
+    def test_gc_details_profile(self, t, app):
+        """Test JFR detailed GC profile (SapMachine only)."""
+        t.run(f"jfr-start-gc-details {app}").should_succeed().no_files()
+        t.run(f"jfr-stop {app}").should_succeed().should_create_no_remote_files().should_create_file("*.jfr")
+
+
+if __name__ == "__main__":
+    import pytest
+
+    pytest.main([__file__, "-v", "--tb=short"])
diff --git a/test/test_jre21.py b/test/test_jre21.py
new file mode 100644
index 0000000..ba81173
--- /dev/null
+++ b/test/test_jre21.py
@@ -0,0 +1,151 @@
+"""
+JRE21 tests - Testing that JRE (without JDK tools) properly fails for all commands.
+
+A JRE doesn't include development tools like jcmd, jmap, jstack, etc.
+All commands should fail with appropriate error messages.
+
+Run with:
+    pytest test_jre21.py -v                           # All JRE21 tests
+    ./test.py run jre21                                # Run JRE21 tests
+"""
+
+from framework.decorators import test
+from framework.runner import TestBase
+
+
+class TestJRE21CommandFailures(TestBase):
+    """Test that JRE21 app fails for all commands requiring JDK tools."""
+
+    @test("jre21", no_restart=True)
+    def test_heap_dump_fails(self, t, app):
+        """Test that heap-dump fails on JRE21."""
+        t.run(f"heap-dump {app}").should_fail().should_contain("jvmmon or jmap are required for generating heap dump")
+
+    @test("jre21", no_restart=True)
+    def test_thread_dump_fails(self, t, app):
+        """Test that thread-dump fails on JRE21."""
+        t.run(f"thread-dump {app}").should_fail().should_contain("jvmmon or jmap are required for")
+
+    @test("jre21", no_restart=True)
+    def test_vm_info_fails(self, t, app):
+        """Test that vm-info fails on JRE21."""
+        t.run(f"vm-info {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_vm_vitals_fails(self, t, app):
+        """Test that vm-vitals fails on JRE21."""
+        t.run(f"vm-vitals {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_vm_version_fails(self, t, app):
+        """Test that vm-version fails on JRE21."""
+        t.run(f"vm-version {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_jcmd_fails(self, t, app):
+        """Test that jcmd fails on JRE21."""
+        t.run(f"jcmd {app} --args 'help'").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_jfr_start_fails(self, t, app):
+        """Test that jfr-start fails on JRE21."""
+        t.run(f"jfr-start {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_jfr_stop_fails(self, t, app):
+        """Test that jfr-stop fails on JRE21."""
+        t.run(f"jfr-stop {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_jfr_status_fails(self, t, app):
+        """Test that jfr-status fails on JRE21."""
+        t.run(f"jfr-status {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_jfr_dump_fails(self, t, app):
+        """Test that jfr-dump fails on JRE21."""
+        t.run(f"jfr-dump {app}").should_fail().should_contain("jcmd not found")
+
+    @test("jre21", no_restart=True)
+    def test_asprof_start_fails(self, t, app):
+        """Test that asprof-start-cpu fails on JRE21."""
+        t.run(f"asprof-start-cpu {app}").should_fail().should_contain("asprof not found")
+
+    @test("jre21", no_restart=True)
+    def test_asprof_status_fails(self, t, app):
+        """Test that asprof-status fails on JRE21."""
+        t.run(f"asprof-status {app}").should_fail().should_contain("asprof not found")
+
+    @test("jre21", no_restart=True)
+    def test_asprof_stop_fails(self, t, app):
+        """Test that asprof-stop fails on JRE21."""
+        t.run(f"asprof-stop {app}").should_fail().should_contain("asprof not found")
+
+    @test("jre21", no_restart=True)
+    def test_asprof_command_fails(self, t, app):
+        """Test that asprof command fails on JRE21."""
+        t.run(f"asprof {app} --args 'help'").should_fail().should_contain("asprof not found")
+
+
+class TestJRE21DryRun(TestBase):
+    """Test that dry-run commands work on JRE21 (they don't actually execute)."""
+
+    @test("jre21", no_restart=True)
+    def test_heap_dump_dry_run_works(self, t, app):
+        """Test that heap-dump dry-run works on JRE21."""
+        t.run(f"heap-dump {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test("jre21", no_restart=True)
+    def test_thread_dump_dry_run_works(self, t, app):
+        """Test that thread-dump dry-run works on JRE21."""
+        t.run(f"thread-dump {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test("jre21", no_restart=True)
+    def test_vm_info_dry_run_works(self, t, app):
+        """Test that vm-info dry-run works on JRE21."""
+        t.run(f"vm-info {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test("jre21", no_restart=True)
+    def test_jcmd_dry_run_works(self, t, app):
+        """Test that jcmd dry-run works on JRE21."""
+        t.run(f"jcmd {app} --args 'help' --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+    @test("jre21", no_restart=True)
+    def test_jfr_start_dry_run_works(self, t, app):
+        """Test that jfr-start dry-run works on JRE21."""
+        t.run(f"jfr-start {app} --dry-run").should_succeed().should_contain("cf ssh").no_files()
+
+
+class TestJRE21Help(TestBase):
+    """Test that help commands work on JRE21."""
+
+    @test("jre21", no_restart=True)
+    def test_heap_dump_help(self, t, app):
+        """Test that heap-dump help works on JRE21."""
+        t.run(f"heap-dump {app} --help").should_succeed().should_contain_help()
+
+    @test("jre21", no_restart=True)
+    def test_thread_dump_help(self, t, app):
+        """Test that thread-dump help works on JRE21."""
+        t.run(f"thread-dump {app} --help").should_succeed().should_contain_help()
+
+    @test("jre21", no_restart=True)
+    def test_vm_info_help(self, t, app):
+        """Test that vm-info help works on JRE21."""
+        t.run(f"vm-info {app} --help").should_succeed().should_contain_help()
+
+    @test("jre21", no_restart=True)
+    def test_jcmd_help(self, t, app):
+        """Test that jcmd help works on JRE21."""
+        t.run(f"jcmd {app} --help").should_succeed().should_contain_help()
+
+    @test("jre21", no_restart=True)
+    def test_jfr_start_help(self, t, app):
+        """Test that jfr-start help works on JRE21."""
+        t.run(f"jfr-start {app} --help").should_succeed().should_contain_help()
+
+
+if __name__ == "__main__":
+    import pytest
+
+    pytest.main([__file__, "-v", "--tb=short"])
diff --git a/utils/cfutils.go b/utils/cfutils.go
new file mode 100644
index 0000000..ed77a3a
--- /dev/null
+++ b/utils/cfutils.go
@@ -0,0 +1,406 @@
+// Package utils provides utility functions for Cloud Foundry CLI Java plugin operations.
+package utils
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/lithammer/fuzzysearch/fuzzy"
+)
+
+// Version represents a semantic version with major, minor, and build numbers.
+type Version struct {
+	Major int
+	Minor int
+	Build int
+}
+
+// CFAppEnv represents the environment configuration for a Cloud Foundry application,
+// including environment variables, staging configuration, and system services.
+type CFAppEnv struct {
+	EnvironmentVariables struct {
+		JbpConfigSpringAutoReconfiguration string `json:"JBP_CONFIG_SPRING_AUTO_RECONFIGURATION"`
+		JbpConfigOpenJdkJre                string `json:"JBP_CONFIG_OPEN_JDK_JRE"`
+		JbpConfigComponents                string `json:"JBP_CONFIG_COMPONENTS"`
+	} `json:"environment_variables"`
+	StagingEnvJSON struct{} `json:"staging_env_json"`
+	RunningEnvJSON struct {
+		CredhubAPI string `json:"CREDHUB_API"`
+	} `json:"running_env_json"`
+	SystemEnvJSON struct {
+		VcapServices struct {
+			FsStorage []struct {
+				Label          string   `json:"label"`
+				Provider       any      `json:"provider"`
+				Plan           string   `json:"plan"`
+				Name           string   `json:"name"`
+				Tags           []string `json:"tags"`
+				InstanceGUID   string   `json:"instance_guid"`
+				InstanceName   string   `json:"instance_name"`
+				BindingGUID    string   `json:"binding_guid"`
+				BindingName    any      `json:"binding_name"`
+				Credentials    struct{} `json:"credentials"`
+				SyslogDrainURL any      `json:"syslog_drain_url"`
+				VolumeMounts   []struct {
+					ContainerDir string `json:"container_dir"`
+					Mode         string `json:"mode"`
+					DeviceType   string `json:"device_type"`
+				} `json:"volume_mounts"`
+			} `json:"fs-storage"`
+		} `json:"VCAP_SERVICES"`
+	} `json:"system_env_json"`
+	ApplicationEnvJSON struct {
+		VcapApplication struct {
+			CfAPI  string `json:"cf_api"`
+			Limits struct {
+				Fds int `json:"fds"`
+			} `json:"limits"`
+			ApplicationName  string   `json:"application_name"`
+			ApplicationUris  []string `json:"application_uris"`
+			Name             string   `json:"name"`
+			SpaceName        string   `json:"space_name"`
+			SpaceID          string   `json:"space_id"`
+			OrganizationID   string   `json:"organization_id"`
+			OrganizationName string   `json:"organization_name"`
+			Uris             []string `json:"uris"`
+			Users            any      `json:"users"`
+			ApplicationID    string   `json:"application_id"`
+		} `json:"VCAP_APPLICATION"`
+	} `json:"application_env_json"`
+}
+
+// GenerateUUID generates a new RFC 4122 Version 4 UUID using Go's built-in crypto/rand.
+func GenerateUUID() string {
+	// Generate 16 random bytes
+	bytes := make([]byte, 16)
+	if _, err := rand.Read(bytes); err != nil {
+		panic(err) // This should never happen with crypto/rand
+	}
+
+	// Set version (4) and variant bits according to RFC 4122
+	bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4
+	bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant bits
+
+	// Format as UUID string: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+	return fmt.Sprintf("%s-%s-%s-%s-%s",
+		hex.EncodeToString(bytes[0:4]),
+		hex.EncodeToString(bytes[4:6]),
+		hex.EncodeToString(bytes[6:8]),
+		hex.EncodeToString(bytes[8:10]),
+		hex.EncodeToString(bytes[10:16]))
+}
+
+func readAppEnv(app string) ([]byte, error) {
+	guid, err := exec.Command("cf", "app", app, "--guid").Output()
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := exec.Command("cf", "curl", fmt.Sprintf("/v3/apps/%s/env", strings.Trim(string(guid), "\n"))).Output()
+	if err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
+func checkUserPathAvailability(app string, path string) (bool, error) {
+	output, err := exec.Command("cf", "ssh", app, "-c", "[[ -d \""+path+"\" && -r \""+path+"\" && -w \""+path+"\" ]] && echo \"exists and read-writeable\"").Output()
+	if err != nil {
+		return false, err
+	}
+
+	if strings.Contains(string(output), "exists and read-writeable") {
+		return true, nil
+	}
+
+	return false, nil
+}
+
+// FindReasonForAccessError determines the reason why accessing a Cloud Foundry app failed,
+// providing helpful diagnostic information and suggestions.
+func FindReasonForAccessError(app string) string {
+	out, err := exec.Command("cf", "apps").Output()
+	if err != nil {
+		return "cf is not logged in, please login and try again"
+	}
+	// Find all app names
+	lines := strings.Split(string(out), "\n")
+	appNames := []string{}
+	foundHeader := false
+	for _, line := range lines {
+		if foundHeader && len(line) > 0 {
+			appNames = append(appNames, strings.Fields(line)[0])
+		} else if strings.Contains(line, "name") && strings.Contains(line, "requested state") && strings.Contains(line, "processes") && strings.Contains(line, "routes") {
+			foundHeader = true
+		}
+	}
+	if len(appNames) == 0 {
+		return "No apps in your realm, please check if you're logged in and the app exists"
+	}
+	if slices.Contains(appNames, app) {
+		return "Problems accessing the app " + app
+	}
+	matches := FuzzySearch(app, appNames, 1)
+	return "Could not find " + app + ". Did you mean " + matches[0] + "?"
+}
+
+// CheckRequiredTools verifies that the necessary tools and permissions are available
+// for the specified Cloud Foundry application, including SSH access.
+func CheckRequiredTools(app string) (bool, error) {
+	guid, err := exec.Command("cf", "app", app, "--guid").Output()
+	if err != nil {
+		return false, errors.New(FindReasonForAccessError(app))
+	}
+	output, err := exec.Command("cf", "curl", "/v3/apps/"+strings.TrimSuffix(string(guid), "\n")+"/ssh_enabled").Output()
+	if err != nil {
+		return false, err
+	}
+	var result map[string]any
+	if err := json.Unmarshal(output, &result); err != nil {
+		return false, err
+	}
+
+	if enabled, ok := result["enabled"].(bool); !ok || !enabled {
+		return false, errors.New("ssh is not enabled for app: '" + app + "', please run below 2 shell commands to enable ssh and try again(please note application should be restarted before take effect):\ncf enable-ssh " + app + "\ncf restart " + app)
+	}
+
+	return true, nil
+}
+
+// GetAvailablePath determines the best available path for operations on the Cloud Foundry app,
+// preferring user-specified paths and falling back to volume mounts or /tmp.
+func GetAvailablePath(data string, userpath string) (string, error) {
+	if len(userpath) > 0 {
+		valid, _ := checkUserPathAvailability(data, userpath)
+		if valid {
+			return userpath, nil
+		}
+
+		return "", errors.New("the container path specified doesn't exist or have no read and write access, please check and try again later")
+	}
+
+	env, err := readAppEnv(data)
+	if err != nil {
+		return "/tmp", err
+	}
+
+	var cfAppEnv CFAppEnv
+	if err := json.Unmarshal(env, &cfAppEnv); err != nil {
+		return "", err
+	}
+
+	for _, v := range cfAppEnv.SystemEnvJSON.VcapServices.FsStorage {
+		for _, v2 := range v.VolumeMounts {
+			if v2.Mode == "rw" {
+				return v2.ContainerDir, nil
+			}
+		}
+	}
+
+	return "/tmp", nil
+}
+
+// CopyOverCat copies a remote file to a local destination using the cf ssh command and cat.
+func CopyOverCat(args []string, src string, dest string) error {
+	f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+	if err != nil {
+		return errors.New("Error creating local file at  " + dest + ". Please check that you are allowed to create files at the given local path.")
+	}
+	defer func() {
+		if closeErr := f.Close(); closeErr != nil {
+			// Log the error, but don't override the main function's error
+			fmt.Fprintf(os.Stderr, "Warning: failed to close file %s: %v\n", dest, closeErr)
+		}
+	}()
+
+	args = append(args, "cat "+src)
+	cat := exec.Command("cf", args...)
+
+	cat.Stdout = f
+
+	err = cat.Start()
+	if err != nil {
+		return errors.New("error occurred during copying dump file: " + src + ", please try again.")
+	}
+
+	err = cat.Wait()
+	if err != nil {
+		return errors.New("error occurred while waiting for the copying complete")
+	}
+
+	return nil
+}
+
+// DeleteRemoteFile removes a file from the remote Cloud Foundry application container.
+func DeleteRemoteFile(args []string, path string) error {
+	args = append(args, "rm -fr "+path)
+	_, err := exec.Command("cf", args...).Output()
+	if err != nil {
+		return errors.New("error occurred while removing dump file generated")
+	}
+
+	return nil
+}
+
+// FindHeapDumpFile locates heap dump files (*.hprof) in the specified path on the remote container.
+func FindHeapDumpFile(args []string, fullpath string, fspath string) (string, error) {
+	return FindFile(args, fullpath, fspath, "*.hprof")
+}
+
+// FindJFRFile locates Java Flight Recorder files (*.jfr) in the specified path on the remote container.
+func FindJFRFile(args []string, fullpath string, fspath string) (string, error) {
+	return FindFile(args, fullpath, fspath, "*.jfr")
+}
+
+// FindFile searches for files matching the given pattern in the remote container,
+// returning the most recently modified file that matches.
+func FindFile(args []string, fullpath string, fspath string, pattern string) (string, error) {
+	cmd := " [ -f '" + fullpath + "' ] && echo '" + fullpath + "' ||  find " + fspath + " -name '" + pattern + "' -printf '%T@ %p\\0' | sort -zk 1nr | sed -z 's/^[^ ]* //' | tr '\\0' '\\n' | head -n 1 "
+
+	args = append(args, cmd)
+	output, err := exec.Command("cf", args...).Output()
+	if err != nil {
+		// Check for SSH authentication errors
+		errorStr := err.Error()
+		if strings.Contains(errorStr, "Error getting one time auth code") ||
+			strings.Contains(errorStr, "Error getting SSH code") ||
+			strings.Contains(errorStr, "Authentication failed") {
+			return "", errors.New("SSH authentication failed - this may be a Cloud Foundry platform issue. Error: " + errorStr)
+		}
+		return "", errors.New("error while checking the generated file: " + errorStr)
+	}
+
+	return strings.Trim(string(output), "\n"), nil
+}
+
+// ListFiles retrieves a list of files in the specified directory on the remote container.
+func ListFiles(args []string, path string) ([]string, error) {
+	cmd := "ls " + path
+	args = append(args, cmd)
+	output, err := exec.Command("cf", args...).Output()
+	if err != nil {
+		return nil, errors.New("error occurred while listing files: " + string(output))
+	}
+	files := strings.Split(strings.Trim(string(output), "\n"), "\n")
+	// Filter all empty strings
+	j := 0
+	for _, s := range files {
+		if s != "" {
+			files[j] = s
+			j++
+		}
+	}
+	return files[:j], nil
+}
+
+// FuzzySearch returns up to `maxResults` words from `words` that are closest in
+// Levenshtein distance to `needle`.
+func FuzzySearch(needle string, words []string, maxResults int) []string {
+	type match struct {
+		distance int
+		word     string
+	}
+
+	matches := make([]match, 0, len(words))
+	for _, w := range words {
+		matches = append(matches, match{
+			distance: fuzzy.LevenshteinDistance(needle, w),
+			word:     w,
+		})
+	}
+
+	sort.Slice(matches, func(i, j int) bool {
+		return matches[i].distance < matches[j].distance
+	})
+
+	if maxResults > len(matches) {
+		maxResults = len(matches)
+	}
+
+	results := make([]string, 0, maxResults)
+	for i := range maxResults {
+		results = append(results, matches[i].word)
+	}
+
+	return results
+}
+
+// JoinWithOr joins strings with commas and "or" for the last element: "x, y, or z".
+func JoinWithOr(a []string) string {
+	if len(a) == 0 {
+		return ""
+	}
+	if len(a) == 1 {
+		return a[0]
+	}
+	return strings.Join(a[:len(a)-1], ", ") + ", or " + a[len(a)-1]
+}
+
+// WrapTextWithPrefix wraps text to fit within maxWidth characters per line,
+// with the first line using the given prefix and subsequent lines indented to match the prefix length.
+func WrapTextWithPrefix(text, prefix string, maxWidth int, miscLineIndent int) string {
+	maxDescLength := maxWidth - len(prefix)
+
+	if len(text) <= maxDescLength {
+		return prefix + text
+	}
+
+	// Split text into multiple lines if too long.
+	words := strings.Fields(text)
+	var lines []string
+	var currentLine string
+
+	for _, word := range words {
+		testLine := currentLine
+		if testLine != "" {
+			testLine += " "
+		}
+		testLine += word
+
+		if len(testLine) <= maxDescLength {
+			currentLine = testLine
+		} else {
+			if currentLine != "" {
+				lines = append(lines, currentLine)
+			}
+			currentLine = word
+		}
+	}
+	if currentLine != "" {
+		lines = append(lines, currentLine)
+	}
+
+	// Join lines with proper indentation.
+	if len(lines) > 0 {
+		result := prefix + lines[0]
+		indent := strings.Repeat(" ", len(prefix))
+		for i := 1; i < len(lines); i++ {
+			result += "\n" + indent + strings.Repeat(" ", miscLineIndent) + lines[i]
+		}
+		return result
+	}
+
+	// Fallback if no lines were created.
+	return prefix + text
+}
+
+// ToSentenceCase returns the input string with the first character uppercased and the rest lowercased.
+// Handles Unicode and empty strings safely.
+func ToSentenceCase(input string) string {
+	if input == "" {
+		return input
+	}
+	runes := []rune(input)
+	if len(runes) == 1 {
+		return strings.ToUpper(string(runes[0]))
+	}
+	return strings.ToUpper(string(runes[0])) + strings.ToLower(string(runes[1:]))
+}
diff --git a/uuid/fakes/fake_uuid_generator.go b/uuid/fakes/fake_uuid_generator.go
deleted file mode 100644
index 5eaf1bf..0000000
--- a/uuid/fakes/fake_uuid_generator.go
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved.
- * This file is licensed under the Apache Software License, v. 2 except as noted
- * otherwise in the LICENSE file at the root of the repository.
- */
-
-// This file was generated by counterfeiter
-package fakes
-
-import (
-	"sync"
-
-	"github.com/SAP/cf-cli-java-plugin/uuid"
-)
-
-type FakeUUIDGenerator struct {
-	GenerateStub        func() string
-	generateMutex       sync.RWMutex
-	generateArgsForCall []struct{}
-	generateReturns     struct {
-		result1 string
-	}
-	invocations      map[string][][]interface{}
-	invocationsMutex sync.RWMutex
-}
-
-func (fake *FakeUUIDGenerator) Generate() string {
-	fake.generateMutex.Lock()
-	fake.generateArgsForCall = append(fake.generateArgsForCall, struct{}{})
-	fake.recordInvocation("Generate", []interface{}{})
-	fake.generateMutex.Unlock()
-	if fake.GenerateStub != nil {
-		return fake.GenerateStub()
-	}
-	return fake.generateReturns.result1
-}
-
-func (fake *FakeUUIDGenerator) GenerateCallCount() int {
-	fake.generateMutex.RLock()
-	defer fake.generateMutex.RUnlock()
-	return len(fake.generateArgsForCall)
-}
-
-func (fake *FakeUUIDGenerator) GenerateReturns(result1 string) {
-	fake.GenerateStub = nil
-	fake.generateReturns = struct {
-		result1 string
-	}{result1}
-}
-
-func (fake *FakeUUIDGenerator) Invocations() map[string][][]interface{} {
-	fake.invocationsMutex.RLock()
-	defer fake.invocationsMutex.RUnlock()
-	fake.generateMutex.RLock()
-	defer fake.generateMutex.RUnlock()
-	return fake.invocations
-}
-
-func (fake *FakeUUIDGenerator) recordInvocation(key string, args []interface{}) {
-	fake.invocationsMutex.Lock()
-	defer fake.invocationsMutex.Unlock()
-	if fake.invocations == nil {
-		fake.invocations = map[string][][]interface{}{}
-	}
-	if fake.invocations[key] == nil {
-		fake.invocations[key] = [][]interface{}{}
-	}
-	fake.invocations[key] = append(fake.invocations[key], args)
-}
-
-var _ uuid.UUIDGenerator = new(FakeUUIDGenerator)
diff --git a/uuid/uuid.go b/uuid/uuid.go
deleted file mode 100644
index 4e4e2f7..0000000
--- a/uuid/uuid.go
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved.
- * This file is licensed under the Apache Software License, v. 2 except as noted
- * otherwise in the LICENSE file at the root of the repository.
- */
-
-package uuid
-
-// UUIDGenerator is an interface that encapsulates the generation of UUIDs for mocking in tests.
-type UUIDGenerator interface {
-	Generate() string
-}