diff --git a/.github/fork-specific-files.txt b/.github/fork-specific-files.txt new file mode 100644 index 00000000000..ae527d989ce --- /dev/null +++ b/.github/fork-specific-files.txt @@ -0,0 +1,10 @@ +.github/dependabot.yml +.github/workflows/codacy.yml +.github/workflows/codeql.yml +.github/workflows/maven.yml +.github/workflows/rebase.yml +.github/workflows/rebase-upstream.yml +.github/workflows/sync-upstream.yml +.github/workflows/create-upstream-pr.yml +.github/fork-specific-files.txt +README.md diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml new file mode 100644 index 00000000000..649e970f935 --- /dev/null +++ b/.github/workflows/codacy.yml @@ -0,0 +1,67 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow checks out code, performs a Codacy security scan +# and integrates the results with the +# GitHub Advanced Security code scanning feature. For more information on +# the Codacy security scan action usage and parameters, see +# https://github.com/codacy/codacy-analysis-cli-action. +# For more information on Codacy Analysis CLI in general, see +# https://github.com/codacy/codacy-analysis-cli. + +name: Codacy Security Scan + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '24 8 * * 2' + +permissions: + contents: read + +jobs: + codacy-security-scan: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + name: Codacy Security Scan + runs-on: ubuntu-latest + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout code + uses: actions/checkout@v4 + + # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis + - name: Run Codacy Analysis CLI + uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b +# uses: codacy/codacy-analysis-cli-action@33d455949345bddfdb845fba76b57b70cc83754b + env: + CODEQL_ACTION_EXTRA_OPTIONS: '{"database":{"interpret-results":["--max-paths", 1]}}' + + with: + # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository + # You can also omit the token and run the tools that support default configurations + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + verbose: true + output: results.sarif + format: sarif + # Adjust severity of non-security issues + gh-code-scanning-compat: true + # Force 0 exit code to allow SARIF file generation + # This will handover control about PR rejection to the GitHub side + max-allowed-issues: 2147483647 + + + + + # Upload the SARIF file generated in the previous step + - name: Upload SARIF results file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/create-upstream-pr.yml b/.github/workflows/create-upstream-pr.yml new file mode 100644 index 00000000000..3540542cff2 --- /dev/null +++ b/.github/workflows/create-upstream-pr.yml @@ -0,0 +1,126 @@ +name: Create Upstream PR Branch + +on: + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number (used for branch name: upstream-pr/issue-{number})' + required: true + type: string + source_branch: + description: 'Source branch containing the commits to cherry-pick' + required: true + type: string + commit_shas: + description: 'Comma-separated list of commit SHAs to cherry-pick onto upstream/master' + required: true + type: string + pr_title: + description: 'Title for the upstream PR (informational only)' + required: false + type: string + +jobs: + create-upstream-pr-branch: + name: Create Clean Upstream PR Branch + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream master + run: | + git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git + git fetch upstream master + + - name: Create branch based on upstream/master + run: | + BRANCH_NAME="upstream-pr/issue-${{ inputs.issue_number }}" + git checkout -b "$BRANCH_NAME" upstream/master + echo "Created branch: $BRANCH_NAME" + + - name: Cherry-pick specified commits + id: cherry_pick + run: | + BRANCH_NAME="upstream-pr/issue-${{ inputs.issue_number }}" + COMMIT_SHAS="${{ inputs.commit_shas }}" + + # Convert comma-separated list to space-separated + SHAS=$(echo "$COMMIT_SHAS" | tr ',' ' ' | tr -s ' ') + + echo "Cherry-picking commits: $SHAS" + + SUCCESS=true + for SHA in $SHAS; do + SHA=$(echo "$SHA" | xargs) # trim whitespace + if [ -z "$SHA" ]; then + continue + fi + echo "Cherry-picking $SHA..." + if ! git cherry-pick "$SHA"; then + echo "Cherry-pick failed for $SHA" + git cherry-pick --abort 2>/dev/null || true + SUCCESS=false + break + fi + done + + if [ "$SUCCESS" = "true" ]; then + echo "success=true" >> $GITHUB_OUTPUT + else + echo "success=false" >> $GITHUB_OUTPUT + fi + + - name: Verify branch contents + if: steps.cherry_pick.outputs.success == 'true' + run: | + echo "=== Commits on this branch (not in upstream/master) ===" + git log upstream/master..HEAD --oneline + echo "" + echo "=== Files changed vs upstream/master ===" + git diff upstream/master..HEAD --stat + + - name: Force-push branch to fork + if: steps.cherry_pick.outputs.success == 'true' + run: | + BRANCH_NAME="upstream-pr/issue-${{ inputs.issue_number }}" + git push --force origin "$BRANCH_NAME" + echo "✅ Branch '$BRANCH_NAME' pushed to fork" + + - name: Output PR creation link + if: steps.cherry_pick.outputs.success == 'true' + run: | + BRANCH_NAME="upstream-pr/issue-${{ inputs.issue_number }}" + REPO_OWNER="${{ github.repository_owner }}" + REPO_NAME="${{ github.event.repository.name }}" + PR_LINK="https://github.com/eclipse-jdt/eclipse.jdt.ui/compare/master...${REPO_OWNER}:${REPO_NAME}:${BRANCH_NAME}" + + echo "" + echo "==========================================" + echo "✅ Branch ready for upstream PR!" + echo "==========================================" + echo "" + echo "Branch: $BRANCH_NAME" + if [ -n "${{ inputs.pr_title }}" ]; then + echo "PR Title: ${{ inputs.pr_title }}" + fi + echo "" + echo "Create PR at:" + echo "$PR_LINK" + echo "" + + - name: Fail if cherry-pick failed + if: steps.cherry_pick.outputs.success == 'false' + run: | + echo "❌ Cherry-pick failed. Check the logs above for details." + exit 1 diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 00000000000..fc0dbaf44dc --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,36 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_run: + workflows: ["Sync Fork with Upstream"] + types: [completed] + branches: [master] + +jobs: + build: + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Set up Maven + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: 3.9.9 + - name: Build with Maven + run: mvn -B package -Pbuild-individual-bundles --file pom.xml diff --git a/.github/workflows/rebase-upstream.yml b/.github/workflows/rebase-upstream.yml new file mode 100644 index 00000000000..d4aa9219044 --- /dev/null +++ b/.github/workflows/rebase-upstream.yml @@ -0,0 +1,230 @@ +name: Rebase Upstream + +on: + issue_comment: + types: [created] + pull_request: + types: [opened] + +jobs: + add-rebase-button: + name: Add Rebase Instructions + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Add comment with rebase instructions + uses: actions/github-script@v8 + with: + script: | + const repoUrl = context.payload.repository.html_url; + const workflowUrl = `${repoUrl}/actions/workflows/rebase-upstream.yml`; + + const comment = `## 🔄 Rebase onto Upstream + + To rebase this PR onto the latest \`eclipse-jdt/eclipse.jdt.ui:master\`, comment: + + \`\`\` + /rebase-upstream + \`\`\` + + This will rebase your PR branch onto the upstream repository's master branch. + + 📋 [View Workflow](${workflowUrl})`; + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + + rebase-upstream: + name: Rebase onto Upstream Master + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/rebase-upstream') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check user permission + id: check + uses: actions/github-script@v8 + with: + result-encoding: string + script: | + const authorAssociation = context.payload.comment.author_association; + const allowedRoles = ['OWNER', 'MEMBER', 'COLLABORATOR']; + + if (allowedRoles.includes(authorAssociation)) { + return 'true'; + } else { + return 'false'; + } + + - name: Add unauthorized reaction + if: steps.check.outputs.result != 'true' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '❌ Only repository collaborators can trigger the rebase.' + }); + + - name: Exit if unauthorized + if: steps.check.outputs.result != 'true' + run: exit 1 + + - name: Add rocket reaction + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket' + }); + + - name: Get PR branch info + id: pr + uses: actions/github-script@v8 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + core.setOutput('head_ref', pr.head.ref); + core.setOutput('head_repo', pr.head.repo.full_name); + core.setOutput('head_clone_url', pr.head.repo.clone_url); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + repository: ${{ steps.pr.outputs.head_repo }} + ref: ${{ steps.pr.outputs.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote and rebase + id: rebase + run: | + git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git + git fetch upstream master + + # Find the actual fork point with upstream + MERGE_BASE=$(git merge-base HEAD upstream/master) + echo "Merge base with upstream: $MERGE_BASE" + + # Count actual commits to rebase (between merge-base and HEAD) + ACTUAL_COMMITS=$(git rev-list --count $MERGE_BASE..HEAD) + echo "Actual commits to rebase: $ACTUAL_COMMITS" + + if [ "$ACTUAL_COMMITS" -eq 0 ]; then + echo "Error: No commits to rebase" + echo "success=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Rebase using merge-base (correct: only replays commits after the fork point) + if git rebase --onto upstream/master $MERGE_BASE; then + echo "success=true" >> $GITHUB_OUTPUT + else + echo "success=false" >> $GITHUB_OUTPUT + git rebase --abort + fi + + - name: Push rebased branch + if: steps.rebase.outputs.success == 'true' + run: | + git push --force-with-lease origin ${{ steps.pr.outputs.head_ref }} + + - name: Add success comment + if: steps.rebase.outputs.success == 'true' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '✅ Successfully rebased onto `eclipse-jdt/eclipse.jdt.ui:master`' + }); + + - name: Add failure comment + if: steps.rebase.outputs.success == 'false' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + + const comment = `❌ Rebase failed due to conflicts. + + To resolve manually: + + \`\`\`bash + # Add upstream remote + git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git + + # Fetch upstream + git fetch upstream master + + # Count the number of commits in your PR (check GitHub PR page) + # For example, if your PR has 3 commits: + PR_COMMIT_COUNT=3 + + # Rebase only your PR commits onto upstream/master + git rebase --onto upstream/master HEAD~\${PR_COMMIT_COUNT} + + # Resolve conflicts, then: + git add . + git rebase --continue + + # Force push when done + git push --force-with-lease + \`\`\``; + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + + - name: Fail workflow if rebase failed + if: steps.rebase.outputs.success == 'false' + run: exit 1 diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000000..072ad1bf8f8 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,347 @@ +name: Sync Fork with Upstream + +on: + schedule: + - cron: '0 3 * * *' # Daily at 3:00 AM UTC + workflow_dispatch: + issue_comment: + types: [created] + +jobs: + sync-scheduled: + name: Sync Fork (Scheduled) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout fork master + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git + git fetch upstream master + + - name: Check if sync needed + id: check_sync + run: | + UPSTREAM_SHA=$(git rev-parse upstream/master) + LAST_COMMIT_MSG=$(git log -1 --pretty=%s) + if [ "$LAST_COMMIT_MSG" = "Fork-specific CI and workflow configurations" ]; then + CURRENT_PARENT=$(git rev-parse HEAD~1 2>/dev/null || echo "none") + if [ "$UPSTREAM_SHA" = "$CURRENT_PARENT" ]; then + echo "Upstream has not changed since last sync. Skipping." + echo "needs_sync=false" >> $GITHUB_OUTPUT + exit 0 + fi + fi + echo "Upstream has new commits. Syncing..." + echo "needs_sync=true" >> $GITHUB_OUTPUT + + - name: Identify and backup fork-specific files + id: backup + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + # Create temporary directory for fork-specific files + mkdir -p /tmp/fork-specific + + # Read the list of fork-specific files from the manifest + MANIFEST=".github/fork-specific-files.txt" + if [ ! -f "$MANIFEST" ]; then + echo "Warning: $MANIFEST not found, no files to backup" + echo "has_fork_files=false" >> $GITHUB_OUTPUT + exit 0 + fi + + HAS_FILES=false + while IFS= read -r file || [ -n "$file" ]; do + # Skip empty lines and comments + [ -z "$file" ] && continue + case "$file" in \#*) continue ;; esac + if [ -f "$file" ]; then + echo "Backing up fork-specific file: $file" + mkdir -p "/tmp/fork-specific/$(dirname "$file")" + cp "$file" "/tmp/fork-specific/$file" + HAS_FILES=true + fi + done < "$MANIFEST" + + if [ "$HAS_FILES" = "true" ]; then + echo "has_fork_files=true" >> $GITHUB_OUTPUT + echo "Found fork-specific files:" + find /tmp/fork-specific -type f + else + echo "has_fork_files=false" >> $GITHUB_OUTPUT + echo "No fork-specific files found" + fi + + - name: Reset to upstream master + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + # Reset fork master to upstream master + git reset --hard upstream/master + + - name: Restore fork-specific files + if: steps.check_sync.outputs.needs_sync == 'true' && steps.backup.outputs.has_fork_files == 'true' + run: | + # Restore all backed-up fork-specific files + if [ -d "/tmp/fork-specific" ]; then + cp -r /tmp/fork-specific/. ./ + fi + + # Stage all restored files + git add -A + + - name: Commit fork-specific changes + if: steps.check_sync.outputs.needs_sync == 'true' && steps.backup.outputs.has_fork_files == 'true' + run: | + # Check if there are changes to commit + if ! git diff --cached --quiet; then + git commit -m "Fork-specific CI and workflow configurations" + echo "✅ Fork-specific changes committed" + else + echo "â„šī¸ No fork-specific changes to commit" + fi + + - name: Push to fork master + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + git push --force origin master + echo "✅ Successfully synced with upstream" + + sync-manual: + name: Sync Fork (Manual) + if: | + github.event_name == 'issue_comment' && + contains(github.event.comment.body, '/sync-upstream') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + steps: + - name: Check user permission + id: check + uses: actions/github-script@v8 + with: + result-encoding: string + script: | + const authorAssociation = context.payload.comment.author_association; + const allowedRoles = ['OWNER', 'MEMBER', 'COLLABORATOR']; + + if (allowedRoles.includes(authorAssociation)) { + return 'true'; + } else { + return 'false'; + } + + - name: Add unauthorized reaction + if: steps.check.outputs.result != 'true' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '❌ Only repository collaborators can trigger the sync.' + }); + + - name: Exit if unauthorized + if: steps.check.outputs.result != 'true' + run: exit 1 + + - name: Add rocket reaction + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket' + }); + + - name: Checkout fork master + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/eclipse-jdt/eclipse.jdt.ui.git + git fetch upstream master + + - name: Check if sync needed + id: check_sync + run: | + UPSTREAM_SHA=$(git rev-parse upstream/master) + LAST_COMMIT_MSG=$(git log -1 --pretty=%s) + if [ "$LAST_COMMIT_MSG" = "Fork-specific CI and workflow configurations" ]; then + CURRENT_PARENT=$(git rev-parse HEAD~1 2>/dev/null || echo "none") + if [ "$UPSTREAM_SHA" = "$CURRENT_PARENT" ]; then + echo "Upstream has not changed since last sync. Skipping." + echo "needs_sync=false" >> $GITHUB_OUTPUT + exit 0 + fi + fi + echo "Upstream has new commits. Syncing..." + echo "needs_sync=true" >> $GITHUB_OUTPUT + + - name: Identify and backup fork-specific files + id: backup + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + # Create temporary directory for fork-specific files + mkdir -p /tmp/fork-specific + + # Read the list of fork-specific files from the manifest + MANIFEST=".github/fork-specific-files.txt" + if [ ! -f "$MANIFEST" ]; then + echo "Warning: $MANIFEST not found, no files to backup" + echo "has_fork_files=false" >> $GITHUB_OUTPUT + exit 0 + fi + + HAS_FILES=false + while IFS= read -r file || [ -n "$file" ]; do + # Skip empty lines and comments + [ -z "$file" ] && continue + case "$file" in \#*) continue ;; esac + if [ -f "$file" ]; then + echo "Backing up fork-specific file: $file" + mkdir -p "/tmp/fork-specific/$(dirname "$file")" + cp "$file" "/tmp/fork-specific/$file" + HAS_FILES=true + fi + done < "$MANIFEST" + + if [ "$HAS_FILES" = "true" ]; then + echo "has_fork_files=true" >> $GITHUB_OUTPUT + echo "Found fork-specific files:" + find /tmp/fork-specific -type f + else + echo "has_fork_files=false" >> $GITHUB_OUTPUT + echo "No fork-specific files found" + fi + + - name: Reset to upstream master + id: reset + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + # Reset fork master to upstream master + git reset --hard upstream/master + echo "success=true" >> $GITHUB_OUTPUT + + - name: Restore fork-specific files + if: steps.check_sync.outputs.needs_sync == 'true' && steps.backup.outputs.has_fork_files == 'true' + run: | + # Restore all backed-up fork-specific files + if [ -d "/tmp/fork-specific" ]; then + cp -r /tmp/fork-specific/. ./ + fi + + # Stage all restored files + git add -A + + - name: Commit fork-specific changes + if: steps.check_sync.outputs.needs_sync == 'true' && steps.backup.outputs.has_fork_files == 'true' + run: | + # Check if there are changes to commit + if ! git diff --cached --quiet; then + git commit -m "Fork-specific CI and workflow configurations" + echo "✅ Fork-specific changes committed" + else + echo "â„šī¸ No fork-specific changes to commit" + fi + + - name: Push to fork master + id: push + if: steps.check_sync.outputs.needs_sync == 'true' + run: | + if git push --force origin master; then + echo "success=true" >> $GITHUB_OUTPUT + else + echo "success=false" >> $GITHUB_OUTPUT + fi + + - name: Add already-up-to-date comment + if: steps.check_sync.outputs.needs_sync == 'false' + uses: actions/github-script@v8 + with: + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'â„šī¸ Fork master is already up to date with `eclipse-jdt/eclipse.jdt.ui:master`. No sync needed.' + }); + + - name: Add success comment + if: steps.push.outputs.success == 'true' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '✅ Successfully synced fork master with `eclipse-jdt/eclipse.jdt.ui:master`' + }); + + - name: Add failure comment + if: steps.push.outputs.success == 'false' + uses: actions/github-script@v8 + with: + script: | + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); + + const comment = `❌ Sync failed. Please check the workflow logs for details. + + [View workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId})`; + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + + - name: Fail workflow if push failed + if: steps.push.outputs.success == 'false' + run: exit 1 diff --git a/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestCaseElement.java b/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestCaseElement.java index 2f60518c2a9..65810a454b7 100644 --- a/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestCaseElement.java +++ b/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestCaseElement.java @@ -25,6 +25,21 @@ public class TestCaseElement extends TestElement implements ITestCaseElement { private boolean fIgnored; private boolean fIsDynamicTest; + /** + * @since 3.15 + */ + private boolean fIsParameterizedTest= false; + + /** + * @since 3.15 + */ + private String fParameterSourceType= null; + + /** + * @since 3.15 + */ + private String fParameterEnumType= null; + public TestCaseElement(TestSuiteElement parent, String id, String testName, String displayName, boolean isDynamicTest, String[] parameterTypes, String uniqueId) { super(parent, id, testName, displayName, parameterTypes, uniqueId); Assert.isNotNull(parent); @@ -86,4 +101,67 @@ public String toString() { public boolean isDynamicTest() { return fIsDynamicTest; } + + /** + * Returns whether this test case is a parameterized test invocation. + * + * @return true if this is a parameterized test + * @since 3.15 + */ + public boolean isParameterizedTest() { + return fIsParameterizedTest; + } + + /** + * Sets whether this test case is a parameterized test invocation. + * + * @param parameterizedTest true if this is a parameterized test + * @since 3.15 + */ + public void setParameterizedTest(boolean parameterizedTest) { + fIsParameterizedTest= parameterizedTest; + } + + /** + * Returns the parameter source annotation type (e.g. "EnumSource", "ValueSource"). + * Returns null if metadata has not been populated yet, or an empty string + * if metadata was populated but this is not a recognized parameterized source. + * + * @return the parameter source type, or null if not yet populated + * @since 3.15 + */ + public String getParameterSourceType() { + return fParameterSourceType; + } + + /** + * Sets the parameter source annotation type. + * + * @param parameterSourceType the source annotation simple name (e.g. "EnumSource") + * @since 3.15 + */ + public void setParameterSourceType(String parameterSourceType) { + fParameterSourceType= parameterSourceType; + } + + /** + * Returns the fully qualified name of the enum type used in {@code @EnumSource}, or + * null if not applicable. + * + * @return the enum type FQN, or null + * @since 3.15 + */ + public String getParameterEnumType() { + return fParameterEnumType; + } + + /** + * Sets the fully qualified name of the enum type used in {@code @EnumSource}. + * + * @param parameterEnumType the enum type FQN + * @since 3.15 + */ + public void setParameterEnumType(String parameterEnumType) { + fParameterEnumType= parameterEnumType; + } } diff --git a/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestSuiteElement.java b/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestSuiteElement.java index a939e1422ec..604798ef4e0 100644 --- a/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestSuiteElement.java +++ b/org.eclipse.jdt.junit.core/src/org/eclipse/jdt/internal/junit/model/TestSuiteElement.java @@ -50,6 +50,21 @@ public ITestElement[] getChildren() { return fChildren.toArray(new ITestElement[fChildren.size()]); } + /** + * Returns the single dynamic child of this suite if it has exactly one dynamic + * (parameterized / generated) child, or null if there are zero, two, + * or more children. + * + * @return the single dynamic child, or null + * @since 3.15 + */ + public TestElement getSingleDynamicChild() { + if (fChildren.size() == 1) { + return fChildren.get(0); + } + return null; + } + public void addChild(TestElement child) { fChildren.add(child); } diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/EnumSourceValidator.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/EnumSourceValidator.java new file mode 100644 index 00000000000..6670c4d0127 --- /dev/null +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/EnumSourceValidator.java @@ -0,0 +1,455 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.internal.junit.ui; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.TextEdit; + +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.ArrayInitializer; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.IAnnotationBinding; +import org.eclipse.jdt.core.dom.IMemberValuePairBinding; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.IVariableBinding; +import org.eclipse.jdt.core.dom.MemberValuePair; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.NormalAnnotation; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.jdt.core.dom.rewrite.ASTRewrite; +import org.eclipse.jdt.core.dom.rewrite.ImportRewrite; +import org.eclipse.jdt.core.dom.rewrite.ListRewrite; + +import org.eclipse.jdt.ui.CodeStyleConfiguration; + +/** + * Utility class for validating and modifying {@code @EnumSource} annotations. + * + *

Provides methods to: + *

+ * + * @since 3.15 + */ +public class EnumSourceValidator { + + private static final String ENUM_SOURCE_ANNOTATION= "org.junit.jupiter.params.provider.EnumSource"; //$NON-NLS-1$ + private static final String ENUM_SOURCE_MODE_EXCLUDE= "EXCLUDE"; //$NON-NLS-1$ + private static final String MEMBER_VALUE= "value"; //$NON-NLS-1$ + private static final String MEMBER_MODE= "mode"; //$NON-NLS-1$ + private static final String MEMBER_NAMES= "names"; //$NON-NLS-1$ + + /** Regex matching the JUnit 5 default parameterized test display-name prefix {@code "[N] "}. */ + private static final String DISPLAY_NAME_INDEX_PREFIX_REGEX= "^\\[\\d+\\]\\s*"; //$NON-NLS-1$ + + /** + * Returns whether the given method has an {@code @EnumSource} annotation with + * {@code mode = Mode.EXCLUDE} and a non-empty names array. + * + * @param method the method to check + * @return true if EXCLUDE mode is active + * @throws JavaModelException if there is an error accessing the Java model + */ + public static boolean isExcludeMode(IMethod method) throws JavaModelException { + IAnnotationBinding binding= findEnumSourceBinding(method); + if (binding == null) { + return false; + } + return ENUM_SOURCE_MODE_EXCLUDE.equals(getMode(binding)); + } + + /** + * Returns the list of currently excluded enum constant names from {@code @EnumSource}. + * Returns an empty list if there are no exclusions or the method does not have + * an {@code @EnumSource} annotation in EXCLUDE mode. + * + * @param method the method to inspect + * @return list of excluded names (never null) + * @throws JavaModelException if there is an error accessing the Java model + */ + public static List getExcludedNames(IMethod method) throws JavaModelException { + IAnnotationBinding binding= findEnumSourceBinding(method); + if (binding == null || !ENUM_SOURCE_MODE_EXCLUDE.equals(getMode(binding))) { + return new ArrayList<>(); + } + return getNames(binding); + } + + /** + * Returns the fully qualified name of the enum type declared in {@code @EnumSource}, + * or null if none is declared explicitly. + * + * @param method the method to inspect + * @return the enum type FQN, or null + * @throws JavaModelException if there is an error accessing the Java model + */ + public static String getEnumTypeName(IMethod method) throws JavaModelException { + IAnnotationBinding binding= findEnumSourceBinding(method); + if (binding == null) { + return null; + } + for (IMemberValuePairBinding pair : binding.getDeclaredMemberValuePairs()) { + if (MEMBER_VALUE.equals(pair.getName())) { + Object val= pair.getValue(); + if (val instanceof ITypeBinding) { + return ((ITypeBinding) val).getQualifiedName(); + } + } + } + // try to infer from method parameter + try { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu != null) { + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + final String[] result= { null }; + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (node.getName().getIdentifier().equals(method.getElementName())) { + IMethodBinding mb= node.resolveBinding(); + if (mb != null && mb.getParameterTypes().length > 0) { + ITypeBinding paramType= mb.getParameterTypes()[0]; + if (paramType != null && paramType.isEnum()) { + result[0]= paramType.getQualifiedName(); + } + } + } + return false; + } + }); + return result[0]; + } + } catch (Exception e) { + // fall through + } + return null; + } + + /** + * Removes all exclusions from {@code @EnumSource} by removing the {@code mode} and + * {@code names} attributes. Also removes the {@code Mode} import if it is no longer + * needed. + * + * @param method the method to modify + * @throws JavaModelException if there is an error accessing the Java model + */ + public static void removeExcludeMode(IMethod method) throws JavaModelException { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu == null) { + return; + } + + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + + AST ast= astRoot.getAST(); + ASTRewrite rewrite= ASTRewrite.create(ast); + final boolean[] modified= { false }; + + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!node.getName().getIdentifier().equals(method.getElementName())) { + return false; + } + for (Object modifier : node.modifiers()) { + if (modifier instanceof NormalAnnotation) { + NormalAnnotation annotation= (NormalAnnotation) modifier; + IAnnotationBinding annBinding= annotation.resolveAnnotationBinding(); + if (annBinding == null) continue; + ITypeBinding annType= annBinding.getAnnotationType(); + if (annType == null || !ENUM_SOURCE_ANNOTATION.equals(annType.getQualifiedName())) continue; + + // Collect the value pair (if any), remove mode and names + MemberValuePair valuePair= null; + List values= annotation.values(); + for (Object v : values) { + MemberValuePair mvp= (MemberValuePair) v; + if (MEMBER_VALUE.equals(mvp.getName().getIdentifier())) { + valuePair= mvp; + } + } + + // Build replacement: NormalAnnotation with only value (if present) + NormalAnnotation newAnnotation= ast.newNormalAnnotation(); + newAnnotation.setTypeName(ast.newName("EnumSource")); //$NON-NLS-1$ + if (valuePair != null) { + MemberValuePair newValuePair= ast.newMemberValuePair(); + newValuePair.setName(ast.newSimpleName(MEMBER_VALUE)); + newValuePair.setValue((org.eclipse.jdt.core.dom.Expression) rewrite.createCopyTarget(valuePair.getValue())); + newAnnotation.values().add(newValuePair); + } + + rewrite.replace(annotation, newAnnotation, null); + modified[0]= true; + break; + } + } + return false; + } + }); + + if (modified[0]) { + applyChangesWithModeImportCleanup(cu, astRoot, rewrite); + } + } + + /** + * Removes a single enum constant name from the {@code @EnumSource} exclusion list. + * If the exclusion list becomes empty after the removal, removes both {@code mode} and + * {@code names} attributes entirely. + * + * @param method the method to modify + * @param enumValueName the enum constant name to re-include + * @throws JavaModelException if there is an error accessing the Java model + */ + public static void removeValueFromExclusion(IMethod method, String enumValueName) throws JavaModelException { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu == null) { + return; + } + + List currentExcluded= getExcludedNames(method); + List newNames= new ArrayList<>(currentExcluded); + newNames.remove(enumValueName); + + if (newNames.isEmpty()) { + removeExcludeMode(method); + return; + } + + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + + AST ast= astRoot.getAST(); + ASTRewrite rewrite= ASTRewrite.create(ast); + final boolean[] modified= { false }; + + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!node.getName().getIdentifier().equals(method.getElementName())) { + return false; + } + for (Object modifier : node.modifiers()) { + if (modifier instanceof NormalAnnotation) { + NormalAnnotation annotation= (NormalAnnotation) modifier; + IAnnotationBinding annBinding= annotation.resolveAnnotationBinding(); + if (annBinding == null) continue; + ITypeBinding annType= annBinding.getAnnotationType(); + if (annType == null || !ENUM_SOURCE_ANNOTATION.equals(annType.getQualifiedName())) continue; + + // Find the names member value pair and remove the value + List values= annotation.values(); + for (Object v : values) { + MemberValuePair mvp= (MemberValuePair) v; + if (MEMBER_NAMES.equals(mvp.getName().getIdentifier())) { + if (mvp.getValue() instanceof ArrayInitializer) { + ArrayInitializer arr= (ArrayInitializer) mvp.getValue(); + ListRewrite listRewrite= rewrite.getListRewrite(arr, ArrayInitializer.EXPRESSIONS_PROPERTY); + List expressions= arr.expressions(); + for (Object expr : expressions) { + if (expr instanceof StringLiteral) { + StringLiteral sl= (StringLiteral) expr; + if (enumValueName.equals(sl.getLiteralValue())) { + listRewrite.remove(sl, null); + modified[0]= true; + break; + } + } + } + } + break; + } + } + break; + } + } + return false; + } + }); + + if (modified[0]) { + applySimpleChanges(cu, astRoot, rewrite); + } + } + + // --- helpers --- + + private static IAnnotationBinding findEnumSourceBinding(IMethod method) throws JavaModelException { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu == null) { + return null; + } + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + + final IAnnotationBinding[] result= { null }; + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!node.getName().getIdentifier().equals(method.getElementName())) { + return false; + } + IMethodBinding mb= node.resolveBinding(); + if (mb == null) return false; + for (IAnnotationBinding ann : mb.getAnnotations()) { + ITypeBinding annType= ann.getAnnotationType(); + if (annType != null && ENUM_SOURCE_ANNOTATION.equals(annType.getQualifiedName())) { + result[0]= ann; + break; + } + } + return false; + } + }); + return result[0]; + } + + private static String getMode(IAnnotationBinding binding) { + for (IMemberValuePairBinding pair : binding.getDeclaredMemberValuePairs()) { + if (MEMBER_MODE.equals(pair.getName())) { + Object val= pair.getValue(); + if (val instanceof IVariableBinding) { + return ((IVariableBinding) val).getName(); + } + } + } + return null; // default INCLUDE + } + + private static List getNames(IAnnotationBinding binding) { + List result= new ArrayList<>(); + for (IMemberValuePairBinding pair : binding.getDeclaredMemberValuePairs()) { + if (MEMBER_NAMES.equals(pair.getName())) { + Object val= pair.getValue(); + if (val instanceof Object[]) { + for (Object item : (Object[]) val) { + if (item instanceof String) { + result.add((String) item); + } + } + } else if (val instanceof String) { + result.add((String) val); + } + } + } + return result; + } + + private static void applySimpleChanges(ICompilationUnit cu, CompilationUnit astRoot, ASTRewrite rewrite) { + try { + TextEdit rewriteEdit= rewrite.rewriteAST(); + cu.applyTextEdit(rewriteEdit, null); + cu.save(null, true); + } catch (Exception e) { + JUnitPlugin.log(e); + } + } + + private static void applyChangesWithModeImportCleanup(ICompilationUnit cu, CompilationUnit astRoot, ASTRewrite rewrite) { + try { + MultiTextEdit multiEdit= new MultiTextEdit(); + + // Remove "EnumSource.Mode" or "Mode" import if no longer referenced + ImportRewrite importRewrite= CodeStyleConfiguration.createImportRewrite(astRoot, true); + importRewrite.removeImport("org.junit.jupiter.params.provider.EnumSource.Mode"); //$NON-NLS-1$ + + TextEdit importEdit= importRewrite.rewriteImports(null); + if (importEdit.hasChildren()) { + multiEdit.addChild(importEdit); + } + TextEdit rewriteEdit= rewrite.rewriteAST(); + if (rewriteEdit.hasChildren()) { + multiEdit.addChild(rewriteEdit); + } + if (multiEdit.hasChildren()) { + cu.applyTextEdit(multiEdit, null); + cu.save(null, true); + } + } catch (Exception e) { + JUnitPlugin.log(e); + } + } + + /** + * Extracts the enum constant name from a parameterized test display name. + *

+ * JUnit 5 default format is {@code "[N] CONSTANT_NAME"}. This method strips + * the leading {@code "[N] "} prefix. If the display name already looks like a plain + * constant name (no prefix), it is returned as-is. + * + * @param displayName the display name of the test case element + * @return the extracted enum constant name + */ + public static String extractEnumConstantFromDisplayName(String displayName) { + if (displayName == null) { + return null; + } + // Strip "[N] " prefix (JUnit default parameterized name format) + String stripped= displayName.replaceFirst(DISPLAY_NAME_INDEX_PREFIX_REGEX, "").trim(); //$NON-NLS-1$ + return stripped.isEmpty() ? displayName : stripped; + } + + /** + * Returns whether the given method has an {@code @EnumSource} annotation. + * + * @param method the method to check + * @return true if the method declares {@code @EnumSource} + * @throws JavaModelException if there is an error accessing the Java model + */ + public static boolean hasEnumSource(IMethod method) throws JavaModelException { + return findEnumSourceBinding(method) != null; + } + + /** + * Returns a copy of the excluded names after removing the given value. + * Useful for validation before committing the change. + * + * @param excludedNames current list of excluded names + * @param valueToRemove the value that would be re-included + * @return new list without the removed value + */ + public static List computeNamesAfterReinclude(List excludedNames, String valueToRemove) { + List result= new ArrayList<>(excludedNames); + result.remove(valueToRemove); + return result; + } + + private EnumSourceValidator() { + // Utility class - no instances + } +} diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ExcludeParameterValueAction.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ExcludeParameterValueAction.java new file mode 100644 index 00000000000..affbac49a19 --- /dev/null +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ExcludeParameterValueAction.java @@ -0,0 +1,133 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.internal.junit.ui; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.dialogs.MessageDialog; + +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.junit.model.ITestElement; + +import org.eclipse.jdt.ui.JavaUI; + +import org.eclipse.jdt.internal.junit.Messages; +import org.eclipse.jdt.internal.junit.model.TestCaseElement; +import org.eclipse.jdt.internal.junit.model.TestSuiteElement; + +/** + * Context menu action that adds a specific enum value to the {@code @EnumSource} exclusion list. + * + *

This action is enabled when: + *

    + *
  • The selected element is a {@link TestCaseElement}
  • + *
  • The parent test method uses {@code @ParameterizedTest} with {@code @EnumSource}
  • + *
+ * + *

A warning dialog is shown if excluding the value would leave 0 or 1 values in the test. + * + * @since 3.15 + */ +public class ExcludeParameterValueAction extends Action { + + private TestCaseElement fTestCaseElement; + private String fEnumValueName; + + public ExcludeParameterValueAction() { + super(JUnitMessages.ExcludeParameterValueAction_label); + } + + /** + * Updates the action based on the currently selected test element. + * + * @param testCaseElement the selected test case element + */ + public void update(TestCaseElement testCaseElement) { + fTestCaseElement= null; + fEnumValueName= null; + setEnabled(false); + + if (testCaseElement == null) { + return; + } + + // Populate metadata lazily + ParameterizedTestMetadataExtractor.populate(testCaseElement); + + if (!"EnumSource".equals(testCaseElement.getParameterSourceType())) { //$NON-NLS-1$ + return; + } + + // Extract the enum constant name from the display name + String displayName= testCaseElement.getDisplayName(); + if (displayName == null) { + displayName= testCaseElement.getTestMethodName(); + } + String enumValue= EnumSourceValidator.extractEnumConstantFromDisplayName(displayName); + if (enumValue == null || enumValue.isEmpty()) { + return; + } + + fTestCaseElement= testCaseElement; + fEnumValueName= enumValue; + setEnabled(true); + } + + @Override + public void run() { + if (fTestCaseElement == null || fEnumValueName == null) { + return; + } + + try { + TestSuiteElement parent= fTestCaseElement.getParent(); + if (parent == null) { + return; + } + + IMethod method= TestMethodFinder.findMethodForParameterizedTest(parent); + if (method == null) { + return; + } + + // Warn if only 0 or 1 test invocations would remain + ITestElement[] siblings= parent.getChildren(); + int remaining= siblings.length - 1; + if (remaining <= 1) { + String message= remaining == 0 + ? Messages.format(JUnitMessages.ExcludeParameterValueAction_warning_noValues, fEnumValueName) + : Messages.format(JUnitMessages.ExcludeParameterValueAction_warning_oneValue, fEnumValueName); + boolean proceed= MessageDialog.openQuestion( + null, + JUnitMessages.ExcludeParameterValueAction_label, + message); + if (!proceed) { + return; + } + } + + TestAnnotationModifier.excludeEnumValue(method, fEnumValueName); + + // Open the editor at the method + try { + JavaUI.openInEditor(method); + } catch (Exception e) { + JUnitPlugin.log(e); + } + + } catch (JavaModelException e) { + JUnitPlugin.log(e); + } + } +} diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.java index 058451fa791..7b0b42d1555 100644 --- a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.java +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.java @@ -54,6 +54,12 @@ public final class JUnitMessages extends NLS { public static String EnableStackFilterAction_action_tooltip; public static String DisableTestAction_label; public static String DisableTestAction_enable_label; + public static String ExcludeParameterValueAction_label; + public static String ExcludeParameterValueAction_warning_noValues; + public static String ExcludeParameterValueAction_warning_oneValue; + public static String ReincludeAllEnumValuesAction_label; + public static String ReincludeEnumValueAction_label; + public static String TestViewer_reinclude_submenu_label; public static String ExpandAllAction_text; public static String ExpandAllAction_tooltip; public static String CollapseAllAction_text; diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.properties b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.properties index 479b17218c0..d780c792131 100644 --- a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.properties +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/JUnitMessages.properties @@ -37,6 +37,12 @@ EnableStackFilterAction_action_description=Filter the stack trace EnableStackFilterAction_action_tooltip=Filter Stack Trace DisableTestAction_label=Disable This Test DisableTestAction_enable_label=Enable This Test +ExcludeParameterValueAction_label=Exclude Enum Value from @EnumSource +ExcludeParameterValueAction_warning_noValues=Excluding ''{0}'' would leave no values. Proceed anyway? +ExcludeParameterValueAction_warning_oneValue=Excluding ''{0}'' would leave only one value. Proceed anyway? +ReincludeAllEnumValuesAction_label=Re-include All Enum Values +ReincludeEnumValueAction_label=Re-include ''{0}'' +TestViewer_reinclude_submenu_label=Re-include Excluded Enum Values ScrollLockAction_action_label=Scroll Lock ScrollLockAction_action_tooltip=Scroll Lock diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ParameterizedTestMetadataExtractor.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ParameterizedTestMetadataExtractor.java new file mode 100644 index 00000000000..00f86b21189 --- /dev/null +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ParameterizedTestMetadataExtractor.java @@ -0,0 +1,166 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.internal.junit.ui; + +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.IAnnotationBinding; +import org.eclipse.jdt.core.dom.IMemberValuePairBinding; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.MethodDeclaration; + +import org.eclipse.jdt.internal.junit.model.TestCaseElement; +import org.eclipse.jdt.internal.junit.model.TestSuiteElement; + +/** + * Utility class that populates parameterized-test metadata on {@link TestCaseElement} instances + * by parsing AST source. + * + *

Metadata is populated lazily (on demand) and cached on the {@link TestCaseElement}. + * After the first call to {@link #populate(TestCaseElement)}, subsequent calls are no-ops. + * + * @since 3.15 + */ +public class ParameterizedTestMetadataExtractor { + + private static final String JUNIT5_PARAMETERIZED_TEST= "org.junit.jupiter.params.ParameterizedTest"; //$NON-NLS-1$ + private static final String JUNIT5_ENUM_SOURCE= "org.junit.jupiter.params.provider.EnumSource"; //$NON-NLS-1$ + private static final String JUNIT5_VALUE_SOURCE= "org.junit.jupiter.params.provider.ValueSource"; //$NON-NLS-1$ + private static final String JUNIT5_METHOD_SOURCE= "org.junit.jupiter.params.provider.MethodSource"; //$NON-NLS-1$ + private static final String JUNIT5_CSV_SOURCE= "org.junit.jupiter.params.provider.CsvSource"; //$NON-NLS-1$ + + /** + * Populates parameterized-test metadata on the given {@link TestCaseElement}. + * + *

Metadata is only populated if it has not been populated yet + * ({@code getParameterSourceType() == null}). + * + * @param testCaseElement the test case element to populate + */ + public static void populate(TestCaseElement testCaseElement) { + if (testCaseElement.getParameterSourceType() != null) { + return; // already populated + } + + // Mark as "checked but not parameterized" by default + testCaseElement.setParameterSourceType(""); //$NON-NLS-1$ + + TestSuiteElement parent= testCaseElement.getParent(); + if (parent == null) { + return; + } + + IMethod method= TestMethodFinder.findMethodForParameterizedTest(parent); + if (method == null) { + return; + } + + try { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu == null) { + return; + } + + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + + final String[] sourceType= { null }; + final String[] enumTypeName= { null }; + + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!node.getName().getIdentifier().equals(method.getElementName())) { + return false; + } + + IMethodBinding mb= node.resolveBinding(); + if (mb == null) { + return false; + } + + boolean isParameterized= false; + for (IAnnotationBinding ann : mb.getAnnotations()) { + ITypeBinding annType= ann.getAnnotationType(); + if (annType == null) continue; + String fqn= annType.getQualifiedName(); + if (JUNIT5_PARAMETERIZED_TEST.equals(fqn)) { + isParameterized= true; + break; + } + } + + if (!isParameterized) { + return false; + } + + for (IAnnotationBinding ann : mb.getAnnotations()) { + ITypeBinding annType= ann.getAnnotationType(); + if (annType == null) continue; + String fqn= annType.getQualifiedName(); + if (JUNIT5_ENUM_SOURCE.equals(fqn)) { + sourceType[0]= "EnumSource"; //$NON-NLS-1$ + // Try to get the declared enum type + for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) { + if ("value".equals(pair.getName())) { //$NON-NLS-1$ + Object val= pair.getValue(); + if (val instanceof ITypeBinding) { + enumTypeName[0]= ((ITypeBinding) val).getQualifiedName(); + } + break; + } + } + // If not explicit, try first method parameter + if (enumTypeName[0] == null && mb.getParameterTypes().length > 0) { + ITypeBinding paramType= mb.getParameterTypes()[0]; + if (paramType != null && paramType.isEnum()) { + enumTypeName[0]= paramType.getQualifiedName(); + } + } + break; + } else if (JUNIT5_VALUE_SOURCE.equals(fqn)) { + sourceType[0]= "ValueSource"; //$NON-NLS-1$ + } else if (JUNIT5_METHOD_SOURCE.equals(fqn)) { + sourceType[0]= "MethodSource"; //$NON-NLS-1$ + } else if (JUNIT5_CSV_SOURCE.equals(fqn)) { + sourceType[0]= "CsvSource"; //$NON-NLS-1$ + } + } + + return false; + } + }); + + if (sourceType[0] != null) { + testCaseElement.setParameterizedTest(true); + testCaseElement.setParameterSourceType(sourceType[0]); + testCaseElement.setParameterEnumType(enumTypeName[0]); + } + + } catch (Exception e) { + JUnitPlugin.log(e); + } + } + + private ParameterizedTestMetadataExtractor() { + // Utility class - no instances + } +} diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeAllEnumValuesAction.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeAllEnumValuesAction.java new file mode 100644 index 00000000000..1eb2df280bf --- /dev/null +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeAllEnumValuesAction.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.internal.junit.ui; + +import org.eclipse.jface.action.Action; + +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.JavaModelException; + +import org.eclipse.jdt.ui.JavaUI; + + +import org.eclipse.jdt.internal.junit.model.TestSuiteElement; + +/** + * Action that removes all exclusions from {@code @EnumSource} (removes the {@code mode} and + * {@code names} attributes), effectively re-including all enum values. + * + * @since 3.15 + */ +public class ReincludeAllEnumValuesAction extends Action { + + private IMethod fMethod; + + public ReincludeAllEnumValuesAction() { + super(JUnitMessages.ReincludeAllEnumValuesAction_label); + } + + /** + * Updates this action with the method to modify. + * + * @param method the test method carrying the {@code @EnumSource} annotation + * @param testSuiteElement the corresponding test suite element (unused, reserved for future use) + */ + public void update(IMethod method, TestSuiteElement testSuiteElement) { + fMethod= method; + setEnabled(method != null); + } + + @Override + public void run() { + if (fMethod == null) { + return; + } + try { + EnumSourceValidator.removeExcludeMode(fMethod); + try { + JavaUI.openInEditor(fMethod); + } catch (Exception e) { + JUnitPlugin.log(e); + } + } catch (JavaModelException e) { + JUnitPlugin.log(e); + } + } +} diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeEnumValueAction.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeEnumValueAction.java new file mode 100644 index 00000000000..b71113bf8dc --- /dev/null +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/ReincludeEnumValueAction.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.internal.junit.ui; + +import org.eclipse.jface.action.Action; + +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.JavaModelException; + +import org.eclipse.jdt.ui.JavaUI; + + +import org.eclipse.jdt.internal.junit.Messages; + +/** + * Action that re-includes a single excluded enum value from {@code @EnumSource}. + * + *

Removes the given value from the {@code names} array. If the array becomes empty, + * the {@code mode} and {@code names} attributes are removed entirely. + * + * @since 3.15 + */ +public class ReincludeEnumValueAction extends Action { + + private final IMethod fMethod; + private final String fEnumValueName; + + /** + * Creates a new action for re-including the given enum constant. + * + * @param method the test method to modify + * @param enumValueName the enum constant name to re-include + */ + public ReincludeEnumValueAction(IMethod method, String enumValueName) { + super(Messages.format(JUnitMessages.ReincludeEnumValueAction_label, enumValueName)); + fMethod= method; + fEnumValueName= enumValueName; + } + + @Override + public void run() { + if (fMethod == null || fEnumValueName == null) { + return; + } + try { + EnumSourceValidator.removeValueFromExclusion(fMethod, fEnumValueName); + try { + JavaUI.openInEditor(fMethod); + } catch (Exception e) { + JUnitPlugin.log(e); + } + } catch (JavaModelException e) { + JUnitPlugin.log(e); + } + } +} diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestAnnotationModifier.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestAnnotationModifier.java index e5989240147..d5f19bea85b 100644 --- a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestAnnotationModifier.java +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestAnnotationModifier.java @@ -25,11 +25,19 @@ import org.eclipse.jdt.core.dom.ASTParser; import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.Annotation; +import org.eclipse.jdt.core.dom.ArrayInitializer; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.IAnnotationBinding; +import org.eclipse.jdt.core.dom.IMemberValuePairBinding; import org.eclipse.jdt.core.dom.IMethodBinding; import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.IVariableBinding; +import org.eclipse.jdt.core.dom.MemberValuePair; import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.NormalAnnotation; +import org.eclipse.jdt.core.dom.QualifiedName; +import org.eclipse.jdt.core.dom.SingleMemberAnnotation; +import org.eclipse.jdt.core.dom.StringLiteral; import org.eclipse.jdt.core.dom.rewrite.ASTRewrite; import org.eclipse.jdt.core.dom.rewrite.ImportRewrite; import org.eclipse.jdt.core.dom.rewrite.ListRewrite; @@ -63,6 +71,9 @@ public class TestAnnotationModifier { private static final String JUNIT5_TEST_FACTORY_ANNOTATION= "org.junit.jupiter.api.TestFactory"; //$NON-NLS-1$ private static final String JUNIT5_TEST_TEMPLATE_ANNOTATION= "org.junit.jupiter.api.TestTemplate"; //$NON-NLS-1$ + private static final String JUNIT5_ENUM_SOURCE_ANNOTATION= "org.junit.jupiter.params.provider.EnumSource"; //$NON-NLS-1$ + private static final String JUNIT5_ENUM_SOURCE_MODE_FQN= "org.junit.jupiter.params.provider.EnumSource.Mode"; //$NON-NLS-1$ + /** * Add @Disabled (JUnit 5) or @Ignore (JUnit 4) annotation to a method. * @@ -300,6 +311,164 @@ private static void applyChanges(ICompilationUnit cu, CompilationUnit astRoot, A } } + /** + * Adds an enum constant name to the {@code @EnumSource} exclusion list on the given method. + * + *

If the annotation currently has no mode (defaults to {@code INCLUDE}), the mode is + * changed to {@code EXCLUDE} and the value is added to the names list. If the annotation + * already uses {@code EXCLUDE} mode, the value is appended to the existing names. + * + * @param method the test method that carries the {@code @EnumSource} annotation + * @param enumValueName the enum constant name to exclude + * @throws JavaModelException if there is an error accessing the Java model + * @since 3.15 + */ + public static void excludeEnumValue(IMethod method, String enumValueName) throws JavaModelException { + ICompilationUnit cu= method.getCompilationUnit(); + if (cu == null) { + return; + } + + ASTParser parser= ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(cu); + parser.setResolveBindings(true); + CompilationUnit astRoot= (CompilationUnit) parser.createAST(null); + + AST ast= astRoot.getAST(); + ASTRewrite rewrite= ASTRewrite.create(ast); + final boolean[] modified= { false }; + + astRoot.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!node.getName().getIdentifier().equals(method.getElementName())) { + return false; + } + for (Object modifier : node.modifiers()) { + if (modifier instanceof Annotation) { + Annotation annotation= (Annotation) modifier; + IAnnotationBinding annBinding= annotation.resolveAnnotationBinding(); + if (annBinding == null) continue; + ITypeBinding annType= annBinding.getAnnotationType(); + if (annType == null || !JUNIT5_ENUM_SOURCE_ANNOTATION.equals(annType.getQualifiedName())) continue; + + modifyEnumSourceInMethod(ast, rewrite, annotation, annBinding, enumValueName); + modified[0]= true; + break; + } + } + return false; + } + }); + + if (modified[0]) { + applyChanges(cu, astRoot, rewrite, JUNIT5_ENUM_SOURCE_MODE_FQN); + } + } + + /** + * Replaces the {@code @EnumSource} annotation node with a new one that includes the given + * value in the {@code EXCLUDE} names list. + */ + private static void modifyEnumSourceInMethod(AST ast, ASTRewrite rewrite, Annotation annotation, + IAnnotationBinding annBinding, String enumValueName) { + + // Read current state from binding + String currentMode= null; + List currentNames= new java.util.ArrayList<>(); + org.eclipse.jdt.core.dom.Expression valueExpr= null; + + // Extract existing "value" expression from annotation AST node for copying + if (annotation instanceof SingleMemberAnnotation) { + valueExpr= ((SingleMemberAnnotation) annotation).getValue(); + } else if (annotation instanceof NormalAnnotation) { + for (Object o : ((NormalAnnotation) annotation).values()) { + MemberValuePair mvp= (MemberValuePair) o; + if ("value".equals(mvp.getName().getIdentifier())) { //$NON-NLS-1$ + valueExpr= mvp.getValue(); + } + } + } + + // Read mode and names from binding (resolved) + for (IMemberValuePairBinding pair : annBinding.getDeclaredMemberValuePairs()) { + if ("mode".equals(pair.getName())) { //$NON-NLS-1$ + Object val= pair.getValue(); + if (val instanceof IVariableBinding) { + currentMode= ((IVariableBinding) val).getName(); + } + } else if ("names".equals(pair.getName())) { //$NON-NLS-1$ + Object val= pair.getValue(); + if (val instanceof Object[]) { + for (Object item : (Object[]) val) { + if (item instanceof String) { + currentNames.add((String) item); + } + } + } else if (val instanceof String) { + currentNames.add((String) val); + } + } + } + + modifyAnnotationToExclude(ast, rewrite, annotation, valueExpr, currentMode, currentNames, enumValueName); + } + + /** + * Builds and installs a replacement {@code @EnumSource} annotation that excludes the given + * enum constant name. + */ + private static void modifyAnnotationToExclude(AST ast, ASTRewrite rewrite, Annotation annotation, + org.eclipse.jdt.core.dom.Expression valueExpr, String currentMode, + List currentNames, String enumValueName) { + + NormalAnnotation newAnnotation= ast.newNormalAnnotation(); + newAnnotation.setTypeName(ast.newName("EnumSource")); //$NON-NLS-1$ + + // Preserve the "value" attribute if present + if (valueExpr != null) { + MemberValuePair valuePair= ast.newMemberValuePair(); + valuePair.setName(ast.newSimpleName("value")); //$NON-NLS-1$ + valuePair.setValue((org.eclipse.jdt.core.dom.Expression) rewrite.createCopyTarget(valueExpr)); + newAnnotation.values().add(valuePair); + } + + // If already EXCLUDE, keep it; otherwise set to EXCLUDE + boolean isAlreadyExclude= "EXCLUDE".equals(currentMode); //$NON-NLS-1$ + + // mode = EnumSource.Mode.EXCLUDE + MemberValuePair modePair= ast.newMemberValuePair(); + modePair.setName(ast.newSimpleName("mode")); //$NON-NLS-1$ + QualifiedName modeValue= ast.newQualifiedName( + ast.newQualifiedName(ast.newSimpleName("EnumSource"), ast.newSimpleName("Mode")), //$NON-NLS-1$ //$NON-NLS-2$ + ast.newSimpleName("EXCLUDE")); //$NON-NLS-1$ + modePair.setValue(modeValue); + newAnnotation.values().add(modePair); + + // names = { existing..., newValue } + ArrayInitializer namesArray= ast.newArrayInitializer(); + + if (isAlreadyExclude) { + // Preserve all existing excluded names + for (String existingName : currentNames) { + StringLiteral sl= ast.newStringLiteral(); + sl.setLiteralValue(existingName); + namesArray.expressions().add(sl); + } + } + // Add the new exclusion + StringLiteral newNameLiteral= ast.newStringLiteral(); + newNameLiteral.setLiteralValue(enumValueName); + namesArray.expressions().add(newNameLiteral); + + MemberValuePair namesPair= ast.newMemberValuePair(); + namesPair.setName(ast.newSimpleName("names")); //$NON-NLS-1$ + namesPair.setValue(namesArray); + newAnnotation.values().add(namesPair); + + rewrite.replace(annotation, newAnnotation, null); + } + private static void applyChangesWithImportRemoval(ICompilationUnit cu, CompilationUnit astRoot, ASTRewrite rewrite, Annotation removedAnnotationNode) { try { MultiTextEdit multiEdit= new MultiTextEdit(); diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestSessionLabelProvider.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestSessionLabelProvider.java index 06121434095..b709d04e026 100644 --- a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestSessionLabelProvider.java +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestSessionLabelProvider.java @@ -95,6 +95,19 @@ private String getTextForFlatLayout(TestCaseElement testCaseElement, String labe parentName= testCaseElement.getTestClassName(); } } + + // Populate metadata on demand for @EnumSource tests + ParameterizedTestMetadataExtractor.populate(testCaseElement); + if ("EnumSource".equals(testCaseElement.getParameterSourceType())) { //$NON-NLS-1$ + String enumType= testCaseElement.getParameterEnumType(); + if (enumType != null) { + // Use simple name for display + int lastDot= enumType.lastIndexOf('.'); + String simpleEnumName= lastDot >= 0 ? enumType.substring(lastDot + 1) : enumType; + parentName= parentName + " [@EnumSource(" + simpleEnumName + ")]"; //$NON-NLS-1$ //$NON-NLS-2$ + } + } + return Messages.format(JUnitMessages.TestSessionLabelProvider_testMethodName_className, new Object[] { label, BasicElementLabels.getJavaElementName(parentName) }); } diff --git a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestViewer.java b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestViewer.java index c66abbadb7a..2347e0e9890 100644 --- a/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestViewer.java +++ b/org.eclipse.jdt.junit/src/org/eclipse/jdt/internal/junit/ui/TestViewer.java @@ -69,7 +69,9 @@ import org.eclipse.debug.core.ILaunchManager; import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IMethod; import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaModelException; import org.eclipse.jdt.internal.junit.launcher.ITestFinder; import org.eclipse.jdt.internal.junit.model.TestCaseElement; @@ -200,6 +202,7 @@ public void run(){ private final IgnoredOnlyFilter fIgnoredOnlyFilter= new IgnoredOnlyFilter(); private final DisableTestAction fDisableTestAction; + private final ExcludeParameterValueAction fExcludeParameterValueAction; private final TestRunnerViewPart fTestRunnerPart; private final Clipboard fClipboard; @@ -233,6 +236,7 @@ public TestViewer(Composite parent, Clipboard clipboard, TestRunnerViewPart runn fClipboard= clipboard; fDisableTestAction= new DisableTestAction(); + fExcludeParameterValueAction= new ExcludeParameterValueAction(); fLayoutMode= TestRunnerViewPart.LAYOUT_HIERARCHICAL; @@ -302,6 +306,10 @@ void handleMenuAboutToShow(IMenuManager manager) { manager.add(new Separator()); manager.add(fDisableTestAction); } + + // Add re-include submenu for @EnumSource tests with exclusions + addReincludeExcludedValuesSubmenu(manager, testSuiteElement); + } else { TestCaseElement testCaseElement= (TestCaseElement) testElement; manager.add(getOpenTestAction(testCaseElement)); @@ -314,6 +322,13 @@ void handleMenuAboutToShow(IMenuManager manager) { manager.add(new Separator()); manager.add(fDisableTestAction); } + + // For @EnumSource parameterized test invocations, offer to exclude the value + fExcludeParameterValueAction.update(testCaseElement); + if (fExcludeParameterValueAction.isEnabled()) { + manager.add(new Separator()); + manager.add(fExcludeParameterValueAction); + } } if (fLayoutMode == TestRunnerViewPart.LAYOUT_HIERARCHICAL) { manager.add(new Separator()); @@ -331,6 +346,50 @@ void handleMenuAboutToShow(IMenuManager manager) { manager.add(new Separator(IWorkbenchActionConstants.MB_ADDITIONS + "-end")); //$NON-NLS-1$ } + /** + * Adds a "Re-include Excluded Enum Values" submenu to the context menu for + * {@code @EnumSource} parameterized tests that currently have exclusions. + * + * @param manager the context menu manager + * @param testSuiteElement the selected test suite element + */ + private void addReincludeExcludedValuesSubmenu(IMenuManager manager, TestSuiteElement testSuiteElement) { + try { + IMethod method= TestMethodFinder.findMethodForParameterizedTest(testSuiteElement); + if (method == null) { + return; + } + + if (!EnumSourceValidator.isExcludeMode(method)) { + return; + } + + List excludedNames= EnumSourceValidator.getExcludedNames(method); + if (excludedNames.isEmpty()) { + return; + } + + manager.add(new Separator()); + MenuManager submenu= new MenuManager(JUnitMessages.TestViewer_reinclude_submenu_label); + + // "Re-include All" action + ReincludeAllEnumValuesAction reincludeAll= new ReincludeAllEnumValuesAction(); + reincludeAll.update(method, testSuiteElement); + submenu.add(reincludeAll); + + if (excludedNames.size() > 1) { + submenu.add(new Separator()); + for (String name : excludedNames) { + submenu.add(new ReincludeEnumValueAction(method, name)); + } + } + + manager.add(submenu); + } catch (JavaModelException e) { + JUnitPlugin.log(e); + } + } + private void addRerunActions(IMenuManager manager, TestCaseElement testCaseElement) { String className= testCaseElement.getClassName(); String testMethodName= testCaseElement.getTestMethodName(); diff --git a/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/junit/tests/ExcludeParameterValueDisplayNameTest.java b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/junit/tests/ExcludeParameterValueDisplayNameTest.java new file mode 100644 index 00000000000..89cc8cd6a7e --- /dev/null +++ b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/junit/tests/ExcludeParameterValueDisplayNameTest.java @@ -0,0 +1,291 @@ +/******************************************************************************* + * Copyright (c) 2025 Carsten Hammer and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Carsten Hammer using github copilot - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.junit.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import org.eclipse.jdt.junit.JUnitCore; +import org.eclipse.jdt.testplugin.JavaProjectHelper; + +import org.eclipse.jdt.core.IClasspathEntry; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaCore; + +import org.eclipse.jdt.internal.junit.ui.EnumSourceValidator; +import org.eclipse.jdt.internal.junit.ui.TestAnnotationModifier; + +import org.eclipse.jdt.ui.tests.core.rules.Java1d8ProjectTestSetup; +import org.eclipse.jdt.ui.tests.core.rules.ProjectTestSetup; + +/** + * Tests for {@code @EnumSource} exclude/re-include functionality. + * + *

Validates {@link TestAnnotationModifier#excludeEnumValue(IMethod, String)}, + * {@link EnumSourceValidator#removeExcludeMode(IMethod)}, and + * {@link EnumSourceValidator#removeValueFromExclusion(IMethod, String)}. + */ +public class ExcludeParameterValueDisplayNameTest { + + @RegisterExtension + public ProjectTestSetup projectSetup= new Java1d8ProjectTestSetup(); + + private IJavaProject fJProject; + private IPackageFragmentRoot fSourceFolder; + + @BeforeEach + public void setUp() throws Exception { + fJProject= projectSetup.getProject(); + fSourceFolder= JavaProjectHelper.addSourceContainer(fJProject, "src"); + + JavaProjectHelper.addRTJar(fJProject); + IClasspathEntry cpe= JavaCore.newContainerEntry(JUnitCore.JUNIT5_CONTAINER_PATH); + JavaProjectHelper.addToClasspath(fJProject, cpe); + JavaProjectHelper.set18CompilerOptions(fJProject); + } + + @AfterEach + public void tearDown() throws Exception { + JavaProjectHelper.clear(fJProject, projectSetup.getDefaultClasspath()); + } + + @Test + public void testExcludeEnumValue_AddsExcludeMode() throws Exception { + IPackageFragment pack1= fSourceFolder.createPackageFragment("test1", false, null); + String original= """ + package test1; + + import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.EnumSource; + + public class MyTest { + enum Color { RED, GREEN, BLUE } + + @ParameterizedTest + @EnumSource(Color.class) + public void testWithEnum(Color color) { + } + } + """; + + ICompilationUnit cu= pack1.createCompilationUnit("MyTest.java", original, false, null); + IType type= cu.getType("MyTest"); + IMethod method= type.getMethod("testWithEnum", new String[] { "QColor;" }); + + assertFalse(EnumSourceValidator.isExcludeMode(method), "Initially should not be in EXCLUDE mode"); + + TestAnnotationModifier.excludeEnumValue(method, "RED"); + + String source= cu.getSource(); + assertTrue(source.contains("mode"), "Should add mode attribute"); + assertTrue(source.contains("EXCLUDE"), "Should set EXCLUDE mode"); + assertTrue(source.contains("\"RED\""), "Should add RED to names"); + assertTrue(EnumSourceValidator.isExcludeMode(method), "Should now be in EXCLUDE mode"); + } + + @Test + public void testExcludeEnumValue_AppendsToExistingExclusions() throws Exception { + IPackageFragment pack1= fSourceFolder.createPackageFragment("test1", false, null); + String original= """ + package test1; + + import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.EnumSource; + import org.junit.jupiter.params.provider.EnumSource.Mode; + + public class MyTest { + enum Color { RED, GREEN, BLUE } + + @ParameterizedTest + @EnumSource(value = Color.class, mode = Mode.EXCLUDE, names = {"RED"}) + public void testWithEnum(Color color) { + } + } + """; + + ICompilationUnit cu= pack1.createCompilationUnit("MyTest.java", original, false, null); + IType type= cu.getType("MyTest"); + IMethod method= type.getMethod("testWithEnum", new String[] { "QColor;" }); + + assertTrue(EnumSourceValidator.isExcludeMode(method), "Initially should be in EXCLUDE mode"); + List before= EnumSourceValidator.getExcludedNames(method); + assertEquals(1, before.size(), "Should have one excluded name before"); + + TestAnnotationModifier.excludeEnumValue(method, "GREEN"); + + String source= cu.getSource(); + assertTrue(source.contains("\"RED\""), "Should still contain RED"); + assertTrue(source.contains("\"GREEN\""), "Should now contain GREEN"); + List after= EnumSourceValidator.getExcludedNames(method); + assertEquals(2, after.size(), "Should have two excluded names after"); + assertTrue(after.contains("RED"), "Should contain RED"); + assertTrue(after.contains("GREEN"), "Should contain GREEN"); + } + + @Test + public void testRemoveExcludeMode_RemovesModeAndNames() throws Exception { + IPackageFragment pack1= fSourceFolder.createPackageFragment("test1", false, null); + String original= """ + package test1; + + import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.EnumSource; + import org.junit.jupiter.params.provider.EnumSource.Mode; + + public class MyTest { + enum Color { RED, GREEN, BLUE } + + @ParameterizedTest + @EnumSource(value = Color.class, mode = Mode.EXCLUDE, names = {"RED", "GREEN"}) + public void testWithEnum(Color color) { + } + } + """; + + ICompilationUnit cu= pack1.createCompilationUnit("MyTest.java", original, false, null); + IType type= cu.getType("MyTest"); + IMethod method= type.getMethod("testWithEnum", new String[] { "QColor;" }); + + assertTrue(EnumSourceValidator.isExcludeMode(method), "Initially should be in EXCLUDE mode"); + + EnumSourceValidator.removeExcludeMode(method); + + assertFalse(EnumSourceValidator.isExcludeMode(method), "Should no longer be in EXCLUDE mode"); + String source= cu.getSource(); + assertFalse(source.contains("mode"), "Should remove mode attribute"); + assertFalse(source.contains("EXCLUDE"), "Should remove EXCLUDE"); + assertFalse(source.contains("\"RED\""), "Should remove names"); + } + + @Test + public void testRemoveValueFromExclusion_RemovesSingleValue() throws Exception { + IPackageFragment pack1= fSourceFolder.createPackageFragment("test1", false, null); + String original= """ + package test1; + + import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.EnumSource; + import org.junit.jupiter.params.provider.EnumSource.Mode; + + public class MyTest { + enum Color { RED, GREEN, BLUE } + + @ParameterizedTest + @EnumSource(value = Color.class, mode = Mode.EXCLUDE, names = {"RED", "GREEN"}) + public void testWithEnum(Color color) { + } + } + """; + + ICompilationUnit cu= pack1.createCompilationUnit("MyTest.java", original, false, null); + IType type= cu.getType("MyTest"); + IMethod method= type.getMethod("testWithEnum", new String[] { "QColor;" }); + + List before= EnumSourceValidator.getExcludedNames(method); + assertEquals(2, before.size(), "Should have two excluded names before"); + + EnumSourceValidator.removeValueFromExclusion(method, "RED"); + + List after= EnumSourceValidator.getExcludedNames(method); + assertEquals(1, after.size(), "Should have one excluded name after"); + assertFalse(after.contains("RED"), "RED should be removed"); + assertTrue(after.contains("GREEN"), "GREEN should remain"); + assertTrue(EnumSourceValidator.isExcludeMode(method), "Should still be in EXCLUDE mode"); + } + + @Test + public void testRemoveValueFromExclusion_LastValueRemovesMode() throws Exception { + IPackageFragment pack1= fSourceFolder.createPackageFragment("test1", false, null); + String original= """ + package test1; + + import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.EnumSource; + import org.junit.jupiter.params.provider.EnumSource.Mode; + + public class MyTest { + enum Color { RED, GREEN, BLUE } + + @ParameterizedTest + @EnumSource(value = Color.class, mode = Mode.EXCLUDE, names = {"RED"}) + public void testWithEnum(Color color) { + } + } + """; + + ICompilationUnit cu= pack1.createCompilationUnit("MyTest.java", original, false, null); + IType type= cu.getType("MyTest"); + IMethod method= type.getMethod("testWithEnum", new String[] { "QColor;" }); + + assertTrue(EnumSourceValidator.isExcludeMode(method), "Initially should be in EXCLUDE mode"); + + EnumSourceValidator.removeValueFromExclusion(method, "RED"); + + assertFalse(EnumSourceValidator.isExcludeMode(method), "Should no longer be in EXCLUDE mode when all values re-included"); + } + + @Test + public void testExtractEnumConstantFromDisplayName_StripsBracketPrefix() { + assertEquals("RED", EnumSourceValidator.extractEnumConstantFromDisplayName("[1] RED")); + assertEquals("GREEN", EnumSourceValidator.extractEnumConstantFromDisplayName("[2] GREEN")); + assertEquals("BLUE", EnumSourceValidator.extractEnumConstantFromDisplayName("[10] BLUE")); + assertEquals("SOME_VALUE", EnumSourceValidator.extractEnumConstantFromDisplayName("SOME_VALUE")); + assertEquals("MY_CONSTANT", EnumSourceValidator.extractEnumConstantFromDisplayName("[5] MY_CONSTANT")); + } + + @Test + public void testComputeNamesAfterReinclude_RemovesValue() { + java.util.List excluded= java.util.Arrays.asList("RED", "GREEN", "BLUE"); + + java.util.List result= EnumSourceValidator.computeNamesAfterReinclude(excluded, "GREEN"); + + assertEquals(2, result.size(), "Should have two names after reinclude"); + assertTrue(result.contains("RED"), "RED should remain"); + assertFalse(result.contains("GREEN"), "GREEN should be removed"); + assertTrue(result.contains("BLUE"), "BLUE should remain"); + } + + @Test + public void testComputeNamesAfterReinclude_EmptyAfterLastRemoval() { + java.util.List excluded= java.util.Arrays.asList("RED"); + + java.util.List result= EnumSourceValidator.computeNamesAfterReinclude(excluded, "RED"); + + assertTrue(result.isEmpty(), "Should be empty after removing the last name"); + } + + @Test + public void testComputeNamesAfterReinclude_ValueNotPresent() { + java.util.List excluded= java.util.Arrays.asList("RED", "GREEN"); + + java.util.List result= EnumSourceValidator.computeNamesAfterReinclude(excluded, "BLUE"); + + assertEquals(2, result.size(), "Should still have two names when value not present"); + assertTrue(result.contains("RED"), "RED should remain"); + assertTrue(result.contains("GREEN"), "GREEN should remain"); + } +}