Skip to content

Commit 7f57f1f

Browse files
authored
Generate a JSON file with newly added lines not covered by tests. (#8173)
* Generate a JSON file with newly added lines not covered by tests.
1 parent 2fe8a25 commit 7f57f1f

File tree

3 files changed

+231
-4
lines changed

3 files changed

+231
-4
lines changed

scripts/code_coverage_report/generate_code_coverage_report/Package.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ let package = Package(
3030
name: "UpdatedFilesCollector",
3131
targets: ["UpdatedFilesCollector"]
3232
),
33+
.executable(
34+
name: "IncrementalCoverageReportGenerator",
35+
targets: ["IncrementalCoverageReportGenerator"]
36+
),
3337
],
3438
dependencies: [
35-
// Dependencies declare other packages that this package depends on.
36-
// .package(url: /* package url */, from: "1.0.0"),
3739
.package(url: "https://github.com/apple/swift-argument-parser", from: "0.2.0"),
3840
],
3941
targets: [
40-
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
41-
// Targets can depend on other targets in this package, and on products in packages this package depends on.
4242
.target(
4343
name: "CoverageReportGenerator",
4444
dependencies: [
@@ -52,6 +52,13 @@ let package = Package(
5252
.product(name: "ArgumentParser", package: "swift-argument-parser"),
5353
]
5454
),
55+
.target(
56+
name: "IncrementalCoverageReportGenerator",
57+
dependencies: [
58+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
59+
"Utils",
60+
]
61+
),
5562
.target(
5663
name: "Utils"
5764
),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Copyright 2021 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
import Foundation
19+
import Utils
20+
21+
private enum Constants {}
22+
23+
extension Constants {
24+
static let xcresultExtension = "xcresult"
25+
// Command to get line execution counts of a file in a xcresult bundle.
26+
static let xcovCommand = "xcrun xccov view --archive --file "
27+
// The pattern is to match text "line_index: execution_counts" from the
28+
// outcome of xcresult bundle, e.g. "305 : 0".
29+
static let lineExecutionCountPattern = "[0-9]+\\s*:\\s*([0-9*]+)"
30+
// Match to the group of the lineExecutionCountPattern, i.e "([0-9*]+)".
31+
static let lineExecutionCountPatternGroup = 1
32+
// A file includes all newly added lines without tests covered.
33+
static let defaultUncoveredLineReportFileName = "uncovered_file_lines.json"
34+
}
35+
36+
/// A JSON file from git_diff_to_json.sh will be decoded to the following instance.
37+
struct FileIncrementalChanges: Codable {
38+
// Name of a file with newly added lines
39+
let file: String
40+
// Indices of newly added lines in this file
41+
let addedLines: [Int]
42+
enum CodingKeys: String, CodingKey {
43+
case file
44+
case addedLines = "added_lines"
45+
}
46+
}
47+
48+
/// `xccov` outcomes of a file from a xcresult bundle will be transfered
49+
/// to the following instance.
50+
struct LineCoverage: Codable {
51+
var fileName: String
52+
// Line execution counts for lines in this file, the indices of the array are
53+
// the (line indices - 1) of this file.
54+
var coverage: [Int?]
55+
// The source/xcresult bundle of the coverage
56+
var xcresultBundle: URL
57+
}
58+
59+
struct IncrementalCoverageReportGenerator: ParsableCommand {
60+
@Option(
61+
help: "Root path of archived files in a xcresult bundle."
62+
)
63+
var fileArchiveRootPath: String
64+
65+
@Option(help: "A dir of xcresult bundles.",
66+
transform: URL.init(fileURLWithPath:))
67+
var xcresultDir: URL
68+
69+
@Option(
70+
help: """
71+
A JSON file with changed files and added line numbers. E.g. JSON file:
72+
'[{"file": "FirebaseDatabase/Sources/Api/FIRDataSnapshot.m", "added_lines": [105,106,107,108,109]},{"file": "FirebaseDatabase/Sources/Core/Utilities/FPath.m", "added_lines": [304,305,306,307,308,309]}]'
73+
""",
74+
transform: { try JSONParser.readJSON(of: [FileIncrementalChanges].self, from: $0) }
75+
)
76+
var changedFiles: [FileIncrementalChanges]
77+
78+
@Option(
79+
help: "Uncovered line JSON file output path"
80+
)
81+
var uncoveredLineFileJson: String = Constants.defaultUncoveredLineReportFileName
82+
83+
/// This will transfer line executions report from a xcresult bundle to an array of LineCoverage.
84+
/// The output will have the file name, line execution counts and the xcresult bundle name.
85+
func createLineCoverageRecord(from coverageFile: URL, changedFile: String,
86+
lineCoverage: [LineCoverage]? = nil) -> [LineCoverage] {
87+
// The indices of the array represent the (line index - 1), while the value is the execution counts.
88+
// Unexecutable lines, e.g. comments, will be nil here.
89+
var lineExecutionCounts: [Int?] = []
90+
do {
91+
let inString = try String(contentsOf: coverageFile)
92+
let lineCoverageRegex = try! NSRegularExpression(pattern: Constants
93+
.lineExecutionCountPattern)
94+
for line in inString.components(separatedBy: "\n") {
95+
let range = NSRange(location: 0, length: line.utf16.count)
96+
if let match = lineCoverageRegex.firstMatch(in: line, options: [], range: range) {
97+
// Get the execution counts and append. Line indices are
98+
// consecutive and so the array will have counts for each line
99+
// and nil for unexecutable lines, e.g. comments.
100+
let nsRange = match.range(at: Constants.lineExecutionCountPatternGroup)
101+
if let range = Range(nsRange, in: line) {
102+
lineExecutionCounts.append(Int(line[range]))
103+
}
104+
}
105+
}
106+
} catch {
107+
fatalError("Failed to open \(coverageFile): \(error)")
108+
}
109+
if var coverageData = lineCoverage {
110+
coverageData
111+
.append(LineCoverage(fileName: changedFile, coverage: lineExecutionCounts,
112+
xcresultBundle: coverageFile))
113+
return coverageData
114+
} else {
115+
return [LineCoverage(fileName: changedFile, coverage: lineExecutionCounts,
116+
xcresultBundle: coverageFile)]
117+
}
118+
}
119+
120+
/// This function is to get union of newly added file lines and lines execution counts, from a xcresult bundle.
121+
/// Return an array of LineCoverage, which includes uncovered line indices of a file and its xcresult bundle source.
122+
func getUncoveredFileLines(fromDiff changedFiles: [FileIncrementalChanges],
123+
xcresultFile: URL,
124+
archiveRootPath rootPath: String) -> [LineCoverage] {
125+
var uncoveredFiles: [LineCoverage] = []
126+
for change in changedFiles {
127+
let archiveFilePath = URL(string: rootPath)!.appendingPathComponent(change.file)
128+
// tempOutputFile is a temp file, with the xcresult bundle name, including line execution counts of a file
129+
let tempOutputFile = xcresultFile.deletingPathExtension()
130+
// Fetch line execution report of a file from a xcresult bundle into a temp file, which has the same name as the xcresult bundle.
131+
Shell.run(
132+
"\(Constants.xcovCommand) \(archiveFilePath.absoluteString) \(xcresultFile.path) > \(tempOutputFile.path)",
133+
displayCommand: true,
134+
displayFailureResult: false
135+
)
136+
for coverageFile in createLineCoverageRecord(
137+
from: tempOutputFile,
138+
changedFile: change.file
139+
) {
140+
var uncoveredLine: LineCoverage = LineCoverage(
141+
fileName: coverageFile.fileName,
142+
coverage: [],
143+
xcresultBundle: coverageFile.xcresultBundle
144+
)
145+
for addedLineIndex in change.addedLines {
146+
// `xccov` report will not involve unexecutable lines which are at
147+
// the end of the file. That means if the last couple lines are
148+
// comments, these lines will not be in the `coverageFile.coverage`.
149+
if addedLineIndex < coverageFile.coverage.count,
150+
let testCoverRun = coverageFile.coverage[addedLineIndex], testCoverRun == 0 {
151+
uncoveredLine.coverage.append(addedLineIndex)
152+
}
153+
}
154+
if !uncoveredLine.coverage.isEmpty { uncoveredFiles.append(uncoveredLine) }
155+
}
156+
}
157+
return uncoveredFiles
158+
}
159+
160+
func run() throws {
161+
let enumerator = FileManager.default.enumerator(atPath: xcresultDir.path)
162+
var uncoveredFiles: [LineCoverage] = []
163+
// Search xcresult bundles from xcresultDir and get union of `git diff` report and xccov output to generate a list of lineCoverage including files and their uncovered lines.
164+
while let file = enumerator?.nextObject() as? String {
165+
var isDir: ObjCBool = false
166+
let xcresultURL = xcresultDir.appendingPathComponent(file, isDirectory: true)
167+
if FileManager.default.fileExists(atPath: xcresultURL.path, isDirectory: &isDir) {
168+
if isDir.boolValue, xcresultURL.path.hasSuffix(Constants.xcresultExtension) {
169+
let uncoveredXcresult = getUncoveredFileLines(
170+
fromDiff: changedFiles,
171+
xcresultFile: xcresultURL,
172+
archiveRootPath: fileArchiveRootPath
173+
)
174+
uncoveredFiles.append(contentsOf: uncoveredXcresult)
175+
}
176+
}
177+
}
178+
// Output uncoveredFiles as a JSON file.
179+
do {
180+
let jsonData = try JSONEncoder().encode(uncoveredFiles)
181+
try String(data: jsonData, encoding: .utf8)!.write(
182+
to: URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
183+
.appendingPathComponent(uncoveredLineFileJson),
184+
atomically: true,
185+
encoding: String.Encoding.utf8
186+
)
187+
} catch {
188+
print("Uncovered lines are not able to be parsed into a JSON file.\n \(error)\n")
189+
}
190+
}
191+
}
192+
193+
IncrementalCoverageReportGenerator.main()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2021 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
19+
public enum JSONParser {
20+
// Decode an instance from a JSON file.
21+
public static func readJSON<T: Codable>(of dataStruct: T.Type, from path: String) throws -> T {
22+
let fileURL = URL(fileURLWithPath: FileManager().currentDirectoryPath)
23+
.appendingPathComponent(path)
24+
let data = try Data(contentsOf: fileURL)
25+
return try JSONDecoder().decode(dataStruct, from: data)
26+
}
27+
}

0 commit comments

Comments
 (0)