Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings
This repository was archived by the owner on Jun 1, 2023. It is now read-only.

Commit a021a9f

Browse files
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‎

Lines changed: 1 addition & 0 deletions
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
]
Lines changed: 168 additions & 0 deletions
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+
}
Lines changed: 177 additions & 0 deletions
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+
}
Lines changed: 39 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /