Skip to content

Commit 33bf228

Browse files
Add test subcommand to PackageToJS plugin
1 parent eac6e6e commit 33bf228

File tree

7 files changed

+353
-155
lines changed

7 files changed

+353
-155
lines changed

Diff for: Examples/Testing/run-tests.mjs

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as t from "./.build/plugins/PackageToJS/outputs/PackageTests/test.js"
2+
3+
console.log(t)

Diff for: Examples/Testing/test.node.mjs

-14
This file was deleted.

Diff for: Plugins/PackageToJS/PackageToJS.swift

+147-141
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import PackagePlugin
44
@main
55
struct PackageToJS: CommandPlugin {
66
struct Options {
7-
/// Product to build (default: executable target if there's only one)
8-
var product: String?
97
/// Path to the output directory
108
var outputPath: String?
119
/// Name of the package (default: lowercased Package.swift name)
@@ -14,34 +12,82 @@ struct PackageToJS: CommandPlugin {
1412
var explain: Bool = false
1513

1614
static func parse(from extractor: inout ArgumentExtractor) -> Options {
17-
let product = extractor.extractOption(named: "product").last
1815
let outputPath = extractor.extractOption(named: "output").last
1916
let packageName = extractor.extractOption(named: "package-name").last
2017
let explain = extractor.extractFlag(named: "explain")
2118
return Options(
22-
product: product, outputPath: outputPath, packageName: packageName,
23-
explain: explain != 0
19+
outputPath: outputPath, packageName: packageName, explain: explain != 0
2420
)
2521
}
22+
}
23+
24+
struct BuildOptions {
25+
/// Product to build (default: executable target if there's only one)
26+
var product: String?
27+
var options: Options
28+
29+
static func parse(from extractor: inout ArgumentExtractor) -> BuildOptions {
30+
let product = extractor.extractOption(named: "product").last
31+
let options = Options.parse(from: &extractor)
32+
return BuildOptions(product: product, options: options)
33+
}
2634

2735
static func help() -> String {
2836
return """
29-
Usage: swift package --swift-sdk <swift-sdk> [swift-package options] plugin run PackageToJS [options]
37+
OVERVIEW: Builds a JavaScript module from a Swift package.
3038
31-
Options:
39+
USAGE: swift package --swift-sdk <swift-sdk> [SwiftPM options] PackageToJS [options] [subcommand]
40+
41+
OPTIONS:
3242
--product <product> Product to build (default: executable target if there's only one)
3343
--output <path> Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package)
3444
--package-name <name> Name of the package (default: lowercased Package.swift name)
3545
--explain Whether to explain the build plan
3646
37-
Examples:
47+
SUBCOMMANDS:
48+
test Builds and runs tests
49+
50+
EXAMPLES:
3851
$ swift package --swift-sdk wasm32-unknown-wasi plugin js
52+
# Build a specific product
3953
$ swift package --swift-sdk wasm32-unknown-wasi plugin js --product Example
54+
# Build in release configuration
4055
$ swift package --swift-sdk wasm32-unknown-wasi -c release plugin js
56+
57+
# Run tests
58+
$ swift package --swift-sdk wasm32-unknown-wasi plugin js test
4159
"""
4260
}
4361
}
4462

63+
struct TestOptions {
64+
/// Whether to only build tests, don't run them
65+
var buildOnly: Bool = false
66+
var options: Options
67+
68+
static func parse(from extractor: inout ArgumentExtractor) -> TestOptions {
69+
let buildOnly = extractor.extractFlag(named: "build-only")
70+
let options = Options.parse(from: &extractor)
71+
return TestOptions(buildOnly: buildOnly != 0, options: options)
72+
}
73+
74+
static func help() -> String {
75+
return """
76+
OVERVIEW: Builds and runs tests
77+
78+
USAGE: swift package --swift-sdk <swift-sdk> [SwiftPM options] PackageToJS test [options]
79+
80+
OPTIONS:
81+
--build-only Whether to build only (default: false)
82+
83+
EXAMPLES:
84+
$ swift package --swift-sdk wasm32-unknown-wasi plugin js test
85+
# Just build tests, don't run them
86+
$ swift package --swift-sdk wasm32-unknown-wasi plugin js test --build-only
87+
"""
88+
}
89+
}
90+
4591
static let friendlyBuildDiagnostics:
4692
[@Sendable (_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [
4793
(
@@ -83,58 +129,129 @@ struct PackageToJS: CommandPlugin {
83129
"""
84130
}),
85131
]
132+
static private func reportBuildFailure(_ build: PackageManager.BuildResult, _ arguments: [String]) {
133+
for diagnostic in Self.friendlyBuildDiagnostics {
134+
if let message = diagnostic(build, arguments) {
135+
printStderr("\n" + message)
136+
}
137+
}
138+
}
86139

87140
func performCommand(context: PluginContext, arguments: [String]) throws {
88141
if arguments.contains(where: { ["-h", "--help"].contains($0) }) {
89-
printStderr(Options.help())
142+
printStderr(BuildOptions.help())
90143
return
91144
}
92145

146+
if arguments.first == "test" {
147+
return try performTestCommand(context: context, arguments: Array(arguments.dropFirst()))
148+
}
149+
150+
return try performBuildCommand(context: context, arguments: arguments)
151+
}
152+
153+
static let JAVASCRIPTKIT_PACKAGE_ID: Package.ID = "javascriptkit"
154+
155+
func performBuildCommand(context: PluginContext, arguments: [String]) throws {
93156
var extractor = ArgumentExtractor(arguments)
94-
let options = Options.parse(from: &extractor)
157+
let buildOptions = BuildOptions.parse(from: &extractor)
95158

96159
if extractor.remainingArguments.count > 0 {
97160
printStderr(
98161
"Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))")
99-
printStderr(Options.help())
162+
printStderr(BuildOptions.help())
100163
exit(1)
101164
}
102165

103166
// Build products
104-
let (productArtifact, build) = try buildWasm(options: options, context: context)
105-
guard let productArtifact = productArtifact else {
106-
for diagnostic in Self.friendlyBuildDiagnostics {
107-
if let message = diagnostic(build, arguments) {
108-
printStderr("\n" + message)
109-
}
110-
}
167+
let productName = try buildOptions.product ?? deriveDefaultProduct(package: context.package)
168+
let build = try buildWasm(productName: productName, context: context)
169+
guard build.succeeded else {
170+
Self.reportBuildFailure(build, arguments)
111171
exit(1)
112172
}
173+
let productArtifact = try build.findWasmArtifact(for: productName)
113174
let outputDir =
114-
if let outputPath = options.outputPath {
175+
if let outputPath = buildOptions.options.outputPath {
115176
URL(fileURLWithPath: outputPath)
116177
} else {
117178
context.pluginWorkDirectoryURL.appending(path: "Package")
118179
}
119180
guard
120181
let selfPackage = findPackageInDependencies(
121-
package: context.package, id: "javascriptkit")
182+
package: context.package, id: Self.JAVASCRIPTKIT_PACKAGE_ID)
122183
else {
123184
throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?")
124185
}
125-
var make = MiniMake(explain: options.explain)
126-
let allTask = constructPackagingPlan(
127-
make: &make, options: options, context: context, wasmProductArtifact: productArtifact,
128-
selfPackage: selfPackage, outputDir: outputDir)
129-
cleanIfBuildGraphChanged(root: allTask, make: make, context: context)
186+
var make = MiniMake(explain: buildOptions.options.explain)
187+
let planner = PackagingPlanner(
188+
options: buildOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir)
189+
let rootTask = planner.planBuild(
190+
make: &make, wasmProductArtifact: productArtifact)
191+
cleanIfBuildGraphChanged(root: rootTask, make: make, context: context)
130192
print("Packaging...")
131-
try make.build(output: allTask)
193+
try make.build(output: rootTask)
132194
print("Packaging finished")
133195
}
134196

135-
private func buildWasm(options: Options, context: PluginContext) throws -> (
136-
productArtifact: URL?, build: PackageManager.BuildResult
137-
) {
197+
func performTestCommand(context: PluginContext, arguments: [String]) throws {
198+
var extractor = ArgumentExtractor(arguments)
199+
let testOptions = TestOptions.parse(from: &extractor)
200+
201+
if extractor.remainingArguments.count > 0 {
202+
printStderr("Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))")
203+
printStderr(TestOptions.help())
204+
exit(1)
205+
}
206+
207+
let productName = "\(context.package.displayName)PackageTests"
208+
let build = try buildWasm(productName: productName, context: context)
209+
guard build.succeeded else {
210+
Self.reportBuildFailure(build, arguments)
211+
exit(1)
212+
}
213+
214+
// NOTE: Find the product artifact from the default build directory
215+
// because PackageManager.BuildResult doesn't include the
216+
// product artifact for tests.
217+
// This doesn't work when `--scratch-path` is used but
218+
// we don't have a way to guess the correct path. (we can find
219+
// the path by building a dummy executable product but it's
220+
// not worth the overhead)
221+
var productArtifact: URL?
222+
for fileExtension in ["wasm", "xctest"] {
223+
let path = ".build/debug/\(productName).\(fileExtension)"
224+
if FileManager.default.fileExists(atPath: path) {
225+
productArtifact = URL(fileURLWithPath: path)
226+
break
227+
}
228+
}
229+
guard let productArtifact = productArtifact else {
230+
throw PackageToJSError("Failed to find '\(productName).wasm' or '\(productName).xctest'")
231+
}
232+
let outputDir = if let outputPath = testOptions.options.outputPath {
233+
URL(fileURLWithPath: outputPath)
234+
} else {
235+
context.pluginWorkDirectoryURL.appending(path: "PackageTests")
236+
}
237+
guard
238+
let selfPackage = findPackageInDependencies(
239+
package: context.package, id: Self.JAVASCRIPTKIT_PACKAGE_ID)
240+
else {
241+
throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?")
242+
}
243+
var make = MiniMake(explain: testOptions.options.explain)
244+
let planner = PackagingPlanner(
245+
options: testOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir)
246+
let rootTask = planner.planTestBuild(
247+
make: &make, wasmProductArtifact: productArtifact)
248+
cleanIfBuildGraphChanged(root: rootTask, make: make, context: context)
249+
print("Packaging tests...")
250+
try make.build(output: rootTask)
251+
print("Packaging tests finished")
252+
}
253+
254+
private func buildWasm(productName: String, context: PluginContext) throws -> PackageManager.BuildResult {
138255
var parameters = PackageManager.BuildParameters(
139256
configuration: .inherit,
140257
logging: .concise
@@ -154,118 +271,7 @@ struct PackageToJS: CommandPlugin {
154271
"--export-if-defined=__main_argc_argv"
155272
]
156273
}
157-
let productName = try options.product ?? deriveDefaultProduct(package: context.package)
158-
let build = try self.packageManager.build(.product(productName), parameters: parameters)
159-
160-
var productArtifact: URL?
161-
if build.succeeded {
162-
let testProductName = "\(context.package.displayName)PackageTests"
163-
if productName == testProductName {
164-
for fileExtension in ["wasm", "xctest"] {
165-
let path = ".build/debug/\(testProductName).\(fileExtension)"
166-
if FileManager.default.fileExists(atPath: path) {
167-
productArtifact = URL(fileURLWithPath: path)
168-
break
169-
}
170-
}
171-
} else {
172-
productArtifact = try build.findWasmArtifact(for: productName)
173-
}
174-
}
175-
176-
return (productArtifact, build)
177-
}
178-
179-
/// Construct the build plan and return the root task key
180-
private func constructPackagingPlan(
181-
make: inout MiniMake,
182-
options: Options,
183-
context: PluginContext,
184-
wasmProductArtifact: URL,
185-
selfPackage: Package,
186-
outputDir: URL
187-
) -> MiniMake.TaskKey {
188-
let selfPackageURL = selfPackage.directoryURL
189-
let selfPath = String(#filePath)
190-
191-
// Prepare output directory
192-
let outputDirTask = make.addTask(
193-
inputFiles: [selfPath], output: outputDir.path, attributes: [.silent]
194-
) {
195-
guard !FileManager.default.fileExists(atPath: $0.output) else { return }
196-
try FileManager.default.createDirectory(
197-
atPath: $0.output, withIntermediateDirectories: true, attributes: nil)
198-
}
199-
200-
var packageInputs: [MiniMake.TaskKey] = []
201-
202-
func syncFile(from: String, to: String) throws {
203-
if FileManager.default.fileExists(atPath: to) {
204-
try FileManager.default.removeItem(atPath: to)
205-
}
206-
try FileManager.default.copyItem(atPath: from, toPath: to)
207-
}
208-
209-
// Copy the wasm product artifact
210-
let wasmFilename = "main.wasm"
211-
let wasm = make.addTask(
212-
inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask],
213-
output: outputDir.appending(path: wasmFilename).path
214-
) {
215-
try syncFile(from: wasmProductArtifact.path, to: $0.output)
216-
}
217-
packageInputs.append(wasm)
218-
219-
// Write package.json
220-
let packageJSON = make.addTask(
221-
inputFiles: [selfPath], inputTasks: [outputDirTask],
222-
output: outputDir.appending(path: "package.json").path
223-
) {
224-
let packageJSON = """
225-
{
226-
"name": "\(options.packageName ?? context.package.id.lowercased())",
227-
"version": "0.0.0",
228-
"type": "module",
229-
"exports": {
230-
".": "./index.js",
231-
"./wasm": "./\(wasmFilename)"
232-
},
233-
"dependencies": {
234-
"@bjorn3/browser_wasi_shim": "^0.4.1"
235-
}
236-
}
237-
"""
238-
try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8)
239-
}
240-
packageInputs.append(packageJSON)
241-
242-
// Copy the template files
243-
let substitutions = [
244-
"@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename
245-
]
246-
for (file, output) in [
247-
("Plugins/PackageToJS/Templates/index.js", "index.js"),
248-
("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"),
249-
("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"),
250-
("Plugins/PackageToJS/Templates/instantiate.d.ts", "instantiate.d.ts"),
251-
("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"),
252-
] {
253-
let inputPath = selfPackageURL.appending(path: file)
254-
let copied = make.addTask(
255-
inputFiles: [selfPath, inputPath.path], inputTasks: [outputDirTask],
256-
output: outputDir.appending(path: output).path
257-
) {
258-
var content = try String(contentsOf: inputPath, encoding: .utf8)
259-
for (key, value) in substitutions {
260-
content = content.replacingOccurrences(of: key, with: value)
261-
}
262-
try content.write(toFile: $0.output, atomically: true, encoding: .utf8)
263-
}
264-
packageInputs.append(copied)
265-
}
266-
return make.addTask(
267-
inputTasks: packageInputs, output: "all", attributes: [.phony, .silent]
268-
) { _ in }
274+
return try self.packageManager.build(.product(productName), parameters: parameters)
269275
}
270276

271277
/// Clean if the build graph of the packaging process has changed

0 commit comments

Comments
 (0)