@@ -264,69 +264,189 @@ struct PackageToJSPlugin: CommandPlugin {
264264 let skeletonCollector = SkeletonCollector ( context: context)
265265 let skeletons = skeletonCollector. collectFromTests ( )
266266
267- // NOTE: Find the product artifact from the default build directory
267+ // NOTE: Find the product artifact(s) from the default build directory
268268 // because PackageManager.BuildResult doesn't include the
269269 // product artifact for tests.
270270 // This doesn't work when `--scratch-path` is used but
271271 // we don't have a way to guess the correct path. (we can find
272272 // the path by building a dummy executable product but it's
273273 // not worth the overhead)
274- var productArtifact : URL ?
275- for fileExtension in [ " wasm " , " xctest " ] {
276- let packageDir = context. package . directoryURL
277- let path = packageDir. appending ( path: " .build/debug/ \( productName) . \( fileExtension) " ) . path
278- if FileManager . default. fileExists ( atPath: path) {
279- productArtifact = URL ( fileURLWithPath: path)
280- break
281- }
282- }
283- guard let productArtifact = productArtifact else {
284- throw PackageToJSError (
285- " Failed to find ' \( productName) .wasm' or ' \( productName) .xctest' "
286- )
287- }
288- let outputDir =
274+ let productArtifacts = try findTestProductArtifacts (
275+ productName: productName,
276+ context: context,
277+ options: testOptions. packageOptions
278+ )
279+
280+ let baseOutputDir =
289281 if let outputPath = testOptions. packageOptions. outputPath {
290282 URL ( fileURLWithPath: outputPath)
291283 } else {
292284 context. pluginWorkDirectoryURL. appending ( path: " PackageTests " )
293285 }
294- var make = MiniMake (
295- explain: testOptions. packageOptions. explain,
296- printProgress: self . printProgress
297- )
298- let planner = PackagingPlanner (
299- options: testOptions. packageOptions,
300- context: context,
301- selfPackage: selfPackage,
302- skeletons: skeletons,
303- outputDir: outputDir,
304- wasmProductArtifact: productArtifact,
305- // If the product artifact doesn't have a .wasm extension, add it
306- // to deliver it with the correct MIME type when serving the test
307- // files for browser tests.
308- wasmFilename: productArtifact. lastPathComponent. hasSuffix ( " .wasm " )
309- ? productArtifact. lastPathComponent
310- : productArtifact. lastPathComponent + " .wasm "
311- )
312- let ( rootTask, binDir) = try planner. planTestBuild (
313- make: & make
314- )
315- cleanIfBuildGraphChanged ( root: rootTask, make: make, context: context)
316- print ( " Packaging tests... " )
317- let scope = MiniMake . VariableScope ( variables: [ : ] )
318- try make. build ( output: rootTask, scope: scope)
319- print ( " Packaging tests finished " )
320286
321- if !testOptions. buildOnly {
322- let testRunner = scope. resolve ( path: binDir. appending ( path: " test.js " ) )
323- try PackageToJS . runTest (
324- testRunner: testRunner,
325- currentDirectoryURL: context. pluginWorkDirectoryURL,
287+ // With multiple runners, install the test harness's npm dependencies once into the
288+ // shared base directory. Each runner is packaged into a subdirectory of it, and Node
289+ // resolves `node_modules` by walking up the directory tree, so one install serves
290+ // them all instead of one per test target.
291+ let sharesNodeModules = productArtifacts. count > 1
292+ if sharesNodeModules, let firstArtifact = productArtifacts. first {
293+ var make = MiniMake (
294+ explain: testOptions. packageOptions. explain,
295+ printProgress: self . printProgress
296+ )
297+ let planner = PackagingPlanner (
298+ options: testOptions. packageOptions,
299+ context: context,
300+ selfPackage: selfPackage,
301+ skeletons: skeletons,
302+ outputDir: baseOutputDir,
303+ wasmProductArtifact: firstArtifact,
304+ wasmFilename: firstArtifact. lastPathComponent. hasSuffix ( " .wasm " )
305+ ? firstArtifact. lastPathComponent
306+ : firstArtifact. lastPathComponent + " .wasm "
307+ )
308+ let rootTask = try planner. planSharedNodeModules ( make: & make)
309+ print ( " Installing shared test dependencies... " )
310+ try make. build ( output: rootTask, scope: MiniMake . VariableScope ( variables: [ : ] ) )
311+ }
312+
313+ // The native build system links every test target into a single combined
314+ // `<Package>PackageTests` binary, but SwiftBuild produces one runner per test
315+ // target. Package and run each artifact. When there is more than one, give each its
316+ // own output subdirectory and build fingerprint so their harnesses don't clobber one
317+ // another, and keep going after a failure so every target's results are reported.
318+ var anyTestFailed = false
319+ for productArtifact in productArtifacts {
320+ let runnerName = productArtifact. deletingPathExtension ( ) . lastPathComponent
321+ let outputDir =
322+ productArtifacts. count == 1
323+ ? baseOutputDir
324+ : baseOutputDir. appending ( path: runnerName)
325+
326+ var make = MiniMake (
327+ explain: testOptions. packageOptions. explain,
328+ printProgress: self . printProgress
329+ )
330+ let planner = PackagingPlanner (
331+ options: testOptions. packageOptions,
332+ context: context,
333+ selfPackage: selfPackage,
334+ skeletons: skeletons,
326335 outputDir: outputDir,
327- testOptions: testOptions
336+ wasmProductArtifact: productArtifact,
337+ // If the product artifact doesn't have a .wasm extension, add it
338+ // to deliver it with the correct MIME type when serving the test
339+ // files for browser tests.
340+ wasmFilename: productArtifact. lastPathComponent. hasSuffix ( " .wasm " )
341+ ? productArtifact. lastPathComponent
342+ : productArtifact. lastPathComponent + " .wasm "
328343 )
344+ let ( rootTask, binDir) = try planner. planTestBuild (
345+ make: & make,
346+ installNodeModules: !sharesNodeModules
347+ )
348+ cleanIfBuildGraphChanged (
349+ root: rootTask,
350+ make: make,
351+ context: context,
352+ fingerprintName: productArtifacts. count == 1 ? " minimake.json " : " minimake- \( runnerName) .json "
353+ )
354+ if productArtifacts. count == 1 {
355+ print ( " Packaging tests... " )
356+ } else {
357+ print ( " Packaging tests for ' \( runnerName) '... " )
358+ }
359+ let scope = MiniMake . VariableScope ( variables: [ : ] )
360+ try make. build ( output: rootTask, scope: scope)
361+ print ( " Packaging tests finished " )
362+
363+ // BridgeJS emits an aggregated `bridge-js.js` (generated from every test
364+ // target's skeletons, so identical across runners). Test preludes import it from
365+ // the base output directory, so surface a copy there when packaging into a
366+ // per-runner subdirectory.
367+ if outputDir != baseOutputDir {
368+ let runnerBridge = outputDir. appending ( path: " bridge-js.js " )
369+ if FileManager . default. fileExists ( atPath: runnerBridge. path) {
370+ let baseBridge = baseOutputDir. appending ( path: " bridge-js.js " )
371+ try ? FileManager . default. removeItem ( at: baseBridge)
372+ try FileManager . default. copyItem ( at: runnerBridge, to: baseBridge)
373+ }
374+ }
375+
376+ if !testOptions. buildOnly {
377+ let testRunner = scope. resolve ( path: binDir. appending ( path: " test.js " ) )
378+ do {
379+ try PackageToJS . runTest (
380+ testRunner: testRunner,
381+ currentDirectoryURL: context. pluginWorkDirectoryURL,
382+ outputDir: outputDir,
383+ testOptions: testOptions
384+ )
385+ } catch {
386+ // Keep running the remaining test runners, but remember the failure so
387+ // the overall command still exits non-zero.
388+ printStderr ( " \( runnerName) : \( error) " )
389+ anyTestFailed = true
390+ }
391+ }
392+ }
393+
394+ if anyTestFailed {
395+ exit ( 1 )
396+ }
397+ }
398+
399+ /// Locate the test product artifact(s) to run.
400+ ///
401+ /// The native build system links all test targets into a single combined
402+ /// `<Package>PackageTests` binary. SwiftBuild instead emits one
403+ /// `<TestTarget>-test-runner.wasm` per test target and only an aggregate
404+ /// orchestration product, so fall back to discovering those runners.
405+ private func findTestProductArtifacts(
406+ productName: String ,
407+ context: PluginContext ,
408+ options: PackageToJS . PackageOptions
409+ ) throws -> [ URL ] {
410+ let fileManager = FileManager . default
411+ let packageDir = context. package . directoryURL
412+ let configuration = ( options. configuration ?? " debug " ) . lowercased ( )
413+
414+ // Native combined test binary.
415+ for fileExtension in [ " wasm " , " xctest " ] {
416+ let path = packageDir. appending ( path: " .build/ \( configuration) / \( productName) . \( fileExtension) " ) . path
417+ if fileManager. fileExists ( atPath: path) {
418+ return [ URL ( fileURLWithPath: path) ]
419+ }
329420 }
421+
422+ // SwiftBuild per-test-target runners:
423+ // .build/out/Products/<Config>-webassembly-wasm32/<TestTarget>-test-runner.wasm
424+ let productsDir = packageDir. appending ( path: " .build/out/Products " )
425+ if let configDirs = try ? fileManager. contentsOfDirectory (
426+ at: productsDir,
427+ includingPropertiesForKeys: nil
428+ ) {
429+ let wasmConfigDirs = configDirs. filter { $0. lastPathComponent. hasSuffix ( " -webassembly-wasm32 " ) }
430+ let chosenDir =
431+ wasmConfigDirs. first { $0. lastPathComponent. lowercased ( ) . hasPrefix ( configuration) }
432+ ?? wasmConfigDirs. first
433+ if let chosenDir,
434+ let entries = try ? fileManager. contentsOfDirectory ( at: chosenDir, includingPropertiesForKeys: nil )
435+ {
436+ let runners =
437+ entries
438+ . filter { $0. lastPathComponent. hasSuffix ( " -test-runner.wasm " ) }
439+ . sorted { $0. lastPathComponent < $1. lastPathComponent }
440+ if !runners. isEmpty {
441+ return runners
442+ }
443+ }
444+ }
445+
446+ throw PackageToJSError (
447+ " Failed to find ' \( productName) .wasm' or ' \( productName) .xctest' (native build system), "
448+ + " or any '*-test-runner.wasm' under .build/out/Products (swiftbuild build system) "
449+ )
330450 }
331451
332452 private func buildWasm(
@@ -414,9 +534,10 @@ struct PackageToJSPlugin: CommandPlugin {
414534 private func cleanIfBuildGraphChanged(
415535 root: MiniMake . TaskKey ,
416536 make: MiniMake ,
417- context: PluginContext
537+ context: PluginContext ,
538+ fingerprintName: String = " minimake.json "
418539 ) {
419- let buildFingerprint = context. pluginWorkDirectoryURL. appending ( path: " minimake.json " )
540+ let buildFingerprint = context. pluginWorkDirectoryURL. appending ( path: fingerprintName )
420541 let lastBuildFingerprint = try ? Data ( contentsOf: buildFingerprint)
421542 let currentBuildFingerprint = try ? make. computeFingerprint ( root: root)
422543 if lastBuildFingerprint != currentBuildFingerprint {
0 commit comments