Skip to content

Commit 6ed854d

Browse files
Lukas Holzerkodiakhq[bot]
andauthored
chore: execute tests based on a dependency graph (#3653)
* chore: build project graph for determining which tests to run * chore: add graphviz visualization to it * chore: update project graph analysis and add affected test command * chore: run ava tests based on the files * chore: update github action with the new command and enhance tests * chore: fail with process.exit on catch * chore: update affected base sha to be the PRs merge into shasum * chore: change checkout depth * chore: update visiting plugins * chore: update error messaging * chore: remove snapshots as they dont work outside of tests dir * chore: change to array as format serializes sets differently on new node versions Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 96e28bf commit 6ed854d

File tree

14 files changed

+750
-4
lines changed

14 files changed

+750
-4
lines changed

.github/workflows/main.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ jobs:
3535
if: "${{ matrix.os == 'windows-latest' }}"
3636
- name: Git checkout
3737
uses: actions/checkout@v2
38+
with:
39+
fetch-depth: 0
3840
- name: Use Node.js ${{ matrix.node-version }}
3941
uses: actions/setup-node@v2
4042
with:
@@ -49,9 +51,18 @@ jobs:
4951
- name: Linting
5052
run: npm run format:ci
5153
if: "${{ matrix.node-version == '*' && !steps.release-check.outputs.IS_RELEASE}}"
54+
- name: Determine Test Command
55+
uses: haya14busa/action-cond@v1
56+
id: testCommand
57+
with:
58+
cond: ${{ github.event_name == 'pull_request' }}
59+
if_true: 'npm run test:affected ${{ github.event.pull_request.base.sha }}' # on pull requests test with the project graph only the affected tests
60+
if_false: 'npm run test:ci' # on the base branch run all the tests as security measure
61+
- name: Prepare tests
62+
run: npm run test:init
5263
- name: Tests
5364
if: '${{ !steps.release-check.outputs.IS_RELEASE }}'
54-
run: npm run test:ci
65+
run: ${{ steps.testCommand.outputs.value }}
5566
env:
5667
# GitHub secrets are not available when running on PR from forks
5768
# We set a flag so we can skip tests that access Netlify API

npm-shrinkwrap.json

Lines changed: 56 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,16 @@
5656
"format:check:prettier": "cross-env-shell prettier --check $npm_package_config_prettier",
5757
"format:fix:prettier": "cross-env-shell prettier --write $npm_package_config_prettier",
5858
"test:dev": "run-s test:init:* test:dev:*",
59-
"test:ci": "run-s test:init:* test:ci:*",
59+
"test:ci": "run-s test:ci:*",
60+
"test:init": "run-s test:init:*",
6061
"test:init:build": "run-s build:*",
6162
"test:init:cli-version": "npm run start -- --version",
6263
"test:init:cli-help": "npm run start -- --help",
6364
"test:init:eleventy-deps": "npm ci --prefix tests/eleventy-site --no-audit",
6465
"test:init:hugo-deps": "npm ci --prefix tests/hugo-site --no-audit",
6566
"test:dev:ava": "ava --verbose",
6667
"test:ci:ava": "nyc -r json ava",
68+
"test:affected": "node ./tools/affected-test.js",
6769
"docs": "node ./site/scripts/docs.js",
6870
"watch": "nyc --reporter=lcov ava --watch",
6971
"build:manifest": "oclif-dev manifest",
@@ -202,12 +204,15 @@
202204
"@oclif/test": "^1.2.5",
203205
"ava": "^3.15.0",
204206
"eslint-plugin-sort-destructure-keys": "^1.3.5",
207+
"fast-glob": "^3.2.7",
205208
"form-data": "^4.0.0",
206209
"from2-string": "^1.1.0",
207210
"got": "^11.8.1",
211+
"graphviz": "^0.0.9",
208212
"ini": "^2.0.0",
209213
"jsonwebtoken": "^8.5.1",
210214
"mkdirp": "^1.0.4",
215+
"mock-fs": "^5.1.2",
211216
"nyc": "^15.0.0",
212217
"p-timeout": "^4.0.0",
213218
"pidtree": "^0.5.0",
@@ -219,11 +224,13 @@
219224
"supertest": "^6.1.6",
220225
"temp-dir": "^2.0.0",
221226
"tomlify-j0.4": "^3.0.0",
222-
"tree-kill": "^1.2.2"
227+
"tree-kill": "^1.2.2",
228+
"typescript": "^4.4.4"
223229
},
224230
"ava": {
225231
"files": [
226232
"src/**/*.test.js",
233+
"tools/**/*.test.js",
227234
"tests/*.test.js"
228235
],
229236
"cache": true,

tools/affected-test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env node
2+
// @ts-check
3+
const { existsSync, statSync } = require('fs')
4+
const process = require('process')
5+
6+
const { grey } = require('chalk')
7+
const execa = require('execa')
8+
const { sync } = require('fast-glob')
9+
10+
const { ava } = require('../package.json')
11+
12+
const { DependencyGraph, fileVisitor, visitorPlugins } = require('./project-graph')
13+
14+
const getChangedFiles = async (compareTarget = 'origin/main') => {
15+
const { stdout } = await execa('git', ['diff', '--name-only', 'HEAD', compareTarget])
16+
return stdout.split('\n')
17+
}
18+
19+
/**
20+
* Get the list of affected files - if some files are touched like the package.json
21+
* everything is affected.
22+
* @param {string[]} changedFiles
23+
* @returns {string[]}
24+
*/
25+
const getAffectedFiles = (changedFiles) => {
26+
const testFiles = sync(ava.files)
27+
28+
// in this case all files are affected
29+
if (changedFiles.includes('npm-shrinkwrap.json') || changedFiles.includes('package.json')) {
30+
console.log('All files are affected based on the changeset')
31+
return testFiles
32+
}
33+
34+
const graph = new DependencyGraph()
35+
36+
testFiles.forEach((file) => {
37+
fileVisitor(file, { graph, visitorPlugins })
38+
})
39+
40+
return [...graph.affected(changedFiles, (file) => file.endsWith('.test.js'))]
41+
}
42+
43+
/**
44+
* The main function
45+
* @param {string[]} args
46+
*/
47+
const main = async (args) => {
48+
const changedFiles =
49+
args[0] && existsSync(args[0])
50+
? args.filter((arg) => existsSync(arg) && statSync(arg).isFile())
51+
: await getChangedFiles(args[0])
52+
53+
const affectedFiles = getAffectedFiles(changedFiles)
54+
55+
if (affectedFiles.length === 0) {
56+
console.log('No files where affected by the changeset!')
57+
return
58+
}
59+
console.log(`Running affected Tests: \n${grey([...affectedFiles].join(', '))}`)
60+
const testRun = execa('nyc', ['-r', 'json', 'ava', ...affectedFiles], {
61+
stdio: 'inherit',
62+
preferLocal: true,
63+
})
64+
65+
process.on('exit', () => {
66+
testRun.kill()
67+
})
68+
69+
try {
70+
await testRun
71+
} catch(error) {
72+
if (error instanceof Error) {
73+
console.log(error.message);
74+
process.exit(1);
75+
}
76+
throw error;
77+
}
78+
}
79+
80+
// Can be invoked with two different arguments:
81+
// Either a list of files where all affected tests should be calculated based on:
82+
// $ npm run test:affected -- ./path/to/file.js ./other-file.js
83+
//
84+
// or by specifying a git diff target
85+
// $ npm run test:affected -- HEAD~1
86+
//
87+
// The default is when running without arguments a git diff target off 'origin/master'
88+
if (require.main === module) {
89+
const args = process.argv.slice(2)
90+
// eslint-disable-next-line promise/prefer-await-to-callbacks,promise/prefer-await-to-then
91+
main(args).catch((error) => {
92+
console.error(error)
93+
process.exit(1)
94+
})
95+
}
96+
97+
module.exports = { getChangedFiles, getAffectedFiles }
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// @ts-check
2+
3+
const graphviz = require('graphviz')
4+
5+
class DependencyGraph {
6+
/** @type {Map<string, Set<string>>} */
7+
graph = new Map()
8+
9+
hasFile(fileName) {
10+
return this.graph.has(fileName)
11+
}
12+
13+
/**
14+
* Adds a node to the graph
15+
* @param {string} fileName
16+
* @returns {void}
17+
*/
18+
addFile(fileName) {
19+
if (!this.graph.has(fileName)) {
20+
this.graph.set(fileName, new Set())
21+
}
22+
}
23+
24+
addDependency(parent, child) {
25+
if (!this.graph.has(parent)) {
26+
this.addFile(parent)
27+
}
28+
if (!this.graph.has(child)) {
29+
this.addFile(child)
30+
}
31+
32+
this.graph.set(parent, this.graph.get(parent).add(child))
33+
}
34+
35+
/**
36+
* Provide a list of all affected files inside the graph based on the provided files
37+
* @param {string[]} files
38+
* @param {(file:string) => boolean} filterFunction
39+
* @returns {Set<string>}
40+
*/
41+
affected(files, filterFunction) {
42+
const affectedFiles = new Set()
43+
44+
const findParents = (leaf) => {
45+
if (((filterFunction && filterFunction(leaf)) || !filterFunction) && this.graph.has(leaf)) {
46+
affectedFiles.add(leaf)
47+
}
48+
49+
this.graph.forEach((value, key) => {
50+
if (value.has(leaf)) {
51+
if ((filterFunction && filterFunction(leaf)) || !filterFunction) {
52+
affectedFiles.add(key)
53+
}
54+
findParents(key)
55+
}
56+
})
57+
}
58+
59+
files.forEach((file) => {
60+
findParents(file)
61+
})
62+
63+
return affectedFiles
64+
}
65+
66+
/**
67+
* Visualizes a dependency graph the output is a graphviz graph
68+
* that can be printed to `.to_dot()` or rendered to a png file
69+
* @returns {graphviz.Graph}
70+
*/
71+
visualize() {
72+
const graph = graphviz.digraph('G')
73+
this.graph.forEach((edges, node) => {
74+
graph.addNode(node)
75+
edges.forEach((edge) => {
76+
graph.addEdge(node, edge)
77+
})
78+
})
79+
80+
return graph
81+
}
82+
}
83+
84+
module.exports = { DependencyGraph }

0 commit comments

Comments
 (0)