├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ ├── cd.yml
│ ├── dependabot-automerge.yml
│ └── jenkins-security-scan.yml
├── .gitignore
├── .mvn
├── extensions.xml
└── maven.config
├── Jenkinsfile
├── README.md
├── demo
├── .gitignore
├── Dockerfile
├── JENKINS_HOME
│ ├── config.xml
│ ├── credentials.xml
│ ├── jenkins.model.JenkinsLocationConfiguration.xml
│ ├── jobs
│ │ ├── demo
│ │ │ └── config.xml
│ │ └── demo_python
│ │ │ └── config.xml
│ ├── log
│ │ └── Pipeline.xml
│ ├── maven-settings-files.xml
│ └── org.jenkinsci.plugin.gitea.servers.GiteaServers.xml
├── Makefile
├── README.md
├── create-pr.sh
├── credentials.xml
├── docker-compose.yml
├── gen.sh
├── gen_pytests.sh
├── lib
│ └── vars
│ │ └── testInParallel.groovy
├── pom.xml
├── repos
│ ├── demo
│ │ ├── Jenkinsfile
│ │ ├── goodbye
│ │ │ └── pom.xml
│ │ └── hello
│ │ │ └── pom.xml
│ └── demo_python
│ │ └── Jenkinsfile
├── run.sh
└── src
│ └── main
│ └── resources
│ └── index.jelly
├── pom.xml
└── src
├── main
├── java
│ └── org
│ │ └── jenkinsci
│ │ └── plugins
│ │ └── parallel_test_executor
│ │ ├── CountDrivenParallelism.java
│ │ ├── InclusionExclusionPattern.java
│ │ ├── MultipleBinaryFileParameterFactory.java
│ │ ├── ParallelTestExecutor.java
│ │ ├── Parallelism.java
│ │ ├── RunListenerImpl.java
│ │ ├── SplitStep.java
│ │ ├── Splitter.java
│ │ ├── TestCase.java
│ │ ├── TestClass.java
│ │ ├── TestCollector.java
│ │ ├── TestEntity.java
│ │ ├── TimeDrivenParallelism.java
│ │ └── testmode
│ │ ├── JavaClassName.java
│ │ ├── JavaParameterizedTestCaseName.java
│ │ ├── JavaTestCaseName.java
│ │ ├── TestCaseName.java
│ │ ├── TestClassAndCaseName.java
│ │ └── TestMode.java
└── resources
│ ├── index.jelly
│ └── org
│ └── jenkinsci
│ └── plugins
│ └── parallel_test_executor
│ ├── CountDrivenParallelism
│ ├── config.groovy
│ └── help.html
│ ├── ParallelTestExecutor
│ ├── config.jelly
│ ├── help-archiveTestResults.html
│ ├── help-includesPatternFile.html
│ ├── help-parallelism.html
│ ├── help-parameters.html
│ ├── help-patternFile.html
│ ├── help-testJob.html
│ ├── help-testMode.html
│ ├── help-testReportFiles.html
│ └── help.html
│ ├── SplitStep
│ ├── config.jelly
│ ├── help-generateInclusions.html
│ ├── help-stage.html
│ ├── help-testMode.html
│ └── help.html
│ ├── TimeDrivenParallelism
│ ├── config.groovy
│ └── help.html
│ └── testmode
│ ├── JavaClassName
│ └── help.html
│ ├── JavaParameterizedTestCaseName
│ └── help.html
│ ├── JavaTestCaseName
│ └── help.html
│ ├── TestCaseName
│ └── help.html
│ └── TestClassAndCaseName
│ └── help.html
└── test
├── java
└── org
│ └── jenkinsci
│ └── plugins
│ └── parallel_test_executor
│ ├── ParallelTestExecutorTest.java
│ └── ParallelTestExecutorUnitTest.java
└── resources
└── org
└── jenkinsci
└── plugins
└── parallel_test_executor
├── ParallelTestExecutorTest
├── config.xml
└── jobs
│ └── old
│ └── config.xml
└── ParallelTestExecutorUnitTest
├── findTestCaseTimeSplitsExclusion
├── report-Test1.xml
├── report-Test2.xml
├── report-Test3.xml
├── report-Test4.xml
└── report-Test5.xml
├── findTestCaseTimeSplitsInclusion
├── report-Test1.xml
├── report-Test2.xml
├── report-Test3.xml
├── report-Test4.xml
└── report-Test5.xml
├── findTestCasesWithParameters
└── report-Test1.xml
├── findTestCasesWithParametersIncluded
└── report-Test1.xml
├── findTestDuplicates
├── report-Test1-bis.xml
└── report-Test1.xml
├── findTestInJavaProjectDirectory
└── src
│ └── test
│ └── java
│ ├── FifthTest.java
│ ├── FirstTest.java
│ ├── FourthTest.java
│ ├── SecondTest.java
│ └── ThirdTest.java
├── findTestOfJavaProjectDirectoryInWorkspace
├── file.log
└── src
│ ├── FakeTest.java
│ ├── main
│ ├── FakeFifthTest.java
│ └── java
│ │ ├── FakeFifthTest.java
│ │ └── somepackage
│ │ ├── FakeFifthTest.java
│ │ ├── FakeFirstTest.java
│ │ ├── FakeFourthTest.java
│ │ └── FakeSecondTest.java
│ └── test
│ ├── AdditionalFile2.java
│ ├── java
│ ├── AdditionalFile.java
│ ├── FakeTest.txt
│ ├── FifthTest.java
│ ├── FirstTest.java
│ ├── FourthTest.java
│ ├── SecondTest.java
│ ├── SomeFile3.txt
│ ├── ThirdTest.java
│ └── somepackage
│ │ └── ThirdTest.java
│ └── someFile2.txt
├── findTestSplits
├── report-Test1.xml
├── report-Test2.xml
├── report-Test3.xml
├── report-Test4.xml
└── report-Test5.xml
├── findTestSplitsInclusions
├── report-Test1.xml
├── report-Test2.xml
├── report-Test3.xml
├── report-Test4.xml
└── report-Test5.xml
├── previousBuildIsOngoing
└── report-Test1.xml
└── testWeDoNotCreateMoreSplitsThanThereAreTests
└── report-Test1.xml
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @jenkinsci/parallel-test-executor-plugin-developers
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: maven
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | open-pull-requests-limit: 10
8 | - package-ecosystem: github-actions
9 | directory: /
10 | schedule:
11 | interval: weekly
12 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | # Note: additional setup is required, see https://www.jenkins.io/redirect/continuous-delivery-of-plugins
2 |
3 | name: cd
4 | on:
5 | workflow_dispatch:
6 | check_run:
7 | types:
8 | - completed
9 |
10 | jobs:
11 | maven-cd:
12 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/maven-cd.yml@v1
13 | secrets:
14 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
15 | MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }}
16 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot-automerge.yml:
--------------------------------------------------------------------------------
1 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#enable-auto-merge-on-a-pull-request
2 |
3 | name: Dependabot auto-merge
4 | on: pull_request
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: write
9 |
10 | jobs:
11 | dependabot:
12 | runs-on: ubuntu-latest
13 | if: ${{ github.actor == 'dependabot[bot]' }}
14 | steps:
15 | - name: Enable auto-merge for Dependabot PRs
16 | run: gh pr merge --auto --merge "$PR_URL"
17 | env:
18 | PR_URL: ${{github.event.pull_request.html_url}}
19 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
20 |
--------------------------------------------------------------------------------
/.github/workflows/jenkins-security-scan.yml:
--------------------------------------------------------------------------------
1 | name: Jenkins Security Scan
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | types: [ opened, synchronize, reopened ]
9 | workflow_dispatch:
10 |
11 | permissions:
12 | security-events: write
13 | contents: read
14 | actions: read
15 |
16 | jobs:
17 | security-scan:
18 | uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2
19 | with:
20 | java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate.
21 | # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default.
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Maven
2 | target
3 | # Jenkins
4 | work
5 |
6 | # IntelliJ project files
7 | *.iml
8 | *.iws
9 | *.ipr
10 | .idea
11 | out
12 |
13 | # eclipse project file
14 | .settings
15 | .classpath
16 | .project
17 | build
18 |
19 | # vim
20 | *~
21 |
--------------------------------------------------------------------------------
/.mvn/extensions.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | io.jenkins.tools.incrementals
4 | git-changelist-maven-extension
5 | 1.8
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.mvn/maven.config:
--------------------------------------------------------------------------------
1 | -Pconsume-incrementals
2 | -Pmight-produce-incrementals
3 | -Dchangelist.format=%d.v%s
4 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | buildPlugin(
2 | useContainerAgent: true,
3 | configurations: [
4 | [platform: 'linux', jdk: 21],
5 | [platform: 'windows', jdk: 17],
6 | ])
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Jenkins Parallel Test Executor Plugin
2 |
3 | This plugin adds a tool that lets you easily execute tests in parallel.
4 | This is achieved by having Jenkins look at the test execution time of the last run,
5 | split tests into multiple units of roughly equal size, then execute them in parallel.
6 |
7 | ## How It Works
8 |
9 | The tool looks at the test execution time from the last time, and divide tests into multiple units of roughly equal size. Each unit is then converted into the exclusion list (by excluding all but the tests that assigned to that unit).
10 |
11 | This tool can be used with any test job that
12 |
13 | 1. produce JUnit-compatible XML files
14 | 2. accept a test-exclusion list in a file.
15 |
16 | You are responsible for configuring the build script to honor the exclusion file. A standard technique is to write the build script to always refer to a fixed exclusion list file, and check in an empty file by that name. You can then specify that file as the "exclusion file name" in the configuration of this builder, and the builder will overwrite the empty file from SCM by the generated one.
17 |
18 | There are two modes: one used with [Jenkins Pipeline](https://jenkins.io/doc/book/pipeline/), the other with freestyle projects. The former is more flexible and straightforward.
19 |
20 | ### Pipeline step
21 |
22 | The `splitTests` step analyzes test results from the last successful build of this job, if any. It returns a set of roughly equal "splits", each representing one chunk of work. Typically you will use the `parallel` step to run each chunk in its own `node`, passing split information to the build tool in various ways. The demo (below) shows this in action.
23 |
24 | ### Freestyle-compatible builder
25 |
26 | For freestyle projects, setup is more complex as you need *two* jobs, an upstream controller and a downstream workhorse. There is a build step which you add to the upstream job and on which you define the downstream job. The builder executes multiple runs of the downstream job concurrently by interleaving tests, saving configuration files to the downstream workspace, achieving the parallel test execution semantics.
27 |
28 | You are responsible for checking **Execute concurrent builds if necessary** on the downstream job to allow the concurrent execution.
29 |
30 | When the downstream builds all finish, the specified report directories are brought back into the upstream job's workspace, where they will be picked up by the standard JUnit test report collector.
31 |
32 | The instructions below for configuring your build tool then apply to the downstream job.
33 |
34 | ## Configuring build tools with exclusions
35 |
36 | ### Maven
37 |
38 | Newer version of Maven Surefire plugin supports `excludesFile` parameter. For example, the following configuration tells Maven to honor `exclusions.txt` at the root of the source tree.
39 |
40 | ```xml
41 |
42 |
43 |
44 | org.apache.maven.plugins
45 | maven-surefire-plugin
46 | 2.14
47 |
48 | ${project.basedir}/exclusions.txt
49 |
50 |
51 |
52 |
53 | ```
54 |
55 | ### Ant
56 |
57 | Ant JUnit task supports the `excludesfile` attribute in its `` sub-element:
58 |
59 | ```xml
60 |
61 |
62 |
63 |
64 |
65 | ```
66 |
67 | ## Demo
68 |
69 | The `demo` subdirectory in sources contains a demo of this plugin based on Docker. It shows both Pipeline and freestyle modes. [README](demo/README.md)
70 |
71 | ## Changelog
72 |
73 | See [GitHub releases](https://github.com/jenkinsci/parallel-test-executor-plugin/releases)
74 | or the [old changelog](https://github.com/jenkinsci/parallel-test-executor-plugin/blob/b2f8c46675f19ce0d5041966e611a0f6a5411b79/old-changelog.md).
75 |
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | plugins
2 |
--------------------------------------------------------------------------------
/demo/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM maven:3.9.6-eclipse-temurin-11 AS maven
2 | FROM jenkins/jenkins:2.387.3
3 |
4 | USER root
5 |
6 | RUN apt-get update && \
7 | apt-get install -y python3-pytest && \
8 | apt-get clean
9 |
10 | COPY --from=maven /usr/share/maven /usr/share/maven/
11 | RUN ln -s /usr/share/maven/bin/mvn /usr/local/bin/mvn && \
12 | ln -s /usr/share/maven/bin/mvnDebug /usr/local/bin/mvnDebug
13 |
14 | ADD lib /tmp/lib
15 | RUN mkdir -p /m2repo
16 |
17 | RUN chown -R jenkins.jenkins /tmp/lib /m2repo
18 |
19 | USER jenkins
20 |
21 | COPY target/test-classes/test-dependencies/*.hpi /usr/share/jenkins/ref/plugins/
22 |
23 | RUN cd /tmp/lib && \
24 | git init && \
25 | git add . && \
26 | git -c user.email=demo@jenkins-ci.org -c user.name="Parallel Test Executor Demo" commit -m 'demo'
27 |
28 | ADD JENKINS_HOME /usr/share/jenkins/ref
29 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 2.375.2
5 | 0
6 | NORMAL
7 | true
8 |
9 |
10 | false
11 |
12 | ${ITEM_ROOTDIR}/workspace
13 | ${ITEM_ROOTDIR}/builds
14 |
15 |
16 |
17 |
18 |
19 | mock
20 | NORMAL
21 | 1
22 |
23 | false
24 |
25 |
26 | 0
27 |
28 |
29 |
30 | All
31 | false
32 | false
33 |
34 |
35 |
36 | all
37 | 0
38 |
39 |
40 | false
41 |
42 |
43 |
44 | false
45 |
46 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/credentials.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | GLOBAL
11 | gitea
12 |
13 | jenkins
14 | SECRET
15 | false
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/jenkins.model.JenkinsLocationConfiguration.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | address not configured yet <nobody@nowhere>
4 | http://localhost:8080/
5 |
6 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/jobs/demo/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 6126b51e-8b62-413c-9e82-333d0cf58bdb
9 | http://gitea:3000
10 | jenkins
11 | demo
12 | gitea
13 |
14 |
15 | 1
16 |
17 |
18 | 1
19 |
20 |
21 | 1
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | true
33 | -1
34 | -1
35 | false
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | testInParallel
45 |
46 |
47 | whatever
48 | /tmp/lib
49 | *
50 |
51 | false
52 |
53 |
54 | master
55 | false
56 | true
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | All
65 | false
66 | false
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | .*
82 | false
83 |
84 |
85 |
86 | All
87 |
88 |
89 | * * * * *
90 | 60000
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/jobs/demo_python/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | This job uses the pytest framework to execute 100 test cases contained in a single python file. It requires a version of parallel test executor plugin that supports test modes to parallel execution based on test cases and not only test classes.
5 |
6 |
7 |
8 | 6126b51e-8b62-413c-9e82-333d0cf58bdb
9 | http://gitea:3000
10 | jenkins
11 | demo_python
12 | gitea
13 |
14 |
15 | 1
16 |
17 |
18 | 1
19 |
20 |
21 | 1
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | true
33 | -1
34 | -1
35 | false
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | testInParallel
45 |
46 |
47 | whatever
48 | /tmp/lib
49 | *
50 |
51 | false
52 |
53 |
54 | master
55 | false
56 | true
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | All
65 | false
66 | false
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | .*
82 | false
83 |
84 |
85 |
86 | All
87 |
88 |
89 | * * * * *
90 | 60000
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/log/Pipeline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Pipeline
4 |
5 |
6 | org.jenkinsci.plugins.workflow
7 | 500
8 |
9 |
10 | org.jenkinsci.plugins.durabletask
11 | 500
12 |
13 |
14 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/maven-settings-files.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | jenkins-mirror
6 |
7 | jenkins-mirror
8 | Jenkins Mirror
9 |
10 |
11 |
14 | /m2repo
15 |
16 | ]]>
17 | org.jenkinsci.plugins.configfiles.maven.MavenSettingsConfig
18 |
19 | true
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/demo/JENKINS_HOME/org.jenkinsci.plugin.gitea.servers.GiteaServers.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | gitea
6 | http://gitea:3000
7 | false
8 | gitea
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/demo/Makefile:
--------------------------------------------------------------------------------
1 | PHONY: validate
2 | validate:
3 | mvn -Dtest=InjectedTest clean test
4 |
5 | PHONY: copy-plugins
6 | copy-plugins:
7 | if [ \! -f ../target/parallel-test-executor.hpi ]; then mvn -f .. -Pquick-build install; fi
8 | if [ \! -f target/test-classes/test-dependencies/index -o \
9 | pom.xml -nt target/test-classes/test-dependencies/index -o \
10 | ../target/parallel-test-executor.hpi -nt target/test-classes/test-dependencies/parallel-test-executor.hpi ]; then \
11 | mvn clean validate hpi:resolve-test-dependencies; fi
12 | @# TODO would be more efficient to move Dockerfile and all it references into a subdirectory, or use .dockerignore
13 |
14 | PHONY: clean
15 | clean:
16 | rm -rf target
17 | docker compose down -v
18 | docker image rm demo-jenkins || true
19 |
20 | PHONY: run
21 | run: copy-plugins
22 | ./run.sh
23 |
--------------------------------------------------------------------------------
/demo/README.md:
--------------------------------------------------------------------------------
1 | # Prerequisites
2 |
3 | * docker
4 | * docker compose
5 |
6 | # Running
7 |
8 | make run
9 |
10 | and then go to: http://localhost:8080/
11 |
12 | # Tear down
13 |
14 | make clean
15 |
16 | # Demo contents
17 |
18 | `pipeline` is a self-contained Pipeline project.
19 |
20 | Run one build of `pipeline » main` — Jenkins will attempt to guess how to split 100 tests across agents.
21 | Run a second build and you will see the load split more reliably across five agents running ~20 tests apiece.
22 |
23 | `pipeline-pytest` is a self-contained Pipeline project. It contains a single file containing 100 test cases.
24 |
25 | Run one build of `pytest » master`—all 100 test cases in its single test class/file will be run on one agent.
26 | Run a second build and you will see the load split across five agents running 20 test cases apiece.
27 |
--------------------------------------------------------------------------------
/demo/create-pr.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euxo pipefail
3 | cd $(dirname $0)
4 | export TOKEN=$(cat target/gitea_token.txt)
5 | pushd target/repo > /dev/null
6 | export BRANCH_NAME=experiment-$(openssl rand -hex 6)
7 | export TARGET_BRANCH=main
8 | git checkout -b "$BRANCH_NAME"
9 | git -c commit.gpgsign=false -c user.email=demo@jenkins-ci.org -c user.name="Parallel Test Executor Demo" commit --allow-empty -m "Empty commit"
10 | git push -u origin "$BRANCH_NAME"
11 | curl -X 'POST' \
12 | 'http://localhost:3000/api/v1/repos/jenkins/demo/pulls' \
13 | -H "Authorization: token $TOKEN" \
14 | -H 'accept: application/json' \
15 | -H 'Content-Type: application/json' \
16 | -d "{
17 | \"base\": \"$TARGET_BRANCH\",
18 | \"title\": \"A pull request from $BRANCH_NAME\",
19 | \"body\": \"Some description\",
20 | \"head\": \"$BRANCH_NAME\"
21 | }"
22 | popd > /dev/null
23 |
--------------------------------------------------------------------------------
/demo/credentials.xml:
--------------------------------------------------------------------------------
1 |
2 | GLOBAL
3 | gitea
4 |
5 | jenkins
6 | SECRET
7 | false
8 |
9 |
--------------------------------------------------------------------------------
/demo/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | networks:
3 | demo:
4 | external: false
5 | volumes:
6 | m2repo:
7 | gitea:
8 | services:
9 | gitea:
10 | image: gitea/gitea:1.18.0
11 | container_name: gitea
12 | environment:
13 | - USER_UID=1000
14 | - USER_GID=1000
15 | - GITEA__server__HTTP_PORT=3000
16 | - GITEA__server__ROOT_URL=http://localhost:3000/
17 | - GITEA__server__DOMAIN=localhost
18 | - GITEA__database__DB_TYPE=sqlite3
19 | - GITEA__security__INSTALL_LOCK=true
20 | restart: always
21 | networks:
22 | - demo
23 | volumes:
24 | - gitea:/data
25 | - /etc/timezone:/etc/timezone:ro
26 | - /etc/localtime:/etc/localtime:ro
27 | ports:
28 | - "3000:3000"
29 | jenkins:
30 | container_name: jenkins
31 | networks:
32 | - demo
33 | build:
34 | context: .
35 | ports:
36 | - "8080:8080"
37 | - "5005:5005"
38 | volumes:
39 | - m2repo:/m2repo:rw
40 | environment:
41 | # TODO without this JENKINS-24752 workaround, it takes too long to provision.
42 | # (Do not add hudson.model.LoadStatistics.decay=0.1; in that case we overprovision slaves which never get used, and OnceRetentionStrategy.check disconnects them after an idle timeout.)
43 | - JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -Dhudson.model.LoadStatistics.clock=1000 -Dhudson.Main.development=true -Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true
44 | healthcheck:
45 | test: [ "CMD", "curl", "-f", "http://localhost:8080" ]
46 | interval: 5s
47 | timeout: 30s
48 |
49 |
--------------------------------------------------------------------------------
/demo/gen.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | mkdir -p hello/src/test/java/foo
3 | mkdir -p goodbye/src/test/java/foo
4 | for i in {00..99}; do
5 | cat > hello/src/test/java/foo/Hello${i}Test.java << EOF
6 | package foo;
7 | import org.junit.Test;
8 | import static org.junit.Assert.*;
9 |
10 | public class Hello${i}Test {
11 | private static final int MULTIPLIER;
12 | static {
13 | String multiplier = System.getenv("MULTIPLIER");
14 | MULTIPLIER = multiplier != null ? Integer.parseInt(multiplier) : 1;
15 | }
16 | @Test public void one() {
17 | if (Math.random() < 0.015) {
18 | fail("oops");
19 | }
20 | }
21 | @Test public void two() {}
22 | @Test public void three() throws Exception {
23 | Thread.sleep(${i##0}0 * MULTIPLIER);
24 | }
25 | @Test public void four() throws Exception {
26 | Thread.sleep(1000 * MULTIPLIER);
27 | }
28 | }
29 | EOF
30 | cat > goodbye/src/test/java/foo/Goodbye${i}Test.java << EOF
31 | package foo;
32 | import org.junit.Test;
33 | import static org.junit.Assert.*;
34 |
35 | public class Goodbye${i}Test {
36 | private static final int MULTIPLIER;
37 | static {
38 | String multiplier = System.getenv("MULTIPLIER");
39 | MULTIPLIER = multiplier != null ? Integer.parseInt(multiplier) : 1;
40 | }
41 | @Test public void one() {
42 | if (Math.random() < 0.015) {
43 | fail("oops");
44 | }
45 | }
46 | @Test public void two() {}
47 | @Test public void three() throws Exception {
48 | Thread.sleep(${i##0}0 * MULTIPLIER);
49 | }
50 | @Test public void four() throws Exception {
51 | Thread.sleep(1000 * MULTIPLIER);
52 | }
53 | }
54 | EOF
55 | done
56 |
--------------------------------------------------------------------------------
/demo/gen_pytests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | mkdir -p src/test/foo
3 | cat > src/test/foo/test_Hello.py << EOF
4 | import pytest
5 | import time
6 | import random
7 |
8 | class TestHello:
9 | EOF
10 |
11 | for i in {00..99}; do
12 | cat >> src/test/foo/test_Hello.py << EOF
13 |
14 | def test_${i}(self):
15 | x = random.random()
16 | time.sleep(x)
17 | if x < 0.015:
18 | pytest.fail("oops")
19 | EOF
20 | done
21 |
22 |
--------------------------------------------------------------------------------
/demo/lib/vars/testInParallel.groovy:
--------------------------------------------------------------------------------
1 | def call(parallelism, testMode, inclusionsFile, exclusionsFile, stageName, prepare, run) {
2 | def splits
3 | node {
4 | deleteDir()
5 | prepare()
6 | splits = splitTests parallelism: parallelism, testMode: testMode, generateInclusions: true, stage: stageName
7 | }
8 | def branches = [:]
9 | for (int i = 0; i < splits.size(); i++) {
10 | def num = i
11 | def split = splits[num]
12 | branches["split${num}"] = {
13 | echo "in split$num: $split"
14 | stage("Test Section #${num + 1}") {
15 | node {
16 | stage('Preparation') {
17 | deleteDir()
18 | prepare()
19 | writeFile file: (split.includes ? inclusionsFile : exclusionsFile), text: split.list.join("\n")
20 | writeFile file: (split.includes ? exclusionsFile : inclusionsFile), text: ''
21 | }
22 | stage('Main') {
23 | run()
24 | }
25 | }
26 | }
27 | }
28 | }
29 | parallel branches
30 | }
31 |
--------------------------------------------------------------------------------
/demo/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | org.jenkins-ci.plugins
7 | plugin
8 | 4.76
9 |
10 |
11 | parallel-test-executor-demo
12 | 1.0-SNAPSHOT
13 | hpi
14 |
15 | 2.387.3
16 |
17 | Parallel Test Executor Demo
18 |
19 |
20 | repo.jenkins-ci.org
21 | https://repo.jenkins-ci.org/public/
22 |
23 |
24 |
25 |
26 | repo.jenkins-ci.org
27 | https://repo.jenkins-ci.org/public/
28 |
29 |
30 |
31 |
32 | org.jenkins-ci.plugins
33 | parallel-test-executor
34 | 999999-SNAPSHOT
35 | test
36 |
37 |
38 |
39 | org.jenkins-ci.plugins.workflow
40 | workflow-job
41 | test
42 |
43 |
44 | org.jenkins-ci.plugins.workflow
45 | workflow-cps
46 | test
47 |
48 |
49 | io.jenkins.plugins
50 | pipeline-groovy-lib
51 | test
52 |
53 |
54 | org.jenkins-ci.plugins.workflow
55 | workflow-multibranch
56 | test
57 |
58 |
59 | org.jenkins-ci.plugins.workflow
60 | workflow-basic-steps
61 | test
62 |
63 |
64 | org.jenkins-ci.plugins.workflow
65 | workflow-durable-task-step
66 | test
67 |
68 |
69 | org.jenkins-ci.plugins
70 | config-file-provider
71 | test
72 |
73 |
74 | org.ow2.asm
75 | asm
76 |
77 |
78 |
79 |
80 | org.jenkins-ci.plugins
81 | git
82 | test
83 |
84 |
85 | org.jenkins-ci.plugins
86 | junit
87 | test
88 |
89 |
90 | org.jenkins-ci.plugins
91 | mock-slave
92 | 125.vcfb_5c627d399
93 | test
94 |
95 |
96 | org.jenkins-ci.plugins
97 | pipeline-stage-step
98 | test
99 |
100 |
101 | org.jenkins-ci.plugins
102 | junit-realtime-test-reporter
103 | test
104 |
105 |
106 | org.jenkins-ci.plugins
107 | gitea
108 | 1.4.5
109 | test
110 |
111 |
112 |
113 |
114 |
115 | io.jenkins.tools.bom
116 | bom-2.387.x
117 | 2543.vfb_1a_5fb_9496d
118 | import
119 | pom
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/demo/repos/demo/Jenkinsfile:
--------------------------------------------------------------------------------
1 | @Library('testInParallel') _
2 |
3 | properties([
4 | parameters([
5 | string(name: 'MULTIPLIER', defaultValue: '1', description: 'Factor by which to artificially slow down tests.'),
6 | string(name: 'SPLIT', defaultValue: '5', description: 'Number of buckets to split tests into.')
7 | ])
8 | ])
9 |
10 | stage('Sources') {
11 | node {
12 | checkout scm
13 | dir('hello') {
14 | stash name: 'hello', excludes: 'target/'
15 | }
16 | dir('goodbye') {
17 | stash name: 'goodbye', excludes: 'target/'
18 | }
19 | }
20 | }
21 |
22 | stage('Testing Hello') {
23 | testInParallel(count(Integer.parseInt(params.SPLIT)), javaTestCase(), 'inclusions.txt', 'exclusions.txt', 'Testing Hello', {
24 | unstash 'hello'
25 | }, {
26 | configFileProvider([configFile(fileId: 'jenkins-mirror', variable: 'SETTINGS')]) {
27 | withEnv(["MULTIPLIER=$params.MULTIPLIER"]) {
28 | sh 'mvn -s $SETTINGS -B -ntp -Dmaven.test.failure.ignore clean test'
29 | junit 'target/surefire-reports/*.xml'
30 | }
31 | }
32 | })
33 | }
34 |
35 | stage('Testing Goodbye') {
36 | testInParallel(count(Integer.parseInt(params.SPLIT)), javaTestCase(), 'inclusions.txt', 'exclusions.txt', 'Testing Goodbye', {
37 | unstash 'goodbye'
38 | }, {
39 | configFileProvider([configFile(fileId: 'jenkins-mirror', variable: 'SETTINGS')]) {
40 | withEnv(["MULTIPLIER=$params.MULTIPLIER"]) {
41 | sh 'mvn -s $SETTINGS -B -ntp -Dmaven.test.failure.ignore clean test'
42 | junit 'target/surefire-reports/*.xml'
43 | }
44 | }
45 | })
46 | }
47 |
--------------------------------------------------------------------------------
/demo/repos/demo/goodbye/pom.xml:
--------------------------------------------------------------------------------
1 |
3 | 4.0.0
4 |
5 | test
6 | goodbye
7 | 1.0-SNAPSHOT
8 | jar
9 |
10 | 11
11 | 11
12 |
13 |
14 |
15 |
16 |
17 | org.apache.maven.plugins
18 | maven-surefire-plugin
19 | 3.2.5
20 |
21 |
22 |
23 |
24 |
25 |
26 | junit
27 | junit
28 | 4.13.1
29 | test
30 |
31 |
32 |
33 |
34 |
35 |
36 | exclusions
37 |
38 |
39 | exclusions.txt
40 |
41 |
42 |
43 |
44 |
45 | maven-surefire-plugin
46 |
47 | exclusions.txt
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | inclusions
56 |
57 |
58 | inclusions.txt
59 |
60 |
61 |
62 |
63 |
64 | maven-surefire-plugin
65 |
66 | inclusions.txt
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/demo/repos/demo/hello/pom.xml:
--------------------------------------------------------------------------------
1 |
3 | 4.0.0
4 |
5 | test
6 | hello
7 | 1.0-SNAPSHOT
8 | jar
9 |
10 | 11
11 | 11
12 |
13 |
14 |
15 |
16 |
17 | org.apache.maven.plugins
18 | maven-surefire-plugin
19 | 3.2.5
20 |
21 |
22 |
23 |
24 |
25 |
26 | junit
27 | junit
28 | 4.13.1
29 | test
30 |
31 |
32 |
33 |
34 |
35 |
36 | exclusions
37 |
38 |
39 | exclusions.txt
40 |
41 |
42 |
43 |
44 |
45 | maven-surefire-plugin
46 |
47 | exclusions.txt
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | inclusions
56 |
57 |
58 | inclusions.txt
59 |
60 |
61 |
62 |
63 |
64 | maven-surefire-plugin
65 |
66 | inclusions.txt
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/demo/repos/demo_python/Jenkinsfile:
--------------------------------------------------------------------------------
1 | @Library('testInParallel') _
2 |
3 | stage('Sources') {
4 | node {
5 | checkout scm
6 | stash name: 'sources', excludes: 'Jenkinsfile'
7 | }
8 | }
9 |
10 | stage('Testing') {
11 | testInParallel(count(5), testCase(), 'inclusions.txt', 'exclusions.txt', 'Testing python', {
12 | unstash 'sources'
13 | }, {
14 | def result = ""
15 | def e = readFile 'exclusions.txt'
16 | def i = readFile 'inclusions.txt'
17 |
18 | if (e) { // use exclusions
19 | e = e.replaceAll("\n", " or ")
20 | result = "not(${e})"
21 | } else if (i) {
22 | i = i.replaceAll("\n", " or ")
23 | result = i
24 | }
25 |
26 | sh "py.test-3 --junit-xml=out.xml -k \'${result}\' || true"
27 | junit 'out.xml'
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/demo/run.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euxo pipefail
3 |
4 | function gitea_create_admin_user() {
5 | local username; username="${1:?}"
6 | local email; email="${2:?}"
7 | mkdir -p target
8 | [ -f target/gitea_output.txt ] || docker compose exec -u 1000:1000 gitea gitea admin user create --admin --username "$username" --random-password --email "$email" > target/gitea_output.txt
9 | }
10 |
11 | function gitea_generate_access_token() {
12 | local username; username="${1:?}"
13 | mkdir -p target
14 | [ -f target/gitea_token.txt ] || docker compose exec -u 1000:1000 gitea gitea admin user generate-access-token --username "$username" --raw > target/gitea_token.txt
15 | }
16 |
17 | function gitea_token() {
18 | cat target/gitea_token.txt
19 | }
20 |
21 | function gitea_repository_exists() {
22 | local name; name="${1:?}"
23 | local token; token="$(gitea_token)"
24 | [ "$(curl -s -o /dev/null -w "%{http_code}" -X 'GET' \
25 | "http://localhost:3000/api/v1/repos/jenkins/$name" \
26 | -H "Authorization: token $token")" = "200" ]
27 |
28 | }
29 |
30 | function gitea_create_repository() {
31 | local name; name="${1:?}"
32 | local token; token="$(gitea_token)"
33 | curl -X 'POST' \
34 | 'http://localhost:3000/api/v1/user/repos' \
35 | -H "Authorization: token $token" \
36 | -H 'accept: application/json' \
37 | -H 'Content-Type: application/json' \
38 | -d "{
39 | \"auto_init\": true,
40 | \"default_branch\": \"main\",
41 | \"name\": \"$name\"
42 | }"
43 | }
44 |
45 | function gitea_init_repository() {
46 | local name; name="${1:?}"
47 | local script; script="${2:?}"
48 | git clone "http://jenkins:$(gitea_token)@localhost:3000/jenkins/$name.git" "target/$name"
49 | cp -R "repos/$name" target/
50 | pushd "target/$name"
51 | bash "../../$script"
52 | git add .
53 | git -c commit.gpgsign=false -c user.email=demo@jenkins-ci.org -c user.name="Parallel Test Executor Demo" commit -m "Initial commit"
54 | git push origin -u
55 | popd
56 | }
57 |
58 | function jenkins_download_cli() {
59 | [ -f target/jenkins-cli.jar ] || wget -O target/jenkins-cli.jar -o /dev/null http://localhost:8080/jnlpJars/jenkins-cli.jar
60 | }
61 |
62 | function jenkins_update_credentials() {
63 | local credentialsId; credentialsId="${1:?}"
64 | local token; token="$(gitea_token)"
65 | jenkins_download_cli
66 | sed -e "s/SECRET/$token/" credentials.xml | java -jar target/jenkins-cli.jar -s http://localhost:8080/ update-credentials-by-xml "SystemCredentialsProvider::SystemContextResolver::jenkins" "(global)" "$credentialsId"
67 | }
68 |
69 | function readme() {
70 | local gitea_username; gitea_username=$(grep "New user" < target/gitea_output.txt | cut -f 2 -d "'")
71 | local gitea_password; gitea_password=$(grep password < target/gitea_output.txt | cut -f 2 -d "'")
72 |
73 | echo "Demo initialized"
74 | echo "Gitea is available on http://localhost:3000 using $gitea_username $gitea_password"
75 | echo "Jenkins is available on http://localhost:8080"
76 | echo "The demo git repo is available in target/repo and can be accessed at http://localhost:3000/jenkins/demo"
77 | open http://localhost:8080/
78 | }
79 |
80 | docker compose up -d --wait
81 | gitea_create_admin_user jenkins demo@jenkins.io
82 | gitea_generate_access_token jenkins
83 | jenkins_update_credentials gitea
84 | if ! gitea_repository_exists demo; then
85 | gitea_create_repository demo
86 | gitea_init_repository demo gen.sh
87 | fi
88 | if ! gitea_repository_exists demo_python; then
89 | gitea_create_repository demo_python
90 | gitea_init_repository demo_python gen_pytests.sh
91 | fi
92 | readme
93 |
--------------------------------------------------------------------------------
/demo/src/main/resources/index.jelly:
--------------------------------------------------------------------------------
1 |
2 |
3 | (placeholder)
4 |
5 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 | 4.0.0
3 |
4 | org.jenkins-ci.plugins
5 | plugin
6 | 5.17
7 |
8 |
9 | parallel-test-executor
10 | ${changelist}
11 | hpi
12 | Parallel Test Executor Plugin
13 | https://github.com/jenkinsci/${project.artifactId}-plugin
14 |
15 |
16 | MIT License
17 | https://opensource.org/licenses/MIT
18 |
19 |
20 |
21 | 999999-SNAPSHOT
22 |
23 | 2.479
24 | ${jenkins.baseline}.3
25 | jenkinsci/${project.artifactId}-plugin
26 |
27 |
28 |
29 | repo.jenkins-ci.org
30 | https://repo.jenkins-ci.org/public/
31 |
32 |
33 |
34 |
35 |
36 | repo.jenkins-ci.org
37 | https://repo.jenkins-ci.org/public/
38 |
39 |
40 |
41 | scm:git:https://github.com/${gitHubRepo}
42 | scm:git:https://github.com/${gitHubRepo}
43 | ${scmTag}
44 | https://github.com/${gitHubRepo}
45 |
46 |
47 |
48 |
49 | io.jenkins.tools.bom
50 | bom-${jenkins.baseline}.x
51 | 4770.v9a_2b_7a_9d8b_7f
52 | import
53 | pom
54 |
55 |
56 |
57 |
58 |
59 | org.jenkins-ci.plugins
60 | parameterized-trigger
61 | true
62 |
63 |
64 | org.jenkins-ci.plugins
65 | variant
66 |
67 |
68 | org.jenkins-ci.plugins
69 | matrix-project
70 | test
71 |
72 |
73 | org.jenkins-ci.plugins
74 | junit
75 |
76 |
77 | org.mockito
78 | mockito-core
79 | test
80 |
81 |
82 | org.jenkins-ci.plugins.workflow
83 | workflow-step-api
84 |
85 |
86 | org.jenkins-ci.plugins
87 | script-security
88 |
89 |
90 | org.jenkins-ci.plugins.workflow
91 | workflow-api
92 |
93 |
94 | org.jenkins-ci.plugins.workflow
95 | workflow-cps
96 | test
97 |
98 |
99 | org.jenkins-ci.plugins.workflow
100 | workflow-job
101 | test
102 |
103 |
104 | org.jenkins-ci.plugins
105 | pipeline-stage-step
106 | test
107 |
108 |
109 | org.jenkins-ci.plugins.workflow
110 | workflow-durable-task-step
111 | test
112 |
113 |
114 | org.jenkins-ci.plugins.workflow
115 | workflow-basic-steps
116 | test
117 |
118 |
119 | org.jenkins-ci.plugins.workflow
120 | workflow-cps
121 | tests
122 | test
123 |
124 |
125 | org.jenkins-ci.plugins
126 | pipeline-milestone-step
127 | test
128 |
129 |
130 | org.6wind.jenkins
131 | lockable-resources
132 | test
133 |
134 |
135 |
136 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/CountDrivenParallelism.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.Extension;
4 | import hudson.model.Descriptor;
5 | import org.kohsuke.stapler.DataBoundConstructor;
6 |
7 | import java.util.List;
8 | import org.jenkinsci.Symbol;
9 |
10 | /**
11 | * @author Kohsuke Kawaguchi
12 | */
13 | public class CountDrivenParallelism extends Parallelism {
14 | public final int size;
15 |
16 | @DataBoundConstructor
17 | public CountDrivenParallelism(int size) {
18 | this.size = size;
19 | }
20 |
21 | @Override
22 | public int calculate(List tests) {
23 | // Don't split into 5 buckets if we only have 2 tests etc
24 | return tests == null ?
25 | size :
26 | Math.min(size, tests.size());
27 | }
28 |
29 | @Symbol("count")
30 | @Extension
31 | public static class DescriptorImpl extends Descriptor {
32 | @Override
33 | public String getDisplayName() {
34 | return "Fixed number of batches";
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/InclusionExclusionPattern.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.Whitelisted;
4 |
5 | import java.io.Serializable;
6 | import java.util.Collections;
7 | import java.util.List;
8 |
9 | /**
10 | * A list of file name patterns to include or exclude
11 | */
12 | public class InclusionExclusionPattern implements Serializable {
13 | @Whitelisted
14 | public boolean isIncludes() {
15 | return includes;
16 | }
17 |
18 | @Whitelisted
19 | public List getList() {
20 | return Collections.unmodifiableList(list);
21 | }
22 |
23 | private final boolean includes;
24 | private final List list;
25 |
26 | InclusionExclusionPattern(List list, boolean includes) {
27 | this.list = list;
28 | this.includes = includes;
29 | }
30 |
31 | @Override
32 | public String toString() {
33 | return "InclusionExclusionPattern{" +
34 | "includes=" + includes +
35 | ", list=" + list +
36 | '}';
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/MultipleBinaryFileParameterFactory.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import com.google.common.collect.Lists;
4 | import hudson.AbortException;
5 | import hudson.FilePath;
6 | import hudson.model.AbstractBuild;
7 | import hudson.model.Action;
8 | import hudson.model.FileParameterValue;
9 | import hudson.model.ParametersAction;
10 | import hudson.model.TaskListener;
11 | import hudson.plugins.parameterizedtrigger.AbstractBuildParameterFactory;
12 | import hudson.plugins.parameterizedtrigger.AbstractBuildParameterFactoryDescriptor;
13 | import hudson.plugins.parameterizedtrigger.AbstractBuildParameters;
14 | import hudson.plugins.parameterizedtrigger.FileBuildParameterFactory;
15 | import org.kohsuke.stapler.DataBoundConstructor;
16 |
17 | import java.io.File;
18 | import java.io.IOException;
19 | import java.util.List;
20 | import java.util.logging.Logger;
21 | import org.jenkinsci.plugins.variant.OptionalExtension;
22 |
23 | /**
24 | * Essentially a copy-paste of {@link hudson.plugins.parameterizedtrigger.BinaryFileParameterFactory} that takes a
25 | * list of mappings {@code name -> filePattern} to generate parameters.
26 | */
27 | public class MultipleBinaryFileParameterFactory extends AbstractBuildParameterFactory {
28 | public static class ParameterBinding {
29 | public ParameterBinding(String parameterName, String filePattern) {
30 | this.parameterName = parameterName;
31 | this.filePattern = filePattern;
32 | }
33 | public final String parameterName;
34 | public final String filePattern;
35 | }
36 |
37 | private final List parametersList;
38 | private final FileBuildParameterFactory.NoFilesFoundEnum noFilesFoundAction;
39 |
40 | @DataBoundConstructor
41 | public MultipleBinaryFileParameterFactory(List parametersList, FileBuildParameterFactory.NoFilesFoundEnum noFilesFoundAction) {
42 | this.parametersList = parametersList;
43 | this.noFilesFoundAction = noFilesFoundAction;
44 | }
45 |
46 | public MultipleBinaryFileParameterFactory(List parametersList) {
47 | this(parametersList, FileBuildParameterFactory.NoFilesFoundEnum.SKIP);
48 | }
49 |
50 | public FileBuildParameterFactory.NoFilesFoundEnum getNoFilesFoundAction() {
51 | return noFilesFoundAction;
52 | }
53 |
54 | @Override
55 | public List getParameters(AbstractBuild, ?> build, TaskListener listener) throws IOException, InterruptedException, AbstractBuildParameters.DontTriggerException {
56 | List result = Lists.newArrayList();
57 | int totalFiles = 0;
58 | for (final ParameterBinding parameterBinding : parametersList) {
59 | // save them into the master because FileParameterValue might need files after the slave workspace have disappeared/reused
60 | FilePath target = new FilePath(build.getRootDir()).child("parameter-files");
61 | FilePath workspace = build.getWorkspace();
62 | if (workspace == null) {
63 | throw new AbortException("no workspace");
64 | }
65 | int k = workspace.copyRecursiveTo(parameterBinding.filePattern, target);
66 | totalFiles += k;
67 | if (k > 0) {
68 | for (final FilePath f : target.list(parameterBinding.filePattern)) {
69 | LOGGER.fine("Triggering build with " + f.getName());
70 |
71 | result.add(new AbstractBuildParameters() {
72 | @Override
73 | public Action getAction(AbstractBuild, ?> build, TaskListener listener) throws IOException, InterruptedException, DontTriggerException {
74 | assert f.getChannel() == null; // we copied files locally. This file must be local to the master
75 | FileParameterValue fv = new FileParameterValue(parameterBinding.parameterName, new File(f.getRemote()), f.getName());
76 | return new ParametersAction(fv);
77 | }
78 | });
79 | }
80 | }
81 | }
82 | if (totalFiles ==0) {
83 | noFilesFoundAction.failCheck(listener);
84 | }
85 |
86 | return result;
87 | }
88 |
89 |
90 | @OptionalExtension(requirePlugins = "parameterized-trigger")
91 | public static class DescriptorImpl extends AbstractBuildParameterFactoryDescriptor {
92 | @Override
93 | public String getDisplayName() {
94 | return "Multiple Binary Files (not meant to be used)";
95 | }
96 | }
97 |
98 | private static final Logger LOGGER = Logger.getLogger(MultipleBinaryFileParameterFactory.class.getName());
99 | }
100 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/ParallelTestExecutor.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import com.google.common.collect.ImmutableSet;
4 | import edu.umd.cs.findbugs.annotations.CheckForNull;
5 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
6 | import hudson.AbortException;
7 | import hudson.FilePath;
8 | import hudson.Launcher;
9 | import hudson.Util;
10 | import hudson.model.AbstractBuild;
11 | import hudson.model.AbstractProject;
12 | import hudson.model.Action;
13 | import hudson.model.AutoCompletionCandidates;
14 | import hudson.model.BuildListener;
15 | import hudson.model.Item;
16 | import hudson.model.ItemGroup;
17 | import hudson.model.Result;
18 | import hudson.model.TaskListener;
19 | import hudson.plugins.parameterizedtrigger.AbstractBuildParameterFactory;
20 | import hudson.plugins.parameterizedtrigger.AbstractBuildParameters;
21 | import hudson.plugins.parameterizedtrigger.BlockableBuildTriggerConfig;
22 | import hudson.plugins.parameterizedtrigger.BlockingBehaviour;
23 | import hudson.plugins.parameterizedtrigger.TriggerBuilder;
24 | import hudson.tasks.BuildStepDescriptor;
25 | import hudson.tasks.Builder;
26 | import hudson.tasks.junit.JUnitResultArchiver;
27 | import java.io.IOException;
28 | import java.io.OutputStream;
29 | import java.io.OutputStreamWriter;
30 | import java.io.PrintWriter;
31 | import java.nio.charset.StandardCharsets;
32 | import java.util.ArrayList;
33 | import java.util.Collections;
34 | import java.util.List;
35 | import java.util.concurrent.atomic.AtomicInteger;
36 | import java.util.logging.Logger;
37 | import org.jenkinsci.plugins.parallel_test_executor.testmode.TestMode;
38 | import org.jenkinsci.plugins.variant.OptionalExtension;
39 | import org.kohsuke.stapler.AncestorInPath;
40 | import org.kohsuke.stapler.DataBoundConstructor;
41 | import org.kohsuke.stapler.DataBoundSetter;
42 | import org.kohsuke.stapler.QueryParameter;
43 |
44 | /**
45 | * @author Kohsuke Kawaguchi
46 | */
47 | public class ParallelTestExecutor extends Builder {
48 | private static final Logger LOGGER = Logger.getLogger(ParallelTestExecutor.class.getName());
49 | public static final int NUMBER_OF_BUILDS_TO_SEARCH = 20;
50 | public static final ImmutableSet RESULTS_OF_BUILDS_TO_CONSIDER = ImmutableSet.of(Result.SUCCESS, Result.UNSTABLE);
51 |
52 | private final Parallelism parallelism;
53 | private final String testJob;
54 | private final String patternFile;
55 | private String includesPatternFile;
56 | private final String testReportFiles;
57 | private final boolean doNotArchiveTestResults;
58 | private final List parameters;
59 | private TestMode testMode;
60 |
61 | @DataBoundConstructor
62 | public ParallelTestExecutor(Parallelism parallelism, String testJob, String patternFile, String testReportFiles, boolean archiveTestResults, List parameters) {
63 | this.parallelism = parallelism;
64 | this.testJob = testJob;
65 | this.patternFile = patternFile;
66 | this.testReportFiles = testReportFiles;
67 | this.parameters = parameters;
68 | this.doNotArchiveTestResults = !archiveTestResults;
69 | }
70 |
71 | public Parallelism getParallelism() {
72 | return parallelism;
73 | }
74 |
75 | public String getTestJob() {
76 | return testJob;
77 | }
78 |
79 | public String getPatternFile() {
80 | return patternFile;
81 | }
82 |
83 | @CheckForNull
84 | public String getIncludesPatternFile() {
85 | return includesPatternFile;
86 | }
87 |
88 | @DataBoundSetter
89 | public void setIncludesPatternFile(String includesPatternFile) {
90 | this.includesPatternFile = Util.fixEmpty(includesPatternFile);
91 | }
92 |
93 | public String getTestReportFiles() {
94 | return testReportFiles;
95 | }
96 |
97 | public boolean isArchiveTestResults() {
98 | return !doNotArchiveTestResults;
99 | }
100 |
101 | @SuppressWarnings("unused") // jetty
102 | public TestMode getTestMode() {
103 | return TestMode.fixDefault(testMode);
104 | }
105 |
106 | @DataBoundSetter
107 | public void setTestMode(TestMode testMode) {
108 | this.testMode = testMode;
109 | }
110 |
111 | public List getParameters() {
112 | return parameters;
113 | }
114 |
115 | /**
116 | * {@link TestEntity}es are divided into multiple sets of roughly equal size.
117 | */
118 | @SuppressFBWarnings(value="EQ_COMPARETO_USE_OBJECT_EQUALS", justification="We wish to consider knapsacks as distinct items, just sort by size.")
119 | static class Knapsack implements Comparable {
120 | /**
121 | * Total duration of all {@link TestEntity}es that are in this knapsack.
122 | */
123 | long total;
124 |
125 | void add(TestEntity tc) {
126 | assert tc.knapsack == null;
127 | tc.knapsack = this;
128 | total += tc.duration;
129 | }
130 |
131 | public int compareTo(Knapsack that) {
132 | long l = this.total - that.total;
133 | if (l < 0) return -1;
134 | if (l > 0) return 1;
135 | return 0;
136 | }
137 | }
138 |
139 | @Override
140 | public boolean perform(AbstractBuild, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException {
141 | FilePath workspace = build.getWorkspace();
142 | if (workspace == null) {
143 | throw new AbortException("no workspace");
144 | }
145 | FilePath dir = workspace.child("test-splits");
146 | dir.deleteRecursive();
147 | List splits = Splitter.findTestSplits(parallelism, testMode, build, listener, includesPatternFile != null,
148 | null, build.getWorkspace());
149 | for (int i = 0; i < splits.size(); i++) {
150 | InclusionExclusionPattern pattern = splits.get(i);
151 | try (OutputStream os = dir.child("split." + i + "." + (pattern.isIncludes() ? "include" : "exclude") + ".txt").write();
152 | OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8);
153 | PrintWriter pw = new PrintWriter(osw)) {
154 | for (String filePattern : pattern.getList()) {
155 | pw.println(filePattern);
156 | }
157 | }
158 | }
159 |
160 | createTriggerBuilder().perform(build, launcher, listener);
161 |
162 | if (isArchiveTestResults()) {
163 | tally(build, launcher, listener);
164 | }
165 |
166 | return true;
167 | }
168 |
169 | /**
170 | * Collects all the test reports
171 | */
172 | private void tally(AbstractBuild, ?> build, Launcher launcher, BuildListener listener) throws IOException, InterruptedException {
173 | new JUnitResultArchiver("test-splits/reports/**/*.xml", false, null).perform(build, launcher, listener);
174 | }
175 |
176 | /**
177 | * Create {@link hudson.plugins.parameterizedtrigger.TriggerBuilder} for launching test jobs.
178 | */
179 | private TriggerBuilder createTriggerBuilder() {
180 | // to let the caller job do a clean up, don't let the failure in the test job early-terminate the build process
181 | // that's why the first argument is ABORTED.
182 | BlockingBehaviour blocking = new BlockingBehaviour(Result.ABORTED, Result.UNSTABLE, Result.FAILURE);
183 | final AtomicInteger iota = new AtomicInteger(0);
184 |
185 | List parameterList = new ArrayList<>();
186 | parameterList.add(
187 | // put a marker action that we look for to collect test reports
188 | new AbstractBuildParameters() {
189 | @Override
190 | public Action getAction(AbstractBuild, ?> build, TaskListener listener) throws IOException, InterruptedException, DontTriggerException {
191 | return new TestCollector(build, ParallelTestExecutor.this, iota.incrementAndGet());
192 | }
193 | });
194 | if (parameters != null) {
195 | parameterList.addAll(parameters);
196 | }
197 |
198 | // actual logic of child process triggering is left up to the parameterized build
199 | List parameterBindings = new ArrayList<>();
200 | parameterBindings.add(new MultipleBinaryFileParameterFactory.ParameterBinding(getPatternFile(), "test-splits/split.*.exclude.txt"));
201 | if (includesPatternFile != null) {
202 | parameterBindings.add(new MultipleBinaryFileParameterFactory.ParameterBinding(getIncludesPatternFile(), "test-splits/split.*.include.txt"));
203 | }
204 | MultipleBinaryFileParameterFactory factory = new MultipleBinaryFileParameterFactory(parameterBindings);
205 | BlockableBuildTriggerConfig config = new BlockableBuildTriggerConfig(
206 | testJob,
207 | blocking,
208 | Collections.singletonList(factory),
209 | parameterList
210 | );
211 |
212 | return new TriggerBuilder(config);
213 | }
214 |
215 | @OptionalExtension(requirePlugins = "parameterized-trigger")
216 | public static class DescriptorImpl extends BuildStepDescriptor {
217 | @Override
218 | public boolean isApplicable(Class extends AbstractProject> aClass) {
219 | return true;
220 | }
221 |
222 | public AutoCompletionCandidates doAutoCompleteTestJob(@QueryParameter String value, @AncestorInPath Item self, @AncestorInPath ItemGroup container) {
223 | return AutoCompletionCandidates.ofJobNames(AbstractProject.class, value, self, container);
224 | }
225 |
226 | @Override
227 | public String getDisplayName() {
228 | return "Parallel test job execution";
229 | }
230 | }
231 |
232 | }
233 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/Parallelism.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.model.AbstractDescribableImpl;
4 |
5 | import java.util.List;
6 |
7 | /**
8 | * Strategy that determines how many knapsacks we'll create.
9 | *
10 | * @author Kohsuke Kawaguchi
11 | */
12 | public abstract class Parallelism extends AbstractDescribableImpl {
13 | /*package*/ Parallelism() {}
14 |
15 | public abstract int calculate(List tests);
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/RunListenerImpl.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.model.AbstractBuild;
4 | import hudson.model.TaskListener;
5 | import hudson.model.listeners.RunListener;
6 |
7 | import edu.umd.cs.findbugs.annotations.NonNull;
8 | import org.jenkinsci.plugins.variant.OptionalExtension;
9 |
10 | /**
11 | * Looks for {@link TestCollector} in the build and collects the test reports.
12 | *
13 | * @author Kohsuke Kawaguchi
14 | */
15 | @OptionalExtension(requirePlugins = "parameterized-trigger")
16 | public class RunListenerImpl extends RunListener> {
17 | @Override
18 | public void onCompleted(AbstractBuild,?> build, @NonNull TaskListener listener) {
19 | TestCollector m = build.getAction(TestCollector.class);
20 | if (m!=null)
21 | m.collect(build,listener);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/SplitStep.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import com.google.common.collect.ImmutableSet;
4 | import hudson.Extension;
5 | import hudson.FilePath;
6 | import hudson.Util;
7 | import hudson.model.Run;
8 | import hudson.model.TaskListener;
9 | import java.util.ArrayList;
10 | import java.util.List;
11 | import java.util.Set;
12 | import org.jenkinsci.plugins.parallel_test_executor.testmode.TestMode;
13 | import org.jenkinsci.plugins.workflow.steps.Step;
14 | import org.jenkinsci.plugins.workflow.steps.StepContext;
15 | import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
16 | import org.jenkinsci.plugins.workflow.steps.StepExecution;
17 | import org.jenkinsci.plugins.workflow.steps.SynchronousStepExecution;
18 | import org.kohsuke.stapler.DataBoundConstructor;
19 | import org.kohsuke.stapler.DataBoundSetter;
20 |
21 | /**
22 | * Allows the splitting logic to be accessed from a workflow.
23 | */
24 | public final class SplitStep extends Step {
25 |
26 | private final Parallelism parallelism;
27 |
28 | private boolean generateInclusions;
29 |
30 | private String stage;
31 |
32 | private TestMode testMode;
33 |
34 | @DataBoundConstructor
35 | public SplitStep(Parallelism parallelism) {
36 | this.parallelism = parallelism;
37 | }
38 |
39 | public Parallelism getParallelism() {
40 | return parallelism;
41 | }
42 |
43 | public boolean isGenerateInclusions() {
44 | return generateInclusions;
45 | }
46 |
47 | @DataBoundSetter
48 | public void setGenerateInclusions(boolean generateInclusions) {
49 | this.generateInclusions = generateInclusions;
50 | }
51 |
52 | @SuppressWarnings("unused") // jelly
53 | public TestMode getTestMode() {
54 | return TestMode.fixDefault(testMode);
55 | }
56 |
57 | @DataBoundSetter
58 | public void setTestMode(TestMode testMode) {
59 | this.testMode = testMode;
60 | }
61 |
62 | /**
63 | * @param estimateTestsFromFiles true if we should estimate the tests from the files
64 | * @deprecated use {@link #setTestMode(TestMode)} instead.
65 | */
66 | @Deprecated
67 | @DataBoundSetter
68 | public void setEstimateTestsFromFiles(boolean estimateTestsFromFiles) {
69 | // no-op
70 | }
71 |
72 | /**
73 | * Method kept only to make the snippet generator happy.
74 | *
75 | * @return true if we should estimate the tests from the files
76 | * @deprecated use {@link #getTestMode()} ()} instead
77 | */
78 | @Deprecated
79 | public boolean isEstimateTestsFromFiles() {
80 | return false;
81 | }
82 |
83 | public String getStage() {
84 | return stage;
85 | }
86 |
87 | @DataBoundSetter
88 | public void setStage(String stage) {
89 | this.stage = Util.fixEmpty(stage);
90 | }
91 |
92 | @Override
93 | public StepExecution start(StepContext context) throws Exception {
94 | return new Execution(context, this);
95 | }
96 |
97 | @Extension
98 | public static final class DescriptorImpl extends StepDescriptor {
99 |
100 | @Override
101 | public Set extends Class>> getRequiredContext() {
102 | return ImmutableSet.of(TaskListener.class, Run.class);
103 | }
104 |
105 | @Override
106 | public String getFunctionName() {
107 | return "splitTests";
108 | }
109 |
110 | @Override
111 | public String getDisplayName() {
112 | return "Split Test Runs";
113 | }
114 | }
115 |
116 | private static final class Execution extends SynchronousStepExecution> {
117 |
118 | private static final long serialVersionUID = 1L;
119 |
120 | private final transient SplitStep step;
121 |
122 | Execution(StepContext context, SplitStep step) {
123 | super(context);
124 | this.step = step;
125 | }
126 |
127 | @Override
128 | protected List> run() throws Exception {
129 | StepContext context = getContext();
130 | Run, ?> build = context.get(Run.class);
131 | TaskListener listener = context.get(TaskListener.class);
132 | FilePath path = context.get(FilePath.class);
133 |
134 | if (step.generateInclusions) {
135 | return Splitter.findTestSplits(step.parallelism, step.testMode, build, listener, step.generateInclusions,
136 | step.stage, path);
137 | } else {
138 | List> result = new ArrayList<>();
139 | for (InclusionExclusionPattern pattern : Splitter.findTestSplits(step.parallelism, step.testMode, build, listener,
140 | step.generateInclusions, step.stage, path)) {
141 | result.add(pattern.getList());
142 | }
143 | return result;
144 | }
145 | }
146 |
147 | }
148 |
149 | }
150 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/Splitter.java:
--------------------------------------------------------------------------------
1 | /*
2 | * The MIT License
3 | *
4 | * Copyright 2024 CloudBees, Inc.
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in
14 | * all copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | * THE SOFTWARE.
23 | */
24 |
25 | package org.jenkinsci.plugins.parallel_test_executor;
26 |
27 | import com.google.common.base.Predicate;
28 | import edu.umd.cs.findbugs.annotations.CheckForNull;
29 | import edu.umd.cs.findbugs.annotations.NonNull;
30 | import edu.umd.cs.findbugs.annotations.Nullable;
31 | import hudson.FilePath;
32 | import hudson.console.ModelHyperlinkNote;
33 | import hudson.model.Item;
34 | import hudson.model.Job;
35 | import hudson.model.Run;
36 | import hudson.model.TaskListener;
37 | import hudson.tasks.junit.ClassResult;
38 | import hudson.tasks.test.AbstractTestResultAction;
39 | import hudson.tasks.test.TabulatedResult;
40 | import hudson.tasks.test.TestResult;
41 | import java.util.ArrayDeque;
42 | import java.util.ArrayList;
43 | import java.util.Collections;
44 | import java.util.List;
45 | import java.util.Map;
46 | import java.util.PriorityQueue;
47 | import java.util.TreeMap;
48 | import java.util.logging.Level;
49 | import java.util.logging.Logger;
50 | import java.util.stream.Collectors;
51 | import jenkins.scm.api.SCMHead;
52 | import jenkins.scm.api.mixin.ChangeRequestSCMHead;
53 | import static org.jenkinsci.plugins.parallel_test_executor.ParallelTestExecutor.NUMBER_OF_BUILDS_TO_SEARCH;
54 | import static org.jenkinsci.plugins.parallel_test_executor.ParallelTestExecutor.RESULTS_OF_BUILDS_TO_CONSIDER;
55 | import org.jenkinsci.plugins.parallel_test_executor.testmode.TestMode;
56 | import org.jenkinsci.plugins.workflow.actions.LabelAction;
57 | import org.jenkinsci.plugins.workflow.flow.FlowExecution;
58 | import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
59 | import org.jenkinsci.plugins.workflow.graph.FlowNode;
60 | import org.jenkinsci.plugins.workflow.graphanalysis.DepthFirstScanner;
61 |
62 | class Splitter {
63 |
64 | private static final Logger LOGGER = Logger.getLogger(Splitter.class.getName());
65 |
66 | static List findTestSplits(Parallelism parallelism, @CheckForNull TestMode inputTestMode, Run,?> build, TaskListener listener,
67 | boolean generateInclusions,
68 | @CheckForNull final String stageName, @CheckForNull FilePath workspace) throws InterruptedException {
69 | TestMode testMode = inputTestMode == null ? TestMode.getDefault() : inputTestMode;
70 | TestResult tr = findPreviousTestResult(build, listener);
71 | Map data = new TreeMap<>();
72 | if (tr != null) {
73 | tr = mayFilterByStageName(tr, stageName, listener);
74 | collect(tr, data, testMode);
75 | } else {
76 | listener.getLogger().println("No record available, try to find test classes");
77 | data = testMode.estimate(workspace, listener);
78 | if(data.isEmpty()) {
79 | listener.getLogger().println("No test classes was found, so executing everything in one place");
80 | return List.of(new InclusionExclusionPattern(List.of(), false));
81 | }
82 | }
83 |
84 | // sort in the descending order of the duration
85 | List sorted = new ArrayList<>(data.values());
86 | Collections.sort(sorted);
87 |
88 | // degree of the parallelism. we need minimum 1
89 | final int n = Math.max(1, parallelism.calculate(sorted));
90 |
91 | List knapsacks = new ArrayList<>(n);
92 | for (int i = 0; i < n; i++)
93 | knapsacks.add(new ParallelTestExecutor.Knapsack());
94 |
95 | /*
96 | This packing problem is a NP-complete problem, so we solve
97 | this simply by a greedy algorithm. We pack heavier items first,
98 | and the result should be of roughly equal size
99 | */
100 | PriorityQueue q = new PriorityQueue<>(knapsacks);
101 | for (var testEntity : sorted) {
102 | ParallelTestExecutor.Knapsack k = q.poll();
103 | k.add(testEntity);
104 | q.add(k);
105 | }
106 |
107 | long total = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE;
108 | for (ParallelTestExecutor.Knapsack k : knapsacks) {
109 | total += k.total;
110 | max = Math.max(max, k.total);
111 | min = Math.min(min, k.total);
112 | }
113 | long average = total / n;
114 | long variance = 0;
115 | for (ParallelTestExecutor.Knapsack k : knapsacks) {
116 | variance += pow(k.total - average);
117 | }
118 | variance /= n;
119 | long stddev = (long) Math.sqrt(variance);
120 | listener.getLogger().printf("%d test %s (%dms) divided into %d sets. Min=%dms, Average=%dms, Max=%dms, stddev=%dms%n",
121 | data.size(), testMode.getWord(), total, n, min, average, max, stddev);
122 |
123 | List r = new ArrayList<>();
124 | for (int i = 0; i < n; i++) {
125 | ParallelTestExecutor.Knapsack k = knapsacks.get(i);
126 | boolean shouldIncludeElements = generateInclusions && i != 0;
127 | List elements = sorted.stream().filter(testEntity -> shouldIncludeElements == (testEntity.knapsack == k))
128 | .flatMap(testEntity -> testEntity.getElements().stream())
129 | .collect(Collectors.toList());
130 | r.add(new InclusionExclusionPattern(elements, shouldIncludeElements));
131 | }
132 | return r;
133 | }
134 |
135 | @NonNull
136 | private static TestResult mayFilterByStageName(@NonNull TestResult tr, @CheckForNull String stageName, @NonNull TaskListener listener) {
137 | Run,?> run = tr.getRun();
138 | if (stageName != null) {
139 | listener.getLogger().println("Looking for stage \"" + stageName + "\" in " + run.getFullDisplayName());
140 | FlowExecution execution = resolveFlowExecution(run, listener);
141 | if (execution != null) {
142 | FlowNode stageId = new DepthFirstScanner().findFirstMatch(execution, new StageNamePredicate(stageName));
143 | if (stageId != null) {
144 | listener.getLogger().println("Found stage \"" + stageName + "\" in " + run.getFullDisplayName());
145 | tr = ((hudson.tasks.junit.TestResult) tr).getResultForPipelineBlock(stageId.getId());
146 | } else {
147 | listener.getLogger().println("No stage \"" + stageName + "\" found in " + run.getFullDisplayName());
148 | }
149 | } else {
150 | listener.getLogger().println("No flow execution found in " + run.getFullDisplayName());
151 | }
152 | }
153 | return tr;
154 | }
155 |
156 | @CheckForNull
157 | private static FlowExecution resolveFlowExecution(Run, ?> prevRun, TaskListener listener) {
158 | if (prevRun instanceof FlowExecutionOwner.Executable) {
159 | FlowExecutionOwner owner = ((FlowExecutionOwner.Executable) prevRun).asFlowExecutionOwner();
160 | if (owner != null) {
161 | return owner.getOrNull();
162 | } else {
163 | listener.getLogger().println("No flow execution owner found in " + prevRun.getFullDisplayName());
164 | }
165 | } else {
166 | listener.getLogger().println("Previous run doesn't have the expected type: " + prevRun);
167 | }
168 | return null;
169 | }
170 |
171 | private static long pow(long l) {
172 | return l * l;
173 | }
174 |
175 | /**
176 | * Visits the structure inside {@link hudson.tasks.test.TestResult}.
177 | */
178 | private static void collect(TestResult r, Map data, TestMode testMode) {
179 | var queue = new ArrayDeque();
180 | queue.push(r);
181 | while (!queue.isEmpty()) {
182 | var current = queue.pop();
183 | if (current instanceof ClassResult) {
184 | var classResult = (ClassResult) current;
185 | LOGGER.log(Level.FINE, () -> "Retrieving test entities from " + classResult.getFullName());
186 | data.putAll(testMode.getTestEntitiesMap(classResult));
187 | } else if (current instanceof TabulatedResult) {
188 | LOGGER.log(Level.FINE, () -> "Considering children of " + current.getFullName());
189 | queue.addAll(((TabulatedResult) current).getChildren());
190 | } else {
191 | LOGGER.log(Level.FINE, () -> "Ignoring " + current.getFullName());
192 | }
193 | }
194 | }
195 |
196 | private static TestResult findPreviousTestResult(Run, ?> b, TaskListener listener) {
197 | Job, ?> project = b.getParent();
198 | // Look for test results starting with the previous build
199 | TestResult result = getTestResult(project, b.getPreviousBuild(), listener);
200 | if (result == null) {
201 | // Look for test results from the target branch builds if this is a change request.
202 | SCMHead head = SCMHead.HeadByItem.findHead(project);
203 | if (head instanceof ChangeRequestSCMHead) {
204 | SCMHead target = ((ChangeRequestSCMHead) head).getTarget();
205 | Item targetBranch = project.getParent().getItem(target.getName());
206 | if (targetBranch instanceof Job) {
207 | result = getTestResult(project, ((Job, ?>) targetBranch).getLastBuild(), listener);
208 | }
209 | }
210 | }
211 | return result;
212 | }
213 |
214 |
215 | static TestResult getTestResult(Job, ?> originProject, Run, ?> b, TaskListener listener) {
216 | TestResult result = null;
217 | for (int i = 0; i < NUMBER_OF_BUILDS_TO_SEARCH; i++) {// limit the search to a small number to avoid loading too much
218 | if (b == null) break;
219 | if (RESULTS_OF_BUILDS_TO_CONSIDER.contains(b.getResult()) && !b.isBuilding()) {
220 | String hyperlink = ModelHyperlinkNote.encodeTo('/' + b.getUrl(), originProject != b.getParent() ? b.getFullDisplayName() : b.getDisplayName());
221 | try {
222 | AbstractTestResultAction> tra = b.getAction(AbstractTestResultAction.class);
223 | if (tra != null) {
224 | Object o = tra.getResult();
225 | if (o instanceof TestResult) {
226 | TestResult tr = (TestResult) o;
227 | if (tr.getTotalCount() == 0) {
228 | listener.getLogger().printf("Build %s has no loadable test results (supposed count %d), skipping%n", hyperlink, tra.getTotalCount());
229 | } else {
230 | listener.getLogger().printf("Using build %s as reference%n", hyperlink);
231 | result = tr;
232 | break;
233 | }
234 | }
235 | }
236 | } catch (RuntimeException e) {
237 | e.printStackTrace(listener.error("Failed to load (corrupt?) build %s, skipping%n", hyperlink));
238 | }
239 | }
240 | b = b.getPreviousBuild();
241 | }
242 | return result;
243 | }
244 |
245 | private static class StageNamePredicate implements Predicate {
246 | private final String stageName;
247 | StageNamePredicate(@NonNull String stageName) {
248 | this.stageName = stageName;
249 | }
250 | @Override
251 | public boolean apply(@Nullable FlowNode input) {
252 | if (input != null) {
253 | LabelAction labelAction = input.getPersistentAction(LabelAction.class);
254 | return labelAction != null && stageName.equals(labelAction.getDisplayName());
255 | }
256 | return false;
257 | }
258 | }
259 |
260 | private Splitter() {}
261 |
262 | }
263 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/TestCase.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.tasks.junit.CaseResult;
4 | import java.util.List;
5 |
6 | /**
7 | * Execution time of a specific test case.
8 | */
9 | public class TestCase extends TestEntity {
10 | String output;
11 |
12 | public TestCase(CaseResult cr) {
13 | this(cr, false);
14 | }
15 |
16 | public TestCase(CaseResult cr, boolean withClassName) {
17 | if (withClassName) {
18 | this.output = cr.getFullName();
19 | } else {
20 | this.output = cr.getName();
21 | }
22 | this.duration = (long)(cr.getDuration()*1000); // milliseconds is a good enough precision for us
23 | }
24 |
25 | @Override
26 | public String getKey() {
27 | return output;
28 | }
29 |
30 | @Override
31 | public List getElements() {
32 | return List.of(output);
33 | }
34 |
35 | @Override
36 | public String toString() {
37 | return output;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/TestClass.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.tasks.junit.ClassResult;
4 | import java.util.ArrayList;
5 | import java.util.List;
6 |
7 | /**
8 | * Execution time of a specific test class.
9 | */
10 | public class TestClass extends TestEntity {
11 |
12 | final String className;
13 |
14 | public TestClass(ClassResult cr) {
15 | String pkgName = cr.getParent().getName();
16 | if (pkgName.equals("(root)")) // UGH
17 | pkgName = "";
18 | else
19 | pkgName += '.';
20 | this.className = pkgName+cr.getName();
21 | this.duration = (long)(cr.getDuration()*1000); // milliseconds is a good enough precision for us
22 | }
23 |
24 | //for test estimation for first run
25 | public TestClass(String className){
26 | this.className = className;
27 | this.duration = 10;
28 | }
29 |
30 | @Override
31 | public String getKey() {
32 | return className;
33 | }
34 |
35 | @Override
36 | public List getElements() {
37 | var sanitizedClassName = className.replace('.', '/');
38 | return List.of(sanitizedClassName +".java", sanitizedClassName +".class");
39 | }
40 |
41 | @Override
42 | public String toString() {
43 | return className +".extension";
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/TestCollector.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
4 | import hudson.FilePath;
5 | import hudson.Util;
6 | import hudson.console.ModelHyperlinkNote;
7 | import hudson.model.AbstractBuild;
8 | import hudson.model.InvisibleAction;
9 | import hudson.model.TaskListener;
10 | import hudson.remoting.VirtualChannel;
11 | import java.io.File;
12 | import java.io.IOException;
13 | import java.io.Serializable;
14 | import jenkins.MasterToSlaveFileCallable;
15 | import org.apache.tools.ant.BuildException;
16 | import org.apache.tools.ant.taskdefs.Copy;
17 |
18 | /**
19 | * Runs at the end of a triggered test sub-task and collects test reports back to the master.
20 | *
21 | * @author Kohsuke Kawaguchi
22 | */
23 | class TestCollector extends InvisibleAction implements Serializable {
24 | private static final long serialVersionUID = -592264249944063364L;
25 |
26 | // none of this is meant to persist
27 | private final transient AbstractBuild,?> collector;
28 | private final transient ParallelTestExecutor testExecutor;
29 | @SuppressFBWarnings(value="SE_TRANSIENT_FIELD_NOT_RESTORED", justification="not needed after initial use")
30 | private final transient int ordinal;
31 |
32 | public TestCollector(AbstractBuild, ?> collector, ParallelTestExecutor testExecutor, int ordinal) {
33 | this.testExecutor = testExecutor;
34 | assert collector!=null;
35 | this.collector = collector;
36 | this.ordinal = ordinal;
37 | }
38 |
39 | /**
40 | * Collects the test reports from the sub build to the master.
41 | */
42 | public void collect(AbstractBuild,?> build, TaskListener listener) {
43 | if (collector==null) return; // must be deserialized. pretend as if this action doesn't exist.
44 |
45 | try {
46 | listener.getLogger().println("Collecting test reports for the master build: "+ ModelHyperlinkNote.encodeTo(collector));
47 |
48 | final FilePath src = build.getWorkspace();
49 | if (src==null) return; // trying to be defensive in case of catastrophic build failure
50 | FilePath workspace = collector.getWorkspace();
51 | if (workspace == null) {
52 | return; // ditto
53 | }
54 | final FilePath dst = workspace.child("test-splits/reports/"+ordinal);
55 | dst.mkdirs();
56 |
57 | final String includes = testExecutor.getTestReportFiles();
58 |
59 | if (src.getChannel()==dst.getChannel()) {
60 | // fast case where a direct copy is possible
61 | // TODO: move this to the core. copyRecursiveTo + 'archive' semantics
62 | src.act(new MasterToSlaveFileCallable() {
63 | private static final long serialVersionUID = 1L;
64 | public Integer invoke(File base, VirtualChannel channel) throws IOException {
65 | if(!base.exists()) return 0;
66 | assert dst.getChannel()==null;
67 |
68 | try {
69 | class CopyImpl extends Copy {
70 | private int copySize;
71 |
72 | public CopyImpl() {
73 | setProject(new org.apache.tools.ant.Project());
74 | }
75 |
76 | @Override
77 | protected void doFileOperations() {
78 | copySize = super.fileCopyMap.size();
79 | super.doFileOperations();
80 | }
81 |
82 | public int getNumCopied() {
83 | return copySize;
84 | }
85 | }
86 |
87 | CopyImpl copyTask = new CopyImpl();
88 | copyTask.setTodir(new File(dst.getRemote()));
89 | copyTask.addFileset(Util.createFileSet(base,includes));
90 | copyTask.setOverwrite(true);
91 | copyTask.setIncludeEmptyDirs(false);
92 | copyTask.setPreserveLastModified(true); // this is the only change from stock 'copyRecursiveTo'
93 |
94 | copyTask.execute();
95 | return copyTask.getNumCopied();
96 | } catch (BuildException e) {
97 | throw new IOException("Failed to copy " + base + "/" + includes + " to " + dst, e);
98 | }
99 | }
100 | });
101 | } else
102 | if (src.getChannel()==null || dst.getChannel()==null) {
103 | // this uses tar, so the timestamp gets preserved
104 | src.copyRecursiveTo(includes, dst);
105 | } else {
106 | // copy via master
107 | File t = Util.createTempDir();
108 | FilePath tmp = new FilePath(t);
109 | try {
110 | src.copyRecursiveTo(testExecutor.getTestReportFiles(), tmp);
111 | tmp.copyRecursiveTo(dst);
112 | } finally {
113 | Util.deleteRecursive(t);
114 | }
115 | }
116 | } catch (IOException | InterruptedException e) {
117 | e.printStackTrace(listener.error("Failed to aggregate test reports for "+collector.getFullDisplayName()));
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/TestEntity.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
4 | import java.util.List;
5 | import org.jenkinsci.plugins.parallel_test_executor.ParallelTestExecutor.Knapsack;
6 |
7 | /**
8 | * Represents a result of the test parallelization granularity of interest.
9 | */
10 | @SuppressFBWarnings(value="EQ_COMPARETO_USE_OBJECT_EQUALS", justification="Cf. justification in Knapsack.")
11 | public abstract class TestEntity implements Comparable {
12 |
13 | protected long duration;
14 | /**
15 | * Knapsack that this test class belongs to.
16 | */
17 | protected Knapsack knapsack;
18 |
19 | protected TestEntity() {}
20 |
21 | public long getDuration() {
22 | return duration;
23 | }
24 |
25 | @Override
26 | public int compareTo(TestEntity that) {
27 | long l = this.duration - that.duration;
28 | // sort them in the descending order
29 | if (l>0) return -1;
30 | if (l<0) return 1;
31 | return 0;
32 | }
33 |
34 | public abstract String getKey();
35 |
36 | public abstract List getElements();
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/TimeDrivenParallelism.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor;
2 |
3 | import hudson.Extension;
4 | import hudson.model.Descriptor;
5 | import org.kohsuke.stapler.DataBoundConstructor;
6 |
7 | import java.util.List;
8 | import java.util.concurrent.TimeUnit;
9 |
10 | import org.jenkinsci.Symbol;
11 |
12 | /**
13 | * @author Kohsuke Kawaguchi
14 | */
15 | public class TimeDrivenParallelism extends Parallelism {
16 | public final int mins;
17 |
18 | @DataBoundConstructor
19 | public TimeDrivenParallelism(int mins) {
20 | this.mins = mins;
21 | }
22 |
23 | @Override
24 | public int calculate(List tests) {
25 | long total=0;
26 | for (TestEntity test : tests) {
27 | total += test.duration;
28 | }
29 | long chunk = TimeUnit.MINUTES.toMillis(mins);
30 | return (int)((total+chunk-1)/chunk);
31 | }
32 |
33 | @Symbol("time")
34 | @Extension
35 | public static class DescriptorImpl extends Descriptor {
36 | @Override
37 | public String getDisplayName() {
38 | return "Fixed time (minutes) for each batch";
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/testmode/JavaClassName.java:
--------------------------------------------------------------------------------
1 | package org.jenkinsci.plugins.parallel_test_executor.testmode;
2 |
3 | import static java.util.function.Function.identity;
4 |
5 | import edu.umd.cs.findbugs.annotations.NonNull;
6 | import hudson.Extension;
7 | import hudson.FilePath;
8 | import hudson.model.Descriptor;
9 | import hudson.model.TaskListener;
10 | import hudson.tasks.junit.CaseResult;
11 | import hudson.tasks.junit.ClassResult;
12 | import java.io.IOException;
13 | import java.util.List;
14 | import java.util.Map;
15 | import java.util.Objects;
16 | import java.util.TreeMap;
17 | import java.util.regex.Matcher;
18 | import java.util.regex.Pattern;
19 | import java.util.stream.Collectors;
20 | import org.jenkinsci.Symbol;
21 | import org.jenkinsci.plugins.parallel_test_executor.TestClass;
22 | import org.jenkinsci.plugins.parallel_test_executor.TestEntity;
23 | import org.kohsuke.stapler.DataBoundConstructor;
24 |
25 | /**
26 | * This mode works best with java projects.
27 | *
28 | * Each exclusion/inclusion generates two lines by replacing "." with "/" in the fully qualified test
29 | * class name and appending ".java" to one line and ".class" to the second line.
30 | *
31 | *
32 | * It is also able to estimate tests to run from the workspace content if no historical context could be found.
33 | *
34 | */
35 | public class JavaClassName extends TestMode {
36 | private static final String PATTERNS = String.join(",", List.of(
37 | "**/src/test/java/**/Test*.java",
38 | "**/src/test/java/**/*Test.java",
39 | "**/src/test/java/**/*Tests.java",
40 | "**/src/test/java/**/*TestCase.java"
41 | ));
42 | private static final Pattern TEST = Pattern.compile(".+/src/test/java/(.+)[.]java");
43 |
44 | @DataBoundConstructor
45 | public JavaClassName() {}
46 |
47 | public boolean isSplitByCase() {
48 | return false;
49 | }
50 |
51 | public boolean useParameters() {
52 | return false;
53 | }
54 |
55 | @Override
56 | @NonNull
57 | public Map getTestEntitiesMap(@NonNull ClassResult classResult) {
58 | if (isSplitByCase()) {
59 | return classResult.getChildren().stream().map(cr -> new JavaTestCase(cr, useParameters())).collect(Collectors.toMap(JavaTestCase::getKey, identity(), JavaTestCase::new));
60 | } else {
61 | TestClass testClass = new TestClass(classResult);
62 | return Map.of(testClass.getKey(), testClass);
63 | }
64 | }
65 |
66 | @Override
67 | public Map estimate(FilePath workspace, @NonNull TaskListener listener) throws InterruptedException {
68 | // TODO estimate test cases
69 | if (workspace == null) {
70 | return Map.of();
71 | }
72 | Map data = new TreeMap<>();
73 | try {
74 | for (FilePath test : workspace.list(PATTERNS)) {
75 | String testPath = test.getRemote().replace('\\', '/');
76 | Matcher m = TEST.matcher(testPath);
77 | if (!m.matches() || m.groupCount() != 1) {
78 | throw new IllegalStateException(testPath + " didn't match expected format");
79 | }
80 | String relativePath = m.group(1); // e.g. pkg/subpkg/SomeTest
81 | data.put(relativePath, new TestClass(relativePath));
82 | }
83 | } catch (IOException e) {
84 | e.printStackTrace(listener.error("Unable to determine tests to run from files"));
85 | }
86 | return data;
87 | }
88 |
89 | @Override
90 | @NonNull
91 | public String getWord() {
92 | return isSplitByCase() ? "cases" : "classes";
93 | }
94 |
95 | private static class JavaTestCase extends TestEntity {
96 | private final String output;
97 | private JavaTestCase(CaseResult cr, boolean useParams) {
98 | // Parameterized tests use ${fqdnClassName}#${methodName}[{parametersDescription}] format
99 | if (useParams) {
100 | this.output = cr.getClassName() + "#" + cr.getName();
101 | } else {
102 | // Some surefire versions don't support parameters, so just drop them and will sum durations
103 | this.output = cr.getClassName() + "#" + cr.getName().split("\\[")[0];
104 | }
105 | this.duration = (long)(cr.getDuration()*1000); // milliseconds is a good enough precision for us
106 | }
107 |
108 | /**
109 | * Merge two java test cases with the same name, summing their durations.
110 | */
111 | private JavaTestCase(TestEntity te1, TestEntity te2) {
112 | if (!te1.getKey().equals(te2.getKey())) {
113 | throw new IllegalArgumentException("Test cases must have the same key");
114 | }
115 | this.output = te1.getKey();
116 | this.duration = te1.getDuration() + te2.getDuration();
117 | }
118 |
119 | @Override
120 | public String getKey() {
121 | return output;
122 | }
123 |
124 | @Override
125 | public List getElements() {
126 | return List.of(output);
127 | }
128 |
129 | @Override
130 | public String toString() {
131 | return output;
132 | }
133 |
134 | @Override
135 | public boolean equals(Object o) {
136 | if (this == o) return true;
137 | if (o == null || getClass() != o.getClass()) return false;
138 | JavaTestCase that = (JavaTestCase) o;
139 | return Objects.equals(output, that.output);
140 | }
141 |
142 | @Override
143 | public int hashCode() {
144 | return Objects.hash(output);
145 | }
146 | }
147 |
148 | @Extension
149 | @Symbol("javaClass")
150 | public static class DescriptorImpl extends Descriptor {
151 | @Override
152 | @NonNull
153 | public String getDisplayName() {
154 | return "By java class name";
155 | }
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/src/main/java/org/jenkinsci/plugins/parallel_test_executor/testmode/JavaParameterizedTestCaseName.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Licensed to the Apache Software Foundation (ASF) under one or more
3 | * contributor license agreements. See the NOTICE file distributed with
4 | * this work for additional information regarding copyright ownership.
5 | * The ASF licenses this file to you under the Apache License, Version 2.0
6 | * (the "License"); you may not use this file except in compliance with
7 | * the License. You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 | package org.jenkinsci.plugins.parallel_test_executor.testmode;
18 |
19 | import edu.umd.cs.findbugs.annotations.NonNull;
20 | import hudson.Extension;
21 | import hudson.model.Descriptor;
22 | import org.jenkinsci.Symbol;
23 | import org.kohsuke.stapler.DataBoundConstructor;
24 |
25 | /**
26 | * This mode works best with java projects.
27 | *
28 | * Parallelize per java test case including parameters if present.
29 | *
30 | *
31 | * It is also able to estimate tests to run from the workspace content if no historical context could be found.
32 | *
2 | Always divide the tests into N parallel sub-tasks of about equal size.
3 | This mode is useful if your build slaves are not elastic to control
4 | the resource consumption by this task.
5 |
2 | If you uncheck this you need to configure your own JUnit archiver in the Publisher section. The test reports are copied
3 | to "test-splits/reports/".
4 |
2 | A text file that lists one Java source file name per line gets created by this path inside
3 | the workspace of the test job.
4 | The path is relative to the workspace of the test job.
5 | Your test job must honor this inclusion list while executing tests.
6 | See the help of this builder for more details.
7 |
2 | A text file that lists one Java source file name per line gets created by this path inside
3 | the workspace of the test job.
4 | The path is relative to the workspace of the test job.
5 | Your test job must honor this exclusion list while executing tests.
6 | See the help of this builder for more details.
7 |
2 | Configure how exclusions and inclusions are formed from the JUnit result XML. The test job
3 | receiving the exclusion/inclusion files must be able to handle the format produced by
4 | the selected mode.
5 |
2 | The GLOB syntax that specifies test reports and its associated files that need to be copied
3 | back into this build to tally the test reports. This includes the JUnit-compatible XML files,
4 | as well as files that capture stdout/stderr of those tests (if they are placed separately.)
5 |
6 |
7 | The path is relative to the workspace of the test job. For Maven builds, this value is normally
8 | "**/target/surefire-reports/".
9 |
2 | Given another job that runs tests, executes multiple runs of it concurrently by interleaving tests,
3 | achieving the parallel test execution semantics.
4 |
5 |
6 | This builder can be used with any test job that (1) produce JUnit-compatible XML files, and
7 | (2) accept a test-exclusion list in a file. This builder looks at the test execution time from
8 | the last time, and divide tests into multiple units of roughly equal size.
9 | Each unit is then converted into the exclusion list (by excluding all but the tests that assigned to that unit),
10 | and the test job is triggered for each unit, with the exclusion file placed inside the workspace at your specified location.
11 |
12 |
13 | Optionally, if your test job supports it, you may provide a test-inclusion file name. If defined, the plugin will
14 | generate inclusion lists for all parallel units but one which will still use exclusion list. This avoids new test
15 | cases from being included in all units on their first run. If you don't use an inclusion file, when a new test is
16 | added, the first build afterward will be executed in all the sub-builds,
17 | because it's not in the exclusion list on any of the units.
18 |
19 |
20 | You are responsible for configuring the build script in the test job to honor the exclusion and inclusion file.
21 | A standard technique is to write the build script to always refer to a fixed exclusion list file,
22 | and check in an empty file by that name to your SCM. You can then specify that file as the "exclusion file name"
23 | in the configuration of this builder, and the builder will overwrite the empty file from SCM
24 | by the generated one.
25 |
26 |
27 | Similarly, you are responsible for checking "Execute concurrent builds if necessary" on the test job
28 | to allow the concurrent execution.
29 |
30 |
31 | At the end of the executions of the test job, the specified report directories are brought back into
32 | this job's workspace, then the standard JUnit test report collector will tally them.
33 |
Configure how exclusions and inclusions are formed from the JUnit result XML. The test job
2 | receiving the exclusion/inclusion files must be able to handle the format produced by
3 | the selected mode.
2 | Asks Jenkins to analyze the timing of tests from the last build and divide the tests for this build into roughly equal subsets for parallel execution.
3 |
2 | Divide the tests into parallel sub-tasks each no bigger than N minutes.
3 | Combined with elastic slaves, such as EC2, the turn-around time of the
4 | tests will remain the same no matter how many tests you add.
5 |
6 |
7 | This value counts just the time spent on executing tests, and not including
8 | other time such as checking out the code, building the test, etc. For example,
9 | if your test job spends 50 minutes in tests and 60 minutes total from start to
10 | finish, then you have 10 minutes "fixed" overhead regardless of the number of tests
11 | it executes. If you use this mode and set the value to "10 minutes", then
12 | you'll have 5 parallel sub-tasks that all ends in roughly 20 minutes
13 | (20 mins = 10 mins tests time + 10 mins overhead.)
14 |
15 |
16 | "N minutes per a sub-task" is a goal, not a hard constraint. So a sub-task
17 | can take longer (for example if you have a single test that takes more than
18 | N minutes.)
19 |
2 | This mode works best with java projects.
3 |
4 |
5 | Each exclusion/inclusion generates two lines by replacing "." with "/" in the fully qualified test
6 | class name and appending ".java" to one line and ".class" to the second line.
7 |