Skip to content
This repository was archived by the owner on Jun 1, 2023. It is now read-only.

Commit a021a9f

Browse files
committed
Add abstractions for end-to-end tests.
1 parent 76175f5 commit a021a9f

File tree

4 files changed

+385
-0
lines changed

4 files changed

+385
-0
lines changed

Package.swift

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ let package = Package(
7171
name: "EndToEndTests",
7272
dependencies: [
7373
.target(name: "swift-doc"),
74+
.product(name: "Markup", package: "Markup"),
7475
]
7576
),
7677
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import XCTest
2+
import Foundation
3+
4+
/// A class that provides abstractions to write tests for the `generate` subcommand.
5+
///
6+
/// Create a subclass of this class to write test cases for the `generate` subcommand. It provides an API to create
7+
/// source files which should be included in the sources. Then you can generate the documentation. If there's an error
8+
/// while generating the documentation for any of the formats, the test automatically fails. Additionally, it provides
9+
/// APIs to assert validations on the generated documentation.
10+
///
11+
/// ``` swift
12+
/// class TestVisibility: GenerateTestCase {
13+
/// func testClassesVisibility() {
14+
/// sourceFile("Example.swift") {
15+
/// #"""
16+
/// public class PublicClass {}
17+
///
18+
/// class InternalClass {}
19+
///
20+
/// private class PrivateClass {}
21+
/// """#
22+
/// }
23+
///
24+
/// generate(minimumAccessLevel: .internal)
25+
///
26+
/// XCTAssertDocumentationContains(.class("PublicClass"))
27+
/// XCTAssertDocumentationContains(.class("InternalClass"))
28+
/// XCTAssertDocumentationNotContains(.class("PrivateClass"))
29+
/// }
30+
/// }
31+
/// ```
32+
///
33+
/// The tests are end-to-end tests. They use the command-line tool to build the documentation and run the assertions by
34+
/// reading and understanding the created output of the documentation.
35+
class GenerateTestCase: XCTestCase {
36+
private var sourcesDirectory: URL?
37+
38+
private var outputs: [GeneratedDocumentation] = []
39+
40+
/// The output formats which should be generated for this test case. You can set a new value in `setUp()` if a test
41+
/// should only generate specific formats.
42+
var testedOutputFormats: [GeneratedDocumentation.Type] = []
43+
44+
override func setUpWithError() throws {
45+
try super.setUpWithError()
46+
47+
sourcesDirectory = try createTemporaryDirectory()
48+
49+
testedOutputFormats = [GeneratedHTMLDocumentation.self, GeneratedCommonMarkDocumentation.self]
50+
}
51+
52+
override func tearDown() {
53+
super.tearDown()
54+
55+
if let sourcesDirectory = self.sourcesDirectory {
56+
try? FileManager.default.removeItem(at: sourcesDirectory)
57+
}
58+
for output in outputs {
59+
try? FileManager.default.removeItem(at: output.directory)
60+
}
61+
}
62+
63+
func sourceFile(_ fileName: String, contents: () -> String, file: StaticString = #filePath, line: UInt = #line) {
64+
guard let sourcesDirectory = self.sourcesDirectory else {
65+
return assertionFailure()
66+
}
67+
do {
68+
try contents().write(to: sourcesDirectory.appendingPathComponent(fileName), atomically: true, encoding: .utf8)
69+
}
70+
catch let error {
71+
XCTFail("Could not create source file '\(fileName)' (\(error))", file: file, line: line)
72+
}
73+
}
74+
75+
func generate(minimumAccessLevel: MinimumAccessLevel, file: StaticString = #filePath, line: UInt = #line) {
76+
for format in testedOutputFormats {
77+
do {
78+
let outputDirectory = try createTemporaryDirectory()
79+
try Process.run(command: swiftDocCommand,
80+
arguments: [
81+
"generate",
82+
"--module-name", "SwiftDoc",
83+
"--format", format.outputFormat,
84+
"--output", outputDirectory.path,
85+
"--minimum-access-level", minimumAccessLevel.rawValue,
86+
sourcesDirectory!.path
87+
]) { result in
88+
if result.terminationStatus != EXIT_SUCCESS {
89+
XCTFail("Generating documentation failed for format \(format.outputFormat)", file: file, line: line)
90+
}
91+
}
92+
93+
outputs.append(format.init(directory: outputDirectory))
94+
}
95+
catch let error {
96+
XCTFail("Could not generate documentation format \(format.outputFormat) (\(error))", file: file, line: line)
97+
}
98+
}
99+
}
100+
}
101+
102+
103+
extension GenerateTestCase {
104+
func XCTAssertDocumentationContains(_ symbolType: SymbolType, file: StaticString = #filePath, line: UInt = #line) {
105+
for output in outputs {
106+
if output.symbol(symbolType) == nil {
107+
XCTFail("Output \(type(of: output).outputFormat) is missing \(symbolType)", file: file, line: line)
108+
}
109+
}
110+
}
111+
112+
func XCTAssertDocumentationNotContains(_ symbolType: SymbolType, file: StaticString = #filePath, line: UInt = #line) {
113+
for output in outputs {
114+
if output.symbol(symbolType) != nil {
115+
XCTFail("Output \(type(of: output).outputFormat) contains \(symbolType) although it should be omitted", file: file, line: line)
116+
}
117+
}
118+
}
119+
120+
enum SymbolType: CustomStringConvertible {
121+
case `class`(String)
122+
case `struct`(String)
123+
case `enum`(String)
124+
case `typealias`(String)
125+
case `protocol`(String)
126+
case function(String)
127+
case variable(String)
128+
case `extension`(String)
129+
130+
var description: String {
131+
switch self {
132+
case .class(let name):
133+
return "class '\(name)'"
134+
case .struct(let name):
135+
return "struct '\(name)'"
136+
case .enum(let name):
137+
return "enum '\(name)'"
138+
case .typealias(let name):
139+
return "typealias '\(name)'"
140+
case .protocol(let name):
141+
return "protocol '\(name)'"
142+
case .function(let name):
143+
return "func '\(name)'"
144+
case .variable(let name):
145+
return "variable '\(name)'"
146+
case .extension(let name):
147+
return "extension '\(name)'"
148+
}
149+
}
150+
}
151+
}
152+
153+
154+
extension GenerateTestCase {
155+
156+
enum MinimumAccessLevel: String {
157+
case `public`, `internal`, `private`
158+
}
159+
}
160+
161+
162+
163+
private func createTemporaryDirectory() throws -> URL {
164+
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
165+
try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true)
166+
167+
return temporaryDirectoryURL
168+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import Foundation
2+
import HTML
3+
import CommonMark
4+
5+
/// A protocol which needs to be implemented by the different documentation generators. It provides an API to operate
6+
/// on the generated documentation.
7+
protocol GeneratedDocumentation {
8+
9+
/// The name of the output format. This needs to be the name name like the value passed to swift-doc's `format` option.
10+
static var outputFormat: String { get }
11+
12+
init(directory: URL)
13+
14+
var directory: URL { get }
15+
16+
func symbol(_ symbolType: GenerateTestCase.SymbolType) -> Page?
17+
}
18+
19+
protocol Page {
20+
var type: String? { get }
21+
22+
var name: String? { get }
23+
}
24+
25+
26+
27+
struct GeneratedHTMLDocumentation: GeneratedDocumentation {
28+
29+
static let outputFormat = "html"
30+
31+
let directory: URL
32+
33+
func symbol(_ symbolType: GenerateTestCase.SymbolType) -> Page? {
34+
switch symbolType {
35+
case .class(let name):
36+
return page(for: name, ofType: "Class")
37+
case .typealias(let name):
38+
return page(for: name, ofType: "Typealias")
39+
case .struct(let name):
40+
return page(for: name, ofType: "Structure")
41+
case .enum(let name):
42+
return page(for: name, ofType: "Enumeration")
43+
case .protocol(let name):
44+
return page(for: name, ofType: "Protocol")
45+
case .function(let name):
46+
return page(for: name, ofType: "Function")
47+
case .variable(let name):
48+
return page(for: name, ofType: "Variable")
49+
case .extension(let name):
50+
return page(for: name, ofType: "Extensions on")
51+
}
52+
}
53+
54+
private func page(for symbolName: String, ofType type: String) -> Page? {
55+
guard let page = page(named: symbolName) else { return nil }
56+
guard page.type == type else { return nil }
57+
58+
return page
59+
}
60+
61+
private func page(named name: String) -> HtmlPage? {
62+
let fileUrl = directory.appendingPathComponent(fileName(forSymbol: name)).appendingPathComponent("index.html")
63+
guard
64+
FileManager.default.isReadableFile(atPath: fileUrl.path),
65+
let contents = try? String(contentsOf: fileUrl),
66+
let document = try? HTML.Document(string: contents)
67+
else { return nil }
68+
69+
return HtmlPage(document: document)
70+
}
71+
72+
private func fileName(forSymbol symbolName: String) -> String {
73+
symbolName
74+
.replacingOccurrences(of: ".", with: "_")
75+
.replacingOccurrences(of: " ", with: "-")
76+
}
77+
78+
private struct HtmlPage: Page {
79+
let document: HTML.Document
80+
81+
var type: String? {
82+
let results = document.search(xpath: "//h1/small")
83+
assert(results.count == 1)
84+
return results.first?.content
85+
}
86+
87+
var name: String? {
88+
let results = document.search(xpath: "//h1/code")
89+
assert(results.count == 1)
90+
return results.first?.content
91+
}
92+
}
93+
}
94+
95+
96+
struct GeneratedCommonMarkDocumentation: GeneratedDocumentation {
97+
98+
static let outputFormat = "commonmark"
99+
100+
let directory: URL
101+
102+
func symbol(_ symbolType: GenerateTestCase.SymbolType) -> Page? {
103+
switch symbolType {
104+
case .class(let name):
105+
return page(for: name, ofType: "class")
106+
case .typealias(let name):
107+
return page(for: name, ofType: "typealias")
108+
case .struct(let name):
109+
return page(for: name, ofType: "struct")
110+
case .enum(let name):
111+
return page(for: name, ofType: "enum")
112+
case .protocol(let name):
113+
return page(for: name, ofType: "protocol")
114+
case .function(let name):
115+
return page(for: name, ofType: "func")
116+
case .variable(let name):
117+
return page(for: name, ofType: "var") ?? page(for: name, ofType: "let")
118+
case .extension(let name):
119+
return page(for: name, ofType: "extension")
120+
}
121+
}
122+
123+
private func page(for symbolName: String, ofType type: String) -> Page? {
124+
guard let page = page(named: symbolName) else { return nil }
125+
guard page.type == type else { return nil }
126+
127+
return page
128+
}
129+
130+
private func page(named name: String) -> CommonMarkPage? {
131+
let fileUrl = directory.appendingPathComponent("\(name).md")
132+
guard
133+
FileManager.default.isReadableFile(atPath: fileUrl.path),
134+
let contents = try? String(contentsOf: fileUrl),
135+
let document = try? CommonMark.Document(contents)
136+
else { return nil }
137+
138+
return CommonMarkPage(document: document)
139+
}
140+
141+
private func fileName(forSymbol symbolName: String) -> String {
142+
symbolName
143+
.replacingOccurrences(of: ".", with: "_")
144+
.replacingOccurrences(of: " ", with: "-")
145+
}
146+
147+
private struct CommonMarkPage: Page {
148+
let document: CommonMark.Document
149+
150+
private var headingElement: Heading? {
151+
document.children.first(where: { ($0 as? Heading)?.level == 1 }) as? Heading
152+
}
153+
154+
var type: String? {
155+
// Our CommonMark pages don't give a hint of the actual type of a documentation page. That's why we extract
156+
// it via a regex out of the declaration. Not very nice, but works for now.
157+
guard
158+
let name = self.name,
159+
let code = document.children.first(where: { $0 is CodeBlock}) as? CodeBlock,
160+
let codeContents = code.literal,
161+
let extractionRegex = try? NSRegularExpression(pattern: "([a-z]+) \(name)")
162+
else { return nil }
163+
164+
guard
165+
let match = extractionRegex.firstMatch(in: codeContents, range: NSRange(location: 0, length: codeContents.utf16.count)),
166+
match.numberOfRanges > 0,
167+
let range = Range(match.range(at: 1), in: codeContents)
168+
else { return nil }
169+
170+
return String(codeContents[range])
171+
}
172+
173+
var name: String? {
174+
headingElement?.children.compactMap { ($0 as? Literal)?.literal }.joined()
175+
}
176+
}
177+
}
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import XCTest
2+
3+
class TestVisibility: GenerateTestCase {
4+
func testClassesVisibility() {
5+
sourceFile("Example.swift") {
6+
#"""
7+
public class PublicClass {}
8+
9+
class InternalClass {}
10+
11+
private class PrivateClass {}
12+
"""#
13+
}
14+
15+
generate(minimumAccessLevel: .internal)
16+
17+
XCTAssertDocumentationContains(.class("PublicClass"))
18+
XCTAssertDocumentationContains(.class("InternalClass"))
19+
XCTAssertDocumentationNotContains(.class("PrivateClass"))
20+
}
21+
22+
/// This example fails (because the tests are wrong, not because of a bug in `swift-doc`).
23+
func testFailingExample() {
24+
sourceFile("Example.swift") {
25+
#"""
26+
public class PublicClass {}
27+
28+
public class AnotherPublicClass {}
29+
30+
class InternalClass {}
31+
"""#
32+
}
33+
34+
generate(minimumAccessLevel: .public)
35+
36+
XCTAssertDocumentationContains(.class("PublicClass"))
37+
XCTAssertDocumentationContains(.class("InternalClass"))
38+
}
39+
}

0 commit comments

Comments
 (0)