diff --git a/Package.swift b/Package.swift index bd4b9e08..67ed9790 100644 --- a/Package.swift +++ b/Package.swift @@ -116,7 +116,10 @@ let package = Package( "WebDriver", "WasmTransformer", ], - exclude: ["Utilities/README.md"] + exclude: ["Utilities/README.md"], + swiftSettings: [ + .enableUpcomingFeature("BareSlashRegexLiterals") + ] ), .target( name: "SwiftToolchain", diff --git a/Plugins/CartonDevPlugin/CartonDevPluginCommand.swift b/Plugins/CartonDevPlugin/CartonDevPluginCommand.swift index 093cc7d4..8194f9aa 100644 --- a/Plugins/CartonDevPlugin/CartonDevPluginCommand.swift +++ b/Plugins/CartonDevPlugin/CartonDevPluginCommand.swift @@ -21,12 +21,17 @@ struct CartonDevPluginCommand: CommandPlugin { var product: String? var release: Bool var verbose: Bool + var pid: String? static func parse(from extractor: inout ArgumentExtractor) throws -> Options { let product = extractor.extractOption(named: "product").last let release = extractor.extractFlag(named: "release") let verbose = extractor.extractFlag(named: "verbose") - return Options(product: product, release: release != 0, verbose: verbose != 0) + let pid = extractor.extractOption(named: "pid").last + return Options( + product: product, release: release != 0, verbose: verbose != 0, + pid: pid + ) } } @@ -82,19 +87,19 @@ struct CartonDevPluginCommand: CommandPlugin { let buildRequestPipe = try createFifo(hint: "build-request", directory: tempDirectory) let buildResponsePipe = try createFifo(hint: "build-response", directory: tempDirectory) - let frontend = try makeCartonFrontendProcess( - context: context, - arguments: [ - "dev", - "--main-wasm-path", productArtifact.path.string, - "--build-request", buildRequestPipe, - "--build-response", buildResponsePipe, - ] - + resourcesPaths.flatMap { ["--resources", $0.string] } - + pathsToWatch.flatMap { ["--watch-path", $0] } - + (options.verbose ? ["--verbose"] : []) - + extractor.remainingArguments - ) + var args: [String] = [ + "dev", + "--main-wasm-path", productArtifact.path.string, + "--build-request", buildRequestPipe, + "--build-response", buildResponsePipe + ] + args += (options.pid.map { ["--pid", $0] } ?? []) + args += resourcesPaths.flatMap { ["--resources", $0.string] } + args += pathsToWatch.flatMap { ["--watch-path", $0] } + args += (options.verbose ? ["--verbose"] : []) + args += extractor.remainingArguments + + let frontend = try makeCartonFrontendProcess(context: context, arguments: args) frontend.forwardTerminationSignals() try frontend.run() diff --git a/Plugins/CartonTestPlugin/CartonTestPluginCommand.swift b/Plugins/CartonTestPlugin/CartonTestPluginCommand.swift index 8bacc259..bb15bf10 100644 --- a/Plugins/CartonTestPlugin/CartonTestPluginCommand.swift +++ b/Plugins/CartonTestPlugin/CartonTestPluginCommand.swift @@ -20,11 +20,16 @@ struct CartonTestPluginCommand: CommandPlugin { struct Options { var environment: Environment var prebuiltTestBundlePath: String? + var pid: String? static func parse(from extractor: inout ArgumentExtractor) throws -> Options { let environment = try Environment.parse(from: &extractor) let prebuiltTestBundlePath = extractor.extractOption(named: "prebuilt-test-bundle-path").first - return Options(environment: environment, prebuiltTestBundlePath: prebuiltTestBundlePath) + let pid = extractor.extractOption(named: "pid").last + return Options( + environment: environment, prebuiltTestBundlePath: prebuiltTestBundlePath, + pid: pid + ) } } @@ -98,17 +103,18 @@ struct CartonTestPluginCommand: CommandPlugin { package: context.package ) - let frontendArguments = - [ - "test", - "--prebuilt-test-bundle-path", testProductArtifactPath, - "--environment", options.environment.rawValue, - "--plugin-work-directory", context.pluginWorkDirectory.string - ] - + resourcesPaths.flatMap { - ["--resources", $0.string] - } + extractor.remainingArguments - let frontend = try makeCartonFrontendProcess(context: context, arguments: frontendArguments) + var args: [String] = [ + "test", + "--prebuilt-test-bundle-path", testProductArtifactPath, + "--environment", options.environment.rawValue, + "--plugin-work-directory", context.pluginWorkDirectory.string + ] + args += (options.pid.map { ["--pid", $0] } ?? []) + args += resourcesPaths.flatMap { + ["--resources", $0.string] + } + args += extractor.remainingArguments + let frontend = try makeCartonFrontendProcess(context: context, arguments: args) try frontend.checkRun(printsLoadingMessage: false, forwardExit: true) } diff --git a/Sources/CartonCore/CartonCoreError.swift b/Sources/CartonCore/CartonCoreError.swift index 50c4573a..78e71420 100644 --- a/Sources/CartonCore/CartonCoreError.swift +++ b/Sources/CartonCore/CartonCoreError.swift @@ -1,6 +1,6 @@ -struct CartonCoreError: Error & CustomStringConvertible { - init(_ description: String) { +public struct CartonCoreError: Error & CustomStringConvertible { + public init(_ description: String) { self.description = description } - var description: String + public var description: String } diff --git a/Sources/CartonDriver/CartonDriverCommand.swift b/Sources/CartonDriver/CartonDriverCommand.swift index 8783b4e9..04dc9845 100644 --- a/Sources/CartonDriver/CartonDriverCommand.swift +++ b/Sources/CartonDriver/CartonDriverCommand.swift @@ -54,6 +54,8 @@ func derivePackageCommandArguments( let pluginArguments: [String] = ["plugin"] var cartonPluginArguments: [String] = extraArguments + let pid = ProcessInfo.processInfo.processIdentifier + switch subcommand { case "bundle": packageArguments += ["--disable-sandbox"] @@ -64,6 +66,7 @@ func derivePackageCommandArguments( cartonPluginArguments = ["--output", "Bundle"] + cartonPluginArguments case "dev": packageArguments += ["--disable-sandbox"] + cartonPluginArguments += ["--pid", pid.description] case "test": // 1. Ask the plugin process to generate the build command based on the given options let commandFile = try makeTemporaryFile(prefix: "test-build") @@ -92,6 +95,7 @@ func derivePackageCommandArguments( // "--environment browser" launches a http server packageArguments += ["--disable-sandbox"] + cartonPluginArguments += ["--pid", pid.description] default: break } diff --git a/Sources/CartonFrontend/Commands/CartonFrontendDevCommand.swift b/Sources/CartonFrontend/Commands/CartonFrontendDevCommand.swift index d59fb2af..86b6fb65 100644 --- a/Sources/CartonFrontend/Commands/CartonFrontendDevCommand.swift +++ b/Sources/CartonFrontend/Commands/CartonFrontendDevCommand.swift @@ -113,6 +113,8 @@ struct CartonFrontendDevCommand: AsyncParsableCommand { ) var mainWasmPath: String + @Option(name: .long, help: .hidden) var pid: Int32? + static let configuration = CommandConfiguration( commandName: "dev", abstract: "Watch the current directory, host the app, rebuild on change." @@ -169,6 +171,7 @@ struct CartonFrontendDevCommand: AsyncParsableCommand { }, resourcesPaths: resources, entrypoint: Self.entrypoint, + pid: pid, terminal: terminal ) ) diff --git a/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift b/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift index 9b19f3dd..99166cf4 100644 --- a/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift +++ b/Sources/CartonFrontend/Commands/CartonFrontendTestCommand.swift @@ -96,6 +96,8 @@ struct CartonFrontendTestCommand: AsyncParsableCommand { )) var pluginWorkDirectory: String = "./" + @Option(name: .long, help: .hidden) var pid: Int32? + func validate() throws { if headless && environment != .browser { throw TestError( @@ -133,6 +135,7 @@ struct CartonFrontendTestCommand: AsyncParsableCommand { port: port, headless: headless, resourcesPaths: resources, + pid: pid, terminal: terminal ).run() case .node: diff --git a/Sources/CartonFrontend/Commands/TestRunners/BrowserTestRunner.swift b/Sources/CartonFrontend/Commands/TestRunners/BrowserTestRunner.swift index 1aa40ea3..d5b99b63 100644 --- a/Sources/CartonFrontend/Commands/TestRunners/BrowserTestRunner.swift +++ b/Sources/CartonFrontend/Commands/TestRunners/BrowserTestRunner.swift @@ -53,6 +53,7 @@ struct BrowserTestRunner: TestRunner { let port: Int let headless: Bool let resourcesPaths: [String] + let pid: Int32? let terminal: InteractiveWriter let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) @@ -63,6 +64,7 @@ struct BrowserTestRunner: TestRunner { port: Int, headless: Bool, resourcesPaths: [String], + pid: Int32?, terminal: InteractiveWriter ) { self.testFilePath = testFilePath @@ -71,6 +73,7 @@ struct BrowserTestRunner: TestRunner { self.port = port self.headless = headless self.resourcesPaths = resourcesPaths + self.pid = pid self.terminal = terminal } @@ -86,6 +89,7 @@ struct BrowserTestRunner: TestRunner { customIndexPath: nil, resourcesPaths: resourcesPaths, entrypoint: Constants.entrypoint, + pid: pid, terminal: terminal ) ) diff --git a/Sources/CartonKit/Server/Server.swift b/Sources/CartonKit/Server/Server.swift index 256ceb96..d6469b78 100644 --- a/Sources/CartonKit/Server/Server.swift +++ b/Sources/CartonKit/Server/Server.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import CartonCore import CartonHelpers import Foundation import Logging @@ -106,6 +107,42 @@ public actor Server { hasher.combine(ObjectIdentifier(self)) } } + + public static let serverName = "carton dev server" + + public struct ServerNameField: CustomStringConvertible { + public init( + name: String = serverName, + version: String = cartonVersion, + pid: Int32 + ) { + self.name = name + self.version = version + self.pid = pid + } + + public var name: String + public var version: String + public var pid: Int32 + + public var description: String { + "\(name)/\(version) (PID \(pid))" + } + + private static let regex = #/([\w ]+)/([\w\.]+) \(PID (\d+)\)/# + + public static func parse(_ string: String) throws -> ServerNameField { + guard let m = try regex.wholeMatch(in: string), + let pid = Int32(m.output.3) else { + throw CartonCoreError("invalid server name: \(string)") + } + + let name = String(m.output.1) + let version = String(m.output.2) + return ServerNameField(name: name, version: version, pid: pid) + } + } + /// Used for decoding `Event` values sent from the WebSocket client. private let decoder = JSONDecoder() @@ -131,6 +168,8 @@ public actor Server { private let configuration: Configuration + private let serverName: ServerNameField + public struct Configuration { let builder: BuilderProtocol? let mainWasmPath: AbsolutePath @@ -141,6 +180,7 @@ public actor Server { let customIndexPath: AbsolutePath? let resourcesPaths: [String] let entrypoint: Entrypoint + let pid: Int32? let terminal: InteractiveWriter public init( @@ -153,6 +193,7 @@ public actor Server { customIndexPath: AbsolutePath?, resourcesPaths: [String], entrypoint: Entrypoint, + pid: Int32?, terminal: InteractiveWriter ) { self.builder = builder @@ -164,6 +205,7 @@ public actor Server { self.customIndexPath = customIndexPath self.resourcesPaths = resourcesPaths self.entrypoint = entrypoint + self.pid = pid self.terminal = terminal } @@ -184,6 +226,9 @@ public actor Server { self.localURL = localURL watcher = nil self.configuration = configuration + self.serverName = ServerNameField( + pid: configuration.pid ?? ProcessInfo.processInfo.processIdentifier + ) guard let builder = configuration.builder else { return @@ -293,7 +338,8 @@ public actor Server { mainWasmPath: configuration.mainWasmPath, customIndexPath: configuration.customIndexPath, resourcesPaths: configuration.resourcesPaths, - entrypoint: configuration.entrypoint + entrypoint: configuration.entrypoint, + serverName: serverName.description ) let channel = try await ServerBootstrap(group: group) // Specify backlog and enable SO_REUSEADDR for the server itself diff --git a/Sources/CartonKit/Server/ServerHTTPHandler.swift b/Sources/CartonKit/Server/ServerHTTPHandler.swift index 0f7c494d..6ad16650 100644 --- a/Sources/CartonKit/Server/ServerHTTPHandler.swift +++ b/Sources/CartonKit/Server/ServerHTTPHandler.swift @@ -28,6 +28,7 @@ final class ServerHTTPHandler: ChannelInboundHandler, RemovableChannelHandler { let customIndexPath: AbsolutePath? let resourcesPaths: [String] let entrypoint: Entrypoint + let serverName: String } let configuration: Configuration @@ -93,6 +94,7 @@ final class ServerHTTPHandler: ChannelInboundHandler, RemovableChannelHandler { self.responseBody = response.body var headers = HTTPHeaders() + headers.add(name: "Server", value: configuration.serverName) headers.add(name: "Content-Type", value: response.contentType) headers.add(name: "Content-Length", value: String(response.body.readableBytes)) headers.add(name: "Connection", value: "close") diff --git a/Tests/CartonCommandTests/CommandTestHelper.swift b/Tests/CartonCommandTests/CommandTestHelper.swift index 3fa845eb..dfdb499b 100644 --- a/Tests/CartonCommandTests/CommandTestHelper.swift +++ b/Tests/CartonCommandTests/CommandTestHelper.swift @@ -15,6 +15,7 @@ import ArgumentParser import XCTest import CartonHelpers +import CartonKit #if canImport(FoundationNetworking) import FoundationNetworking @@ -91,8 +92,10 @@ func swiftRunProcess( stdout: { (chunk) in outputBuffer += chunk stdoutStream.write(sequence: chunk) + stdoutStream.flush() }, stderr: { (chunk) in stderrStream.write(sequence: chunk) + stderrStream.flush() }, redirectStderr: false ) @@ -134,3 +137,18 @@ func fetchWebContent(at url: URL, timeout: Duration) async throws -> (response: return (response: response, body: body) } + +func checkServerNameField(response: HTTPURLResponse, expectedPID: Int32) throws { + guard let string = response.value(forHTTPHeaderField: "Server") else { + throw CommandTestError("no Server header") + } + let field = try Server.ServerNameField.parse(string) + + guard field.name == Server.serverName else { + throw CommandTestError("invalid server name: \(field)") + } + + guard field.pid == expectedPID else { + throw CommandTestError("Expected PID \(expectedPID) but got PID \(field.pid).") + } +} diff --git a/Tests/CartonCommandTests/DevCommandTests.swift b/Tests/CartonCommandTests/DevCommandTests.swift index 3317629f..9d36a99b 100644 --- a/Tests/CartonCommandTests/DevCommandTests.swift +++ b/Tests/CartonCommandTests/DevCommandTests.swift @@ -82,6 +82,7 @@ final class DevCommandTests: XCTestCase { let (response, data) = try await fetchDevServerWithRetry(at: try URL(string: url).unwrap("url")) XCTAssertEqual(response.statusCode, 200, "Response was not ok") + try checkServerNameField(response: response, expectedPID: process.process.processID) let expectedHtml = """ diff --git a/Tests/CartonCommandTests/FrontendDevServerTests.swift b/Tests/CartonCommandTests/FrontendDevServerTests.swift index ee8b343f..376dc945 100644 --- a/Tests/CartonCommandTests/FrontendDevServerTests.swift +++ b/Tests/CartonCommandTests/FrontendDevServerTests.swift @@ -5,6 +5,70 @@ import CartonKit import SwiftToolchain import WebDriver +struct DevServerClient { + var process: CartonHelpers.Process + + init( + wasmFile: AbsolutePath, + resourcesDir: AbsolutePath, + terminal: InteractiveWriter, + onStdout: ((String) -> Void)? + ) throws { + process = Process( + arguments: [ + "swift", "run", "carton-frontend", "dev", + "--skip-auto-open", "--verbose", + "--main-wasm-path", wasmFile.pathString, + "--resources", resourcesDir.pathString + ], + outputRedirection: .stream( + stdout: { (chunk) in + let string = String(decoding: chunk, as: UTF8.self) + + onStdout?(string) + + terminal.write(string) + }, stderr: { (_) in }, + redirectStderr: true + ) + ) + try process.launch() + } + + func dispose() { + process.signal(SIGINT) + } + + func fetchBinary( + at url: URL, + file: StaticString = #file, line: UInt = #line + ) async throws -> Data { + let (response, body) = try await withRetry( + maxAttempts: 5, initialDelay: .seconds(3), retryInterval: .seconds(10) + ) { + try await fetchWebContent(at: url, timeout: .seconds(10)) + } + XCTAssertEqual(response.statusCode, 200, file: file, line: line) + + try checkServerNameField(response: response, expectedPID: process.processID) + + return body + } + + 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 { + throw CommandTestError("not UTF-8 string content") + } + + return string + } +} + final class FrontendDevServerTests: XCTestCase { func testDevServerPublish() async throws { let fs = localFileSystem @@ -33,42 +97,27 @@ final class FrontendDevServerTests: XCTestCase { var gotHelloStdout = false var gotHelloStderr = false - let devServer = Process( - arguments: [ - "swift", "run", "carton-frontend", "dev", - "--skip-auto-open", "--verbose", - "--main-wasm-path", wasmFile.pathString, - "--resources", resourcesDir.pathString - ], - outputRedirection: .stream( - stdout: { (chunk) in - let string = String(decoding: chunk, as: UTF8.self) - - if string.contains("stdout: hello stdout") { - gotHelloStdout = true - } - if string.contains("stderr: hello stderr") { - gotHelloStderr = true - } - - terminal.write(string) - }, stderr: { (_) in }, - redirectStderr: true - ) + let cl = try DevServerClient( + wasmFile: wasmFile, + resourcesDir: resourcesDir, + terminal: terminal, + onStdout: { (string) in + if string.contains("stdout: hello stdout") { + gotHelloStdout = true + } + if string.contains("stderr: hello stderr") { + gotHelloStderr = true + } + } ) - try devServer.launch() defer { - devServer.signal(SIGINT) + cl.dispose() } let host = try URL(string: "http://127.0.0.1:8080").unwrap("url") do { - let indexHtml = try await withRetry( - maxAttempts: 5, initialDelay: .seconds(3), retryInterval: .seconds(10) - ) { - try await fetchString(at: host) - } + let indexHtml = try await cl.fetchString(at: host) XCTAssertEqual(indexHtml, """ @@ -86,32 +135,32 @@ final class FrontendDevServerTests: XCTestCase { } do { - let devJs = try await fetchString(at: host.appendingPathComponent("dev.js")) + let devJs = try await cl.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 mainWasm = try await cl.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 styleCss = try await cl.fetchString(at: host.appendingPathComponent(name)) let expected = try String(contentsOf: resourcesDir.appending(component: name).asURL) XCTAssertEqual(styleCss, expected) } - let service = try await WebDriverServices.find(terminal: terminal) + let webDriver = try await WebDriverServices.find(terminal: terminal) defer { - service.dispose() + webDriver.dispose() } - let client = try await service.client() + let webDriverClient = try await webDriver.client() - try await client.goto(url: host) + try await webDriverClient.goto(url: host) try await withRetry(maxAttempts: 10, initialDelay: .seconds(3), retryInterval: .seconds(3)) { if gotHelloStdout, gotHelloStderr { @@ -120,29 +169,6 @@ final class FrontendDevServerTests: XCTestCase { throw CommandTestError("no output") } - try await client.closeSession() - } - - 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 + try await webDriverClient.closeSession() } } diff --git a/Tests/Fixtures/EchoExecutable/Package.swift b/Tests/Fixtures/EchoExecutable/Package.swift index f97fa8cf..9123e860 100644 --- a/Tests/Fixtures/EchoExecutable/Package.swift +++ b/Tests/Fixtures/EchoExecutable/Package.swift @@ -5,5 +5,5 @@ let package = Package( name: "Foo", products: [.executable(name: "my-echo", targets: ["my-echo"])], dependencies: [.package(path: "../../..")], - targets: [.target(name: "my-echo")] + targets: [.executableTarget(name: "my-echo")] )