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

Commit 256bd8b

Browse files
committed
Add abstractions for end-to-end tests.
1 parent 9d85789 commit 256bd8b

File tree

4 files changed

+396
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)