Skip to content

Commit c12c7e9

Browse files
PackageToJS: run per-target test runners under the swiftbuild build system
`swift package js test` assumed the native build system's single combined `<Package>PackageTests` binary: it looked for one `.wasm`/`.xctest` under `.build/<config>/`, packaged it, and ran it. SwiftBuild produces no combined test binary. It emits one `<TestTarget>-test-runner.wasm` per test target under `.build/out/Products/`, plus an aggregate target that only orchestrates building them, so the old lookup failed with "Failed to find 'JavaScriptKitPackageTests.wasm'".
1 parent 0e35c39 commit c12c7e9

2 files changed

Lines changed: 253 additions & 66 deletions

File tree

Plugins/PackageToJS/Sources/PackageToJS.swift

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,19 @@ struct PackageToJS {
7575
// First, resolve symlink to get the actual path as SwiftPM 6.0 and earlier returns unresolved
7676
// symlink path for product artifact.
7777
let wasmProductArtifact = wasmProductArtifact.resolvingSymlinksInPath()
78-
let buildConfiguration = wasmProductArtifact.deletingLastPathComponent().lastPathComponent
78+
let parentDirName = wasmProductArtifact.deletingLastPathComponent().lastPathComponent
79+
80+
// SwiftBuild lays artifacts out as
81+
// .build/out/Products/<Config>-webassembly-wasm32/<name>.wasm
82+
// which doesn't encode the triple in the path. Recognize that layout and map it to
83+
// the WebAssembly triple JavaScriptKit builds for.
84+
if parentDirName.hasSuffix("-webassembly-wasm32") {
85+
let buildConfiguration = parentDirName.split(separator: "-").first.map { $0.lowercased() } ?? "debug"
86+
return (buildConfiguration, "wasm32-unknown-wasip1")
87+
}
88+
89+
// Native layout: .build/<triple>/<config>/<name>.wasm
90+
let buildConfiguration = parentDirName
7991
let triple = wasmProductArtifact.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent
8092
return (buildConfiguration, triple)
8193
}
@@ -645,30 +657,84 @@ struct PackagingPlanner {
645657
return (packageInputs, outputDirTask, intermediatesDirTask, packageJsonTask)
646658
}
647659

660+
/// Plan the shared npm install for a directory that hosts several per-runner test
661+
/// bundles as subdirectories. Node resolves `node_modules` by walking up the directory
662+
/// tree, so a single install here serves every runner underneath, avoiding one install
663+
/// per test target.
664+
func planSharedNodeModules(make: inout MiniMake) throws -> MiniMake.TaskKey {
665+
let outputDirTask = make.addTask(
666+
inputFiles: [selfPath],
667+
output: outputDir,
668+
attributes: [.silent]
669+
) {
670+
try system.createDirectory(atPath: $1.resolve(path: $0.output).path)
671+
}
672+
let intermediatesDirTask = make.addTask(
673+
inputFiles: [selfPath],
674+
output: intermediatesDir,
675+
attributes: [.silent]
676+
) {
677+
try system.createDirectory(atPath: $1.resolve(path: $0.output).path)
678+
}
679+
let packageJsonTask = planCopyTemplateFile(
680+
make: &make,
681+
file: "Plugins/PackageToJS/Templates/package.json",
682+
output: "package.json",
683+
outputDirTask: outputDirTask,
684+
inputFiles: [],
685+
inputTasks: []
686+
)
687+
return planNpmInstall(
688+
make: &make,
689+
intermediatesDirTask: intermediatesDirTask,
690+
packageJsonTask: packageJsonTask
691+
)
692+
}
693+
694+
private func planNpmInstall(
695+
make: inout MiniMake,
696+
intermediatesDirTask: MiniMake.TaskKey,
697+
packageJsonTask: MiniMake.TaskKey
698+
) -> MiniMake.TaskKey {
699+
make.addTask(
700+
inputFiles: [
701+
selfPath,
702+
outputDir.appending(path: "package.json"),
703+
],
704+
inputTasks: [intermediatesDirTask, packageJsonTask],
705+
output: intermediatesDir.appending(path: "npm-install.stamp")
706+
) {
707+
try system.npmInstall(packageDir: $1.resolve(path: outputDir).path)
708+
try system.writeFile(atPath: $1.resolve(path: $0.output).path, content: Data())
709+
}
710+
}
711+
648712
/// Construct the test build plan and return the root task key
713+
///
714+
/// - Parameter installNodeModules: whether to install the test harness's npm
715+
/// dependencies into this bundle's directory. Pass `false` when several runners share
716+
/// a single `node_modules` planned once via `planSharedNodeModules`.
649717
func planTestBuild(
650-
make: inout MiniMake
718+
make: inout MiniMake,
719+
installNodeModules: Bool = true
651720
) throws -> (rootTask: MiniMake.TaskKey, binDir: BuildPath) {
652721
var (allTasks, outputDirTask, intermediatesDirTask, packageJsonTask) = try planBuildInternal(
653722
make: &make,
654723
noOptimize: false,
655724
debugInfoFormat: .dwarf
656725
)
657726

658-
// Install npm dependencies used in the test harness
659-
allTasks.append(
660-
make.addTask(
661-
inputFiles: [
662-
selfPath,
663-
outputDir.appending(path: "package.json"),
664-
],
665-
inputTasks: [intermediatesDirTask, packageJsonTask],
666-
output: intermediatesDir.appending(path: "npm-install.stamp")
667-
) {
668-
try system.npmInstall(packageDir: $1.resolve(path: outputDir).path)
669-
try system.writeFile(atPath: $1.resolve(path: $0.output).path, content: Data())
670-
}
671-
)
727+
// Install npm dependencies used in the test harness, unless a shared node_modules is
728+
// provided in a parent directory (see planSharedNodeModules).
729+
if installNodeModules {
730+
allTasks.append(
731+
planNpmInstall(
732+
make: &make,
733+
intermediatesDirTask: intermediatesDirTask,
734+
packageJsonTask: packageJsonTask
735+
)
736+
)
737+
}
672738

673739
let binDir = outputDir.appending(path: "bin")
674740
let binDirTask = make.addTask(

Plugins/PackageToJS/Sources/PackageToJSPlugin.swift

Lines changed: 171 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)