TestHelpers.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. //===----------------------------------------------------------------------===//
  2. //
  3. // This source file is part of the Swift Argument Parser open source project
  4. //
  5. // Copyright (c) 2020 Apple Inc. and the Swift project authors
  6. // Licensed under Apache License v2.0 with Runtime Library Exception
  7. //
  8. // See https://swift.org/LICENSE.txt for license information
  9. //
  10. //===----------------------------------------------------------------------===//
  11. import ArgumentParser
  12. import ArgumentParserToolInfo
  13. import XCTest
  14. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
  15. extension CollectionDifference.Change {
  16. var offset: Int {
  17. switch self {
  18. case .insert(let offset, _, _):
  19. return offset
  20. case .remove(let offset, _, _):
  21. return offset
  22. }
  23. }
  24. }
  25. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
  26. extension CollectionDifference.Change: Swift.Comparable
  27. where ChangeElement: Equatable {
  28. public static func < (lhs: Self, rhs: Self) -> Bool {
  29. guard lhs.offset == rhs.offset else {
  30. return lhs.offset < rhs.offset
  31. }
  32. switch (lhs, rhs) {
  33. case (.remove, .insert):
  34. return true
  35. case (.insert, .remove):
  36. return false
  37. default:
  38. return true
  39. }
  40. }
  41. }
  42. // extensions to the ParsableArguments protocol to facilitate XCTestExpectation support
  43. public protocol TestableParsableArguments: ParsableArguments {
  44. var didValidateExpectation: XCTestExpectation { get }
  45. }
  46. extension TestableParsableArguments {
  47. public mutating func validate() throws {
  48. didValidateExpectation.fulfill()
  49. }
  50. }
  51. // extensions to the ParsableCommand protocol to facilitate XCTestExpectation support
  52. public protocol TestableParsableCommand: ParsableCommand,
  53. TestableParsableArguments
  54. {
  55. var didRunExpectation: XCTestExpectation { get }
  56. }
  57. extension TestableParsableCommand {
  58. public mutating func run() throws {
  59. didRunExpectation.fulfill()
  60. }
  61. }
  62. extension XCTestExpectation {
  63. public convenience init(singleExpectation description: String) {
  64. self.init(description: description)
  65. expectedFulfillmentCount = 1
  66. assertForOverFulfill = true
  67. }
  68. }
  69. // swift-format-ignore: AlwaysUseLowerCamelCase
  70. public func AssertResultFailure<T, U: Error>(
  71. _ expression: @autoclosure () -> Result<T, U>,
  72. _ message: @autoclosure () -> String = "",
  73. file: StaticString = #filePath,
  74. line: UInt = #line
  75. ) {
  76. switch expression() {
  77. case .success:
  78. let msg = message()
  79. XCTFail(msg.isEmpty ? "Incorrectly succeeded" : msg, file: file, line: line)
  80. case .failure:
  81. break
  82. }
  83. }
  84. // swift-format-ignore: AlwaysUseLowerCamelCase
  85. public func AssertErrorMessage<A>(
  86. _ type: A.Type, _ arguments: [String], _ errorMessage: String,
  87. file: StaticString = #filePath, line: UInt = #line
  88. ) where A: ParsableArguments {
  89. do {
  90. _ = try A.parse(arguments)
  91. XCTFail("Parsing should have failed.", file: file, line: line)
  92. } catch {
  93. // We expect to hit this path, i.e. getting an error:
  94. XCTAssertEqual(A.message(for: error), errorMessage, file: file, line: line)
  95. }
  96. }
  97. // swift-format-ignore: AlwaysUseLowerCamelCase
  98. public func AssertFullErrorMessage<A>(
  99. _ type: A.Type, _ arguments: [String], _ errorMessage: String,
  100. file: StaticString = #filePath, line: UInt = #line
  101. ) where A: ParsableArguments {
  102. do {
  103. _ = try A.parse(arguments)
  104. XCTFail("Parsing should have failed.", file: (file), line: line)
  105. } catch {
  106. // We expect to hit this path, i.e. getting an error:
  107. XCTAssertEqual(
  108. A.fullMessage(for: error), errorMessage, file: (file), line: line)
  109. }
  110. }
  111. // swift-format-ignore: AlwaysUseLowerCamelCase
  112. public func AssertParse<A>(
  113. _ type: A.Type, _ arguments: [String], file: StaticString = #filePath,
  114. line: UInt = #line, closure: (A) throws -> Void
  115. ) where A: ParsableArguments {
  116. do {
  117. let parsed = try type.parse(arguments)
  118. try closure(parsed)
  119. } catch {
  120. let message = type.message(for: error)
  121. XCTFail("\"\(message)\" — \(error)", file: (file), line: line)
  122. }
  123. }
  124. // swift-format-ignore: AlwaysUseLowerCamelCase
  125. public func AssertParseCommand<A: ParsableCommand>(
  126. _ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String],
  127. file: StaticString = #filePath, line: UInt = #line,
  128. closure: (A) throws -> Void
  129. ) {
  130. do {
  131. let command = try rootCommand.parseAsRoot(arguments)
  132. guard let aCommand = command as? A else {
  133. XCTFail(
  134. "Command is of unexpected type: \(command)", file: (file), line: line)
  135. return
  136. }
  137. try closure(aCommand)
  138. } catch {
  139. let message = rootCommand.message(for: error)
  140. XCTFail("\"\(message)\" — \(error)", file: file, line: line)
  141. }
  142. }
  143. // swift-format-ignore: AlwaysUseLowerCamelCase
  144. public func AssertParseCommandErrorMessage<A: ParsableCommand>(
  145. _ rootCommand: ParsableCommand.Type, _ type: A.Type, _ arguments: [String],
  146. _ errorMessage: String,
  147. file: StaticString = #filePath, line: UInt = #line
  148. ) {
  149. do {
  150. let command = try rootCommand.parseAsRoot(arguments)
  151. guard (command as? A) != nil else {
  152. XCTFail(
  153. "Command is of unexpected type: \(command)", file: (file), line: line)
  154. return
  155. }
  156. XCTFail("Parsing as root should have failed.", file: file, line: line)
  157. } catch {
  158. let message = rootCommand.message(for: error)
  159. XCTAssertEqual(message, errorMessage, file: file, line: line)
  160. }
  161. }
  162. // swift-format-ignore: AlwaysUseLowerCamelCase
  163. public func AssertEqualStrings(
  164. actual: String,
  165. expected: String,
  166. file: StaticString = #filePath,
  167. line: UInt = #line
  168. ) {
  169. // Normalize line endings to '\n'.
  170. let actual =
  171. actual
  172. .replacingOccurrences(of: "\r\n", with: "\n")
  173. .replacingOccurrences(of: "\r", with: "\n")
  174. let expected =
  175. expected
  176. .replacingOccurrences(of: "\r\n", with: "\n")
  177. .replacingOccurrences(of: "\r", with: "\n")
  178. // If the input strings are not equal, create a simple diff for debugging...
  179. guard actual != expected else {
  180. // Otherwise they are equal, early exit.
  181. return
  182. }
  183. let stringComparison: String
  184. // If collectionDifference is available, use it to make a nicer error message.
  185. if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
  186. let actualLines = actual.components(separatedBy: .newlines)
  187. let expectedLines = expected.components(separatedBy: .newlines)
  188. let difference = actualLines.difference(from: expectedLines)
  189. var result = ""
  190. var insertions: [Int: String] = [:]
  191. var removals: [Int: String] = [:]
  192. for change in difference {
  193. switch change {
  194. case .insert(let offset, let element, _):
  195. insertions[offset] = element
  196. case .remove(let offset, let element, _):
  197. removals[offset] = element
  198. }
  199. }
  200. var expectedLine = 0
  201. var actualLine = 0
  202. while expectedLine < expectedLines.count || actualLine < actualLines.count {
  203. if let removal = removals[expectedLine] {
  204. result += "–\(removal)\n"
  205. expectedLine += 1
  206. } else if let insertion = insertions[actualLine] {
  207. result += "+\(insertion)\n"
  208. actualLine += 1
  209. } else {
  210. result += " \(expectedLines[expectedLine])\n"
  211. expectedLine += 1
  212. actualLine += 1
  213. }
  214. }
  215. stringComparison = result
  216. } else {
  217. stringComparison = """
  218. Expected:
  219. \(expected)
  220. Actual:
  221. \(actual)
  222. """
  223. }
  224. XCTFail(
  225. "Actual output does not match the expected output:\n\(stringComparison)",
  226. file: file,
  227. line: line)
  228. }
  229. // swift-format-ignore: AlwaysUseLowerCamelCase
  230. public func AssertHelp<T: ParsableArguments>(
  231. _ visibility: ArgumentVisibility,
  232. for _: T.Type,
  233. columns: Int? = 80,
  234. equals expected: String,
  235. file: StaticString = #filePath,
  236. line: UInt = #line
  237. ) {
  238. let flag: String
  239. let includeHidden: Bool
  240. switch visibility {
  241. case .default:
  242. flag = "--help"
  243. includeHidden = false
  244. case .hidden:
  245. flag = "--help-hidden"
  246. includeHidden = true
  247. case .private:
  248. XCTFail("Should not be called.", file: file, line: line)
  249. return
  250. default:
  251. XCTFail("Unrecognized visibility.", file: file, line: line)
  252. return
  253. }
  254. do {
  255. _ = try T.parse([flag])
  256. XCTFail(file: file, line: line)
  257. } catch {
  258. let helpString = T.fullMessage(for: error, columns: columns)
  259. AssertEqualStrings(
  260. actual: helpString, expected: expected, file: file, line: line)
  261. }
  262. let helpString = T.helpMessage(includeHidden: includeHidden, columns: columns)
  263. AssertEqualStrings(
  264. actual: helpString, expected: expected, file: file, line: line)
  265. }
  266. // swift-format-ignore: AlwaysUseLowerCamelCase
  267. public func AssertHelp<T: ParsableCommand, U: ParsableCommand>(
  268. _ visibility: ArgumentVisibility,
  269. for _: T.Type,
  270. root _: U.Type,
  271. columns: Int? = 80,
  272. equals expected: String,
  273. file: StaticString = #filePath,
  274. line: UInt = #line
  275. ) {
  276. let includeHidden: Bool
  277. switch visibility {
  278. case .default:
  279. includeHidden = false
  280. case .hidden:
  281. includeHidden = true
  282. case .private:
  283. XCTFail("Should not be called.", file: file, line: line)
  284. return
  285. default:
  286. XCTFail("Unrecognized visibility.", file: file, line: line)
  287. return
  288. }
  289. let helpString = U.helpMessage(
  290. for: T.self, includeHidden: includeHidden, columns: columns)
  291. AssertEqualStrings(
  292. actual: helpString, expected: expected, file: file, line: line)
  293. }
  294. extension XCTest {
  295. public var debugURL: URL {
  296. let bundleURL = Bundle(for: type(of: self)).bundleURL
  297. return bundleURL.lastPathComponent.hasSuffix("xctest")
  298. ? bundleURL.deletingLastPathComponent()
  299. : bundleURL
  300. }
  301. // swift-format-ignore: AlwaysUseLowerCamelCase
  302. @discardableResult
  303. public func AssertExecuteCommand(
  304. command: String,
  305. expected: String? = nil,
  306. exitCode: ExitCode = .success,
  307. file: StaticString = #filePath,
  308. line: UInt = #line,
  309. environment: [String: String] = [:]
  310. ) throws -> String {
  311. try AssertExecuteCommand(
  312. command: command.split(separator: " ").map(String.init),
  313. expected: expected,
  314. exitCode: exitCode,
  315. file: file,
  316. line: line,
  317. environment: environment
  318. )
  319. }
  320. // swift-format-ignore: AlwaysUseLowerCamelCase
  321. @discardableResult
  322. public func AssertExecuteCommand(
  323. command: [String],
  324. expected: String? = nil,
  325. exitCode: ExitCode = .success,
  326. file: StaticString = #filePath,
  327. line: UInt = #line,
  328. environment: [String: String] = [:]
  329. ) throws -> String {
  330. #if os(Windows)
  331. throw XCTSkip("Unsupported on this platform")
  332. #endif
  333. let arguments = Array(command.dropFirst())
  334. let commandName = String(command.first!)
  335. let commandURL = debugURL.appendingPathComponent(commandName)
  336. guard (try? commandURL.checkResourceIsReachable()) ?? false else {
  337. XCTFail(
  338. "No executable at '\(commandURL.standardizedFileURL.path)'.",
  339. file: file, line: line)
  340. return ""
  341. }
  342. #if !canImport(Darwin) || os(macOS)
  343. let process = Process()
  344. process.executableURL = commandURL
  345. process.arguments = arguments
  346. let output = Pipe()
  347. process.standardOutput = output
  348. let error = Pipe()
  349. process.standardError = error
  350. if !environment.isEmpty {
  351. if let existingEnvironment = process.environment {
  352. process.environment =
  353. existingEnvironment.merging(environment) { (_, new) in new }
  354. } else {
  355. process.environment = environment
  356. }
  357. }
  358. guard (try? process.run()) != nil else {
  359. XCTFail("Couldn't run command process.", file: file, line: line)
  360. return ""
  361. }
  362. process.waitUntilExit()
  363. let outputData = output.fileHandleForReading.readDataToEndOfFile()
  364. let outputActual = String(data: outputData, encoding: .utf8)!
  365. let errorData = error.fileHandleForReading.readDataToEndOfFile()
  366. let errorActual = String(data: errorData, encoding: .utf8)!
  367. if let expected = expected {
  368. AssertEqualStrings(
  369. actual: errorActual + outputActual,
  370. expected: expected,
  371. file: file,
  372. line: line)
  373. }
  374. XCTAssertEqual(
  375. process.terminationStatus, exitCode.rawValue, file: file, line: line)
  376. #else
  377. throw XCTSkip("Not supported on this platform")
  378. #endif
  379. return outputActual
  380. }
  381. // swift-format-ignore: AlwaysUseLowerCamelCase
  382. public func AssertJSONEqualFromString<T: Codable & Equatable>(
  383. actual: String, expected: String, for type: T.Type,
  384. file: StaticString = #filePath, line: UInt = #line
  385. ) throws {
  386. AssertEqualStrings(
  387. actual: actual,
  388. expected: expected,
  389. file: file,
  390. line: line)
  391. let actualJSONData = try XCTUnwrap(
  392. actual.data(using: .utf8), file: file, line: line)
  393. let actualDumpJSON = try XCTUnwrap(
  394. JSONDecoder().decode(type, from: actualJSONData), file: file, line: line)
  395. let expectedJSONData = try XCTUnwrap(
  396. expected.data(using: .utf8), file: file, line: line)
  397. let expectedDumpJSON = try XCTUnwrap(
  398. JSONDecoder().decode(type, from: expectedJSONData), file: file, line: line
  399. )
  400. XCTAssertEqual(actualDumpJSON, expectedDumpJSON)
  401. }
  402. }
  403. // MARK: - Snapshot testing
  404. extension XCTest {
  405. @discardableResult
  406. public func assertSnapshot(
  407. actual: String,
  408. extension: String,
  409. record: Bool = false,
  410. test: StaticString = #function,
  411. file: StaticString = #filePath,
  412. line: UInt = #line
  413. ) throws -> String? {
  414. let snapshotDirectoryURL = URL(fileURLWithPath: "\(file)")
  415. .deletingLastPathComponent()
  416. .appendingPathComponent("Snapshots")
  417. let snapshotFileURL =
  418. snapshotDirectoryURL
  419. .appendingPathComponent("\(test).\(`extension`)")
  420. let snapshotExists = FileManager.default.fileExists(
  421. atPath: snapshotFileURL.path)
  422. let recordEnvironment =
  423. ProcessInfo.processInfo.environment["RECORD_SNAPSHOTS"] != nil
  424. if record || recordEnvironment || !snapshotExists {
  425. let recordedValue = actual
  426. try FileManager.default.createDirectory(
  427. at: snapshotDirectoryURL,
  428. withIntermediateDirectories: true,
  429. attributes: nil)
  430. try recordedValue.write(
  431. to: snapshotFileURL, atomically: true, encoding: .utf8)
  432. XCTFail("Recorded new baseline", file: file, line: line)
  433. return nil
  434. } else {
  435. let expected = try String(contentsOf: snapshotFileURL, encoding: .utf8)
  436. AssertEqualStrings(
  437. actual: actual,
  438. expected: expected,
  439. file: file,
  440. line: line)
  441. return expected
  442. }
  443. }
  444. public func assertGenerateManual(
  445. multiPage: Bool,
  446. command: String,
  447. record: Bool = false,
  448. test: StaticString = #function,
  449. file: StaticString = #filePath,
  450. line: UInt = #line
  451. ) throws {
  452. #if os(Windows)
  453. throw XCTSkip("Unsupported on this platform")
  454. #endif
  455. let commandURL = debugURL.appendingPathComponent(command)
  456. var command = [
  457. "generate-manual", commandURL.path,
  458. "--date", "1996-05-12",
  459. "--section", "9",
  460. "--authors", "Jane Appleseed",
  461. "--authors", "<johnappleseed@apple.com>",
  462. "--authors", "The Appleseeds<appleseeds@apple.com>",
  463. "--output-directory", "-",
  464. ]
  465. if multiPage {
  466. command.append("--multi-page")
  467. }
  468. let actual = try AssertExecuteCommand(
  469. command: command,
  470. file: file,
  471. line: line)
  472. try self.assertSnapshot(
  473. actual: actual,
  474. extension: "mdoc",
  475. record: record,
  476. test: test,
  477. file: file,
  478. line: line)
  479. }
  480. public func assertGeneratedReference(
  481. command: String,
  482. doccFlavored: Bool,
  483. record: Bool = false,
  484. test: StaticString = #function,
  485. file: StaticString = #filePath,
  486. line: UInt = #line
  487. ) throws {
  488. #if os(Windows)
  489. throw XCTSkip("Unsupported on this platform")
  490. #endif
  491. let commandURL = debugURL.appendingPathComponent(command)
  492. let command: [String]
  493. if doccFlavored {
  494. command = [
  495. "generate-docc-reference", commandURL.path,
  496. "--output-directory", "-",
  497. "--style", "docc",
  498. ]
  499. } else {
  500. command = [
  501. "generate-docc-reference", commandURL.path,
  502. "--output-directory", "-",
  503. ]
  504. }
  505. let actual = try AssertExecuteCommand(
  506. command: command,
  507. file: file,
  508. line: line)
  509. try self.assertSnapshot(
  510. actual: actual,
  511. extension: "md",
  512. record: record,
  513. test: test,
  514. file: file,
  515. line: line)
  516. }
  517. public func assertDumpHelp<T: ParsableArguments>(
  518. type: T.Type,
  519. record: Bool = false,
  520. test: StaticString = #function,
  521. file: StaticString = #filePath,
  522. line: UInt = #line
  523. ) throws {
  524. let actual: String
  525. do {
  526. _ = try T.parse(["--experimental-dump-help"])
  527. XCTFail(file: file, line: line)
  528. return
  529. } catch {
  530. actual = T.fullMessage(for: error)
  531. }
  532. let apiOutput = T._dumpHelp()
  533. AssertEqualStrings(actual: actual, expected: apiOutput)
  534. let expected = try self.assertSnapshot(
  535. actual: actual,
  536. extension: "json",
  537. record: record,
  538. test: test,
  539. file: file,
  540. line: line)
  541. guard let expected else { return }
  542. try AssertJSONEqualFromString(
  543. actual: actual,
  544. expected: expected,
  545. for: ToolInfoV0.self,
  546. file: file,
  547. line: line)
  548. }
  549. public func assertDumpHelp(
  550. command: String,
  551. record: Bool = false,
  552. test: StaticString = #function,
  553. file: StaticString = #filePath,
  554. line: UInt = #line
  555. ) throws {
  556. let actual = try AssertExecuteCommand(
  557. command: command + " --experimental-dump-help",
  558. expected: nil,
  559. file: file,
  560. line: line)
  561. try self.assertSnapshot(
  562. actual: actual,
  563. extension: "json",
  564. record: record,
  565. test: test,
  566. file: file,
  567. line: line)
  568. }
  569. }