├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── .mocharc.json ├── .npmrc ├── LICENSE ├── README.md ├── bin └── cypress-parallel ├── cucumber.d.ts ├── cucumber.json ├── features ├── configuration │ ├── disable-knapsack-output.feature │ ├── knapsack.feature │ └── node.feature ├── knapsack.feature ├── step_definitions │ ├── cli_steps.ts │ ├── config_steps.ts │ └── file_steps.ts ├── support │ ├── configFileUpdater.ts │ ├── helpers.ts │ ├── hooks.ts │ └── world.ts └── unweighed-strategies │ ├── distribute.feature │ └── estimate.feature ├── knapsack-reporter.ts ├── lib ├── assertions.ts ├── ci.ts ├── cli.ts ├── configuration.ts ├── debug.ts ├── error.ts ├── index.ts ├── type-guards.ts ├── unweighed-strategies │ ├── distribute.ts │ ├── estimate.ts │ └── utils.ts └── unweighed-strategy.ts ├── package.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_style = space 5 | indent_size = 2 6 | insert_final_newline = true 7 | 8 | [*.{js, ts}] 9 | max_line_length = 80 10 | bracket_spacing = true 11 | jsx_bracket_same_line = false 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | ### Current behavior 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ### Desired behavior 16 | 17 | 18 | 19 | ### Test code to reproduce 20 | 21 | 23 | 24 | 27 | 28 | ### Versions 29 | 30 | * **cypress version**: 31 | * **cypress-parallel version**: 32 | * **node version**: 33 | * **os version**: 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | prepare-versions: 7 | runs-on: ubuntu-20.04 8 | outputs: 9 | matrix: ${{ steps.set-matrix.outputs.matrix }} 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - id: set-matrix 14 | name: Prepare 15 | run: echo "::set-output name=matrix::$(node -p "JSON.stringify(require('./package.json').peerDependencies['cypress'].split(' || '))")" 16 | - run: npm -v 17 | 18 | test: 19 | needs: prepare-versions 20 | runs-on: ubuntu-20.04 21 | container: 22 | image: cypress/base:14.17.0 23 | # Why exactly 1001? See https://github.com/actions/checkout/issues/211 and https://github.com/actions/runner/issues/691. 24 | options: --user 1001 25 | strategy: 26 | matrix: 27 | cypress-version: ${{fromJson(needs.prepare-versions.outputs.matrix)}} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | - name: Cache TypeScript installs 32 | uses: actions/cache@v2 33 | with: 34 | path: ~/.dts 35 | key: dts 36 | - name: Cache Cypress binaries 37 | uses: actions/cache@v2 38 | with: 39 | path: ~/.cache/Cypress 40 | key: cypress@${{ matrix.cypress-version }} 41 | - name: Dependencies (1) 42 | run: npm install 43 | env: 44 | CYPRESS_INSTALL_BINARY: "0" 45 | - name: Dependencies (2) 46 | run: npm install --no-save cypress@${{ matrix.cypress-version }} 47 | - name: Build 48 | run: npm run build 49 | - name: Test 50 | run: npm run test 51 | - name: Version 52 | run: npx cypress --version 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | **/*.d.ts 3 | !features/**/* 4 | !cucumber.d.ts 5 | !cucumber.js 6 | 7 | # Temporary directory for test execution 8 | tmp/ 9 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec": "lib/**/*.test.js" 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Jonas Amundsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-parallel 2 | 3 | [![Build status](https://github.com/badeball/cypress-parallel/actions/workflows/build.yml/badge.svg)](https://github.com/badeball/cypress-parallel/actions/workflows/build.yml) 4 | [![Npm package weekly downloads](https://badgen.net/npm/dw/@badeball/cypress-parallel)](https://npmjs.com/package/@badeball/cypress-parallel) 5 | 6 | Divides your test files into equal buckets and runs a single bucket. This is 7 | ideal for parallizing Cypress tests in a CI environment, without relying on 8 | external services, such as Cypress' Dashboard Service. 9 | 10 | ## Table of Contents 11 | 12 | - [Installation](#installation) 13 | - [How it works](#how-it-works) 14 | - [Usage](#usage) 15 | - [--node \:\](#--node-indextotal) 16 | - [--knapsack \](#--knapsack-file) 17 | - [--disable-knapsack-output](#--disable-knapsack-output) 18 | - [--unweighed-strategy "estimate" | "distribute"](#--unweighed-strategy-estimate--distribute) 19 | - [How to handle knapsack.json](#how-to-handle-knapsackjson) 20 | - [CI configuration](#ci-configuration) 21 | - [Gitlab CI](#gitlab-ci) 22 | - [Other providers](#other-providers) 23 | 24 | ## Installation 25 | 26 | ``` 27 | $ npm install @badeball/cypress-parallel 28 | ``` 29 | 30 | ## How it works 31 | 32 | 1. It will search through your project for test files 33 | 2. A knapsack containing file weights is read (defaults to `knapsack.json`) 34 | 3. Tests are divided into N buckets 35 | 4. The Ith bucket is executed by passing `--spec` to Cypress with said bucket 36 | of files 37 | 5. The knapsack is overwritten with potentially new weights 38 | 39 | N and I is determined either by a flag `--node` or by environment variables for 40 | some CI providers. 41 | 42 | The overwritten knapsack can then be comitted back into VCS. This will allow 43 | the library to always divide your tests somewhat evenly among the nodes. 44 | 45 | ## Usage 46 | 47 | Below are all the configuration options and some extended explanation for some of them. 48 | 49 | ``` 50 | $ npx cypress-parallel --help 51 | Usage: cypress-parallel [options] 52 | 53 | Options: 54 | -v, --version output the version number 55 | --cypress-run-command specifies the command to run cypress (in non-interactive mode), defaults to 'npx cypress 56 | run' or 'yarn cypress run' depending on how invoked 57 | --node : specifies number of buckets and which to run 58 | --knapsack specifies the path to the knapsack file (default: "knapsack.json") 59 | --read-knapsack specifies the path to the knapsack file to read 60 | --write-knapsack specifies the path to the knapsack file to write 61 | --disable-knapsack-output disables knapsack output (default: false) 62 | --unweighed-strategy strategy to utilize for unweighed test files ('estimate' (default) | 'distribute') 63 | (default: "estimate") 64 | -h, --help display help for command 65 | ``` 66 | 67 | Unrecognized arguments are passed along to Cypress, so arguments such as `-e / 68 | --env` can be used as shown below 69 | 70 | ``` 71 | $ npx cypress-parallel --env foo=bar 72 | ``` 73 | 74 | ### --node \:\ 75 | 76 | The utility will automatically pick up node configuration for some CI 77 | providers. Otherwise you can specify node index and total node count using 78 | `--node`, as shown below. 79 | 80 | ``` 81 | $ npx cypress-parallel --node 1:5 82 | ``` 83 | 84 | ### --knapsack \ 85 | 86 | Specifies the location of the knapsack file. Defaults to `knapsack.json`. 87 | 88 | ### --read-knapsack \ 89 | 90 | Optionally specified the location of the knapsack file to read. 91 | 92 | ### --write-knapsack \ 93 | 94 | Optionally specified the location of the knapsack file to write. 95 | 96 | ### --disable-knapsack-output 97 | 98 | Disables outputting knapsack data to the file system. This is always disabled 99 | when you specify `--reporter` or `--reporter-options` to Cypress. If you 100 | require custom options and still want to obtain the knapsack output, you need 101 | to configure `cypress-multi-reporters` with `@badeball/cypress-parallel/knapsack-reporter` 102 | yourself. 103 | 104 | ### --unweighed-strategy "estimate" | "distribute" 105 | 106 | What strategy to utilize if encountering a test file that isn't contained in 107 | the knapsack. The "estimate" strategy will estimate expected execution time 108 | based off of file length (line numbers). The "distribute" strategy will merely 109 | distribute unknown files evenly amongst the nodes. 110 | 111 | Custom stragies can be implemented using [cusmiconfig][cusmiconfig], as shown below. 112 | 113 | ```js 114 | module.export = { 115 | /** @type {import("@badeball/cypress-parallel").UnweighedStrategy} */ 116 | unweighedStrategy(weighedFiles, unweighedFiles, nodeCount) { 117 | // Implement me. 118 | }, 119 | }; 120 | ``` 121 | 122 | [cusmiconfig]: https://github.com/davidtheclark/cosmiconfig 123 | 124 | ## How to handle knapsack.json 125 | 126 | The knapsack contains every test file's weight and is instrumental for dividing 127 | tests evenly among nodes. This file needs to be available during the run and it 128 | should ideally be kept up-to-date. One way of handling this is to check it into 129 | version control somewhat regularly. Another way of handling it is to make your 130 | CI provider cache the data and re-surface it on subsequent runs. This last 131 | approach requires you to combine the knapsack data for every node before 132 | caching it though, which may or may not be so easy depending on your provider. 133 | 134 | ## CI configuration 135 | 136 | Below is an example of how to configure Gitlab CI to parallelize Cypress tests. 137 | Contributions of similar examples for other providers are welcome. 138 | 139 | ### Gitlab CI 140 | 141 | This example illustrate two things, 1) running tests in parallel and 2) 142 | combining knapsack data into a single, downloadable artifact. The latter is 143 | completely optional and you need to decide for yourself how you want to handle 144 | this. 145 | 146 | ```yaml 147 | test: 148 | stage: Test (1) 149 | parallel: 5 150 | artifacts: 151 | when: always 152 | paths: 153 | - knapsack-$CI_NODE_INDEX.json 154 | expire_in: 1 day 155 | script: 156 | - npx cypress-parallel --write-knapsack "knapsack-$CI_NODE_INDEX.json" 157 | 158 | knapsack: 159 | stage: Test (2) 160 | script: 161 | - cat knapsack-*.json | jq -sS add | tee knapsack.json 162 | artifacts: 163 | when: always 164 | paths: 165 | - knapsack.json 166 | expire_in: 1 day 167 | ``` 168 | 169 | ### Other providers 170 | 171 | If your provider does not provide a keyword such as Gitlab's `parallel`, then you can always simply 172 | just create N explicit jobs, similar to that shown below. 173 | 174 | ```yaml 175 | test_1: 176 | stage: Test 177 | script: 178 | - npx cypress-parallel --node 1:5 179 | 180 | test_2: 181 | stage: Test 182 | script: 183 | - npx cypress-parallel --node 2:5 184 | 185 | test_3: 186 | stage: Test 187 | script: 188 | - npx cypress-parallel --node 3:5 189 | 190 | test_4: 191 | stage: Test 192 | script: 193 | - npx cypress-parallel --node 4:5 194 | 195 | test_5: 196 | stage: Test 197 | script: 198 | - npx cypress-parallel --node 5:5 199 | ``` 200 | -------------------------------------------------------------------------------- /bin/cypress-parallel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { run } = require("../"); 4 | 5 | run(process.argv, process.env, process.cwd()); 6 | -------------------------------------------------------------------------------- /cucumber.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@cucumber/cucumber" { 2 | interface IWorld { 3 | tmpDir: string; 4 | verifiedLastRunError: boolean; 5 | lastRun: { 6 | stdout: string; 7 | stderr: string; 8 | output: string; 9 | exitCode: number; 10 | }; 11 | } 12 | } 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /cucumber.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": { 3 | "publishQuiet": true, 4 | "format": ["@cucumber/pretty-formatter"], 5 | "requireModule": ["ts-node/register"], 6 | "require": ["features/**/*.ts"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /features/configuration/disable-knapsack-output.feature: -------------------------------------------------------------------------------- 1 | Feature: --disable-knapsack-output 2 | Rule: it should disable outputting of the knapsack 3 | Background: 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => expect(true).to.be.true); 7 | """ 8 | 9 | Scenario: 10 | Given I run cypress-parallel with "--node 1:1 --disable-knapsack-output" 11 | Then it passes 12 | And I should not see a file named "knapsack.json" 13 | -------------------------------------------------------------------------------- /features/configuration/knapsack.feature: -------------------------------------------------------------------------------- 1 | Feature: --knapsack 2 | Rule: it should store knapsack at provided location (default 'knapsack.json') 3 | Background: 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => expect(true).to.be.true); 7 | """ 8 | 9 | Scenario: no value provided 10 | Given a file named "knapsack.json" with: 11 | """ 12 | {} 13 | """ 14 | And I run cypress-parallel with "--node 1:1" 15 | Then it passes 16 | And I should see a file "knapsack.json" with content matching: 17 | """ 18 | { 19 | "cypress/e2e/a.js": \d+ 20 | } 21 | """ 22 | 23 | Scenario: custom location provided 24 | Given a file named "sackknap.json" with: 25 | """ 26 | {} 27 | """ 28 | And I run cypress-parallel with "--knapsack sackknap.json --node 1:1" 29 | Then it passes 30 | And I should see a file "sackknap.json" with content matching: 31 | """ 32 | { 33 | "cypress/e2e/a.js": \d+ 34 | } 35 | """ 36 | 37 | Scenario: custom read & write location provided 38 | Given a file named "knapsack.json" with: 39 | """ 40 | {} 41 | """ 42 | And I run cypress-parallel with "--read-knapsack knapsack.json --write-knapsack sackknap.json --node 1:1" 43 | Then it passes 44 | And I should see a file "sackknap.json" with content matching: 45 | """ 46 | { 47 | "cypress/e2e/a.js": \d+ 48 | } 49 | """ 50 | -------------------------------------------------------------------------------- /features/configuration/node.feature: -------------------------------------------------------------------------------- 1 | Feature: --node 2 | Rule: it should determine bucket of tests 3 | Background: 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => expect(true).to.be.true); 7 | """ 8 | And a file named "cypress/e2e/b.js" with: 9 | """ 10 | it("should pass", () => expect(true).to.be.true); 11 | """ 12 | And a file named "cypress/e2e/c.js" with: 13 | """ 14 | it("should pass", () => expect(true).to.be.true); 15 | """ 16 | And a file named "cypress/e2e/d.js" with: 17 | """ 18 | it("should pass", () => expect(true).to.be.true); 19 | """ 20 | And a file named "cypress/e2e/e.js" with: 21 | """ 22 | it("should pass", () => expect(true).to.be.true); 23 | """ 24 | 25 | Scenario Outline: --node :5 26 | Given I run cypress-parallel with "--node :5" 27 | Then it passes 28 | And it should appear as if only a single test named "" ran 29 | 30 | Examples: 31 | | index | test | 32 | | 1 | a.js | 33 | | 2 | b.js | 34 | | 3 | c.js | 35 | | 4 | d.js | 36 | | 5 | e.js | 37 | -------------------------------------------------------------------------------- /features/knapsack.feature: -------------------------------------------------------------------------------- 1 | Feature: knapsack.json 2 | Rule: it should handle any *normal* knapsack scenario somewhat gracefully, otherwise error 3 | Scenario: knapsack missing entirely 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => {}); 7 | """ 8 | Given I run cypress-parallel with "--node 1:1" 9 | Then stderr should containing a warning "Unable to find knapsack.json, continuing without it..." 10 | But it passes 11 | 12 | Scenario: knapsack missing a file 13 | Given a file named "cypress/e2e/a.js" with: 14 | """ 15 | it("should pass", () => {}); 16 | """ 17 | And a file named "knapsack.json" with: 18 | """ 19 | {} 20 | """ 21 | Given I run cypress-parallel with "--node 1:1" 22 | Then it passes 23 | 24 | Scenario: knapsack containing a removed file 25 | Given a file named "cypress/e2e/a.js" with: 26 | """ 27 | it("should pass", () => {}); 28 | """ 29 | And a file named "knapsack.json" with: 30 | """ 31 | { 32 | "cypress/e2e/a.js": 1, 33 | "cypress/e2e/b.js": 1 34 | } 35 | """ 36 | Given I run cypress-parallel with "--node 1:1" 37 | Then it passes 38 | 39 | Scenario: knapsack containing "ignored" files (due to EG. narrow specPattern) 40 | Given additional Cypress configuration 41 | """ 42 | { 43 | "e2e": { 44 | "specPattern": "**/a.js" 45 | } 46 | } 47 | """ 48 | And a file named "cypress/e2e/a.js" with: 49 | """ 50 | it("should pass", () => {}); 51 | """ 52 | And a file named "cypress/e2e/b.js" with: 53 | """ 54 | it("should pass", () => {}); 55 | """ 56 | And a file named "knapsack.json" with: 57 | """ 58 | { 59 | "cypress/e2e/a.js": 1, 60 | "cypress/e2e/b.js": 1 61 | } 62 | """ 63 | Given I run cypress-parallel with "--node 1:1" 64 | Then it passes 65 | And it should appear as if only a single test ran 66 | 67 | Scenario: knapsack isn't JSON 68 | Given a file named "cypress/e2e/a.js" with: 69 | """ 70 | it("should pass", () => {}); 71 | """ 72 | And a file named "knapsack.json" with: 73 | """ 74 | foobar 75 | """ 76 | Given I run cypress-parallel with "--node 1:1" 77 | Then it fails 78 | And the output should contain 79 | """ 80 | Knapsack isn't valid JSON, got 'foobar' 81 | """ 82 | 83 | Scenario: knapsack isn't a valid Record 84 | Given a file named "cypress/e2e/a.js" with: 85 | """ 86 | it("should pass", () => {}); 87 | """ 88 | And a file named "knapsack.json" with: 89 | """ 90 | "foobar" 91 | """ 92 | Given I run cypress-parallel with "--node 1:1" 93 | Then it fails 94 | And the output should contain 95 | """ 96 | Knapsack is wrongly formatted, got 'foobar' 97 | """ 98 | 99 | Scenario: knapsack isn't readable 100 | Given a file named "cypress/e2e/a.js" with: 101 | """ 102 | it("should pass", () => {}); 103 | """ 104 | And an empty, but unreadable file named "knapsack.json" 105 | Given I run cypress-parallel with "--node 1:1" 106 | Then it fails 107 | And the output should contain 108 | """ 109 | Unable to read knapsack: EACCES: permission denied 110 | """ 111 | -------------------------------------------------------------------------------- /features/step_definitions/cli_steps.ts: -------------------------------------------------------------------------------- 1 | import { When, Then } from "@cucumber/cucumber"; 2 | import assert from "assert"; 3 | import childProcess from "child_process"; 4 | 5 | function execAsync( 6 | command: string 7 | ): Promise<{ stdout: string; stderr: string }> { 8 | return new Promise((resolve, reject) => { 9 | childProcess.exec(command, (error, stdout, stderr) => { 10 | if (error) { 11 | reject(error); 12 | } else { 13 | resolve({ stdout, stderr }); 14 | } 15 | }); 16 | }); 17 | } 18 | 19 | When("I run cypress-parallel", { timeout: 60 * 1000 }, async function () { 20 | await this.run(); 21 | }); 22 | 23 | When( 24 | "I run cypress-parallel with {string}", 25 | { timeout: 60 * 1000 }, 26 | async function (unparsedArgs) { 27 | // Use user's preferred shell to split args. 28 | const { stdout } = await execAsync( 29 | `node -p "JSON.stringify(process.argv)" -- ${unparsedArgs}` 30 | ); 31 | 32 | // Drop 1st arg, which is the path of node. 33 | const [, ...args] = JSON.parse(stdout); 34 | 35 | await this.run(args); 36 | } 37 | ); 38 | 39 | Then("it passes", function () { 40 | assert.equal(this.lastRun.exitCode, 0, "Expected a zero exit code"); 41 | }); 42 | 43 | Then("it fails", function () { 44 | assert.notEqual(this.lastRun.exitCode, 0, "Expected a non-zero exit code"); 45 | this.verifiedLastRunError = true; 46 | }); 47 | 48 | Then("it should appear as if only a single test ran", function () { 49 | assert.match(this.lastRun.stdout, /All specs passed!\s+\d+ms\s+1\s+1\D/); 50 | }); 51 | 52 | Then( 53 | "it should appear as if only a single test named {string} ran", 54 | function (specName) { 55 | assert.match(this.lastRun.stdout, /All specs passed!\s+\d+ms\s+1\s+1\D/); 56 | assert( 57 | this.lastRun.stdout.includes(specName), 58 | "Expected output to include " + specName 59 | ); 60 | } 61 | ); 62 | 63 | Then("it should appear as if both tests ran", function () { 64 | assert.match(this.lastRun.stdout, /All specs passed!\s+\d+ms\s+2\s+2\D/); 65 | }); 66 | 67 | /** 68 | * Shamelessly copied from the RegExp.escape proposal. 69 | */ 70 | const rescape = (s: string) => String(s).replace(/[\\^$*+?.()|[\]{}]/g, "\\$&"); 71 | 72 | const specExpr = (scenarioName: string) => 73 | new RegExp(`✔\\s+${rescape(scenarioName)}\\s+\\d+ms`); 74 | 75 | Then("it should appear to have run the specs", function (specsTable) { 76 | for (const { Name: spec } of specsTable.hashes()) { 77 | assert.match(this.lastRun.stdout, specExpr(spec)); 78 | } 79 | }); 80 | 81 | Then("the output should contain", function (content) { 82 | assert.match(this.lastRun.output, new RegExp(rescape(content))); 83 | }); 84 | 85 | Then("stderr should containing a warning {string}", function (content) { 86 | assert.match(this.lastRun.stderr, new RegExp(rescape(content))); 87 | }); 88 | -------------------------------------------------------------------------------- /features/step_definitions/config_steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import { insertValuesInConfigFile } from "../support/configFileUpdater"; 4 | 5 | Given("additional Cypress configuration", async function (jsonContent) { 6 | await insertValuesInConfigFile( 7 | path.join(this.tmpDir, "cypress.config.js"), 8 | JSON.parse(jsonContent) 9 | ); 10 | }); 11 | -------------------------------------------------------------------------------- /features/step_definitions/file_steps.ts: -------------------------------------------------------------------------------- 1 | import { Given, Then } from "@cucumber/cucumber"; 2 | import stripIndent from "strip-indent"; 3 | import path from "path"; 4 | import assert from "assert"; 5 | import { promises as fs, constants } from "fs"; 6 | import { writeFile } from "../support/helpers"; 7 | 8 | Given("a file named {string} with:", async function (filePath, fileContent) { 9 | const absoluteFilePath = path.join(this.tmpDir, filePath); 10 | 11 | await writeFile(absoluteFilePath, stripIndent(fileContent)); 12 | }); 13 | 14 | Given("an empty file named {string}", async function (filePath) { 15 | const absoluteFilePath = path.join(this.tmpDir, filePath); 16 | 17 | await writeFile(absoluteFilePath, ""); 18 | }); 19 | 20 | Given( 21 | "an empty, but unreadable file named {string}", 22 | async function (filePath) { 23 | const absoluteFilePath = path.join(this.tmpDir, filePath); 24 | 25 | await writeFile(absoluteFilePath, ""); 26 | 27 | /** 28 | * I'm guessing it's created with 0755, so 355 is the u-r equivalent. 29 | */ 30 | await fs.chmod(absoluteFilePath, 0o355); 31 | } 32 | ); 33 | 34 | Then( 35 | "I should nonetheless see a file named {string}", 36 | async function (filePath) { 37 | const absoluteFilePath = path.join(this.tmpDir, filePath); 38 | 39 | try { 40 | await fs.access(absoluteFilePath, constants.F_OK); 41 | } catch (e: any) { 42 | if (e.code === "ENOENT") { 43 | throw new Error(`Expected ${filePath} to exist, but it doesn't`); 44 | } else { 45 | throw e; 46 | } 47 | } 48 | } 49 | ); 50 | 51 | Then("I should not see a file named {string}", async function (filePath) { 52 | const absoluteFilePath = path.join(this.tmpDir, filePath); 53 | 54 | try { 55 | await fs.access(absoluteFilePath, constants.F_OK); 56 | throw new Error(`Expected ${filePath} to not exist, but it did`); 57 | } catch (e: any) { 58 | if (e.code !== "ENOENT") { 59 | throw e; 60 | } 61 | } 62 | }); 63 | 64 | Then( 65 | "I should see a file {string} with content:", 66 | async function (filePath, expectedContent) { 67 | const absoluteFilePath = path.join(this.tmpDir, filePath); 68 | 69 | try { 70 | const actualContent = (await fs.readFile(absoluteFilePath)).toString(); 71 | 72 | assert.strictEqual(actualContent, expectedContent); 73 | } catch (e: any) { 74 | if (e.code === "ENOENT") { 75 | throw new Error(`Expected ${filePath} to exist, but it doesn't`); 76 | } else { 77 | throw e; 78 | } 79 | } 80 | } 81 | ); 82 | 83 | Then( 84 | "I should see a file {string} with content matching:", 85 | async function (filePath, expectedContentExpr) { 86 | const absoluteFilePath = path.join(this.tmpDir, filePath); 87 | 88 | try { 89 | const actualContent = (await fs.readFile(absoluteFilePath)).toString(); 90 | 91 | assert.match(actualContent, new RegExp(expectedContentExpr)); 92 | } catch (e: any) { 93 | if (e.code === "ENOENT") { 94 | throw new Error(`Expected ${filePath} to exist, but it doesn't`); 95 | } else { 96 | throw e; 97 | } 98 | } 99 | } 100 | ); 101 | -------------------------------------------------------------------------------- /features/support/configFileUpdater.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import fs from "fs/promises"; 4 | import { parse } from "@babel/parser"; 5 | import type { File } from "@babel/types"; 6 | import type { NodePath } from "ast-types/lib/node-path"; 7 | import { visit } from "recast"; 8 | import type { namedTypes } from "ast-types"; 9 | import prettier from "prettier"; 10 | 11 | /** 12 | * Shamelessly copied from Cypress. 13 | */ 14 | 15 | export async function insertValuesInConfigFile( 16 | filePath: string, 17 | obj: Record = {} 18 | ) { 19 | await insertValuesInJavaScript(filePath, obj); 20 | 21 | return true; 22 | } 23 | 24 | export async function insertValuesInJavaScript( 25 | filePath: string, 26 | obj: Record 27 | ) { 28 | const fileContents = await fs.readFile(filePath, { encoding: "utf8" }); 29 | 30 | const finalCode = await insertValueInJSString(fileContents, obj); 31 | 32 | const prettifiedCode = prettier.format(finalCode, { parser: "babel" }); 33 | 34 | await fs.writeFile(filePath, prettifiedCode); 35 | } 36 | 37 | export async function insertValueInJSString( 38 | fileContents: string, 39 | obj: Record 40 | ): Promise { 41 | const ast = parse(fileContents, { 42 | plugins: ["typescript"], 43 | sourceType: "module", 44 | }); 45 | 46 | let objectLiteralNode: namedTypes.ObjectExpression | undefined; 47 | 48 | function handleExport( 49 | nodePath: 50 | | NodePath 51 | | NodePath 52 | ): void { 53 | if ( 54 | nodePath.node.type === "CallExpression" && 55 | nodePath.node.callee.type === "Identifier" 56 | ) { 57 | const functionName = nodePath.node.callee.name; 58 | 59 | if (isDefineConfigFunction(ast, functionName)) { 60 | return handleExport(nodePath.get("arguments", 0)); 61 | } 62 | } 63 | 64 | if ( 65 | nodePath.node.type === "ObjectExpression" && 66 | !nodePath.node.properties.find((prop) => prop.type !== "ObjectProperty") 67 | ) { 68 | objectLiteralNode = nodePath.node; 69 | 70 | return; 71 | } 72 | 73 | throw new Error( 74 | "Cypress was unable to add/update values in your configuration file." 75 | ); 76 | } 77 | 78 | visit(ast, { 79 | visitAssignmentExpression(nodePath) { 80 | if (nodePath.node.left.type === "MemberExpression") { 81 | if ( 82 | nodePath.node.left.object.type === "Identifier" && 83 | nodePath.node.left.object.name === "module" && 84 | nodePath.node.left.property.type === "Identifier" && 85 | nodePath.node.left.property.name === "exports" 86 | ) { 87 | handleExport(nodePath.get("right")); 88 | } 89 | } 90 | 91 | return false; 92 | }, 93 | visitExportDefaultDeclaration(nodePath) { 94 | handleExport(nodePath.get("declaration")); 95 | 96 | return false; 97 | }, 98 | }); 99 | 100 | const splicers: Splicer[] = []; 101 | 102 | if (!objectLiteralNode) { 103 | // if the export is no object litteral 104 | throw new Error( 105 | "Cypress was unable to add/update values in your configuration file." 106 | ); 107 | } 108 | 109 | setRootKeysSplicers(splicers, obj, objectLiteralNode!, " "); 110 | setSubKeysSplicers(splicers, obj, objectLiteralNode!, " ", " "); 111 | 112 | // sort splicers to keep the order of the original file 113 | const sortedSplicers = splicers.sort((a, b) => 114 | a.start === b.start ? 0 : a.start > b.start ? 1 : -1 115 | ); 116 | 117 | if (!sortedSplicers.length) return fileContents; 118 | 119 | let nextStartingIndex = 0; 120 | let resultCode = ""; 121 | 122 | sortedSplicers.forEach((splicer) => { 123 | resultCode += 124 | fileContents.slice(nextStartingIndex, splicer.start) + 125 | splicer.replaceString; 126 | nextStartingIndex = splicer.end; 127 | }); 128 | 129 | return resultCode + fileContents.slice(nextStartingIndex); 130 | } 131 | 132 | export function isDefineConfigFunction( 133 | ast: File, 134 | functionName: string 135 | ): boolean { 136 | let value = false; 137 | 138 | visit(ast, { 139 | visitVariableDeclarator(nodePath) { 140 | // if this is a require of cypress 141 | if ( 142 | nodePath.node.init?.type === "CallExpression" && 143 | nodePath.node.init.callee.type === "Identifier" && 144 | nodePath.node.init.callee.name === "require" && 145 | nodePath.node.init.arguments[0].type === "StringLiteral" && 146 | nodePath.node.init.arguments[0].value === "cypress" 147 | ) { 148 | if (nodePath.node.id?.type === "ObjectPattern") { 149 | const defineConfigFunctionNode = nodePath.node.id.properties.find( 150 | (prop) => { 151 | return ( 152 | prop.type === "ObjectProperty" && 153 | prop.key.type === "Identifier" && 154 | prop.key.name === "defineConfig" 155 | ); 156 | } 157 | ); 158 | 159 | if (defineConfigFunctionNode) { 160 | value = 161 | (defineConfigFunctionNode as any).value?.name === functionName; 162 | } 163 | } 164 | } 165 | 166 | return false; 167 | }, 168 | visitImportDeclaration(nodePath) { 169 | if ( 170 | nodePath.node.source.type === "StringLiteral" && 171 | nodePath.node.source.value === "cypress" 172 | ) { 173 | const defineConfigFunctionNode = nodePath.node.specifiers?.find( 174 | (specifier) => { 175 | return ( 176 | specifier.type === "ImportSpecifier" && 177 | specifier.imported.type === "Identifier" && 178 | specifier.imported.name === "defineConfig" 179 | ); 180 | } 181 | ); 182 | 183 | if (defineConfigFunctionNode) { 184 | value = 185 | (defineConfigFunctionNode as any).local?.name === functionName; 186 | } 187 | } 188 | 189 | return false; 190 | }, 191 | }); 192 | 193 | return value; 194 | } 195 | 196 | function setRootKeysSplicers( 197 | splicers: Splicer[], 198 | obj: Record, 199 | objectLiteralNode: namedTypes.ObjectExpression, 200 | lineStartSpacer: string 201 | ) { 202 | const objectLiteralStartIndex = (objectLiteralNode as any).start + 1; 203 | // add values 204 | const objKeys = Object.keys(obj).filter((key) => 205 | ["boolean", "number", "string"].includes(typeof obj[key]) 206 | ); 207 | 208 | // update values 209 | const keysToUpdate = objKeys.filter((key) => { 210 | return objectLiteralNode.properties.find((prop) => { 211 | return ( 212 | prop.type === "ObjectProperty" && 213 | prop.key.type === "Identifier" && 214 | prop.key.name === key 215 | ); 216 | }); 217 | }); 218 | 219 | keysToUpdate.forEach((key) => { 220 | const propertyToUpdate = propertyFromKey(objectLiteralNode, key); 221 | 222 | if (propertyToUpdate) { 223 | setSplicerToUpdateProperty( 224 | splicers, 225 | propertyToUpdate, 226 | obj[key], 227 | key, 228 | obj 229 | ); 230 | } 231 | }); 232 | 233 | const keysToInsert = objKeys.filter((key) => !keysToUpdate.includes(key)); 234 | 235 | if (keysToInsert.length) { 236 | const valuesInserted = `\n${lineStartSpacer}${keysToInsert 237 | .map((key) => `${key}: ${JSON.stringify(obj[key])},`) 238 | .join(`\n${lineStartSpacer}`)}`; 239 | 240 | splicers.push({ 241 | start: objectLiteralStartIndex, 242 | end: objectLiteralStartIndex, 243 | replaceString: valuesInserted, 244 | }); 245 | } 246 | } 247 | 248 | function setSubKeysSplicers( 249 | splicers: Splicer[], 250 | obj: Record, 251 | objectLiteralNode: namedTypes.ObjectExpression, 252 | lineStartSpacer: string, 253 | parentLineStartSpacer: string 254 | ) { 255 | const objectLiteralStartIndex = (objectLiteralNode as any).start + 1; 256 | 257 | const keysToUpdateWithObjects: string[] = []; 258 | 259 | const objSubkeys = Object.keys(obj) 260 | .filter((key) => typeof obj[key] === "object") 261 | .reduce((acc: Array<{ parent: string; subkey: string }>, key) => { 262 | keysToUpdateWithObjects.push(key); 263 | Object.entries(obj[key]).forEach(([subkey, value]) => { 264 | if (["boolean", "number", "string"].includes(typeof value)) { 265 | acc.push({ parent: key, subkey }); 266 | } 267 | }); 268 | 269 | return acc; 270 | }, []); 271 | 272 | // add values where the parent key needs to be created 273 | const subkeysToInsertWithoutKey = objSubkeys.filter(({ parent }) => { 274 | return !objectLiteralNode.properties.find((prop) => { 275 | return ( 276 | prop.type === "ObjectProperty" && 277 | prop.key.type === "Identifier" && 278 | prop.key.name === parent 279 | ); 280 | }); 281 | }); 282 | const keysToInsertForSubKeys: Record = {}; 283 | 284 | subkeysToInsertWithoutKey.forEach((keyTuple) => { 285 | const subkeyList = keysToInsertForSubKeys[keyTuple.parent] || []; 286 | 287 | subkeyList.push(keyTuple.subkey); 288 | keysToInsertForSubKeys[keyTuple.parent] = subkeyList; 289 | }); 290 | 291 | let subvaluesInserted = ""; 292 | 293 | for (const key in keysToInsertForSubKeys) { 294 | subvaluesInserted += `\n${parentLineStartSpacer}${key}: {`; 295 | keysToInsertForSubKeys[key].forEach((subkey) => { 296 | subvaluesInserted += `\n${parentLineStartSpacer}${lineStartSpacer}${subkey}: ${JSON.stringify( 297 | obj[key][subkey] 298 | )},`; 299 | }); 300 | 301 | subvaluesInserted += `\n${parentLineStartSpacer}},`; 302 | } 303 | 304 | if (subkeysToInsertWithoutKey.length) { 305 | splicers.push({ 306 | start: objectLiteralStartIndex, 307 | end: objectLiteralStartIndex, 308 | replaceString: subvaluesInserted, 309 | }); 310 | } 311 | 312 | // add/update values where parent key already exists 313 | keysToUpdateWithObjects 314 | .filter((parent) => { 315 | return objectLiteralNode.properties.find((prop) => { 316 | return ( 317 | prop.type === "ObjectProperty" && 318 | prop.key.type === "Identifier" && 319 | prop.key.name === parent 320 | ); 321 | }); 322 | }) 323 | .forEach((key) => { 324 | const propertyToUpdate = propertyFromKey(objectLiteralNode, key); 325 | 326 | if (propertyToUpdate?.value.type === "ObjectExpression") { 327 | setRootKeysSplicers( 328 | splicers, 329 | obj[key], 330 | propertyToUpdate.value, 331 | parentLineStartSpacer + lineStartSpacer 332 | ); 333 | } 334 | }); 335 | } 336 | 337 | function setSplicerToUpdateProperty( 338 | splicers: Splicer[], 339 | propertyToUpdate: namedTypes.ObjectProperty, 340 | updatedValue: any, 341 | key: string, 342 | obj: Record 343 | ) { 344 | if ( 345 | propertyToUpdate && 346 | (isPrimitive(propertyToUpdate.value) || 347 | isUndefinedOrNull(propertyToUpdate.value)) 348 | ) { 349 | splicers.push({ 350 | start: (propertyToUpdate.value as any).start, 351 | end: (propertyToUpdate.value as any).end, 352 | replaceString: 353 | typeof updatedValue === "function" 354 | ? updatedValue.toString() 355 | : JSON.stringify(updatedValue), 356 | }); 357 | } else { 358 | throw new Error( 359 | "Cypress was unable to add/update values in your configuration file." 360 | ); 361 | } 362 | } 363 | 364 | function propertyFromKey( 365 | objectLiteralNode: namedTypes.ObjectExpression | undefined, 366 | key: string 367 | ): namedTypes.ObjectProperty | undefined { 368 | return objectLiteralNode?.properties.find((prop) => { 369 | return ( 370 | prop.type === "ObjectProperty" && 371 | prop.key.type === "Identifier" && 372 | prop.key.name === key 373 | ); 374 | }) as namedTypes.ObjectProperty; 375 | } 376 | 377 | function isPrimitive( 378 | value: NodePath["node"] 379 | ): value is 380 | | namedTypes.NumericLiteral 381 | | namedTypes.StringLiteral 382 | | namedTypes.BooleanLiteral { 383 | return ( 384 | value.type === "NumericLiteral" || 385 | value.type === "StringLiteral" || 386 | value.type === "BooleanLiteral" 387 | ); 388 | } 389 | 390 | function isUndefinedOrNull( 391 | value: NodePath["node"] 392 | ): value is namedTypes.Identifier { 393 | return ( 394 | value.type === "Identifier" && ["undefined", "null"].includes(value.name) 395 | ); 396 | } 397 | 398 | interface Splicer { 399 | start: number; 400 | end: number; 401 | replaceString: string; 402 | } 403 | -------------------------------------------------------------------------------- /features/support/helpers.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { promises as fs } from "fs"; 3 | 4 | export async function writeFile(filePath: string, fileContent: string) { 5 | await fs.mkdir(path.dirname(filePath), { recursive: true }); 6 | await fs.writeFile(filePath, fileContent); 7 | } 8 | -------------------------------------------------------------------------------- /features/support/hooks.ts: -------------------------------------------------------------------------------- 1 | import { After, Before, formatterHelpers } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import assert from "assert"; 4 | import { promises as fs, constants } from "fs"; 5 | import { writeFile } from "./helpers"; 6 | 7 | const projectPath = path.join(__dirname, "..", ".."); 8 | 9 | Before(async function ({ gherkinDocument, pickle }) { 10 | assert(gherkinDocument.uri, "Expected gherkinDocument.uri to be present"); 11 | 12 | const relativeUri = path.relative(process.cwd(), gherkinDocument.uri); 13 | 14 | const { line } = formatterHelpers.PickleParser.getPickleLocation({ 15 | gherkinDocument, 16 | pickle, 17 | }); 18 | 19 | this.tmpDir = path.join(projectPath, "tmp", `${relativeUri}_${line}`); 20 | 21 | await fs.rm(this.tmpDir, { recursive: true, force: true }); 22 | 23 | await writeFile( 24 | path.join(this.tmpDir, "cypress.config.js"), 25 | ` 26 | const { defineConfig } = require("cypress"); 27 | 28 | module.exports = defineConfig({ 29 | e2e: { 30 | video: false, 31 | supportFile: false, 32 | specPattern: "cypress/e2e/**/*.js", 33 | } 34 | }) 35 | ` 36 | ); 37 | 38 | await fs.mkdir(path.join(this.tmpDir, "node_modules", "@badeball"), { 39 | recursive: true, 40 | }); 41 | 42 | await fs.symlink( 43 | path.join(projectPath, "node_modules", "cypress-multi-reporters"), 44 | path.join(this.tmpDir, "node_modules", "cypress-multi-reporters") 45 | ); 46 | 47 | const selfLink = path.join( 48 | projectPath, 49 | "node_modules", 50 | "@badeball", 51 | "cypress-parallel" 52 | ); 53 | 54 | try { 55 | await fs.access(selfLink, constants.F_OK); 56 | await fs.unlink(selfLink); 57 | } catch {} 58 | 59 | await fs.symlink(projectPath, selfLink, "dir"); 60 | }); 61 | 62 | After(function () { 63 | if ( 64 | this.lastRun != null && 65 | this.lastRun.exitCode !== 0 && 66 | !this.verifiedLastRunError 67 | ) { 68 | throw new Error( 69 | `Last run errored unexpectedly. Output:\n\n${this.lastRun.output}` 70 | ); 71 | } 72 | }); 73 | -------------------------------------------------------------------------------- /features/support/world.ts: -------------------------------------------------------------------------------- 1 | import { setWorldConstructor, IWorld } from "@cucumber/cucumber"; 2 | import path from "path"; 3 | import childProcess from "child_process"; 4 | import { PassThrough, Readable } from "stream"; 5 | import { WritableStreamBuffer } from "stream-buffers"; 6 | 7 | const projectPath = path.join(__dirname, "..", ".."); 8 | 9 | function combine(...streams: Readable[]) { 10 | return streams.reduce((combined, stream) => { 11 | stream.pipe(combined, { end: false }); 12 | stream.once( 13 | "end", 14 | () => streams.every((s) => s.readableEnded) && combined.emit("end") 15 | ); 16 | return combined; 17 | }, new PassThrough()); 18 | } 19 | 20 | class World { 21 | async run(this: IWorld, extraArgs = []) { 22 | const child = childProcess.spawn( 23 | path.join(projectPath, "bin", "cypress-parallel"), 24 | ["run", ...extraArgs], 25 | { 26 | stdio: ["ignore", "pipe", "pipe"], 27 | cwd: this.tmpDir, 28 | env: { 29 | ...process.env, 30 | NO_COLOR: "1", 31 | }, 32 | } 33 | ); 34 | 35 | const combined = combine(child.stdout, child.stderr); 36 | 37 | if (process.env.DEBUG) { 38 | child.stdout.pipe(process.stdout); 39 | child.stderr.pipe(process.stderr); 40 | } 41 | 42 | const stdoutBuffer = child.stdout.pipe(new WritableStreamBuffer()); 43 | const stderrBuffer = child.stderr.pipe(new WritableStreamBuffer()); 44 | const outputBuffer = combined.pipe(new WritableStreamBuffer()); 45 | 46 | const exitCode = await new Promise((resolve) => { 47 | child.on("close", resolve); 48 | }); 49 | 50 | const stdout = stdoutBuffer.getContentsAsString() || ""; 51 | const stderr = stderrBuffer.getContentsAsString() || ""; 52 | const output = outputBuffer.getContentsAsString() || ""; 53 | 54 | this.verifiedLastRunError = false; 55 | 56 | this.lastRun = { 57 | stdout, 58 | stderr, 59 | output, 60 | exitCode, 61 | }; 62 | } 63 | } 64 | 65 | setWorldConstructor(World); 66 | -------------------------------------------------------------------------------- /features/unweighed-strategies/distribute.feature: -------------------------------------------------------------------------------- 1 | Feature: distribute (unweighed strategy) 2 | Rule: it should distribute unweighed files evenly, despite content length 3 | Background: 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => { 7 | expect(true).to.be.true 8 | }); 9 | """ 10 | And a file named "cypress/e2e/b.js" with: 11 | """ 12 | it("should pass", () => { 13 | expect(true).to.be.true 14 | }); 15 | it("should pass", () => { 16 | expect(true).to.be.true 17 | }); 18 | """ 19 | And a file named "cypress/e2e/c.js" with: 20 | """ 21 | it("should pass", () => expect(true).to.be.true); 22 | """ 23 | And a file named "cypress/e2e/d.js" with: 24 | """ 25 | it("should pass", () => expect(true).to.be.true); 26 | """ 27 | And a file named "knapsack.json" with: 28 | """ 29 | { 30 | "cypress/e2e/a.js": 30, 31 | "cypress/e2e/b.js": 60 32 | } 33 | """ 34 | 35 | Scenario: 1 / 2 node 36 | Given I run cypress-parallel with "--node 1:2 --unweighed-strategy distribute" 37 | Then it passes 38 | And it should appear to have run the specs 39 | | Name | 40 | | a.js | 41 | | c.js | 42 | 43 | Scenario: 2 / 2 node 44 | Given I run cypress-parallel with "--node 2:2 --unweighed-strategy distribute" 45 | Then it passes 46 | And it should appear to have run the specs 47 | | Name | 48 | | b.js | 49 | | d.js | 50 | -------------------------------------------------------------------------------- /features/unweighed-strategies/estimate.feature: -------------------------------------------------------------------------------- 1 | Feature: estimate (unweighed strategy) 2 | Rule: it should distribute unweighed files based off of content length (line numbers) 3 | Background: 4 | Given a file named "cypress/e2e/a.js" with: 5 | """ 6 | it("should pass", () => { 7 | expect(true).to.be.true 8 | }); 9 | """ 10 | And a file named "cypress/e2e/b.js" with: 11 | """ 12 | it("should pass", () => { 13 | expect(true).to.be.true 14 | }); 15 | it("should pass", () => { 16 | expect(true).to.be.true 17 | }); 18 | """ 19 | And a file named "cypress/e2e/c.js" with: 20 | """ 21 | it("should pass", () => expect(true).to.be.true); 22 | """ 23 | And a file named "cypress/e2e/d.js" with: 24 | """ 25 | it("should pass", () => expect(true).to.be.true); 26 | """ 27 | And a file named "knapsack.json" with: 28 | """ 29 | { 30 | "cypress/e2e/a.js": 30, 31 | "cypress/e2e/b.js": 60 32 | } 33 | """ 34 | 35 | Scenario: 1 / 2 node 36 | Given I run cypress-parallel with "--node 1:2 --unweighed-strategy estimate" 37 | Then it passes 38 | And it should appear to have run the specs 39 | | Name | 40 | | a.js | 41 | | c.js | 42 | | d.js | 43 | 44 | Scenario: 2 / 2 node 45 | Given I run cypress-parallel with "--node 2:2 --unweighed-strategy estimate" 46 | Then it passes 47 | And it should appear to have run the specs 48 | | Name | 49 | | b.js | 50 | -------------------------------------------------------------------------------- /knapsack-reporter.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import Mocha from "mocha"; 4 | 5 | import { createError } from "./lib/assertions"; 6 | 7 | const { EVENT_RUN_END, EVENT_SUITE_BEGIN } = Mocha.Runner.constants; 8 | 9 | export = class KnapsackReporter { 10 | constructor(runner: any, options: any) { 11 | const stats = runner.stats; 12 | const { reporterOptions } = options; 13 | const { output } = reporterOptions ? reporterOptions : { output: null }; 14 | 15 | if (!output) { 16 | throw createError( 17 | "'output' must be configured for KnapsackReporter to work" 18 | ); 19 | } 20 | 21 | let spec: any; 22 | 23 | runner 24 | .on(EVENT_SUITE_BEGIN, (suite: any) => { 25 | if (suite.root) { 26 | spec = suite.file; 27 | } 28 | }) 29 | .once(EVENT_RUN_END, () => { 30 | if (!spec) { 31 | throw createError("'spec' hasn't been determined"); 32 | } 33 | 34 | const { duration } = stats; 35 | 36 | const content = fs.existsSync(output) 37 | ? JSON.parse(fs.readFileSync(output).toString()) 38 | : {}; 39 | 40 | fs.writeFileSync( 41 | output, 42 | JSON.stringify( 43 | { 44 | ...content, 45 | [spec]: duration, 46 | }, 47 | null, 48 | 2 49 | ) 50 | ); 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/assertions.ts: -------------------------------------------------------------------------------- 1 | import { name, homepage } from "../package.json"; 2 | 3 | export function createError(message: string) { 4 | return new Error( 5 | `${name}: ${message} (this might be a bug, please report at ${homepage})` 6 | ); 7 | } 8 | 9 | export function fail(message: string) { 10 | throw createError(message); 11 | } 12 | -------------------------------------------------------------------------------- /lib/ci.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NodeConfiguration, 3 | parseAndValidateNodeConfiguration, 4 | } from "./configuration"; 5 | 6 | import debug from "./debug"; 7 | 8 | function tryResolveNodeConfigurationFromProvider( 9 | env: NodeJS.ProcessEnv, 10 | provider: string, 11 | indexKey: string, 12 | countKey: string 13 | ) { 14 | const index = env[indexKey], 15 | count = env[countKey]; 16 | 17 | if (typeof index === "string" && typeof count === "string") { 18 | debug(`found ${indexKey} and ${countKey} (${provider})`); 19 | 20 | return parseAndValidateNodeConfiguration(index, count); 21 | } 22 | } 23 | 24 | export function tryResolveNodeConfiguration( 25 | env: NodeJS.ProcessEnv 26 | ): NodeConfiguration | undefined { 27 | return ( 28 | // https://docs.gitlab.com/ee/ci/yaml/#parallel 29 | // https://devcenter.heroku.com/articles/heroku-ci-parallel-test-runs#parallelizing-your-test-suite 30 | tryResolveNodeConfigurationFromProvider( 31 | env, 32 | "Gitlab, Heroku", 33 | "CI_NODE_INDEX", 34 | "CI_NODE_TOTAL" 35 | ) || 36 | // https://circleci.com/docs/2.0/parallelism-faster-jobs/#using-environment-variables-to-split-tests 37 | tryResolveNodeConfigurationFromProvider( 38 | env, 39 | "CircleCI", 40 | "CIRCLE_NODE_INDEX", 41 | "CIRCLE_NODE_TOTAL" 42 | ) 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /lib/cli.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs, constants as fsConstants } from "fs"; 2 | 3 | import path from "path"; 4 | 5 | import util from "util"; 6 | 7 | import child_process from "child_process"; 8 | 9 | import { 10 | getConfiguration as getCypressConfiguration, 11 | getTestFiles, 12 | } from "@badeball/cypress-configuration"; 13 | 14 | import { Command, InvalidArgumentError } from "commander"; 15 | 16 | import { isNpm, isYarn } from "is-npm"; 17 | 18 | import { parse } from "shell-quote"; 19 | 20 | import { tryResolveNodeConfiguration } from "./ci"; 21 | 22 | import { 23 | IParallelConfiguration, 24 | NodeConfigurationParseError, 25 | parseAndValidateNodeConfiguration, 26 | } from "./configuration"; 27 | 28 | import debug from "./debug"; 29 | 30 | import { CypressParallelError } from "./error"; 31 | 32 | import { distribute } from "./unweighed-strategies/distribute"; 33 | 34 | import { estimate } from "./unweighed-strategies/estimate"; 35 | 36 | import { 37 | resolveCustomStrategy, 38 | UnweighedFile, 39 | WeighedFile, 40 | } from "./unweighed-strategy"; 41 | 42 | import { isKnapsack, isString } from "./type-guards"; 43 | 44 | import { compare } from "./unweighed-strategies/utils"; 45 | 46 | import { name, version } from "../package.json"; 47 | 48 | function determineCypressRunCommand() { 49 | if (isNpm) { 50 | return "npx cypress run"; 51 | } else if (isYarn) { 52 | return "yarn cypress run"; 53 | } else { 54 | throw new CypressParallelError( 55 | "Unable to determine how to run Cypress, please specify a cypress run command" 56 | ); 57 | } 58 | } 59 | 60 | function parseNodeConfiguration(value: string) { 61 | const values = value.split(":"); 62 | 63 | if (values.length !== 2) { 64 | throw new InvalidArgumentError( 65 | "Expected --node configuration matching :" 66 | ); 67 | } 68 | 69 | const [index, count] = values; 70 | 71 | try { 72 | return parseAndValidateNodeConfiguration(index, count); 73 | } catch (e) { 74 | if (e instanceof NodeConfigurationParseError) { 75 | throw new InvalidArgumentError(e.message); 76 | } else { 77 | throw e; 78 | } 79 | } 80 | } 81 | 82 | function parseUnweighedStrategy(value: string) { 83 | if (value !== "estimate" && value !== "distribute") { 84 | throw new InvalidArgumentError( 85 | "Valid unweighed strategies are 'estimate' and 'distribute'" 86 | ); 87 | } 88 | 89 | return value; 90 | } 91 | 92 | async function readKnapsack(filepath: string) { 93 | const aboluteFilepath = path.isAbsolute(filepath) 94 | ? filepath 95 | : path.join(process.cwd(), filepath); 96 | 97 | let knapsackContent; 98 | 99 | try { 100 | knapsackContent = (await fs.readFile(aboluteFilepath)).toString(); 101 | } catch (e: any) { 102 | if (e?.code === "ENOENT") { 103 | const knapsackName = path.basename(aboluteFilepath); 104 | 105 | console.warn(`Unable to find ${knapsackName}, continuing without it...`); 106 | return {}; 107 | } else { 108 | throw new CypressParallelError(`Unable to read knapsack: ${e.message}`); 109 | } 110 | } 111 | 112 | let maybeKnapsack; 113 | 114 | try { 115 | maybeKnapsack = JSON.parse(knapsackContent); 116 | } catch { 117 | throw new CypressParallelError( 118 | `Knapsack isn't valid JSON, got ${util.inspect(knapsackContent)}` 119 | ); 120 | } 121 | 122 | if (isKnapsack(maybeKnapsack)) { 123 | return maybeKnapsack; 124 | } else { 125 | throw new CypressParallelError( 126 | `Knapsack is wrongly formatted, got ${util.inspect(maybeKnapsack)}` 127 | ); 128 | } 129 | } 130 | 131 | const program = new Command(); 132 | 133 | program.version(`${name}-v${version}`, "-v, --version"); 134 | 135 | program.allowUnknownOption(); 136 | 137 | program.option( 138 | "--cypress-run-command ", 139 | "specifies the command to run cypress (in non-interactive mode), defaults to 'npx cypress run' or 'yarn cypress run' depending on how invoked" 140 | ); 141 | 142 | program.option( 143 | "--node :", 144 | "specifies number of buckets and which to run", 145 | parseNodeConfiguration 146 | ); 147 | 148 | program.option( 149 | "--knapsack ", 150 | "specifies the path to the knapsack file", 151 | "knapsack.json" 152 | ); 153 | 154 | program.option( 155 | "--read-knapsack ", 156 | "specifies the path to the knapsack file to read" 157 | ); 158 | 159 | program.option( 160 | "--write-knapsack ", 161 | "specifies the path to the knapsack file to write" 162 | ); 163 | 164 | program.option("--disable-knapsack-output", "disables knapsack output", false); 165 | 166 | program.option( 167 | "--unweighed-strategy ", 168 | "strategy to utilize for unweighed test files ('estimate' (default) | 'distribute')", 169 | parseUnweighedStrategy, 170 | "estimate" 171 | ); 172 | 173 | type PartialBy = Omit & Partial>; 174 | 175 | export async function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string) { 176 | try { 177 | program.parse(argv); 178 | 179 | const cypressArgs = program.parseOptions(argv).unknown; 180 | 181 | if (cypressArgs.includes("-s") || cypressArgs.includes("--spec")) { 182 | throw new CypressParallelError( 183 | "Unable to parallelize tests that are already scoped" 184 | ); 185 | } 186 | 187 | const options = program.opts() as PartialBy< 188 | IParallelConfiguration, 189 | "node" | "cypressRunCommand" 190 | >; 191 | 192 | const node = options.node || tryResolveNodeConfiguration(env); 193 | 194 | if (!node) { 195 | throw new CypressParallelError( 196 | "Unable to determine node index and node count, please specify --node :" 197 | ); 198 | } 199 | 200 | let parallelConfiguration: IParallelConfiguration = { 201 | cypressRunCommand: 202 | options.cypressRunCommand || determineCypressRunCommand(), 203 | node, 204 | unweighedStrategy: 205 | (await resolveCustomStrategy()) || options.unweighedStrategy, 206 | knapsack: options.knapsack, 207 | readKnapsack: options.readKnapsack, 208 | writeKnapsack: options.writeKnapsack, 209 | disableKnapsackOutput: options.disableKnapsackOutput, 210 | }; 211 | 212 | const cypressConfiguration = getCypressConfiguration({ 213 | testingType: "e2e", 214 | argv, 215 | env, 216 | cwd, 217 | }); 218 | 219 | const reporterOptions = ["-r", "--reporter", "-o", "--reporter-options"]; 220 | 221 | for (const reporterOption of reporterOptions) { 222 | if (cypressArgs.includes(reporterOption)) { 223 | parallelConfiguration = { 224 | ...parallelConfiguration, 225 | disableKnapsackOutput: true, 226 | }; 227 | break; 228 | } 229 | } 230 | 231 | let unweighedStrategy; 232 | 233 | if (parallelConfiguration.unweighedStrategy === "estimate") { 234 | unweighedStrategy = estimate; 235 | } else if (parallelConfiguration.unweighedStrategy === "distribute") { 236 | unweighedStrategy = distribute; 237 | } else { 238 | unweighedStrategy = parallelConfiguration.unweighedStrategy; 239 | } 240 | 241 | const knapsack = await readKnapsack( 242 | parallelConfiguration.readKnapsack ?? parallelConfiguration.knapsack 243 | ); 244 | 245 | const testFiles = getTestFiles(cypressConfiguration); 246 | 247 | const weighedFiles: WeighedFile[] = await Object.entries(knapsack).reduce< 248 | Promise 249 | >(async (weighedFilesPromise, entry) => { 250 | const weighedFiles = await weighedFilesPromise; 251 | 252 | const file = path.join(cypressConfiguration.projectRoot, entry[0]); 253 | 254 | if (!testFiles.includes(file)) { 255 | return weighedFiles; 256 | } 257 | 258 | try { 259 | await fs.access(file, fsConstants.F_OK); 260 | } catch { 261 | return weighedFiles; 262 | } 263 | 264 | return [ 265 | ...weighedFiles, 266 | { 267 | file, 268 | content: (await fs.readFile(file)).toString(), 269 | weight: entry[1], 270 | }, 271 | ]; 272 | }, Promise.resolve([])); 273 | 274 | const unweighedFiles: UnweighedFile[] = await testFiles 275 | .map((testFile) => 276 | path.relative(cypressConfiguration.projectRoot, testFile) 277 | ) 278 | .filter((testFile) => !Object.keys(knapsack).includes(testFile)) 279 | .reduce>(async (testFilesPromise, testFile) => { 280 | const testFiles = await testFilesPromise; 281 | 282 | const file = path.join(cypressConfiguration.projectRoot, testFile); 283 | 284 | return [ 285 | ...testFiles, 286 | { 287 | file, 288 | content: (await fs.readFile(file)).toString(), 289 | }, 290 | ]; 291 | }, Promise.resolve([])); 292 | 293 | const schedule = unweighedStrategy( 294 | weighedFiles, 295 | unweighedFiles, 296 | parallelConfiguration.node.count 297 | ); 298 | 299 | debug( 300 | `Schedule determined ${util.inspect( 301 | schedule.map((group) => group.map((file) => file.file)) 302 | )}` 303 | ); 304 | 305 | /** 306 | * Validate the generated schedule. 307 | */ 308 | const outputFiles = schedule 309 | .flatMap((node) => node.map((file) => file.file)) 310 | .sort(compare); 311 | 312 | for (const testFile of testFiles) { 313 | if (!outputFiles.includes(testFile)) { 314 | const relativePath = path.relative( 315 | cypressConfiguration.projectRoot, 316 | testFile 317 | ); 318 | 319 | throw new CypressParallelError( 320 | `Test file ${relativePath} wasn't distributed by the configured strategy` 321 | ); 322 | } 323 | } 324 | 325 | for (const outputFile of outputFiles) { 326 | if (!testFiles.includes(outputFile)) { 327 | const relativePath = path.relative( 328 | cypressConfiguration.projectRoot, 329 | outputFile 330 | ); 331 | 332 | throw new CypressParallelError( 333 | `The configured strategy produced ${relativePath}, which wasn't part of the input` 334 | ); 335 | } 336 | } 337 | 338 | const testFilesForNode = schedule[parallelConfiguration.node.index - 1].map( 339 | (testFile) => 340 | path.relative(cypressConfiguration.projectRoot, testFile.file) 341 | ); 342 | 343 | const parsedRunCmd = parse(parallelConfiguration.cypressRunCommand); 344 | 345 | if (!parsedRunCmd.every(isString)) { 346 | throw new Error( 347 | `Expected a run command without shell operators (such as '&&'), but go ${util.inspect( 348 | parallelConfiguration.cypressRunCommand 349 | )}` 350 | ); 351 | } 352 | 353 | const reporterArgs = parallelConfiguration.disableKnapsackOutput 354 | ? [] 355 | : [ 356 | "--reporter", 357 | "cypress-multi-reporters", 358 | "--reporter-options", 359 | JSON.stringify({ 360 | reporterEnabled: 361 | "spec, @badeball/cypress-parallel/knapsack-reporter", 362 | badeballCypressParallelKnapsackReporterReporterOptions: { 363 | output: 364 | parallelConfiguration.writeKnapsack ?? 365 | parallelConfiguration.knapsack, 366 | }, 367 | }), 368 | ]; 369 | 370 | const [cmd, ...args] = parsedRunCmd; 371 | 372 | const fullArgs = [ 373 | ...args, 374 | ...cypressArgs, 375 | ...reporterArgs, 376 | "--spec", 377 | testFilesForNode.join(","), 378 | ]; 379 | 380 | debug(`Running ${util.inspect(cmd)} with ${util.inspect(fullArgs)}`); 381 | 382 | const proc = child_process.spawn(cmd, fullArgs, { 383 | stdio: "inherit", 384 | }); 385 | 386 | proc.on("exit", function (code, signal) { 387 | process.on("exit", function () { 388 | if (signal) { 389 | process.kill(process.pid, signal); 390 | } else if (code) { 391 | process.exitCode = code; 392 | } 393 | }); 394 | }); 395 | 396 | process.on("SIGINT", () => proc.kill("SIGINT")); 397 | process.on("SIGTERM", () => proc.kill("SIGTERM")); 398 | } catch (e) { 399 | if (e instanceof CypressParallelError) { 400 | console.error(e.message); 401 | } else if (e instanceof Error) { 402 | console.error(e.stack); 403 | } else { 404 | console.error(util.inspect(e)); 405 | } 406 | 407 | process.exitCode = 1; 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /lib/configuration.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | 3 | import { CypressParallelError } from "./error"; 4 | 5 | import { UnweighedStrategy } from "./unweighed-strategy"; 6 | 7 | export interface NodeConfiguration { 8 | index: number; 9 | count: number; 10 | } 11 | 12 | export interface IParallelConfiguration { 13 | readonly cypressRunCommand: string; 14 | readonly node: NodeConfiguration; 15 | readonly knapsack: string; 16 | readonly writeKnapsack?: string; 17 | readonly readKnapsack?: string; 18 | readonly disableKnapsackOutput: boolean; 19 | readonly unweighedStrategy: "estimate" | "distribute" | UnweighedStrategy; 20 | } 21 | 22 | const NUMBER_EXPR = /^\d+$/; 23 | 24 | export class NodeConfigurationParseError extends CypressParallelError {} 25 | 26 | export function parseAndValidateNodeConfiguration( 27 | unparsedIndex: string, 28 | unparsedCount: string 29 | ) { 30 | if (!NUMBER_EXPR.test(unparsedIndex)) { 31 | throw new NodeConfigurationParseError( 32 | `Expected a number for node index, but got ${util.inspect(unparsedIndex)}` 33 | ); 34 | } 35 | 36 | if (!NUMBER_EXPR.test(unparsedCount)) { 37 | throw new NodeConfigurationParseError( 38 | `Expected a number for node count, but got ${util.inspect(unparsedCount)}` 39 | ); 40 | } 41 | 42 | const index = parseInt(unparsedIndex, 10); 43 | const count = parseInt(unparsedCount, 10); 44 | 45 | if (!(index > 0)) { 46 | throw new NodeConfigurationParseError("Expected node index > 0"); 47 | } 48 | 49 | if (!(count > 0)) { 50 | throw new NodeConfigurationParseError("Expected node count > 0"); 51 | } 52 | 53 | if (!(count >= index)) { 54 | throw new NodeConfigurationParseError("Expected node count >= node index"); 55 | } 56 | 57 | return { 58 | index, 59 | count, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /lib/debug.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | 3 | export default debug("cypress-parallel"); 4 | -------------------------------------------------------------------------------- /lib/error.ts: -------------------------------------------------------------------------------- 1 | export class CypressParallelError extends Error {} 2 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | WeighedFile, 3 | UnweighedFile, 4 | NodeSchedule, 5 | UnweighedStrategy, 6 | } from "./unweighed-strategy"; 7 | 8 | export { run } from "./cli"; 9 | -------------------------------------------------------------------------------- /lib/type-guards.ts: -------------------------------------------------------------------------------- 1 | import { NodeConfiguration } from "./configuration"; 2 | 3 | export function isString(value: unknown): value is string { 4 | return typeof value === "string"; 5 | } 6 | 7 | export function isNumber(value: unknown): value is number { 8 | return typeof value === "number"; 9 | } 10 | 11 | export function isNodeConfiguration(value: any): value is NodeConfiguration { 12 | return typeof value?.index === "number" && typeof value?.count === "number"; 13 | } 14 | 15 | export function isKnapsack(value: unknown): value is Record { 16 | return ( 17 | value !== null && 18 | typeof value === "object" && 19 | Object.values(value as object).every(isNumber) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/unweighed-strategies/distribute.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WeighedFile, 3 | UnweighedFile, 4 | NodeSchedule, 5 | } from "../unweighed-strategy"; 6 | 7 | import { compare, weightOfSchedule } from "./utils"; 8 | 9 | export function distribute( 10 | weighedFiles: WeighedFile[], 11 | unweighedFiles: UnweighedFile[], 12 | nodeCount: number 13 | ) { 14 | const intermediateSchedule: WeighedFile[][] = Array.from( 15 | { length: nodeCount }, 16 | () => { 17 | return []; 18 | } 19 | ); 20 | 21 | weighedFiles.sort((a, b) => { 22 | if (a.weight === b.weight) { 23 | return compare(a.file, b.file); 24 | } else { 25 | return compare(b.weight, a.weight); 26 | } 27 | }); 28 | 29 | for (const weighedFile of weighedFiles) { 30 | const leastAssignedNode = intermediateSchedule.sort( 31 | (a, b) => weightOfSchedule(a) - weightOfSchedule(b) 32 | )[0]; 33 | 34 | leastAssignedNode.push(weighedFile); 35 | } 36 | 37 | const determinedSchedule: NodeSchedule[] = intermediateSchedule.sort( 38 | (a, b) => weightOfSchedule(a) - weightOfSchedule(b) 39 | ); 40 | 41 | for (let i = 0; i < unweighedFiles.length; i++) { 42 | determinedSchedule[i % nodeCount].push(unweighedFiles[i]); 43 | } 44 | 45 | return determinedSchedule; 46 | } 47 | -------------------------------------------------------------------------------- /lib/unweighed-strategies/estimate.ts: -------------------------------------------------------------------------------- 1 | import { WeighedFile, UnweighedFile } from "../unweighed-strategy"; 2 | 3 | import { distribute } from "./distribute"; 4 | 5 | import { compare, weightOfSchedule } from "./utils"; 6 | 7 | function zip(collection: [A, B][]): [A[], B[]] { 8 | if (collection.length === 0) { 9 | return [[], []]; 10 | } 11 | 12 | const [[firstA, firstB], ...rest] = collection; 13 | 14 | return rest.reduce<[A[], B[]]>( 15 | ([colA, colB], [a, b]) => { 16 | return [ 17 | [...colA, a], 18 | [...colB, b], 19 | ]; 20 | }, 21 | [[firstA], [firstB]] 22 | ); 23 | } 24 | 25 | function sum(collection: number[]) { 26 | return collection.reduce((sum, n) => sum + n, 0); 27 | } 28 | 29 | export function estimate( 30 | weighedFiles: WeighedFile[], 31 | unweighedFiles: UnweighedFile[], 32 | nodeCount: number 33 | ) { 34 | if (weighedFiles.length === 0) { 35 | return distribute(weighedFiles, unweighedFiles, nodeCount); 36 | } 37 | 38 | const averageTimePerLine = zip( 39 | weighedFiles.map((testFile) => [ 40 | testFile.weight, 41 | testFile.content.split("\n").length, 42 | ]) 43 | ) 44 | .map((collection) => sum(collection)) 45 | .reduce((totalTime, totalLines) => totalTime / totalLines); 46 | 47 | const estimatedFiles = unweighedFiles.map((unweighedFile) => { 48 | const estimatedWeight = 49 | unweighedFile.content.split("\n").length * averageTimePerLine; 50 | 51 | return { 52 | ...unweighedFile, 53 | weight: estimatedWeight, 54 | }; 55 | }); 56 | 57 | const allFiles = [...weighedFiles, ...estimatedFiles].sort((a, b) => { 58 | if (a.weight === b.weight) { 59 | return compare(a.file, b.file); 60 | } else { 61 | return compare(b.weight, a.weight); 62 | } 63 | }); 64 | 65 | const schedule: WeighedFile[][] = Array.from({ length: nodeCount }, () => { 66 | return []; 67 | }); 68 | 69 | for (const file of allFiles) { 70 | const leastAssignedNode = schedule.sort( 71 | (a, b) => weightOfSchedule(a) - weightOfSchedule(b) 72 | )[0]; 73 | 74 | leastAssignedNode.push(file); 75 | } 76 | 77 | return schedule; 78 | } 79 | -------------------------------------------------------------------------------- /lib/unweighed-strategies/utils.ts: -------------------------------------------------------------------------------- 1 | import { WeighedFile } from "../unweighed-strategy"; 2 | 3 | export function weightOfSchedule(schedule: WeighedFile[]) { 4 | return schedule.reduce((weight, testFile) => weight + testFile.weight, 0); 5 | } 6 | 7 | export function compare(a: number, b: number): 1 | 0 | -1; 8 | export function compare(a: string, b: string): 1 | 0 | -1; 9 | export function compare(a: number | string, b: number | string) { 10 | if (a < b) { 11 | return -1; 12 | } 13 | if (a > b) { 14 | return 1; 15 | } 16 | 17 | return 0; 18 | } 19 | -------------------------------------------------------------------------------- /lib/unweighed-strategy.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | 3 | import { cosmiconfig } from "cosmiconfig"; 4 | 5 | import { CypressParallelError } from "./error"; 6 | 7 | export interface WeighedFile { 8 | file: string; 9 | content: string; 10 | weight: number; 11 | } 12 | 13 | export interface UnweighedFile { 14 | file: string; 15 | content: string; 16 | } 17 | 18 | export interface UnweighedStrategy { 19 | ( 20 | weighedFiles: WeighedFile[], 21 | unweighedFiles: UnweighedFile[], 22 | nodeCount: number 23 | ): NodeSchedule[]; 24 | } 25 | 26 | export type NodeSchedule = (WeighedFile | UnweighedFile)[]; 27 | 28 | export class ConfigurationError extends CypressParallelError {} 29 | 30 | export async function resolveCustomStrategy(searchFrom?: string) { 31 | const result = await cosmiconfig("cypress-parallel").search(searchFrom); 32 | 33 | if (result) { 34 | const { config: rawConfig } = result; 35 | 36 | if (typeof rawConfig !== "object" || rawConfig == null) { 37 | throw new CypressParallelError( 38 | `Malformed configuration, expected an object, but got ${util.inspect( 39 | rawConfig 40 | )}` 41 | ); 42 | } 43 | 44 | const { unweighedStrategy } = rawConfig; 45 | 46 | if (unweighedStrategy) { 47 | if (typeof unweighedStrategy === "function") { 48 | return unweighedStrategy as UnweighedStrategy; 49 | } else { 50 | throw new ConfigurationError( 51 | `Expected a function (unweighedStrategy), but got ${util.inspect( 52 | unweighedStrategy 53 | )}` 54 | ); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@badeball/cypress-parallel", 3 | "version": "2.0.0", 4 | "author": "Jonas Amundsen", 5 | "license": "MIT", 6 | "homepage": "https://github.com/badeball/cypress-parallel", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/badeball/cypress-parallel.git" 10 | }, 11 | "keywords": [ 12 | "cypress", 13 | "cypress-parallel" 14 | ], 15 | "main": "lib/index.js", 16 | "types": "lib/index.d.ts", 17 | "files": [ 18 | "lib/**/*.js", 19 | "lib/**/*.d.ts", 20 | "knapsack-reporter.js", 21 | "knapsack-reporter.d.ts" 22 | ], 23 | "bin": { 24 | "cypress-parallel": "./bin/cypress-parallel" 25 | }, 26 | "scripts": { 27 | "clean": "rm -f knapsack-reporter.{js,d.ts} && bash -O globstar -c 'rm -f lib/**/*.{js,d.ts}'", 28 | "build": "tsc", 29 | "watch": "tsc --watch", 30 | "fmt": "prettier --ignore-path .gitignore --write '**/*.ts'", 31 | "test": "npm run test:fmt && npm run test:integration", 32 | "test:fmt": "prettier --ignore-path .gitignore --check '**/*.ts'", 33 | "test:integration": "cucumber-js", 34 | "prepublishOnly": "npm run clean && npm run build && npm run test" 35 | }, 36 | "dependencies": { 37 | "@babel/types": "^7.23.0", 38 | "@badeball/cypress-configuration": "^6.1.0", 39 | "ast-types": "^0.16.1", 40 | "commander": "^8.1.0", 41 | "cosmiconfig": "^7.0.1", 42 | "cypress-multi-reporters": "^1.5.0", 43 | "is-npm": "^5.0.0", 44 | "recast": "^0.23.4", 45 | "shell-quote": "^1.7.2" 46 | }, 47 | "devDependencies": { 48 | "@cucumber/cucumber": "^8.0.0", 49 | "@cucumber/pretty-formatter": "^1.0.0-alpha.1", 50 | "@types/debug": "^4.1.7", 51 | "@types/mocha": "^9.1.0", 52 | "@types/prettier": "^2.7.3", 53 | "@types/shell-quote": "^1.7.1", 54 | "@types/stream-buffers": "^3.0.4", 55 | "cypress": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", 56 | "dtslint": "^4.0.5", 57 | "esbuild": "^0.11.12", 58 | "mocha": "^8.2.0", 59 | "prettier": "^2.8.8", 60 | "stream-buffers": "^3.0.2", 61 | "strip-indent": "^3.0.0", 62 | "ts-node": "^10.2.1", 63 | "typescript": "^4.0.3" 64 | }, 65 | "peerDependencies": { 66 | "cypress": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "skipLibCheck": true, 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "Node", 8 | "resolveJsonModule": true, 9 | "target": "ES2017", 10 | "module": "CommonJS", 11 | "types": ["node"], 12 | "lib": ["dom", "ES2019"], 13 | "inlineSourceMap": true 14 | }, 15 | "include": [ 16 | "lib/**/*.ts", 17 | "knapsack-reporter.ts" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------