Add unit test for dev server (#466)

* make CartonCore module and move Environment

* refactor DevCommandTests.checkForExpectedContent

* add DevServerTestApp Fixture

* add FrontendDevServerTests
This commit is contained in:
omochimetaru 2024-05-22 11:31:19 +09:00 committed by GitHub
parent 078f1edf58
commit eba0fdb76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 287 additions and 77 deletions

View File

@ -64,7 +64,10 @@ let package = Package(
) )
), ),
dependencies: ["carton-frontend"], dependencies: ["carton-frontend"],
exclude: ["CartonPluginShared/README.md"] exclude: [
"CartonCore/README.md",
"CartonPluginShared/README.md"
]
), ),
.plugin( .plugin(
name: "CartonTestPlugin", name: "CartonTestPlugin",
@ -75,7 +78,10 @@ let package = Package(
) )
), ),
dependencies: ["carton-frontend"], dependencies: ["carton-frontend"],
exclude: ["CartonPluginShared/README.md"] exclude: [
"CartonCore/README.md",
"CartonPluginShared/README.md"
]
), ),
.plugin( .plugin(
name: "CartonDevPlugin", name: "CartonDevPlugin",
@ -86,7 +92,10 @@ let package = Package(
) )
), ),
dependencies: ["carton-frontend"], dependencies: ["carton-frontend"],
exclude: ["CartonPluginShared/README.md"] exclude: [
"CartonCore/README.md",
"CartonPluginShared/README.md"
]
), ),
.executableTarget(name: "carton-plugin-helper"), .executableTarget(name: "carton-plugin-helper"),
.target( .target(
@ -131,10 +140,15 @@ let package = Package(
name: "CartonHelpers", name: "CartonHelpers",
dependencies: [ dependencies: [
"TSCclibc", "TSCclibc",
"TSCLibc" "TSCLibc",
"CartonCore"
], ],
exclude: ["Basics/README.md"] exclude: ["Basics/README.md"]
), ),
.target(
name: "CartonCore",
exclude: ["README.md"]
),
.target(name: "WebDriverClient", dependencies: []), .target(name: "WebDriverClient", dependencies: []),
// This target is used only for release automation tasks and // This target is used only for release automation tasks and
// should not be installed by `carton` users. // should not be installed by `carton` users.
@ -158,6 +172,7 @@ let package = Package(
name: "CartonCommandTests", name: "CartonCommandTests",
dependencies: [ dependencies: [
"CartonFrontend", "CartonFrontend",
"SwiftToolchain",
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
] ]
), ),

View File

@ -0,0 +1 @@
../../Sources/CartonCore

View File

@ -0,0 +1 @@
../../Sources/CartonCore

View File

@ -0,0 +1 @@
../../Sources/CartonCore

View File

@ -14,12 +14,16 @@
/// The target environment to build for. /// The target environment to build for.
/// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning. /// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning.
internal enum Environment: String, CaseIterable { public enum Environment: String, CaseIterable {
public static var allCasesNames: [String] {
allCases.map(\.rawValue)
}
case command case command
case node case node
case browser case browser
static func parse(_ string: String) -> (Environment?, diagnostics: String?) { public static func parse(_ string: String) -> (Environment?, diagnostics: String?) {
// Find from canonical names // Find from canonical names
if let found = allCases.first(where: { $0.rawValue == string }) { if let found = allCases.first(where: { $0.rawValue == string }) {
return (found, nil) return (found, nil)
@ -36,12 +40,24 @@ internal enum Environment: String, CaseIterable {
} }
} }
struct Parameters { public struct Parameters {
var otherSwiftcFlags: [String] = [] public init(otherSwiftcFlags: [String] = [], otherLinkerFlags: [String] = []) {
var otherLinkerFlags: [String] = [] self.otherSwiftcFlags = otherSwiftcFlags
self.otherLinkerFlags = otherLinkerFlags
}
public var otherSwiftcFlags: [String] = []
public var otherLinkerFlags: [String] = []
public func asBuildArguments() -> [String] {
var args: [String] = []
args += otherSwiftcFlags.flatMap { ["-Xswiftc", $0] }
args += otherLinkerFlags.flatMap { ["-Xlinker", $0] }
return args
}
} }
func applyBuildParameters(_ parameters: inout Parameters) { public func applyBuildParameters(_ parameters: inout Parameters) {
// NOTE: We only support static linking for now, and the new SwiftDriver // NOTE: We only support static linking for now, and the new SwiftDriver
// does not infer `-static-stdlib` for WebAssembly targets intentionally // does not infer `-static-stdlib` for WebAssembly targets intentionally
// for future dynamic linking support. // for future dynamic linking support.
@ -59,4 +75,10 @@ internal enum Environment: String, CaseIterable {
#endif #endif
} }
} }
public func buildParameters() -> Parameters {
var p = Parameters()
applyBuildParameters(&p)
return p
}
} }

View File

@ -0,0 +1,2 @@
Place the source files to be used by the driver, plugin, and frontend in this directory.
To reduce the build time of the plugin, please include only the minimum necessary content.

View File

@ -15,16 +15,7 @@
import ArgumentParser import ArgumentParser
import CartonHelpers import CartonHelpers
import CartonKit import CartonKit
import CartonCore
/// The target environment to build for.
/// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning.
enum Environment: String, CaseIterable, ExpressibleByArgument {
public static var allCasesNames: [String] { Environment.allCases.map { $0.rawValue } }
case command
case node
case browser
}
enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument { enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument {
case stackOverflow case stackOverflow

View File

@ -0,0 +1,4 @@
import ArgumentParser
import CartonCore
extension Environment: ExpressibleByArgument {}

View File

@ -16,6 +16,10 @@ import ArgumentParser
import XCTest import XCTest
import CartonHelpers import CartonHelpers
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct CommandTestError: Swift.Error & CustomStringConvertible { struct CommandTestError: Swift.Error & CustomStringConvertible {
init(_ description: String) { init(_ description: String) {
self.description = description self.description = description
@ -24,6 +28,22 @@ struct CommandTestError: Swift.Error & CustomStringConvertible {
var description: String var description: String
} }
extension Optional {
func unwrap(_ name: String) throws -> Wrapped {
guard let self else {
throw CommandTestError("\(name) is none")
}
return self
}
}
extension Duration {
var asTimeInterval: TimeInterval {
let (sec, atto) = components
return TimeInterval(sec) + TimeInterval(atto) / 1e18
}
}
func findExecutable(name: String) throws -> AbsolutePath { func findExecutable(name: String) throws -> AbsolutePath {
let whichBin = "/usr/bin/which" let whichBin = "/usr/bin/which"
let process = Process() let process = Process()
@ -107,3 +127,39 @@ func swiftRun(_ arguments: [String], packageDirectory: URL) async throws
result.setOutput(.success(process.output())) result.setOutput(.success(process.output()))
return result return result
} }
func fetchWebContent(at url: URL, timeout: Duration) async throws -> (response: HTTPURLResponse, body: Data) {
let session = URLSession.shared
let request = URLRequest(
url: url, cachePolicy: .reloadIgnoringCacheData,
timeoutInterval: timeout.asTimeInterval
)
let (body, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw CommandTestError("Response from \(url.absoluteString) is not HTTPURLResponse")
}
return (response: response, body: body)
}
func withRetry<R>(maxAttempts: Int, delay: Duration, body: () async throws -> R) async throws -> R {
var attempt = 0
while true {
try await Task.sleep(for: delay)
attempt += 1
do {
return try await body()
} catch {
if attempt < maxAttempts {
print("attempt \(attempt) failed: \(error), retrying...")
continue
}
throw error
}
}
}

View File

@ -25,8 +25,6 @@ import XCTest
#endif #endif
final class DevCommandTests: XCTestCase { final class DevCommandTests: XCTestCase {
private var client: URLSession?
#if os(macOS) #if os(macOS)
func testWithNoArguments() async throws { func testWithNoArguments() async throws {
// FIXME: Don't assume a specific port is available since it can be used by others or tests // FIXME: Don't assume a specific port is available since it can be used by others or tests
@ -36,7 +34,7 @@ final class DevCommandTests: XCTestCase {
packageDirectory: packageDirectory.url packageDirectory: packageDirectory.url
) )
await checkForExpectedContent(process: process, at: "http://127.0.0.1:8080") try await checkForExpectedContent(process: process, at: "http://127.0.0.1:8080")
} }
} }
@ -48,23 +46,44 @@ final class DevCommandTests: XCTestCase {
packageDirectory: packageDirectory.url packageDirectory: packageDirectory.url
) )
await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081") try await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081")
} }
} }
#endif #endif
func checkForExpectedContent(process: SwiftRunProcess, at url: String) async { private func fetchDevServerWithRetry(at url: URL) async throws -> (response: HTTPURLResponse, body: Data) {
// client time out for connecting and responding // client time out for connecting and responding
let timeOut: Int64 = 60 let timeOut: Duration = .seconds(60)
// client delay... let the server start up // client delay... let the server start up
let delay: UInt32 = 30 let delay: Duration = .seconds(30)
// only try 5 times. // only try 5 times.
let polls = 5 let count = 5
let expectedHtml = do {
""" return try await withRetry(maxAttempts: count, delay: delay) {
try await fetchWebContent(at: url, timeout: timeOut)
}
} catch {
throw CommandTestError(
"Could not reach server.\n" +
"No response from server after \(count) tries or \(count * Int(delay.components.seconds)) seconds.\n" +
"Last error: \(error)"
)
}
}
func checkForExpectedContent(process: SwiftRunProcess, at url: String) async throws {
defer {
// end the process regardless of success
process.process.signal(SIGTERM)
}
let (response, data) = try await fetchDevServerWithRetry(at: try URL(string: url).unwrap("url"))
XCTAssertEqual(response.statusCode, 200, "Response was not ok")
let expectedHtml = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -77,53 +96,11 @@ final class DevCommandTests: XCTestCase {
</html> </html>
""" """
client = .shared guard let actualHtml = String(data: data, encoding: .utf8) else {
throw CommandTestError("Could not decode as UTF-8 string")
var response: HTTPURLResponse?
var responseBody: Data?
var count = 0
// give the server some time to start
repeat {
sleep(delay)
count += 1
guard
let (body, urlResponse) = try? await client?.data(
for: URLRequest(
url: URL(string: url)!,
cachePolicy: .reloadIgnoringCacheData,
timeoutInterval: TimeInterval(timeOut)
)
)
else {
continue
}
response = urlResponse as? HTTPURLResponse
responseBody = body
} while count < polls && response == nil
// end the process regardless of success
process.process.signal(SIGTERM)
if let response = response {
XCTAssertTrue(response.statusCode == 200, "Response was not ok")
guard let data = responseBody else {
XCTFail("Could not map data")
return
}
guard let actualHtml = String(data: data, encoding: .utf8) else {
XCTFail("Could not convert data to string")
return
}
// test may be brittle as the template may change over time.
XCTAssertEqual(actualHtml, expectedHtml, "HTML output does not match")
} else {
print("no response from server after \(count) tries or \(Int(count) * Int(delay)) seconds")
XCTFail("Could not reach server")
} }
// test may be brittle as the template may change over time.
XCTAssertEqual(actualHtml, expectedHtml, "HTML output does not match")
} }
} }

View File

@ -0,0 +1,107 @@
import XCTest
import CartonCore
import CartonHelpers
import CartonKit
import SwiftToolchain
final class FrontendDevServerTests: XCTestCase {
func testDevServerPublish() async throws {
let fs = localFileSystem
let terminal = InteractiveWriter.stdout
let projectDir = try testFixturesDirectory.appending(component: "DevServerTestApp")
let buildDir = projectDir.appending(components: [".build", "wasm32-unknown-wasi", "debug"])
let wasmFile = buildDir.appending(component: "app.wasm")
let resourcesDir = buildDir.appending(component: "DevServerTestApp_app.resources")
try fs.changeCurrentWorkingDirectory(to: projectDir)
if !fs.exists(wasmFile) {
let tools = try ToolchainSystem(fileSystem: fs)
let (builderSwift, _) = try await tools.inferSwiftPath(terminal)
var args: [String] = [
builderSwift.pathString, "build", "--triple", "wasm32-unknown-wasi"
]
args += Environment.browser.buildParameters().asBuildArguments()
try await Process.run(args, terminal)
}
try await Process.run(["swift", "build", "--target", "carton-frontend"], terminal)
let devServer = Process(
arguments: [
"swift", "run", "carton-frontend", "dev",
"--skip-auto-open", "--verbose",
"--main-wasm-path", wasmFile.pathString,
"--resources", resourcesDir.pathString
]
)
try devServer.launch()
defer {
devServer.signal(SIGINT)
}
try await Task.sleep(for: .seconds(3))
let host = try URL(string: "http://127.0.0.1:8080").unwrap("url")
do {
let indexHtml = try await fetchString(at: host)
XCTAssertEqual(indexHtml, """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script type="module" src="dev.js"></script>
</head>
<body>
</body>
</html>
"""
)
}
do {
let devJs = try await fetchString(at: host.appendingPathComponent("dev.js"))
let expected = try XCTUnwrap(String(data: StaticResource.dev, encoding: .utf8))
XCTAssertEqual(devJs, expected)
}
do {
let mainWasm = try await fetchBinary(at: host.appendingPathComponent("main.wasm"))
let expected = try Data(contentsOf: wasmFile.asURL)
XCTAssertEqual(mainWasm, expected)
}
do {
let name = "style.css"
let styleCss = try await fetchString(at: host.appendingPathComponent(name))
let expected = try String(contentsOf: resourcesDir.appending(component: name).asURL)
XCTAssertEqual(styleCss, expected)
}
}
private func fetchBinary(
at url: URL,
file: StaticString = #file, line: UInt = #line
) async throws -> Data {
let (response, body) = try await fetchWebContent(at: url, timeout: .seconds(10))
XCTAssertEqual(response.statusCode, 200, file: file, line: line)
return body
}
private func fetchString(
at url: URL,
file: StaticString = #file, line: UInt = #line
) async throws -> String? {
let data = try await fetchBinary(at: url)
guard let string = String(data: data, encoding: .utf8) else {
XCTFail("not UTF-8 string content", file: file, line: line)
return nil
}
return string
}
}

View File

@ -0,0 +1,7 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm
.netrc

View File

@ -0,0 +1,21 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DevServerTestApp",
products: [
.executable(name: "app", targets: ["app"])
],
dependencies: [
.package(path: "../../..")
],
targets: [
.target(
name: "app",
resources: [
.copy("style.css")
]
)
]
)

View File

@ -0,0 +1 @@
print("hello dev server")

View File

@ -0,0 +1,4 @@
* {
margin: 0;
padding: 0;
}