This tool is based on the “iOS coverage detection principle and incremental code test coverage tool implementation” a practice (intrusion deletion), this article pays more attention to the implementation details, the principle part can refer to the original text.
The final effect is to modify the push script:
echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
rate=$(cd $(dirname $PWD)/RCodeCoverage/ && python coverage.py $proejctName | grep "RCoverageRate:" | sed 's/RCoverageRate:\([0-9-]*\).*/\1/g')
if [ $rate -eq1];then
echo 'No coverage information, skip... '
elif[$(echo "$rate < 80.0" | bc) = 1 ];then
echo 'Code coverage is'$rate', not meeting demand '
echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
exit 1
else
echo 'Code coverage is'$rate', about to upload code '
fi
echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
Copy the code
Next to each commit-msg is the code coverage information for the current commit:
Making links: xuezhulian/Coverage
The following describes the implementation of the tool in terms of increments and coverage.
The incremental
The incremental results are obtained according to Git.
Git status gets how many commits are currently required.
aheadCommitRe = re.compile('Your branch is ahead of \'. * \' by ([0-9]*) commit')
aheadCommitNum = None
for line in os.popen('git status').xreadlines():
result = aheadCommitRe.findall(line)
if result:
aheadCommitNum = result[0]
break
Copy the code
Git rev-parse can get the commit id and git log diff if there is an uncommitted git commit.
if aheadCommitNum:
for i in range(0,int(aheadCommitNum)):
commitid = os.popen('git rev-parse HEAD~%s'%i).read().strip()
pushdiff.commitdiffs.append(CommitDiff(commitid))
stashName = 'git-diff-stash'
os.system('git stash save \'%s\'; git log -%s -v -U0> "%s/diff"'%(stashName,aheadCommitNum,SCRIPT_DIR))
if string.find(os.popen('git stash list').readline(),stashName) ! = -1: os.system('git stash pop')
else:
#prevent change last commit msg without new commit
print 'No new commit'
exit(1)
Copy the code
Classes and rows modified based on diff matches are only considered newly added, not deleted.
commitidRe = re.compile('commit (\w{40})')
classRe = re.compile('\+\+\+ b(.*)')
changedLineRe = re.compile('\+(\d+),*(\d*) \@\@')
commitdiff = None
classdiff = None
for line in diffFile.xreadlines():
#match commit id
commmidResult = commitidRe.findall(line)
if commmidResult:
commitid = commmidResult[0].strip()
if pushdiff.contains_commitdiff(commitid):
commitdiff = pushdiff.commitdiff(commitid)
else:
#TODO filter merge
commitdiff = None
if not commitdiff:
continue
#match class name
classResult = classRe.findall(line)
if classResult:
classname = classResult[0].strip().split('/')[-1]
classdiff = commitdiff.classdiff(classname)
if not classdiff:
continue
#match lines
lineResult = changedLineRe.findall(line)
if lineResult:
(startIndex,lines) = lineResult[0]
# add nothing
if cmp(lines,'0') == 0:
pass
#add startIndex line
elif cmp(lines,' ') == 0:
classdiff.changedlines.add(int(startIndex))
#add lines from startindex
else:
for num in range(0,int(lines)):
classdiff.changedlines.add(int(startIndex) + num)
Copy the code
Now you know how many commits need to be committed for each push, which files are modified for each commit, and the corresponding rows. You get the incremental part.
coverage
Coverage information was obtained by analyzing gCNO and GCDA files using lCOV tools. These two files are described in detail in the original text and will not be repeated here.
The first thing we need to do is determine the path of GCNO and GCDA. Xcode ->build Phases ->run script Add script exportenV. sh Export environment variables.
//exportenv.sh
scripts="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" export | egrep '( BUILT_PRODUCTS_DIR)|(CURRENT_ARCH)|(OBJECT_FILE_DIR_normal)|(SRCROOT)|(OBJROOT)|(TARGET_DEVICE_IDENTIFIER)|(TARGET_DEVIC E_MODEL)|(PRODUCT_BUNDLE_IDENTIFIER)' > "${scripts}/env.sh"
Copy the code
- SCRIPT_DIR :/Users/yuencong/Desktop/coverage/RCodeCoverage
- SRCROOT :/Users/yuencong/Desktop/coverage/Example
- OBJROOT :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex
- OBJECT_FILE_DIR_normal:/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/In termediates.noindex/Example.build/Debug-iphonesimulator/Example.build/Objects-normal
- PRODUCT_BUNDLE_ID :coverage.Example
- TARGET_DEVICE_ID :E87EED9C-5536-486A-BAB4-F9F7C6ED6287
- BUILT_PRODUCTS_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Products/Debug-iphonesim ulator
- GCDA_DIR :/Users/yuencong/Library/Developer/CoreSimulator/Devices/E87EED9C-5536-486A-BAB4-F9F7C6ED6287/data/Containers/Data/Appli cation//C4B45B67-5138-4636-8A8F-D042A06E7229/Documents/gcda_files
- GCNO_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex/Ex ample.build/Debug-iphonesimulator/Example.build/Objects-normal/x86_64
The path to GCNO_DIR is OBJECT_FILE_DIR_normal+arch. We only collect information in the simulator, so arch is x86_64. At present, the overall architecture of our APP is modular, and each module corresponds to a target, which is managed by Cocoapods. The normal path for each target is different. If we want a gcno file in the pod directory, we take the local pod repository path as an argument and change the normal path based on the podSpec file.
def handlepoddir():
global OBJECT_FILE_DIR_normal
global SRCROOT
#default main repo
iflen(sys.argv) ! = 2:return
#filter coverage dir
if sys.argv[1] == SCRIPT_DIR.split('/') [1] :return
repodir = sys.argv[1]
SRCROOT = SCRIPT_DIR.replace(SCRIPT_DIR.split('/')[-1],repodir.strip())
os.environ['SRCROOT'] = SRCROOT
podspec = None
for podspecPath in os.popen('find %s -name \"*.podspec\" -maxdepth 1' %SRCROOT).xreadlines():
podspec = podspecPath.strip()
break
if podspec and os.path.exists(podspec):
podspecFile = open(podspec,'r')
snameRe = re.compile('s.name\s*=\s*[\"|\']([\w-]*)[\"|\']') for line in podspecFile.xreadlines(): snameResult = snameRe.findall(line) if snameResult: break sname = snameResult[0].strip() OBJECT_FILE_DIR_normal = OBJROOT + '/Pods.build/%s/%s.build/Objects-normal'%(BUILT_PRODUCTS_DIR,sname) if not os.path.exists(OBJECT_FILE_DIR_normal): print 'Error:\nOBJECT_FILE_DIR_normal:%s invalid path'%OBJECT_FILE_DIR_normal exit(1) os.environ['OBJECT_FILE_DIR_normal'] = OBJECT_FILE_DIR_normalCopy the code
Gcda files are stored in the emulator. The path to the current emulator can be identified with TARGET_DEVICE_ID. Under the folder corresponding to each APP in this path, there is a PList file that records the bundleID of the APP, and the APP is matched according to this BundleID. Then spell out the path to the GCDA file.
def gcdadir():
GCDA_DIR = None
USER_ROOT = os.environ['HOME'].strip()
APPLICATIONS_DIR = '%s/Library/Developer/CoreSimulator/Devices/%s/data/Containers/Data/Application/' %(USER_ROOT,TARGET_DEVICE_ID)
if not os.path.exists(APPLICATIONS_DIR):
print 'Error:\nAPPLICATIONS_DIR:%s invaild file path'%APPLICATIONS_DIR
exit(1)
APPLICATION_ID_RE = re.compile('\w{8}-\w{4}-\w{4}-\w{4}-\w{12}')
for file in os.listdir(APPLICATIONS_DIR):
if not APPLICATION_ID_RE.findall(file):
continue
plistPath = APPLICATIONS_DIR + file.strip() + '/.com.apple.mobile_container_manager.metadata.plist'
if not os.path.exists(plistPath):
continue
plistFile = open(plistPath,'r')
plistContent = plistFile.read()
plistFile.close()
ifstring.find(plistContent,PRODUCT_BUNDLE_ID) ! = -1: GCDA_DIR = APPLICATIONS_DIR + file +'/Documents/gcda_files'
break
if not GCDA_DIR:
print 'GCDA DIR invalid,please check xcode config'
exit(1)
if not os.path.exists(GCDA_DIR):
print 'GCDA_DIR:%s path invalid'%GCDA_DIR
exit(1)
os.environ['GCDA_DIR'] = GCDA_DIR
print("GCDA_DIR :"+GCDA_DIR)
Copy the code
After determining the gCNO and GCDA directory paths. Copy the gCNO and gCda files corresponding to the modified files obtained by git analysis to the source folder in the script directory.
sourcespath = SCRIPT_DIR + '/sources'
if os.path.isdir(sourcespath):
shutil.rmtree(sourcespath)
os.makedirs(sourcespath)
for filename in changedfiles:
gcdafile = GCDA_DIR+'/'+filename+'.gcda'
if os.path.exists(gcdafile):
shutil.copy(gcdafile,sourcespath)
else:
print 'Error:GCDA file not found for %s' %gcdafile
exit(1)
gcnofile = GCNO_DIR + '/'+filename + '.gcno'
if not os.path.exists(gcnofile):
gcnofile = gcnofile.replace(OBJECT_FILE_DIR_normal,OBJECT_FILE_DIR_main)
if not os.path.exists(gcnofile):
print 'Error:GCNO file not found for %s' %gcnofile
exit(1)
shutil.copy(gcnofile,sourcespath)
Copy the code
Next, we used the LCOV tool, which allows us to visualize code coverage and easily see which lines of files are not executed in the event of substandard coverage. The lcov command creates an intermediate file. Info based on gcno and gcda. The.info file records the functions contained in the file, the functions executed, the lines contained in the file, and the lines executed.
This is the key field we use to analyze coverage.
- SF:
<absolute path to the source file>
- FN:
<line number of function start>,<function name>
- FNDA:
<execution count>,<function name>
- FNF:
<number of functions found>
- FNH:
<number of function hit>
- DA:
<line number>,<execution count>[,<checksum>]
- LH:
<number of lines with a non-zero execution count>
- LF:
<number of instrumented lines>
The process of generating.info
os.system(lcov + '-c -b %s -d %s -o \"Coverage.info\"' %(SCRIPT_DIR,sourcespath))
if not os.path.exists(SCRIPT_DIR+'/Coverage.info') :print 'Error:failed to generate Coverage.info'
exit(1)
if os.path.getsize(SCRIPT_DIR+'/Coverage.info') = = 0:print 'Error:Coveragte.info size is 0'
os.remove(SCRIPT_DIR+'/Coverage.info')
exit(1)
Copy the code
Next, modify the.info file with git information to implement incremental changes. First, delete the classes that Git does not record changes.
for line in os.popen(lcov + ' -l Coverage.info').xreadlines():
result = headerFileRe.findall(line)
if result and not result[0].strip() in changedClasses:
filterClasses.add(result[0].strip())
iflen(filterClasses) ! = 0: os.system(lcov +'--remove Coverage.info *%s* -o Coverage.info' %'* *'.join(filterClasses))
Copy the code
Delete lines that Git does not record changes to
for line in lines:
#match file name
if line.startswith('SF:'):
infoFilew.write('end_of_record\n')
classname = line.strip().split('/')[-1].strip()
changedlines = pushdiff.changedLinesForClass(classname)
if len(changedlines) == 0:
lcovclassinfo = None
else:
lcovclassinfo = lcovInfo.lcovclassinfo(classname)
infoFilew.write(line)
if not lcovclassinfo:
continue
#match lines
DAResult = DARe.findall(line)
if DAResult:
(startIndex,count) = DAResult[0]
if not int(startIndex) in changedlines:
continue
infoFilew.write(line)
if int(count) == 0:
lcovclassinfo.nohitlines.add(int(startIndex))
else:
lcovclassinfo.hitlines.add(int(startIndex))
continue
Copy the code
Now, the.info file only records the coverage information of the classes and rows that Git has changed, while the LcovInfo data structure holds the relevant information, which will be used later in analyzing the coverage of each commit. Generate visual coverage information from the ·genhtml““ command. The result is saved in the coverage path in the script directory, and you can open index.html to see incremental coverage.
if not os.path.getsize('Coverage.info') == 0:
os.system(genhtml + 'Coverage.info -o Coverage')
os.remove('Coverage.info')
Copy the code
Index.html example, secondary pages will have more details:
The last step is to modify commit-msg with git rebase to look like the opening.
for i in reversed(range(0,len(pushdiff.commitdiffs))):
commitdiff = pushdiff.commitdiffs[i]
if not commitdiff:
os.system('git rebase --abort')
continue
coveragerate = commitdiff.coveragerate()
lines = os.popen('git log -1 --pretty=%B').readlines()
commitMsg = lines[0].strip()
commitMsgRe = re.compile('coverage: ([0-9\.-]*)')
result = commitMsgRe.findall(commitMsg)
if result:
if result[0].strip() == '%.2f'%coveragerate:
os.system('git rebase --continue')
continue
commitMsg = commitMsg.replace('coverage: %s'%result[0],'coverage: %.2f'%coveragerate)
else:
commitMsg = commitMsg + ' coverage: %.2f%%'%coveragerate
lines[0] = commitMsg+'\n'
stashName = 'commit-amend-stash'
os.system('git stash save \'%s\'; git commit --amend -m \'%s \' --no-edit; ' %(stashName,' '.join(lines)))
if string.find(os.popen('cd %s; git stash list'%SRCROOT).readline(),stashName) ! = -1: os.system('git stash pop')
os.system('git rebase --continue; ')
Copy the code