diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39b8622..f68465a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,262 +1,284 @@ -# GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: -# - Validate Gradle Wrapper. -# - Run 'test' and 'verifyPlugin' tasks. -# - Run Qodana inspections. -# - Run the 'buildPlugin' task and prepare artifact for further tests. -# - Run the 'runPluginVerifier' task. -# - Create a draft release. +# GitHub Actions Workflow for testing and preparing the plugin release in two stages: +# - Run the unit / integration / headless UI tests on every push and pull request. +# - On non-PR events, detect whether the current project version still needs a release. +# - When it does, build the plugin ZIP and create or refresh a GitHub Release *draft*. # -# The workflow is triggered on push and pull_request events. +# Publishing to the JetBrains Marketplace is intentionally NOT done here. It is handled by +# release.yml, which only runs after a maintainer manually publishes the drafted GitHub Release. # # GitHub Actions reference: https://help.github.com/en/actions -# -## JBIJPPTPL name: Build + on: - # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) + # Pushes to the main branch may prepare a release draft. push: branches: [ main ] - # Trigger the workflow on any pull request + # Every pull request only runs the tests (never prepares a release). pull_request: + # Allow manual runs (also able to prepare a release draft). + workflow_dispatch: + +# Restrictive default; individual jobs widen this only where required. +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - + # Cancel superseded pull request runs for fast feedback, but never cancel a + # release-producing push/dispatch run mid-flight: that could interrupt a draft + # update between editing the release and uploading the ZIP. Such runs serialize + # within the group instead. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - # Prepare environment and build the plugin - build: - name: Build + # Run the test suite split into unit / integration / headless UI tiers. + test: + name: Test runs-on: ubuntu-latest - outputs: - version: ${{ steps.properties.outputs.version }} - changelog: ${{ steps.properties.outputs.changelog }} - pluginVerifierHomeDir: ${{ steps.properties.outputs.pluginVerifierHomeDir }} + permissions: + contents: read steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Validate wrapper - - name: Gradle Wrapper Validation - uses: gradle/actions/wrapper-validation@v3 - - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - + - name: Make gradlew executable run: chmod +x gradlew - # Set environment variables - - name: Export Properties - id: properties + # IntelliJ Platform test fixtures are not compatible with the Gradle Configuration Cache, + # so the test command disables it; --continue runs every tier even if one fails. + - name: Run tests + run: >- + ./gradlew unitTest integrationTest ideaUiTest + --continue + --no-configuration-cache + --console=plain + + # Test reports are only useful (and only uploaded) when something failed. + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + build/reports/tests + build/test-results + retention-days: 7 + if-no-files-found: ignore + + # Decide whether the current project version still needs a release draft. + # Runs only for non-PR events. Reads the version from gradle.properties (the single source + # of truth) and distinguishes draft / published / tagged / brand-new states. + detect: + name: Detect release state + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + release_tag: ${{ steps.state.outputs.release_tag }} + should_prepare_release: ${{ steps.state.outputs.should_prepare_release }} + steps: + - name: Fetch Sources + uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags are needed for the "tag already exists" check + + - name: Read project version + id: version shell: bash run: | - PROPERTIES="$(./gradlew properties --console=plain -q)" - VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" - CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "pluginVerifierHomeDir=~/.pluginVerifier" >> $GITHUB_OUTPUT - - echo "changelog<> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # Build plugin - - name: Build plugin - run: ./gradlew buildPlugin - - # Prepare plugin archive content for creating artifact - - name: Prepare Plugin Artifact - id: artifact + set -euo pipefail + VERSION="$(sed -n -E 's/^[[:space:]]*pluginVersion[[:space:]]*=[[:space:]]*([^[:space:]#]+).*/\1/p' gradle.properties | head -n1)" + if [ -z "${VERSION}" ]; then + echo "::error::Unable to read pluginVersion from gradle.properties" + exit 1 + fi + echo "Resolved project version: ${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Determine release state + id: state + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} shell: bash run: | - cd ${{ github.workspace }}/build/distributions - FILENAME=`ls *.zip` - unzip "$FILENAME" -d content - - echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT - - # Store already-built plugin as an artifact for downloading - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.artifact.outputs.filename }} - path: ./build/distributions/content/*/* - - # Run tests and upload a code coverage report - test: - name: Test - needs: [ build ] + set -euo pipefail + should_prepare_release=false + # New releases use the bare project version (matching the CHANGELOG compare links and + # the project's git-tag history). Legacy releases created by the previous workflow used + # a "v" prefix, so detection accepts BOTH forms and operates on whichever already exists. + release_tag="${VERSION}" + + existing="" + for cand in "${VERSION}" "v${VERSION}"; do + if gh release view "${cand}" >/dev/null 2>&1; then + existing="${cand}" + break + fi + done + + if [ -n "${existing}" ]; then + release_tag="${existing}" + # A GitHub Release already exists for this version. Use its draft flag (NOT mere + # existence) to decide whether it is publishable or still in progress. + is_draft="$(gh release view "${existing}" --json isDraft --jq .isDraft)" + if [ "${is_draft}" = "true" ]; then + echo "Draft release ${existing} already exists -> refresh it" + should_prepare_release=true + else + echo "Published release ${existing} already exists -> skip" + fi + elif git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null \ + || git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + # No release, but a tag for this version is already present -> treat as released. + echo "Tag for ${VERSION} already exists without a release -> skip" + else + echo "No release and no tag for ${VERSION} -> create a new draft (${release_tag})" + should_prepare_release=true + fi + + echo "should_prepare_release=${should_prepare_release}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + + # Build the plugin and create / refresh the GitHub Release draft for manual verification. + # If a maintainer accepts and publishes it, release.yml takes over. + releaseDraft: + name: Release draft + needs: [ test, detect ] + if: >- + github.event_name != 'pull_request' && + needs.detect.outputs.should_prepare_release == 'true' runs-on: ubuntu-latest + permissions: + contents: write + # A fixed per-branch group so concurrent runs cannot modify the same draft at once; + # do not cancel an in-flight run mid-update (it could corrupt the draft). + concurrency: + group: release-draft-${{ github.ref }} + cancel-in-progress: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.detect.outputs.version }} + # Tag to operate on: the existing release's tag (possibly legacy "v"-prefixed) or, for a + # brand-new release, the bare project version. Resolved by the detect job. + RELEASE_TAG: ${{ needs.detect.outputs.release_tag }} steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Make gradlew executable run: chmod +x gradlew - # Run tests - - name: Run Tests - run: ./gradlew check - - # Collect Tests Result of failed tests - - name: Collect Tests Result - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: tests-result - path: ${{ github.workspace }}/build/reports/tests - # Upload the Kover report to CodeCov - - name: Upload Code Coverage Report - uses: codecov/codecov-action@v4 - with: - files: ${{ github.workspace }}/build/reports/kover/report.xml + - name: Build plugin + run: ./gradlew buildPlugin --console=plain - # Run Qodana inspections and provide report + - name: Verify distribution exists + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + zips=(build/distributions/*.zip) + if [ ${#zips[@]} -eq 0 ]; then + echo "::error::No plugin ZIP was produced in build/distributions" + exit 1 + fi + printf 'Found distribution: %s\n' "${zips[@]}" + + - name: Generate release notes + shell: bash + run: | + set -euo pipefail + # Upcoming-release notes normally live under the [Unreleased] heading; fall back to a + # version-specific section (in case notes were already moved), then to a plain default. + NOTES="$(./gradlew getChangelog --console=plain -q --no-header --no-configuration-cache --unreleased 2>/dev/null || true)" + if [ -z "${NOTES}" ]; then + NOTES="$(./gradlew getChangelog --console=plain -q --no-header --no-configuration-cache --version "${VERSION}" 2>/dev/null || true)" + fi + if [ -z "${NOTES}" ]; then + NOTES="Release ${VERSION}" + fi + printf '%s\n' "${NOTES}" > RELEASE_NOTES.md + echo "Generated release notes:" + cat RELEASE_NOTES.md + + - name: Create or refresh draft release + shell: bash + run: | + set -euo pipefail + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + # Re-check at execution time: a maintainer may have published the release between the + # detect job and now. Never overwrite a published release or revert it back to a draft. + is_draft="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" + if [ "${is_draft}" != "true" ]; then + echo "::error::Release ${RELEASE_TAG} has been published; refusing to modify a published release." + exit 1 + fi + echo "Refreshing existing draft release ${RELEASE_TAG}" + gh release edit "${RELEASE_TAG}" \ + --target "${GITHUB_SHA}" \ + --title "${RELEASE_TAG}" \ + --notes-file RELEASE_NOTES.md + gh release upload "${RELEASE_TAG}" build/distributions/*.zip --clobber + else + echo "Creating new draft release ${RELEASE_TAG}" + gh release create "${RELEASE_TAG}" \ + --draft \ + --target "${GITHUB_SHA}" \ + --title "${RELEASE_TAG}" \ + --notes-file RELEASE_NOTES.md \ + build/distributions/*.zip + fi + + # Qodana static analysis. Independent and non-blocking: it never gates the release draft. inspectCode: name: Inspect code - needs: [ build ] runs-on: ubuntu-latest permissions: contents: write checks: write pull-requests: write steps: - - # Free GitHub Actions Environment Disk Space - name: Maximize Build Space uses: jlumbroso/free-disk-space@main with: tool-cache: false large-packages: false - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit - fetch-depth: 0 # a full history is required for pull request analysis + ref: ${{ github.event.pull_request.head.sha }} # the real PR commit, not the merge commit + fetch-depth: 0 # full history is required for pull request analysis - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Run Qodana inspections - name: Qodana - Code Inspection uses: JetBrains/qodana-action@v2024.2 with: cache-default-branch-only: true - - # Run plugin structure verification along with IntelliJ Plugin Verifier - verify: - name: Verify plugin - needs: [ build ] - runs-on: ubuntu-latest - steps: - - # Free GitHub Actions Environment Disk Space - - name: Maximize Build Space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - large-packages: false - - # Check out the current repository - - name: Fetch Sources - uses: actions/checkout@v4 - - # Set up Java environment for the next steps - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: zulu - java-version: 17 - - # Setup Gradle - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Make gradlew executable - run: chmod +x gradlew - # Cache Plugin Verifier IDEs - - name: Setup Plugin Verifier IDEs Cache - uses: actions/cache@v4 - with: - path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides - key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} - - # Run Verify Plugin task and IntelliJ Plugin Verifier tool - - name: Run Plugin Verification tasks - run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} - - # Collect Plugin Verifier Result - - name: Collect Plugin Verifier Result - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: pluginVerifier-result - path: ${{ github.workspace }}/build/reports/pluginVerifier - - # Prepare a draft release for GitHub Releases page for the manual verification - # If accepted and published, release workflow would be triggered - releaseDraft: - name: Release draft - if: github.event_name != 'pull_request' - needs: [ build, test, inspectCode, verify ] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - # Check out the current repository - - name: Fetch Sources - uses: actions/checkout@v4 - - # Remove old release drafts by using the curl request for the available releases with a draft flag - - name: Remove Old Release Drafts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api repos/{owner}/{repo}/releases \ - --jq '.[] | select(.draft == true) | .id' \ - | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} - - # Create a new release draft which is not publicly visible and requires manual acceptance - - name: Create Release Draft - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "v${{ needs.build.outputs.version }}" \ - --draft \ - --title "v${{ needs.build.outputs.version }}" \ - --notes "$(cat << 'EOM' - ${{ needs.build.outputs.changelog }} - EOM - )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c7526b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +# Publishes the plugin to the JetBrains Marketplace. +# +# This runs ONLY after a maintainer manually publishes a GitHub Release (the draft created by +# build.yml). It is never triggered by a push or by draft creation, so Marketplace credentials +# are only read for a trusted, human-approved "release: published" event. + +name: Release + +on: + release: + types: [ published ] + +# Avoid double-publishing if the event is delivered more than once for the same tag. +concurrency: + group: marketplace-publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Publish to JetBrains Marketplace + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Fetch Sources (at the released tag) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + # The release tag must exactly match the project version that will actually be published. + - name: Verify release tag matches project version + shell: bash + run: | + set -euo pipefail + VERSION="$(./gradlew properties --console=plain -q --no-configuration-cache | grep "^version:" | cut -f2- -d ' ')" + if [ -z "${VERSION}" ]; then + echo "::error::Unable to read project version from Gradle properties" + exit 1 + fi + TAG="${{ github.event.release.tag_name }}" + # Accept both the bare version and the legacy "v"-prefixed form (e.g. v2.0.0). + NORMALIZED_TAG="${TAG#v}" + echo "Project version: ${VERSION}" + echo "Release tag: ${TAG} (normalized: ${NORMALIZED_TAG})" + if [ "${NORMALIZED_TAG}" != "${VERSION}" ]; then + echo "::error::Release tag '${TAG}' does not match project version '${VERSION}'" + exit 1 + fi + + # Fail early (and clearly) when a publishing/signing secret is missing. Only the names are + # printed, never the values. + - name: Check required secrets + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + missing=() + [ -n "${PUBLISH_TOKEN:-}" ] || missing+=("PUBLISH_TOKEN") + [ -n "${CERTIFICATE_CHAIN:-}" ] || missing+=("CERTIFICATE_CHAIN") + [ -n "${PRIVATE_KEY:-}" ] || missing+=("PRIVATE_KEY") + [ -n "${PRIVATE_KEY_PASSWORD:-}" ] || missing+=("PRIVATE_KEY_PASSWORD") + if [ ${#missing[@]} -ne 0 ]; then + echo "::error::Missing required secrets: ${missing[*]}" + exit 1 + fi + echo "All required secrets are present." + + # publishPlugin depends on signPlugin (signing config) and patchChangelog, so the plugin is + # signed before it is uploaded. Credentials are read from the environment by build.gradle.kts. + - name: Publish plugin + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + run: ./gradlew publishPlugin --console=plain diff --git a/.github/workflows/run-plugin-verifier.yml b/.github/workflows/run-plugin-verifier.yml new file mode 100644 index 0000000..0fae391 --- /dev/null +++ b/.github/workflows/run-plugin-verifier.yml @@ -0,0 +1,63 @@ +# Runs the IntelliJ Plugin Verifier on demand and on a weekly schedule. +# +# The Plugin Verifier downloads additional IDE distributions and is slow / disk-heavy, so it is +# kept out of the main Build workflow and never blocks release-draft creation. The Gradle +# `pluginVerification { ... }` configuration is retained so it can also be run locally. + +name: Run Plugin Verifier + +on: + workflow_dispatch: + schedule: + # Every Monday at 06:00 UTC. + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + verify: + name: Verify plugin + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + steps: + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + + - name: Fetch Sources + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Setup Plugin Verifier IDEs Cache + uses: actions/cache@v4 + with: + path: ~/.pluginVerifier/ides + key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} + + - name: Run Plugin Verification tasks + run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=~/.pluginVerifier --console=plain + + - name: Collect Plugin Verifier Result + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: pluginVerifier-result + path: build/reports/pluginVerifier + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml index c901413..376915a 100644 --- a/.github/workflows/run-ui-tests.yml +++ b/.github/workflows/run-ui-tests.yml @@ -1,63 +1,63 @@ -# GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: -# - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. -# - Wait for IDE to start. -# - Run UI tests with a separate Gradle task. +# GitHub Actions Workflow for running the headless Swing/UI component tests (the `ideaUiTest` +# Gradle task) on Linux, Windows, and macOS. # -# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. +# These are plain headless component tests, so this workflow does NOT start a RemoteRobot server +# and does NOT launch a full IDE. If the project later gains real RemoteRobot client tests, add a +# separate workflow that runs runIdeForUiTests + robot-server and the RemoteRobot client. # -# Workflow is triggered manually. +# Triggered manually. name: Run UI Tests + on: - workflow_dispatch + workflow_dispatch: + +permissions: + contents: read jobs: - testUI: + ideaUiTest: + name: UI tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read strategy: fail-fast: false matrix: - include: - - os: ubuntu-latest - runIde: | - export DISPLAY=:99.0 - Xvfb -ac :99 -screen 0 1920x1080x16 & - gradle runIdeForUiTests & - - os: windows-latest - runIde: start gradlew.bat runIdeForUiTests - - os: macos-latest - runIde: ./gradlew runIdeForUiTests & + os: [ ubuntu-latest, windows-latest, macos-latest ] steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - # Run IDEA prepared for UI testing - - name: Run IDE - run: ${{ matrix.runIde }} + - name: Make gradlew executable + shell: bash + run: chmod +x gradlew - # Wait for IDEA to be started - - name: Health Check - uses: jtalk/url-health-check-action@v4 - with: - url: http://127.0.0.1:8082 - max-attempts: 15 - retry-delay: 30s + # Run via the Gradle wrapper through bash on every OS (Git Bash is available on Windows). + - name: Run headless Swing/UI component tests + shell: bash + run: ./gradlew ideaUiTest --no-configuration-cache --console=plain - # Run tests - - name: Tests - run: ./gradlew test + # Upload a separate, short-lived report per OS, only when the tests fail. + - name: Upload UI test report + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: ui-test-report-${{ matrix.os }} + path: | + build/reports/tests + build/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 848c384..839ce15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ # runtime-pivot-plugin Changelog ## [Unreleased] +### Changed +- 重构 GitHub Actions CI/CD 流程:分层测试(单元 / 集成 / 无头 UI 组件)、推送主分支时自动创建并刷新 Release 草稿、GitHub Release 正式发布后自动签名并上传 JetBrains Marketplace +- Plugin Verifier 改为按需 / 每周定时单独运行,不再阻塞 Release 草稿创建 ## [2.0.0] - 2025-03-23 ### Refactor diff --git a/build.gradle.kts b/build.gradle.kts index 9cc0614..c30cec8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -157,6 +157,46 @@ tasks { } intellijPlatformTesting { + // The CI test stage is split into three tiers backed by the single `test` source set + // (selected by package). They are routed through the IntelliJ Platform test runtime so + // that BasePlatformTestCase fixtures and Swing components work without a running IDE. + // + // IntelliJ Platform test fixtures are not compatible with the Gradle Configuration Cache, + // so each task is explicitly marked incompatible (the CI workflow also passes + // --no-configuration-cache) to avoid cache-serialization failures after tests pass. + testIde { + register("unitTest") { + task { + group = "verification" + description = "Runs fast unit tests that do not require an IntelliJ Platform fixture." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.unit.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + register("integrationTest") { + task { + group = "verification" + description = "Runs IntelliJ Platform integration tests (BasePlatformTestCase)." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.integration.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + register("ideaUiTest") { + task { + group = "verification" + description = "Runs headless Swing/UI component tests (no RemoteRobot server)." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.ui.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + } + runIde { register("runIdeForUiTests") { task { diff --git a/gradle.properties b/gradle.properties index 0400e19..d2f6e2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.runtime.pivot.plugin pluginName = runtime-pivot pluginRepositoryUrl = https://github.com/wl2027/runtime-pivot # SemVer format -> https://semver.org -pluginVersion = 2.0.0 +pluginVersion = 2.1.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 221 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java b/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java new file mode 100644 index 0000000..c253ba2 --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java @@ -0,0 +1,35 @@ +package com.runtime.pivot.plugin.integration; + +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.extensions.PluginId; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.config.RuntimePivotSettings; + +/** + * IntelliJ Platform integration tests. These run inside a light platform fixture so that the + * plugin under development is loaded and its services / actions are registered. + */ +public class RuntimePivotPluginIntegrationTest extends BasePlatformTestCase { + + public void testPluginIsLoaded() { + PluginId pluginId = PluginId.getId(RuntimePivotConstants.PLUGIN_ID); + assertNotNull("Plugin descriptor for " + RuntimePivotConstants.PLUGIN_ID + " should be available", + PluginManagerCore.getPlugin(pluginId)); + } + + public void testProjectServiceIsRegistered() { + RuntimePivotSettings settings = RuntimePivotSettings.getInstance(getProject()); + assertNotNull("RuntimePivotSettings project service should be available", settings); + assertTrue("attachAgent should default to true", settings.isAttachAgent()); + } + + public void testActionsAreRegistered() { + ActionManager actionManager = ActionManager.getInstance(); + assertNotNull("RuntimePivot.ClassLoaderTree action should be registered", + actionManager.getAction("RuntimePivot.ClassLoaderTree")); + assertNotNull("RuntimePivot.XSessionMonitoring action should be registered", + actionManager.getAction("RuntimePivot.XSessionMonitoring")); + } +} diff --git a/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java b/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java new file mode 100644 index 0000000..1bce7b7 --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java @@ -0,0 +1,57 @@ +package com.runtime.pivot.plugin.ui; + +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.enums.XStackBreakpointType; +import org.junit.Test; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreeModel; +import java.awt.BorderLayout; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Headless Swing/UI component tests. These build Swing components on the Event Dispatch Thread and + * assert on their structure. They run fully headless and do NOT start a RemoteRobot server. + */ +public class RuntimePivotSwingComponentTest { + + @Test + public void buildsBreakpointTreeModelOnEventDispatchThread() throws Exception { + final JTree[] holder = new JTree[1]; + SwingUtilities.invokeAndWait(() -> { + DefaultMutableTreeNode root = new DefaultMutableTreeNode("Breakpoints"); + for (XStackBreakpointType type : XStackBreakpointType.values()) { + root.add(new DefaultMutableTreeNode(type.getDescription())); + } + holder[0] = new JTree(root); + }); + + JTree tree = holder[0]; + assertNotNull(tree); + TreeModel model = tree.getModel(); + assertEquals(XStackBreakpointType.values().length, model.getChildCount(model.getRoot())); + } + + @Test + public void buildsLabeledPanelOnEventDispatchThread() throws Exception { + final JPanel[] holder = new JPanel[1]; + SwingUtilities.invokeAndWait(() -> { + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JLabel(RuntimePivotConstants.MSG_TITLE), BorderLayout.NORTH); + holder[0] = panel; + }); + + JPanel panel = holder[0]; + assertNotNull(panel); + assertEquals(1, panel.getComponentCount()); + assertTrue(panel.getComponent(0) instanceof JLabel); + assertEquals(RuntimePivotConstants.MSG_TITLE, ((JLabel) panel.getComponent(0)).getText()); + } +} diff --git a/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java b/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java new file mode 100644 index 0000000..6a1114c --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java @@ -0,0 +1,36 @@ +package com.runtime.pivot.plugin.unit; + +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.enums.XStackBreakpointType; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +/** + * Fast unit tests that exercise pure plugin logic without spinning up an IntelliJ Platform fixture. + */ +public class RuntimePivotConstantsTest { + + @Test + public void pluginIdMatchesDeclaredId() { + // Must stay in sync with the declared in META-INF/plugin.xml. + assertEquals("com.runtime.pivot.plugin", RuntimePivotConstants.PLUGIN_ID); + } + + @Test + public void agentJarNameIsStable() { + assertEquals("runtime-pivot-agent", RuntimePivotConstants.AGENT_JAR_NAME); + } + + @Test + public void breakpointTypesExposeNonEmptyDescriptions() { + XStackBreakpointType[] values = XStackBreakpointType.values(); + assertEquals(5, values.length); + for (XStackBreakpointType type : values) { + assertNotNull("description for " + type.name() + " should not be null", type.getDescription()); + assertFalse("description for " + type.name() + " should not be empty", type.getDescription().isEmpty()); + } + } +}