Skip to content

Commit 137e41d

Browse files
PackageToJS: Generate each test bundle's glue from what its binary links in
`js test` generated the glue from every test target's skeletons and handed the same copy to each SwiftBuild per-target runner, which described exports those binaries don't have. Package the aggregated glue once into the shared base directory, where preludes import it by a fixed path, and give each runner glue scoped to the target it links in. This supersedes the registration guard in the JS code generator.
1 parent 281249a commit 137e41d

2 files changed

Lines changed: 146 additions & 86 deletions

File tree

Plugins/PackageToJS/Sources/PackageToJS.swift

Lines changed: 93 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -614,54 +614,13 @@ struct PackagingPlanner {
614614
packageInputs.append(packageJsonTask)
615615

616616
if !skeletons.isEmpty {
617-
let bridge = try loadBridgeJS()
618-
let skeletonFiles = skeletons.map { BuildPath(absolute: $0.source.path) }
619-
let bridgeJs = outputDir.appending(path: "bridge-js.js")
620-
let bridgeDts = outputDir.appending(path: "bridge-js.d.ts")
621-
let bridgeModules = outputDir.appending(path: "bridge-js-modules")
622617
packageInputs.append(
623-
make.addTask(
624-
inputFiles: skeletonFiles + [selfPath, wasmImportsPath],
625-
inputTasks: [outputDirTask, wasmImportsTask],
626-
output: bridgeJs
627-
) { _, scope in
628-
let features = try Self.loadWasmFeatures(at: scope.resolve(path: wasmImportsPath))
629-
let output = try bridge.link.link(sharedMemory: features.sharedMemory)
630-
try system.writeFile(
631-
atPath: scope.resolve(path: bridgeJs).path,
632-
content: Data(output.outputJs.utf8)
633-
)
634-
try system.writeFile(
635-
atPath: scope.resolve(path: bridgeDts).path,
636-
content: Data(output.outputDts.utf8)
637-
)
638-
}
639-
)
640-
let bridgeModulesStamp = intermediatesDir.appending(path: "bridge-js-modules.stamp")
641-
packageInputs.append(
642-
make.addTask(
643-
inputFiles: skeletonFiles + bridge.modules.map(\.source) + [selfPath],
644-
inputTasks: [outputDirTask, intermediatesDirTask],
645-
output: bridgeModulesStamp
646-
) { _, scope in
647-
let modulesDirectory = scope.resolve(path: bridgeModules)
648-
try system.removeItemIfExists(atPath: modulesDirectory.path)
649-
if !bridge.modules.isEmpty {
650-
try system.createDirectory(atPath: modulesDirectory.path)
651-
}
652-
for module in bridge.modules {
653-
let destination = scope.resolve(path: outputDir.appending(path: module.relativeOutputPath))
654-
try system.createDirectory(atPath: destination.deletingLastPathComponent().path)
655-
try system.syncFile(
656-
from: scope.resolve(path: module.source).path,
657-
to: destination.path
658-
)
659-
}
660-
try system.writeFile(
661-
atPath: scope.resolve(path: bridgeModulesStamp).path,
662-
content: Data()
663-
)
664-
}
618+
contentsOf: try planBridgeJS(
619+
make: &make,
620+
outputDirTask: outputDirTask,
621+
intermediatesDirTask: intermediatesDirTask,
622+
wasmImportsTask: wasmImportsTask
623+
)
665624
)
666625
}
667626

@@ -736,11 +695,15 @@ struct PackagingPlanner {
736695
)
737696
}
738697

739-
/// Plan the shared npm install for a directory that hosts several per-runner test
740-
/// bundles as subdirectories. Node resolves `node_modules` by walking up the directory
741-
/// tree, so a single install here serves every runner underneath, avoiding one install
742-
/// per test target.
743-
func planSharedNodeModules(make: inout MiniMake) throws -> MiniMake.TaskKey {
698+
/// Plan the artifacts that several per-runner test bundles packaged as subdirectories
699+
/// share.
700+
///
701+
/// Node resolves `node_modules` by walking up the directory tree, so a single install
702+
/// here serves every runner underneath, avoiding one install per test target. The glue
703+
/// generated here describes every test target rather than the one target a runner links
704+
/// in, so that test preludes - which are loaded for every runner and import it by a
705+
/// fixed path - see the whole package's API.
706+
func planSharedTestArtifacts(make: inout MiniMake) throws -> MiniMake.TaskKey {
744707
let outputDirTask = make.addTask(
745708
inputFiles: [selfPath],
746709
output: outputDir,
@@ -765,13 +728,87 @@ struct PackagingPlanner {
765728
inputFiles: [],
766729
inputTasks: []
767730
)
768-
return planNpmInstall(
769-
make: &make,
770-
intermediatesDirTask: intermediatesDirTask,
771-
packageJsonTask: packageJsonTask
731+
var tasks = [
732+
planNpmInstall(
733+
make: &make,
734+
intermediatesDirTask: intermediatesDirTask,
735+
packageJsonTask: packageJsonTask
736+
)
737+
]
738+
if !skeletons.isEmpty {
739+
tasks.append(
740+
contentsOf: try planBridgeJS(
741+
make: &make,
742+
outputDirTask: outputDirTask,
743+
intermediatesDirTask: intermediatesDirTask,
744+
wasmImportsTask: wasmImportsTask
745+
)
746+
)
747+
}
748+
return make.addTask(
749+
inputTasks: tasks,
750+
output: BuildPath(phony: "shared-test-artifacts"),
751+
attributes: [.phony, .silent]
772752
)
773753
}
774754

755+
/// Plan the tasks generating the BridgeJS glue and syncing the JavaScript modules it
756+
/// imports into the output directory
757+
private func planBridgeJS(
758+
make: inout MiniMake,
759+
outputDirTask: MiniMake.TaskKey,
760+
intermediatesDirTask: MiniMake.TaskKey,
761+
wasmImportsTask: MiniMake.TaskKey
762+
) throws -> [MiniMake.TaskKey] {
763+
let bridge = try loadBridgeJS()
764+
let skeletonFiles = skeletons.map { BuildPath(absolute: $0.source.path) }
765+
let wasmImportsPath = self.wasmImportsPath
766+
let bridgeJs = outputDir.appending(path: "bridge-js.js")
767+
let bridgeDts = outputDir.appending(path: "bridge-js.d.ts")
768+
let bridgeModules = outputDir.appending(path: "bridge-js-modules")
769+
let bridgeJsTask = make.addTask(
770+
inputFiles: skeletonFiles + [selfPath, wasmImportsPath],
771+
inputTasks: [outputDirTask, wasmImportsTask],
772+
output: bridgeJs
773+
) { _, scope in
774+
let features = try Self.loadWasmFeatures(at: scope.resolve(path: wasmImportsPath))
775+
let output = try bridge.link.link(sharedMemory: features.sharedMemory)
776+
try system.writeFile(
777+
atPath: scope.resolve(path: bridgeJs).path,
778+
content: Data(output.outputJs.utf8)
779+
)
780+
try system.writeFile(
781+
atPath: scope.resolve(path: bridgeDts).path,
782+
content: Data(output.outputDts.utf8)
783+
)
784+
}
785+
let bridgeModulesStamp = intermediatesDir.appending(path: "bridge-js-modules.stamp")
786+
let bridgeModulesTask = make.addTask(
787+
inputFiles: skeletonFiles + bridge.modules.map(\.source) + [selfPath],
788+
inputTasks: [outputDirTask, intermediatesDirTask],
789+
output: bridgeModulesStamp
790+
) { _, scope in
791+
let modulesDirectory = scope.resolve(path: bridgeModules)
792+
try system.removeItemIfExists(atPath: modulesDirectory.path)
793+
if !bridge.modules.isEmpty {
794+
try system.createDirectory(atPath: modulesDirectory.path)
795+
}
796+
for module in bridge.modules {
797+
let destination = scope.resolve(path: outputDir.appending(path: module.relativeOutputPath))
798+
try system.createDirectory(atPath: destination.deletingLastPathComponent().path)
799+
try system.syncFile(
800+
from: scope.resolve(path: module.source).path,
801+
to: destination.path
802+
)
803+
}
804+
try system.writeFile(
805+
atPath: scope.resolve(path: bridgeModulesStamp).path,
806+
content: Data()
807+
)
808+
}
809+
return [bridgeJsTask, bridgeModulesTask]
810+
}
811+
775812
/// Plan the task parsing the imports of the product .wasm file
776813
///
777814
/// NOTE: The imports are parsed from the product artifact instead of the final .wasm

Plugins/PackageToJS/Sources/PackageToJSPlugin.swift

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,12 @@ struct PackageToJSPlugin: CommandPlugin {
294294
context.pluginWorkDirectoryURL.appending(path: "PackageTests")
295295
}
296296

297-
// With multiple runners, install the test harness's npm dependencies once into the
298-
// shared base directory. Each runner is packaged into a subdirectory of it, and Node
299-
// resolves `node_modules` by walking up the directory tree, so one install serves
300-
// them all instead of one per test target.
301-
let sharesNodeModules = productArtifacts.count > 1
302-
if sharesNodeModules, let firstArtifact = productArtifacts.first {
297+
// With multiple runners, package what they share into the base directory once: the
298+
// test harness's npm dependencies, and the glue describing every test target that
299+
// preludes import by a fixed path. Each runner is packaged into a subdirectory of
300+
// it.
301+
let hasSharedArtifacts = productArtifacts.count > 1
302+
if hasSharedArtifacts, let firstArtifact = productArtifacts.first {
303303
var make = MiniMake(
304304
explain: testOptions.packageOptions.explain,
305305
printProgress: self.printProgress
@@ -315,8 +315,8 @@ struct PackageToJSPlugin: CommandPlugin {
315315
? firstArtifact.lastPathComponent
316316
: firstArtifact.lastPathComponent + ".wasm"
317317
)
318-
let rootTask = try planner.planSharedNodeModules(make: &make)
319-
print("Installing shared test dependencies...")
318+
let rootTask = try planner.planSharedTestArtifacts(make: &make)
319+
print("Packaging shared test artifacts...")
320320
try make.build(output: rootTask, scope: MiniMake.VariableScope(variables: [:]))
321321
}
322322

@@ -341,7 +341,14 @@ struct PackageToJSPlugin: CommandPlugin {
341341
options: testOptions.packageOptions,
342342
context: context,
343343
selfPackage: selfPackage,
344-
skeletons: skeletons,
344+
// Generate the glue from what this binary actually links in: the combined
345+
// binary holds every test target, while a per-target runner holds one.
346+
skeletons: runnerSkeletons(
347+
runnerName: runnerName,
348+
isCombinedBinary: !hasSharedArtifacts,
349+
context: context,
350+
aggregated: skeletons
351+
),
345352
outputDir: outputDir,
346353
wasmProductArtifact: productArtifact,
347354
// If the product artifact doesn't have a .wasm extension, add it
@@ -353,7 +360,7 @@ struct PackageToJSPlugin: CommandPlugin {
353360
)
354361
let (rootTask, binDir) = try planner.planTestBuild(
355362
make: &make,
356-
installNodeModules: !sharesNodeModules
363+
installNodeModules: !hasSharedArtifacts
357364
)
358365
cleanIfBuildGraphChanged(
359366
root: rootTask,
@@ -370,21 +377,6 @@ struct PackageToJSPlugin: CommandPlugin {
370377
try make.build(output: rootTask, scope: scope)
371378
print("Packaging tests finished")
372379

373-
// BridgeJS emits an aggregated `bridge-js.js` (generated from every test
374-
// target's skeletons, so identical across runners). Test preludes import it from
375-
// the base output directory, so surface a copy there when packaging into a
376-
// per-runner subdirectory. The glue imports the JavaScript modules it was linked
377-
// against by a path relative to itself, so carry those over as well.
378-
if outputDir != baseOutputDir {
379-
for entry in ["bridge-js.js", "bridge-js-modules"] {
380-
let packaged = outputDir.appending(path: entry)
381-
guard FileManager.default.fileExists(atPath: packaged.path) else { continue }
382-
let shared = baseOutputDir.appending(path: entry)
383-
try? FileManager.default.removeItem(at: shared)
384-
try FileManager.default.copyItem(at: packaged, to: shared)
385-
}
386-
}
387-
388380
if !testOptions.buildOnly {
389381
let testRunner = scope.resolve(path: binDir.appending(path: "test.js"))
390382
do {
@@ -408,6 +400,25 @@ struct PackageToJSPlugin: CommandPlugin {
408400
}
409401
}
410402

403+
/// The skeletons describing the API a single test binary exposes.
404+
///
405+
/// The native build system links every test target into one binary, so it gets the
406+
/// skeletons of all of them. SwiftBuild produces a `<TestTarget>-test-runner` per test
407+
/// target, which only exposes that target and its dependencies.
408+
private func runnerSkeletons(
409+
runnerName: String,
410+
isCombinedBinary: Bool,
411+
context: PluginContext,
412+
aggregated: [BridgeJSSkeletonInput]
413+
) -> [BridgeJSSkeletonInput] {
414+
let testRunnerSuffix = "-test-runner"
415+
guard !isCombinedBinary, runnerName.hasSuffix(testRunnerSuffix) else {
416+
return aggregated
417+
}
418+
let targetName = String(runnerName.dropLast(testRunnerSuffix.count))
419+
return SkeletonCollector(context: context).collectFromTest(targetName: targetName)
420+
}
421+
411422
/// Locate the test product artifact(s) to run.
412423
///
413424
/// The native build system links all test targets into a single combined
@@ -854,16 +865,28 @@ class SkeletonCollector {
854865
}
855866

856867
func collectFromTests() -> [BridgeJSSkeletonInput] {
857-
let tests = context.package.targets.filter {
858-
guard let target = $0 as? SwiftSourceModuleTarget else { return false }
859-
return target.kind == .test
860-
}
861-
for test in tests {
868+
for test in testTargets {
862869
visit(target: test, package: context.package)
863870
}
864871
return skeletons
865872
}
866873

874+
/// Collect the skeletons of a single test target and everything it links in.
875+
func collectFromTest(targetName: String) -> [BridgeJSSkeletonInput] {
876+
guard let test = testTargets.first(where: { $0.name == targetName }) else {
877+
return []
878+
}
879+
visit(target: test, package: context.package)
880+
return skeletons
881+
}
882+
883+
private var testTargets: [Target] {
884+
context.package.targets.filter {
885+
guard let target = $0 as? SwiftSourceModuleTarget else { return false }
886+
return target.kind == .test
887+
}
888+
}
889+
867890
private func visit(product: Product, package: Package) {
868891
if visitedProducts.contains(product.id) { return }
869892
visitedProducts.insert(product.id)

0 commit comments

Comments
 (0)