const fs = require('fs'); const xml2js = require('xml2js'); const parser = new xml2js.Parser(); // Grab the command options const args = process.argv.slice(2); const numGroups = +args[args.indexOf('--num-groups') + 1]; const runningAvgLength = +args[args.indexOf('--running-avg-length') + 1]; // Load the junit results from the various groups on this build const currentBuildResults = []; for (let i = 0; i < numGroups; i++) { try { const data = fs.readFileSync(`../../tmp/results/junit/${i}.xml`); parser.parseString(data, (err, { testsuites: { testsuite } }) => { (testsuite || []) .map(testsuite => testsuite.testcase) .forEach((testcase = []) => testcase.forEach(({ $: { name, time } }) => { currentBuildResults.push({ name, time: +time }) }) ); }); } catch(e) { console.log(`Could not find '/tmp/results/junit/${i}.xml'`); } } // Try to load previous test balance let previousBuildResults = []; try { previousBuildResults = JSON.parse(fs.readFileSync(`../../tmp/test-groups/data.json`)); } catch (err) { console.log([ 'No historical data found! Perhaps the test groups cache key format has ', 'been changed since the last successful build.' ].join('\n')); } // Add new results and limit the record to the specified number for the running // average const allBuildResults = [currentBuildResults, ...previousBuildResults].slice(0, runningAvgLength); let averageResults = []; // We assume that all the tests that were run this time will be the ones run // next time, so we only calculate the averages for those. This is will ignore // any tests that were not run most recently but which were run in previous // builds currentBuildResults.forEach(({ name }) => { const times = []; // Check each build result to see if the test was run allBuildResults.forEach(buildResults => { const { time } = buildResults.find(test => test.name === name) || {}; // It's possible this particular test wasn't run in this build if (time !== undefined) { times.push(!isNaN(time) ? time : 0); } }) const averageTime = Math.floor(times.reduce((p, x) => p + x, 0) / times.length * 1000) / 1000; averageResults.push({ name, time: averageTime }); }); // This is a naïve but simple allocation which just take the largest remaining // test and puts it in the group with the lowest total const groupTestNames = Array(numGroups); const totals = Array(numGroups).fill(0); const getMin = () => totals.reduce((min, total, i) => total < totals[min] ? i : min, 0); averageResults.sort((a, b) => b.time - a.time); averageResults.forEach(({ name, time }) => { const min = getMin(); groupTestNames[min] ? groupTestNames[min].push(name) : groupTestNames[min] = [name]; totals[min] += time; }); // Create the (long) regular expressions for each group groupTestNames.forEach((names, i) => { const escapedNames = names.map( name => name.replace(/['"-\/\\^$*+?.()|[\]{}]/g, '\\$&') ); let testMatcher = `^${escapedNames.join('$|^')}$`; // Put any new tests on the first group - in case the number of groups happens // to reduce we can still catch these tests if (i === 0) { // Create the (even longer) regular expression to catch any other tests that // might get added to the suite or which may have been turned off for the // most recent build const otherTestsMatcher = groupTestNames.map(names => { const escapedNames = names.map( name => name.replace(/['"-\/\\^$*+?.()|[\]{}]/g, '\\$&') ); return `^${escapedNames.join('$|^')}$`; }).join('|'); testMatcher += `|^(?!.*(${otherTestsMatcher})).*$`; } fs.writeFileSync(`../../tmp/test-groups/${i}.txt`, testMatcher); console.log([ `Group ${i} Tests (${names.length} tests, ~${Math.round(totals[i])}s):`, ...names, ' ' ].join('\n')); }); console.log(`Total Tests Balanced: ${averageResults.length}`) // Write the results so we can use them for calculating averages in the next // build fs.writeFileSync( '../../tmp/test-groups/data.json', JSON.stringify(allBuildResults) );