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:
parent
078f1edf58
commit
eba0fdb76c
|
@ -64,7 +64,10 @@ let package = Package(
|
|||
)
|
||||
),
|
||||
dependencies: ["carton-frontend"],
|
||||
exclude: ["CartonPluginShared/README.md"]
|
||||
exclude: [
|
||||
"CartonCore/README.md",
|
||||
"CartonPluginShared/README.md"
|
||||
]
|
||||
),
|
||||
.plugin(
|
||||
name: "CartonTestPlugin",
|
||||
|
@ -75,7 +78,10 @@ let package = Package(
|
|||
)
|
||||
),
|
||||
dependencies: ["carton-frontend"],
|
||||
exclude: ["CartonPluginShared/README.md"]
|
||||
exclude: [
|
||||
"CartonCore/README.md",
|
||||
"CartonPluginShared/README.md"
|
||||
]
|
||||
),
|
||||
.plugin(
|
||||
name: "CartonDevPlugin",
|
||||
|
@ -86,7 +92,10 @@ let package = Package(
|
|||
)
|
||||
),
|
||||
dependencies: ["carton-frontend"],
|
||||
exclude: ["CartonPluginShared/README.md"]
|
||||
exclude: [
|
||||
"CartonCore/README.md",
|
||||
"CartonPluginShared/README.md"
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "carton-plugin-helper"),
|
||||
.target(
|
||||
|
@ -131,10 +140,15 @@ let package = Package(
|
|||
name: "CartonHelpers",
|
||||
dependencies: [
|
||||
"TSCclibc",
|
||||
"TSCLibc"
|
||||
"TSCLibc",
|
||||
"CartonCore"
|
||||
],
|
||||
exclude: ["Basics/README.md"]
|
||||
),
|
||||
.target(
|
||||
name: "CartonCore",
|
||||
exclude: ["README.md"]
|
||||
),
|
||||
.target(name: "WebDriverClient", dependencies: []),
|
||||
// This target is used only for release automation tasks and
|
||||
// should not be installed by `carton` users.
|
||||
|
@ -158,6 +172,7 @@ let package = Package(
|
|||
name: "CartonCommandTests",
|
||||
dependencies: [
|
||||
"CartonFrontend",
|
||||
"SwiftToolchain",
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
]
|
||||
),
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../Sources/CartonCore
|
|
@ -0,0 +1 @@
|
|||
../../Sources/CartonCore
|
|
@ -0,0 +1 @@
|
|||
../../Sources/CartonCore
|
|
@ -14,12 +14,16 @@
|
|||
|
||||
/// The target environment to build for.
|
||||
/// `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 node
|
||||
case browser
|
||||
|
||||
static func parse(_ string: String) -> (Environment?, diagnostics: String?) {
|
||||
public static func parse(_ string: String) -> (Environment?, diagnostics: String?) {
|
||||
// Find from canonical names
|
||||
if let found = allCases.first(where: { $0.rawValue == string }) {
|
||||
return (found, nil)
|
||||
|
@ -36,12 +40,24 @@ internal enum Environment: String, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
struct Parameters {
|
||||
var otherSwiftcFlags: [String] = []
|
||||
var otherLinkerFlags: [String] = []
|
||||
public struct Parameters {
|
||||
public init(otherSwiftcFlags: [String] = [], 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
|
||||
// does not infer `-static-stdlib` for WebAssembly targets intentionally
|
||||
// for future dynamic linking support.
|
||||
|
@ -59,4 +75,10 @@ internal enum Environment: String, CaseIterable {
|
|||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func buildParameters() -> Parameters {
|
||||
var p = Parameters()
|
||||
applyBuildParameters(&p)
|
||||
return p
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -15,16 +15,7 @@
|
|||
import ArgumentParser
|
||||
import CartonHelpers
|
||||
import CartonKit
|
||||
|
||||
/// 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
|
||||
}
|
||||
import CartonCore
|
||||
|
||||
enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument {
|
||||
case stackOverflow
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
import ArgumentParser
|
||||
import CartonCore
|
||||
|
||||
extension Environment: ExpressibleByArgument {}
|
|
@ -16,6 +16,10 @@ import ArgumentParser
|
|||
import XCTest
|
||||
import CartonHelpers
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
struct CommandTestError: Swift.Error & CustomStringConvertible {
|
||||
init(_ description: String) {
|
||||
self.description = description
|
||||
|
@ -24,6 +28,22 @@ struct CommandTestError: Swift.Error & CustomStringConvertible {
|
|||
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 {
|
||||
let whichBin = "/usr/bin/which"
|
||||
let process = Process()
|
||||
|
@ -107,3 +127,39 @@ func swiftRun(_ arguments: [String], packageDirectory: URL) async throws
|
|||
result.setOutput(.success(process.output()))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,8 +25,6 @@ import XCTest
|
|||
#endif
|
||||
|
||||
final class DevCommandTests: XCTestCase {
|
||||
private var client: URLSession?
|
||||
|
||||
#if os(macOS)
|
||||
func testWithNoArguments() async throws {
|
||||
// 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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081")
|
||||
try await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081")
|
||||
}
|
||||
}
|
||||
#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
|
||||
let timeOut: Int64 = 60
|
||||
let timeOut: Duration = .seconds(60)
|
||||
|
||||
// client delay... let the server start up
|
||||
let delay: UInt32 = 30
|
||||
let delay: Duration = .seconds(30)
|
||||
|
||||
// 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>
|
||||
<html>
|
||||
<head>
|
||||
|
@ -77,53 +96,11 @@ final class DevCommandTests: XCTestCase {
|
|||
</html>
|
||||
"""
|
||||
|
||||
client = .shared
|
||||
|
||||
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")
|
||||
guard let actualHtml = String(data: data, encoding: .utf8) else {
|
||||
throw CommandTestError("Could not decode as UTF-8 string")
|
||||
}
|
||||
|
||||
// test may be brittle as the template may change over time.
|
||||
XCTAssertEqual(actualHtml, expectedHtml, "HTML output does not match")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm
|
||||
.netrc
|
|
@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
|
@ -0,0 +1 @@
|
|||
print("hello dev server")
|
|
@ -0,0 +1,4 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
Loading…
Reference in New Issue