Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ let package = Package(

.testTarget(
name: "JavaScriptKitTests",
dependencies: ["JavaScriptKit"],
dependencies: ["JavaScriptKit", "JavaScriptEventLoopTestSupport"],
swiftSettings: [
.enableExperimentalFeature("Extern"),
.define("Tracing", .when(traits: ["Tracing"])),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -219,7 +219,7 @@ let package = Package(
),
.testTarget(
name: "BridgeJSIdentityTests",
dependencies: ["JavaScriptKit", "JavaScriptEventLoop"],
dependencies: ["JavaScriptKit", "JavaScriptEventLoop", "JavaScriptEventLoopTestSupport"],
exclude: [
"bridge-js.config.json",
"Generated/JavaScript",
Expand Down
98 changes: 82 additions & 16 deletions Plugins/PackageToJS/Sources/PackageToJS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Config>-webassembly-wasm32/<name>.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/<triple>/<config>/<name>.wasm
let buildConfiguration = parentDirName
let triple = wasmProductArtifact.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent
return (buildConfiguration, triple)
}
Expand Down Expand Up @@ -645,30 +657,84 @@ 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,
noOptimize: false,
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(
Expand Down
221 changes: 171 additions & 50 deletions Plugins/PackageToJS/Sources/PackageToJSPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<Package>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
/// `<Package>PackageTests` binary. SwiftBuild instead emits one
/// `<TestTarget>-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/<Config>-webassembly-wasm32/<TestTarget>-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(
Expand Down Expand Up @@ -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 {
Expand Down
Loading