diff --git a/Package.swift b/Package.swift index 96a660f..997ebd1 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] ), diff --git a/Plugins/CartonBundlePlugin/CartonCore b/Plugins/CartonBundlePlugin/CartonCore new file mode 120000 index 0000000..5f7e083 --- /dev/null +++ b/Plugins/CartonBundlePlugin/CartonCore @@ -0,0 +1 @@ +../../Sources/CartonCore \ No newline at end of file diff --git a/Plugins/CartonDevPlugin/CartonCore b/Plugins/CartonDevPlugin/CartonCore new file mode 120000 index 0000000..5f7e083 --- /dev/null +++ b/Plugins/CartonDevPlugin/CartonCore @@ -0,0 +1 @@ +../../Sources/CartonCore \ No newline at end of file diff --git a/Plugins/CartonTestPlugin/CartonCore b/Plugins/CartonTestPlugin/CartonCore new file mode 120000 index 0000000..5f7e083 --- /dev/null +++ b/Plugins/CartonTestPlugin/CartonCore @@ -0,0 +1 @@ +../../Sources/CartonCore \ No newline at end of file diff --git a/Plugins/CartonPluginShared/Environment.swift b/Sources/CartonCore/Environment.swift similarity index 67% rename from Plugins/CartonPluginShared/Environment.swift rename to Sources/CartonCore/Environment.swift index 74cff8d..73625d1 100644 --- a/Plugins/CartonPluginShared/Environment.swift +++ b/Sources/CartonCore/Environment.swift @@ -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 + } } diff --git a/Sources/CartonCore/README.md b/Sources/CartonCore/README.md new file mode 100644 index 0000000..779f776 --- /dev/null +++ b/Sources/CartonCore/README.md @@ -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. diff --git a/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift b/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift index 0473744..1a59157 100644 --- a/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift +++ b/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift @@ -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 diff --git a/Sources/CartonKit/Utilities/EnvironmentEx.swift b/Sources/CartonKit/Utilities/EnvironmentEx.swift new file mode 100644 index 0000000..5e99186 --- /dev/null +++ b/Sources/CartonKit/Utilities/EnvironmentEx.swift @@ -0,0 +1,4 @@ +import ArgumentParser +import CartonCore + +extension Environment: ExpressibleByArgument {} diff --git a/Tests/CartonCommandTests/CommandTestHelper.swift b/Tests/CartonCommandTests/CommandTestHelper.swift index 9a1862f..85740b7 100644 --- a/Tests/CartonCommandTests/CommandTestHelper.swift +++ b/Tests/CartonCommandTests/CommandTestHelper.swift @@ -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(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 + } + } +} diff --git a/Tests/CartonCommandTests/DevCommandTests.swift b/Tests/CartonCommandTests/DevCommandTests.swift index b39f375..c947906 100644 --- a/Tests/CartonCommandTests/DevCommandTests.swift +++ b/Tests/CartonCommandTests/DevCommandTests.swift @@ -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 = """ @@ -77,53 +96,11 @@ final class DevCommandTests: XCTestCase { """ - 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") } } diff --git a/Tests/CartonCommandTests/FrontendDevServerTests.swift b/Tests/CartonCommandTests/FrontendDevServerTests.swift new file mode 100644 index 0000000..b0911fe --- /dev/null +++ b/Tests/CartonCommandTests/FrontendDevServerTests.swift @@ -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, """ + + + + + + + + + + + """ + ) + } + + 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 + } +} diff --git a/Tests/Fixtures/DevServerTestApp/.gitignore b/Tests/Fixtures/DevServerTestApp/.gitignore new file mode 100644 index 0000000..71d4f9f --- /dev/null +++ b/Tests/Fixtures/DevServerTestApp/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm +.netrc diff --git a/Tests/Fixtures/DevServerTestApp/Package.swift b/Tests/Fixtures/DevServerTestApp/Package.swift new file mode 100644 index 0000000..c0d8179 --- /dev/null +++ b/Tests/Fixtures/DevServerTestApp/Package.swift @@ -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") + ] + ) + ] +) diff --git a/Tests/Fixtures/DevServerTestApp/Sources/app/main.swift b/Tests/Fixtures/DevServerTestApp/Sources/app/main.swift new file mode 100644 index 0000000..f5a46ac --- /dev/null +++ b/Tests/Fixtures/DevServerTestApp/Sources/app/main.swift @@ -0,0 +1 @@ +print("hello dev server") diff --git a/Tests/Fixtures/DevServerTestApp/Sources/app/style.css b/Tests/Fixtures/DevServerTestApp/Sources/app/style.css new file mode 100644 index 0000000..830576d --- /dev/null +++ b/Tests/Fixtures/DevServerTestApp/Sources/app/style.css @@ -0,0 +1,4 @@ +* { + margin: 0; + padding: 0; +}