mirror of
https://github.com/github/codeql-action.git
synced 2025-12-27 01:30:10 +08:00
448 lines
11 KiB
JavaScript
448 lines
11 KiB
JavaScript
'use strict';
|
|
const nodePath = require('path');
|
|
const debug = require('debug')('ava:watcher');
|
|
const chokidar = require('chokidar');
|
|
const diff = require('lodash/difference');
|
|
const flatten = require('lodash/flatten');
|
|
const chalk = require('./chalk').get();
|
|
const {applyTestFileFilter, classify, getChokidarIgnorePatterns} = require('./globs');
|
|
const {levels: providerLevels} = require('./provider-manager');
|
|
|
|
function rethrowAsync(err) {
|
|
// Don't swallow exceptions. Note that any
|
|
// expected error should already have been logged
|
|
setImmediate(() => {
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
const MIN_DEBOUNCE_DELAY = 10;
|
|
const INITIAL_DEBOUNCE_DELAY = 100;
|
|
const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n');
|
|
|
|
class Debouncer {
|
|
constructor(watcher) {
|
|
this.watcher = watcher;
|
|
this.timer = null;
|
|
this.repeat = false;
|
|
}
|
|
|
|
debounce(delay) {
|
|
if (this.timer) {
|
|
this.again = true;
|
|
return;
|
|
}
|
|
|
|
delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY;
|
|
|
|
const timer = setTimeout(async () => {
|
|
await this.watcher.busy;
|
|
// Do nothing if debouncing was canceled while waiting for the busy
|
|
// promise to fulfil
|
|
if (this.timer !== timer) {
|
|
return;
|
|
}
|
|
|
|
if (this.again) {
|
|
this.timer = null;
|
|
this.again = false;
|
|
this.debounce(delay / 2);
|
|
} else {
|
|
this.watcher.runAfterChanges();
|
|
this.timer = null;
|
|
this.again = false;
|
|
}
|
|
}, delay);
|
|
|
|
this.timer = timer;
|
|
}
|
|
|
|
cancel() {
|
|
if (this.timer) {
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
this.again = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
class TestDependency {
|
|
constructor(file, dependencies) {
|
|
this.file = file;
|
|
this.dependencies = dependencies;
|
|
}
|
|
|
|
contains(dependency) {
|
|
return this.dependencies.includes(dependency);
|
|
}
|
|
}
|
|
|
|
class Watcher {
|
|
constructor({api, filter = [], globs, projectDir, providers, reporter}) {
|
|
this.debouncer = new Debouncer(this);
|
|
|
|
this.clearLogOnNextRun = true;
|
|
this.runVector = 0;
|
|
this.previousFiles = [];
|
|
this.globs = {cwd: projectDir, ...globs};
|
|
|
|
const patternFilters = filter.map(({pattern}) => pattern);
|
|
|
|
this.providers = providers.filter(({level}) => level >= providerLevels.pathRewrites);
|
|
this.run = (specificFiles = [], updateSnapshots = false) => {
|
|
const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0;
|
|
if (this.runVector > 0) {
|
|
this.clearLogOnNextRun = true;
|
|
}
|
|
|
|
this.runVector++;
|
|
|
|
let runOnlyExclusive = false;
|
|
if (specificFiles.length > 0) {
|
|
const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file));
|
|
runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length;
|
|
if (runOnlyExclusive) {
|
|
// The test files that previously contained exclusive tests are always
|
|
// run, together with the remaining specific files.
|
|
const remainingFiles = diff(specificFiles, exclusiveFiles);
|
|
specificFiles = this.filesWithExclusiveTests.concat(remainingFiles);
|
|
}
|
|
|
|
if (filter.length > 0) {
|
|
specificFiles = applyTestFileFilter({cwd: projectDir, filter: patternFilters, testFiles: specificFiles});
|
|
}
|
|
|
|
this.pruneFailures(specificFiles);
|
|
}
|
|
|
|
this.touchedFiles.clear();
|
|
this.previousFiles = specificFiles;
|
|
this.busy = api.run({
|
|
files: specificFiles,
|
|
filter,
|
|
runtimeOptions: {
|
|
clearLogOnNextRun,
|
|
previousFailures: this.sumPreviousFailures(this.runVector),
|
|
runOnlyExclusive,
|
|
runVector: this.runVector,
|
|
updateSnapshots: updateSnapshots === true
|
|
}
|
|
})
|
|
.then(runStatus => { // eslint-disable-line promise/prefer-await-to-then
|
|
reporter.endRun();
|
|
reporter.lineWriter.writeLine(END_MESSAGE);
|
|
|
|
if (this.clearLogOnNextRun && (
|
|
runStatus.stats.failedHooks > 0 ||
|
|
runStatus.stats.failedTests > 0 ||
|
|
runStatus.stats.failedWorkers > 0 ||
|
|
runStatus.stats.internalErrors > 0 ||
|
|
runStatus.stats.timeouts > 0 ||
|
|
runStatus.stats.uncaughtExceptions > 0 ||
|
|
runStatus.stats.unhandledRejections > 0
|
|
)) {
|
|
this.clearLogOnNextRun = false;
|
|
}
|
|
})
|
|
.catch(rethrowAsync);
|
|
};
|
|
|
|
this.testDependencies = [];
|
|
this.trackTestDependencies(api);
|
|
|
|
this.touchedFiles = new Set();
|
|
this.trackTouchedFiles(api);
|
|
|
|
this.filesWithExclusiveTests = [];
|
|
this.trackExclusivity(api);
|
|
|
|
this.filesWithFailures = [];
|
|
this.trackFailures(api);
|
|
|
|
this.dirtyStates = {};
|
|
this.watchFiles();
|
|
this.rerunAll();
|
|
}
|
|
|
|
watchFiles() {
|
|
chokidar.watch(['**/*'], {
|
|
cwd: this.globs.cwd,
|
|
ignored: getChokidarIgnorePatterns(this.globs),
|
|
ignoreInitial: true
|
|
}).on('all', (event, path) => {
|
|
if (event === 'add' || event === 'change' || event === 'unlink') {
|
|
debug('Detected %s of %s', event, path);
|
|
this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event;
|
|
this.debouncer.debounce();
|
|
}
|
|
});
|
|
}
|
|
|
|
trackTestDependencies(api) {
|
|
api.on('run', plan => {
|
|
plan.status.on('stateChange', evt => {
|
|
if (evt.type !== 'dependencies') {
|
|
return;
|
|
}
|
|
|
|
const dependencies = evt.dependencies.filter(filePath => {
|
|
const {isIgnoredByWatcher} = classify(filePath, this.globs);
|
|
return !isIgnoredByWatcher;
|
|
});
|
|
this.updateTestDependencies(evt.testFile, dependencies);
|
|
});
|
|
});
|
|
}
|
|
|
|
updateTestDependencies(file, dependencies) {
|
|
// Ensure the rewritten test file path is included in the dependencies,
|
|
// since changes to non-rewritten paths are ignored.
|
|
for (const {main} of this.providers) {
|
|
const rewritten = main.resolveTestFile(file);
|
|
if (!dependencies.includes(rewritten)) {
|
|
dependencies = [rewritten, ...dependencies];
|
|
}
|
|
}
|
|
|
|
if (dependencies.length === 0) {
|
|
this.testDependencies = this.testDependencies.filter(dep => dep.file !== file);
|
|
return;
|
|
}
|
|
|
|
const isUpdate = this.testDependencies.some(dep => {
|
|
if (dep.file !== file) {
|
|
return false;
|
|
}
|
|
|
|
dep.dependencies = dependencies;
|
|
|
|
return true;
|
|
});
|
|
|
|
if (!isUpdate) {
|
|
this.testDependencies.push(new TestDependency(file, dependencies));
|
|
}
|
|
}
|
|
|
|
trackTouchedFiles(api) {
|
|
api.on('run', plan => {
|
|
plan.status.on('stateChange', evt => {
|
|
if (evt.type !== 'touched-files') {
|
|
return;
|
|
}
|
|
|
|
for (const file of evt.files) {
|
|
this.touchedFiles.add(file);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
trackExclusivity(api) {
|
|
api.on('run', plan => {
|
|
plan.status.on('stateChange', evt => {
|
|
if (evt.type !== 'worker-finished') {
|
|
return;
|
|
}
|
|
|
|
const fileStats = plan.status.stats.byFile.get(evt.testFile);
|
|
const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests;
|
|
this.updateExclusivity(evt.testFile, ranExclusiveTests);
|
|
});
|
|
});
|
|
}
|
|
|
|
updateExclusivity(file, hasExclusiveTests) {
|
|
const index = this.filesWithExclusiveTests.indexOf(file);
|
|
|
|
if (hasExclusiveTests && index === -1) {
|
|
this.filesWithExclusiveTests.push(file);
|
|
} else if (!hasExclusiveTests && index !== -1) {
|
|
this.filesWithExclusiveTests.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
trackFailures(api) {
|
|
api.on('run', plan => {
|
|
this.pruneFailures(plan.files);
|
|
|
|
const currentVector = this.runVector;
|
|
plan.status.on('stateChange', evt => {
|
|
if (!evt.testFile) {
|
|
return;
|
|
}
|
|
|
|
switch (evt.type) {
|
|
case 'hook-failed':
|
|
case 'internal-error':
|
|
case 'test-failed':
|
|
case 'uncaught-exception':
|
|
case 'unhandled-rejection':
|
|
case 'worker-failed':
|
|
this.countFailure(evt.testFile, currentVector);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
pruneFailures(files) {
|
|
const toPrune = new Set(files);
|
|
this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
|
|
}
|
|
|
|
countFailure(file, vector) {
|
|
const isUpdate = this.filesWithFailures.some(state => {
|
|
if (state.file !== file) {
|
|
return false;
|
|
}
|
|
|
|
state.count++;
|
|
return true;
|
|
});
|
|
|
|
if (!isUpdate) {
|
|
this.filesWithFailures.push({
|
|
file,
|
|
vector,
|
|
count: 1
|
|
});
|
|
}
|
|
}
|
|
|
|
sumPreviousFailures(beforeVector) {
|
|
let total = 0;
|
|
|
|
for (const state of this.filesWithFailures) {
|
|
if (state.vector < beforeVector) {
|
|
total += state.count;
|
|
}
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
cleanUnlinkedTests(unlinkedTests) {
|
|
for (const testFile of unlinkedTests) {
|
|
this.updateTestDependencies(testFile, []);
|
|
this.updateExclusivity(testFile, false);
|
|
this.pruneFailures([testFile]);
|
|
}
|
|
}
|
|
|
|
observeStdin(stdin) {
|
|
stdin.resume();
|
|
stdin.setEncoding('utf8');
|
|
|
|
stdin.on('data', async data => {
|
|
data = data.trim().toLowerCase();
|
|
if (data !== 'r' && data !== 'rs' && data !== 'u') {
|
|
return;
|
|
}
|
|
|
|
// Cancel the debouncer, it might rerun specific tests whereas *all* tests
|
|
// need to be rerun
|
|
this.debouncer.cancel();
|
|
await this.busy;
|
|
// Cancel the debouncer again, it might have restarted while waiting for
|
|
// the busy promise to fulfil
|
|
this.debouncer.cancel();
|
|
this.clearLogOnNextRun = false;
|
|
if (data === 'u') {
|
|
this.updatePreviousSnapshots();
|
|
} else {
|
|
this.rerunAll();
|
|
}
|
|
});
|
|
}
|
|
|
|
rerunAll() {
|
|
this.dirtyStates = {};
|
|
this.run();
|
|
}
|
|
|
|
updatePreviousSnapshots() {
|
|
this.dirtyStates = {};
|
|
this.run(this.previousFiles, true);
|
|
}
|
|
|
|
runAfterChanges() {
|
|
const {dirtyStates} = this;
|
|
this.dirtyStates = {};
|
|
|
|
let dirtyPaths = Object.keys(dirtyStates).filter(path => {
|
|
if (this.touchedFiles.has(path)) {
|
|
debug('Ignoring known touched file %s', path);
|
|
this.touchedFiles.delete(path);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
for (const {main} of this.providers) {
|
|
dirtyPaths = dirtyPaths.filter(path => {
|
|
if (main.ignoreChange(path)) {
|
|
debug('Ignoring changed file %s', path);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const dirtyHelpersAndSources = [];
|
|
const dirtyTests = [];
|
|
for (const filePath of dirtyPaths) {
|
|
const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs);
|
|
if (!isIgnoredByWatcher) {
|
|
if (isTest) {
|
|
dirtyTests.push(filePath);
|
|
} else {
|
|
dirtyHelpersAndSources.push(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink');
|
|
const unlinkedTests = diff(dirtyTests, addedOrChangedTests);
|
|
|
|
this.cleanUnlinkedTests(unlinkedTests);
|
|
|
|
// No need to rerun tests if the only change is that tests were deleted
|
|
if (unlinkedTests.length === dirtyPaths.length) {
|
|
return;
|
|
}
|
|
|
|
if (dirtyHelpersAndSources.length === 0) {
|
|
// Run any new or changed tests
|
|
this.run(addedOrChangedTests);
|
|
return;
|
|
}
|
|
|
|
// Try to find tests that depend on the changed source files
|
|
const testsByHelpersOrSource = dirtyHelpersAndSources.map(path => {
|
|
return this.testDependencies.filter(dep => dep.contains(path)).map(dep => {
|
|
debug('%s is a dependency of %s', path, dep.file);
|
|
return dep.file;
|
|
});
|
|
}, this).filter(tests => tests.length > 0);
|
|
|
|
// Rerun all tests if source files were changed that could not be traced to
|
|
// specific tests
|
|
if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) {
|
|
debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources);
|
|
debug('Rerunning all tests');
|
|
this.run();
|
|
return;
|
|
}
|
|
|
|
// Run all affected tests
|
|
this.run([...new Set(addedOrChangedTests.concat(flatten(testsByHelpersOrSource)))]);
|
|
}
|
|
}
|
|
|
|
module.exports = Watcher;
|