What you should know before reading:
git commandsnodejsAs a project iterates, new features are added and old ones are rewritten, so the E2E suite keeps growing. But a given change never touches all of it. So: can we run only the E2E specs that this commit affects?
I split it into four sub-problems:
commit and the commit the current branch was created from — the branch normally starts from the latest master, and the E2E on master has, in most cases, already been verified.commitID is marked inside the commit message.git through nodejs' child_process module and extract what we need with regular expressions.cypress through child_process as well, but pipe the output through a Stream so it appears in real time.typescriptconst getBranchFirstCommitID = () => { return new Promise<string>((res, rej) => { // compare with master exec('git cherry -v master', (err, data) => { if (err) rej(err); const dataArr = data.split('\n') as string[]; const commitContent = dataArr[0].split(' '); res(commitContent[1]); }); }); };
typescriptconst getCurrentCommit = () => { return new Promise<string>((res, rej) => exec(`git log -1`, (err, stdout, stderr) => { if (err) { rej(stderr); } res(stdout); }) ); }; const getCompareCommitID = async () => { const commitContent = await getCurrentCommit(); const regx = /\[Compare: (.+?)\]/; if (!regx.test(commitContent)) { return await getBranchFirstCommitID(); } const resultArray = commitContent.match(regx) as RegExpMatchArray; return resultArray[1]; };
If getCompareCommitID returns 'ALL', the whole E2E suite runs.
The convention here: every E2E file path contains the string cypress.
typescriptconst getDiffFlies = (baseCommitID: string, commitID: string) => { return new Promise<string>((res, rej) => exec(`git diff --name-only ${baseCommitID} ${commitID}`, (err, stdout, stderr) => { if (err) { rej(stderr); } res(stdout); }) ); }; const getCommitID = (index: number) => { return new Promise<string>((res, rej) => exec(`git show HEAD~${index} --pretty=format:"%h" --no-patch`, (err, stdout) => { if (err) { rej(err); } res(stdout); }) ); }; // Some project structures hold several subsystems, so we match the current one const getProjectDiffFiles = async () => { try { const compareCommitID = await getCompareCommitID(); const currentCommitID = await getCommitID(0); if (compareCommitID === 'ALL') { // it will run all tests return []; } const diffFiles = (await getDiffFlies(currentCommitID, compareCommitID)).split('\n'); const currentProjectDiffFile = diffFiles .filter((file) => { const {dir} = parse(file); const currentDirArr = __dirname.split(sep); const currentDir = currentDirArr[currentDirArr.length - 2] as string; if (dir.includes(currentDir) && dir.includes('cypress')) { return true; } return false; }) .map((file) => { return join(...file.split(sep).slice(2)) as string; }); return currentProjectDiffFile; } catch (error) { console.log(error); // eslint-disable-line throw new Error('get commit id is fail'); } };
child_process offers several ways to run a command. exec is the usual choice, but it only emits output once the command has finished, and CI steps usually have a response-time budget — a long E2E run through exec can time out.
So I use spawn, which streams the log while the command runs.
typescriptconst testRunner = (specPath: string[]) => { console.log('It will run test', specPath.join(',')); // eslint-disable-line const runner = spawn('cypress', ['run', '--headless', '--spec', specPath.join(',')]); runner.stderr.on('data', (data) => { if (data) console.log(data.toString()); // eslint-disable-line }); runner.stdout.on('data', (data) => { console.log(data.toString()); // eslint-disable-line }); }; const main = async () => { const testPath = await getProjectDiffFiles(); testRunner(testPath); };
That is incremental E2E testing.