diff --git a/Package.swift b/Package.swift index 3d0f1e943..b33285d4f 100644 --- a/Package.swift +++ b/Package.swift @@ -82,7 +82,7 @@ let package = Package( .testTarget( name: "JavaScriptKitTests", - dependencies: ["JavaScriptKit"], + dependencies: ["JavaScriptKit", "JavaScriptEventLoopTestSupport"], swiftSettings: [ .enableExperimentalFeature("Extern"), .define("Tracing", .when(traits: ["Tracing"])), @@ -191,7 +191,7 @@ let package = Package( ), .testTarget( name: "BridgeJSRuntimeTests", - dependencies: ["JavaScriptKit", "JavaScriptEventLoop"], + dependencies: ["JavaScriptKit", "JavaScriptEventLoop", "JavaScriptEventLoopTestSupport"], exclude: [ "bridge-js.config.json", "bridge-js.d.ts", @@ -219,7 +219,7 @@ let package = Package( ), .testTarget( name: "BridgeJSIdentityTests", - dependencies: ["JavaScriptKit", "JavaScriptEventLoop"], + dependencies: ["JavaScriptKit", "JavaScriptEventLoop", "JavaScriptEventLoopTestSupport"], exclude: [ "bridge-js.config.json", "Generated/JavaScript", diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index ff3e2ce5a..feb03ab83 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -75,7 +75,19 @@ struct PackageToJS { // First, resolve symlink to get the actual path as SwiftPM 6.0 and earlier returns unresolved // symlink path for product artifact. let wasmProductArtifact = wasmProductArtifact.resolvingSymlinksInPath() - let buildConfiguration = wasmProductArtifact.deletingLastPathComponent().lastPathComponent + let parentDirName = wasmProductArtifact.deletingLastPathComponent().lastPathComponent + + // SwiftBuild lays artifacts out as + // .build/out/Products/-webassembly-wasm32/.wasm + // which doesn't encode the triple in the path. Recognize that layout and map it to + // the WebAssembly triple JavaScriptKit builds for. + if parentDirName.hasSuffix("-webassembly-wasm32") { + let buildConfiguration = parentDirName.split(separator: "-").first.map { $0.lowercased() } ?? "debug" + return (buildConfiguration, "wasm32-unknown-wasip1") + } + + // Native layout: .build///.wasm + let buildConfiguration = parentDirName let triple = wasmProductArtifact.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent return (buildConfiguration, triple) } @@ -645,9 +657,66 @@ struct PackagingPlanner { return (packageInputs, outputDirTask, intermediatesDirTask, packageJsonTask) } + /// Plan the shared npm install for a directory that hosts several per-runner test + /// bundles as subdirectories. Node resolves `node_modules` by walking up the directory + /// tree, so a single install here serves every runner underneath, avoiding one install + /// per test target. + func planSharedNodeModules(make: inout MiniMake) throws -> MiniMake.TaskKey { + let outputDirTask = make.addTask( + inputFiles: [selfPath], + output: outputDir, + attributes: [.silent] + ) { + try system.createDirectory(atPath: $1.resolve(path: $0.output).path) + } + let intermediatesDirTask = make.addTask( + inputFiles: [selfPath], + output: intermediatesDir, + attributes: [.silent] + ) { + try system.createDirectory(atPath: $1.resolve(path: $0.output).path) + } + let packageJsonTask = planCopyTemplateFile( + make: &make, + file: "Plugins/PackageToJS/Templates/package.json", + output: "package.json", + outputDirTask: outputDirTask, + inputFiles: [], + inputTasks: [] + ) + return planNpmInstall( + make: &make, + intermediatesDirTask: intermediatesDirTask, + packageJsonTask: packageJsonTask + ) + } + + private func planNpmInstall( + make: inout MiniMake, + intermediatesDirTask: MiniMake.TaskKey, + packageJsonTask: MiniMake.TaskKey + ) -> MiniMake.TaskKey { + make.addTask( + inputFiles: [ + selfPath, + outputDir.appending(path: "package.json"), + ], + inputTasks: [intermediatesDirTask, packageJsonTask], + output: intermediatesDir.appending(path: "npm-install.stamp") + ) { + try system.npmInstall(packageDir: $1.resolve(path: outputDir).path) + try system.writeFile(atPath: $1.resolve(path: $0.output).path, content: Data()) + } + } + /// Construct the test build plan and return the root task key + /// + /// - Parameter installNodeModules: whether to install the test harness's npm + /// dependencies into this bundle's directory. Pass `false` when several runners share + /// a single `node_modules` planned once via `planSharedNodeModules`. func planTestBuild( - make: inout MiniMake + make: inout MiniMake, + installNodeModules: Bool = true ) throws -> (rootTask: MiniMake.TaskKey, binDir: BuildPath) { var (allTasks, outputDirTask, intermediatesDirTask, packageJsonTask) = try planBuildInternal( make: &make, @@ -655,20 +724,17 @@ struct PackagingPlanner { debugInfoFormat: .dwarf ) - // Install npm dependencies used in the test harness - allTasks.append( - make.addTask( - inputFiles: [ - selfPath, - outputDir.appending(path: "package.json"), - ], - inputTasks: [intermediatesDirTask, packageJsonTask], - output: intermediatesDir.appending(path: "npm-install.stamp") - ) { - try system.npmInstall(packageDir: $1.resolve(path: outputDir).path) - try system.writeFile(atPath: $1.resolve(path: $0.output).path, content: Data()) - } - ) + // Install npm dependencies used in the test harness, unless a shared node_modules is + // provided in a parent directory (see planSharedNodeModules). + if installNodeModules { + allTasks.append( + planNpmInstall( + make: &make, + intermediatesDirTask: intermediatesDirTask, + packageJsonTask: packageJsonTask + ) + ) + } let binDir = outputDir.appending(path: "bin") let binDirTask = make.addTask( diff --git a/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift index 7686372f9..e106682a1 100644 --- a/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift +++ b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift @@ -264,69 +264,189 @@ struct PackageToJSPlugin: CommandPlugin { let skeletonCollector = SkeletonCollector(context: context) let skeletons = skeletonCollector.collectFromTests() - // NOTE: Find the product artifact from the default build directory + // NOTE: Find the product artifact(s) from the default build directory // because PackageManager.BuildResult doesn't include the // product artifact for tests. // This doesn't work when `--scratch-path` is used but // we don't have a way to guess the correct path. (we can find // the path by building a dummy executable product but it's // not worth the overhead) - var productArtifact: URL? - for fileExtension in ["wasm", "xctest"] { - let packageDir = context.package.directoryURL - let path = packageDir.appending(path: ".build/debug/\(productName).\(fileExtension)").path - if FileManager.default.fileExists(atPath: path) { - productArtifact = URL(fileURLWithPath: path) - break - } - } - guard let productArtifact = productArtifact else { - throw PackageToJSError( - "Failed to find '\(productName).wasm' or '\(productName).xctest'" - ) - } - let outputDir = + let productArtifacts = try findTestProductArtifacts( + productName: productName, + context: context, + options: testOptions.packageOptions + ) + + let baseOutputDir = if let outputPath = testOptions.packageOptions.outputPath { URL(fileURLWithPath: outputPath) } else { context.pluginWorkDirectoryURL.appending(path: "PackageTests") } - var make = MiniMake( - explain: testOptions.packageOptions.explain, - printProgress: self.printProgress - ) - let planner = PackagingPlanner( - options: testOptions.packageOptions, - context: context, - selfPackage: selfPackage, - skeletons: skeletons, - outputDir: outputDir, - wasmProductArtifact: productArtifact, - // If the product artifact doesn't have a .wasm extension, add it - // to deliver it with the correct MIME type when serving the test - // files for browser tests. - wasmFilename: productArtifact.lastPathComponent.hasSuffix(".wasm") - ? productArtifact.lastPathComponent - : productArtifact.lastPathComponent + ".wasm" - ) - let (rootTask, binDir) = try planner.planTestBuild( - make: &make - ) - cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) - print("Packaging tests...") - let scope = MiniMake.VariableScope(variables: [:]) - try make.build(output: rootTask, scope: scope) - print("Packaging tests finished") - if !testOptions.buildOnly { - let testRunner = scope.resolve(path: binDir.appending(path: "test.js")) - try PackageToJS.runTest( - testRunner: testRunner, - currentDirectoryURL: context.pluginWorkDirectoryURL, + // With multiple runners, install the test harness's npm dependencies once into the + // shared base directory. Each runner is packaged into a subdirectory of it, and Node + // resolves `node_modules` by walking up the directory tree, so one install serves + // them all instead of one per test target. + let sharesNodeModules = productArtifacts.count > 1 + if sharesNodeModules, let firstArtifact = productArtifacts.first { + var make = MiniMake( + explain: testOptions.packageOptions.explain, + printProgress: self.printProgress + ) + let planner = PackagingPlanner( + options: testOptions.packageOptions, + context: context, + selfPackage: selfPackage, + skeletons: skeletons, + outputDir: baseOutputDir, + wasmProductArtifact: firstArtifact, + wasmFilename: firstArtifact.lastPathComponent.hasSuffix(".wasm") + ? firstArtifact.lastPathComponent + : firstArtifact.lastPathComponent + ".wasm" + ) + let rootTask = try planner.planSharedNodeModules(make: &make) + print("Installing shared test dependencies...") + try make.build(output: rootTask, scope: MiniMake.VariableScope(variables: [:])) + } + + // The native build system links every test target into a single combined + // `PackageTests` binary, but SwiftBuild produces one runner per test + // target. Package and run each artifact. When there is more than one, give each its + // own output subdirectory and build fingerprint so their harnesses don't clobber one + // another, and keep going after a failure so every target's results are reported. + var anyTestFailed = false + for productArtifact in productArtifacts { + let runnerName = productArtifact.deletingPathExtension().lastPathComponent + let outputDir = + productArtifacts.count == 1 + ? baseOutputDir + : baseOutputDir.appending(path: runnerName) + + var make = MiniMake( + explain: testOptions.packageOptions.explain, + printProgress: self.printProgress + ) + let planner = PackagingPlanner( + options: testOptions.packageOptions, + context: context, + selfPackage: selfPackage, + skeletons: skeletons, outputDir: outputDir, - testOptions: testOptions + wasmProductArtifact: productArtifact, + // If the product artifact doesn't have a .wasm extension, add it + // to deliver it with the correct MIME type when serving the test + // files for browser tests. + wasmFilename: productArtifact.lastPathComponent.hasSuffix(".wasm") + ? productArtifact.lastPathComponent + : productArtifact.lastPathComponent + ".wasm" ) + let (rootTask, binDir) = try planner.planTestBuild( + make: &make, + installNodeModules: !sharesNodeModules + ) + cleanIfBuildGraphChanged( + root: rootTask, + make: make, + context: context, + fingerprintName: productArtifacts.count == 1 ? "minimake.json" : "minimake-\(runnerName).json" + ) + if productArtifacts.count == 1 { + print("Packaging tests...") + } else { + print("Packaging tests for '\(runnerName)'...") + } + let scope = MiniMake.VariableScope(variables: [:]) + try make.build(output: rootTask, scope: scope) + print("Packaging tests finished") + + // BridgeJS emits an aggregated `bridge-js.js` (generated from every test + // target's skeletons, so identical across runners). Test preludes import it from + // the base output directory, so surface a copy there when packaging into a + // per-runner subdirectory. + if outputDir != baseOutputDir { + let runnerBridge = outputDir.appending(path: "bridge-js.js") + if FileManager.default.fileExists(atPath: runnerBridge.path) { + let baseBridge = baseOutputDir.appending(path: "bridge-js.js") + try? FileManager.default.removeItem(at: baseBridge) + try FileManager.default.copyItem(at: runnerBridge, to: baseBridge) + } + } + + if !testOptions.buildOnly { + let testRunner = scope.resolve(path: binDir.appending(path: "test.js")) + do { + try PackageToJS.runTest( + testRunner: testRunner, + currentDirectoryURL: context.pluginWorkDirectoryURL, + outputDir: outputDir, + testOptions: testOptions + ) + } catch { + // Keep running the remaining test runners, but remember the failure so + // the overall command still exits non-zero. + printStderr("\(runnerName): \(error)") + anyTestFailed = true + } + } + } + + if anyTestFailed { + exit(1) + } + } + + /// Locate the test product artifact(s) to run. + /// + /// The native build system links all test targets into a single combined + /// `PackageTests` binary. SwiftBuild instead emits one + /// `-test-runner.wasm` per test target and only an aggregate + /// orchestration product, so fall back to discovering those runners. + private func findTestProductArtifacts( + productName: String, + context: PluginContext, + options: PackageToJS.PackageOptions + ) throws -> [URL] { + let fileManager = FileManager.default + let packageDir = context.package.directoryURL + let configuration = (options.configuration ?? "debug").lowercased() + + // Native combined test binary. + for fileExtension in ["wasm", "xctest"] { + let path = packageDir.appending(path: ".build/\(configuration)/\(productName).\(fileExtension)").path + if fileManager.fileExists(atPath: path) { + return [URL(fileURLWithPath: path)] + } } + + // SwiftBuild per-test-target runners: + // .build/out/Products/-webassembly-wasm32/-test-runner.wasm + let productsDir = packageDir.appending(path: ".build/out/Products") + if let configDirs = try? fileManager.contentsOfDirectory( + at: productsDir, + includingPropertiesForKeys: nil + ) { + let wasmConfigDirs = configDirs.filter { $0.lastPathComponent.hasSuffix("-webassembly-wasm32") } + let chosenDir = + wasmConfigDirs.first { $0.lastPathComponent.lowercased().hasPrefix(configuration) } + ?? wasmConfigDirs.first + if let chosenDir, + let entries = try? fileManager.contentsOfDirectory(at: chosenDir, includingPropertiesForKeys: nil) + { + let runners = + entries + .filter { $0.lastPathComponent.hasSuffix("-test-runner.wasm") } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + if !runners.isEmpty { + return runners + } + } + } + + throw PackageToJSError( + "Failed to find '\(productName).wasm' or '\(productName).xctest' (native build system), " + + "or any '*-test-runner.wasm' under .build/out/Products (swiftbuild build system)" + ) } private func buildWasm( @@ -414,9 +534,10 @@ struct PackageToJSPlugin: CommandPlugin { private func cleanIfBuildGraphChanged( root: MiniMake.TaskKey, make: MiniMake, - context: PluginContext + context: PluginContext, + fingerprintName: String = "minimake.json" ) { - let buildFingerprint = context.pluginWorkDirectoryURL.appending(path: "minimake.json") + let buildFingerprint = context.pluginWorkDirectoryURL.appending(path: fingerprintName) let lastBuildFingerprint = try? Data(contentsOf: buildFingerprint) let currentBuildFingerprint = try? make.computeFingerprint(root: root) if lastBuildFingerprint != currentBuildFingerprint {