Skip to content

Setup unit test infrastructure for PackageToJS #298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 15, 2025
Merged
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
20 changes: 0 additions & 20 deletions .github/workflows/compatibility.yml

This file was deleted.

23 changes: 5 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ jobs:
id: setup-swiftwasm
with:
target: ${{ matrix.entry.target }}
- name: Configure Swift SDK
run: echo "SWIFT_SDK_ID=${{ steps.setup-swiftwasm.outputs.swift-sdk-id }}" >> $GITHUB_ENV
- name: Configure environment variables
run: |
echo "SWIFT_SDK_ID=${{ steps.setup-swiftwasm.outputs.swift-sdk-id }}" >> $GITHUB_ENV
echo "SWIFT_PATH=$(dirname $(which swiftc))" >> $GITHUB_ENV
- run: make bootstrap
- run: make unittest
# Skip unit tests with uwasi because its proc_exit throws
Expand All @@ -49,6 +51,7 @@ jobs:
run: |
make regenerate_swiftpm_resources
git diff --exit-code Sources/JavaScriptKit/Runtime
- run: swift test --package-path ./Plugins/PackageToJS

native-build:
# Check native build to make it easy to develop applications by Xcode
Expand All @@ -64,19 +67,3 @@ jobs:
- run: swift build
env:
DEVELOPER_DIR: /Applications/${{ matrix.xcode }}.app/Contents/Developer/

embedded-build:
name: Build for embedded target
runs-on: ubuntu-22.04
strategy:
matrix:
entry:
- os: ubuntu-22.04
toolchain:
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/install-swift
with:
download-url: ${{ matrix.entry.toolchain.download-url }}
- run: ./Examples/Embedded/build.sh
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ SWIFT_BUILD_FLAGS := --swift-sdk $(SWIFT_SDK_ID)
.PHONY: bootstrap
bootstrap:
npm ci
npx playwright install

.PHONY: build
build:
Expand Down
6 changes: 5 additions & 1 deletion Plugins/PackageToJS/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ let package = Package(
platforms: [.macOS(.v13)],
targets: [
.target(name: "PackageToJS"),
.testTarget(name: "PackageToJSTests", dependencies: ["PackageToJS"]),
.testTarget(
name: "PackageToJSTests",
dependencies: ["PackageToJS"],
exclude: ["__Snapshots__"]
),
]
)
155 changes: 115 additions & 40 deletions Plugins/PackageToJS/Sources/MiniMake.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ struct MiniMake {

/// Information about a task enough to capture build
/// graph changes
struct TaskInfo: Codable {
struct TaskInfo: Encodable {
/// Input tasks not yet built
let wants: [TaskKey]
/// Set of files that must be built before this task
let inputs: [String]
/// Output task name
let output: String
/// Set of file paths that must be built before this task
let inputs: [BuildPath]
/// Output file path
let output: BuildPath
/// Attributes of the task
let attributes: [TaskAttribute]
/// Salt for the task, used to differentiate between otherwise identical tasks
Expand All @@ -30,41 +30,69 @@ struct MiniMake {
/// A task to build
struct Task {
let info: TaskInfo
/// Input tasks not yet built
/// Input tasks (files and phony tasks) not yet built
let wants: Set<TaskKey>
/// Attributes of the task
let attributes: Set<TaskAttribute>
/// Display name of the task
let displayName: String
/// Key of the task
let key: TaskKey
/// Build operation
let build: (Task) throws -> Void
let build: (_ task: Task, _ scope: VariableScope) throws -> Void
/// Whether the task is done
var isDone: Bool

var inputs: [String] { self.info.inputs }
var output: String { self.info.output }
var inputs: [BuildPath] { self.info.inputs }
var output: BuildPath { self.info.output }
}

/// A task key
struct TaskKey: Codable, Hashable, Comparable, CustomStringConvertible {
struct TaskKey: Encodable, Hashable, Comparable, CustomStringConvertible {
let id: String
var description: String { self.id }

fileprivate init(id: String) {
self.id = id
}

func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.id)
}

static func < (lhs: TaskKey, rhs: TaskKey) -> Bool { lhs.id < rhs.id }
}

struct VariableScope {
let variables: [String: String]

func resolve(path: BuildPath) -> URL {
var components = [String]()
for component in path.components {
switch component {
case .prefix(let variable):
guard let value = variables[variable] else {
fatalError("Build path variable \"\(variable)\" not defined!")
}
components.append(value)
case .constant(let path):
components.append(path)
}
}
guard let first = components.first else {
fatalError("Build path is empty")
}
var url = URL(fileURLWithPath: first)
for component in components.dropFirst() {
url = url.appending(path: component)
}
return url
}
}

/// All tasks in the build system
private var tasks: [TaskKey: Task]
/// Whether to explain why tasks are built
private var shouldExplain: Bool
/// Current working directory at the time the build started
private let buildCwd: String
/// Prints progress of the build
private var printProgress: ProgressPrinter.PrintProgress

Expand All @@ -74,20 +102,16 @@ struct MiniMake {
) {
self.tasks = [:]
self.shouldExplain = explain
self.buildCwd = FileManager.default.currentDirectoryPath
self.printProgress = printProgress
}

/// Adds a task to the build system
mutating func addTask(
inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String,
inputFiles: [BuildPath] = [], inputTasks: [TaskKey] = [], output: BuildPath,
attributes: [TaskAttribute] = [], salt: (any Encodable)? = nil,
build: @escaping (Task) throws -> Void
build: @escaping (_ task: Task, _ scope: VariableScope) throws -> Void
) -> TaskKey {
let displayName =
output.hasPrefix(self.buildCwd)
? String(output.dropFirst(self.buildCwd.count + 1)) : output
let taskKey = TaskKey(id: output)
let taskKey = TaskKey(id: output.description)
let saltData = try! salt.map {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
Expand All @@ -99,17 +123,20 @@ struct MiniMake {
)
self.tasks[taskKey] = Task(
info: info, wants: Set(inputTasks), attributes: Set(attributes),
displayName: displayName, key: taskKey, build: build, isDone: false)
key: taskKey, build: build, isDone: false)
return taskKey
}

/// Computes a stable fingerprint of the build graph
///
/// This fingerprint must be stable across builds and must change
/// if the build graph changes in any way.
func computeFingerprint(root: TaskKey) throws -> Data {
func computeFingerprint(root: TaskKey, prettyPrint: Bool = false) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
if prettyPrint {
encoder.outputFormatting.insert(.prettyPrinted)
}
let tasks = self.tasks.sorted { $0.key < $1.key }.map { $0.value.info }
return try encoder.encode(tasks)
}
Expand All @@ -126,7 +153,13 @@ struct MiniMake {

/// Prints progress of the build
struct ProgressPrinter {
typealias PrintProgress = (_ subject: Task, _ total: Int, _ built: Int, _ message: String) -> Void
struct Context {
let subject: Task
let total: Int
let built: Int
let scope: VariableScope
}
typealias PrintProgress = (_ context: Context, _ message: String) -> Void

/// Total number of tasks to build
let total: Int
Expand All @@ -145,17 +178,17 @@ struct MiniMake {
private static var yellow: String { "\u{001B}[33m" }
private static var reset: String { "\u{001B}[0m" }

mutating func started(_ task: Task) {
self.print(task, "\(Self.green)building\(Self.reset)")
mutating func started(_ task: Task, scope: VariableScope) {
self.print(task, scope, "\(Self.green)building\(Self.reset)")
}

mutating func skipped(_ task: Task) {
self.print(task, "\(Self.yellow)skipped\(Self.reset)")
mutating func skipped(_ task: Task, scope: VariableScope) {
self.print(task, scope, "\(Self.yellow)skipped\(Self.reset)")
}

private mutating func print(_ task: Task, _ message: @autoclosure () -> String) {
private mutating func print(_ task: Task, _ scope: VariableScope, _ message: @autoclosure () -> String) {
guard !task.attributes.contains(.silent) else { return }
self.printProgress(task, self.total, self.built, message())
self.printProgress(Context(subject: task, total: self.total, built: self.built, scope: scope), message())
self.built += 1
}
}
Expand All @@ -176,32 +209,32 @@ struct MiniMake {
}

/// Cleans all outputs of all tasks
func cleanEverything() {
func cleanEverything(scope: VariableScope) {
for task in self.tasks.values {
try? FileManager.default.removeItem(atPath: task.output)
try? FileManager.default.removeItem(at: scope.resolve(path: task.output))
}
}

/// Starts building
func build(output: TaskKey) throws {
func build(output: TaskKey, scope: VariableScope) throws {
/// Returns true if any of the task's inputs have a modification date later than the task's output
func shouldBuild(task: Task) -> Bool {
if task.attributes.contains(.phony) {
return true
}
let outputURL = URL(fileURLWithPath: task.output)
if !FileManager.default.fileExists(atPath: task.output) {
let outputURL = scope.resolve(path: task.output)
if !FileManager.default.fileExists(atPath: outputURL.path) {
explain("Task \(task.output) should be built because it doesn't exist")
return true
}
let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
return task.inputs.contains { input in
let inputURL = URL(fileURLWithPath: input)
let inputURL = scope.resolve(path: input)
// Ignore directory modification times
var isDirectory: ObjCBool = false
let fileExists = FileManager.default.fileExists(
atPath: input, isDirectory: &isDirectory)
atPath: inputURL.path, isDirectory: &isDirectory)
if fileExists && isDirectory.boolValue {
return false
}
Expand Down Expand Up @@ -238,14 +271,56 @@ struct MiniMake {
}

if shouldBuild(task: task) {
progressPrinter.started(task)
try task.build(task)
progressPrinter.started(task, scope: scope)
try task.build(task, scope)
} else {
progressPrinter.skipped(task)
progressPrinter.skipped(task, scope: scope)
}
task.isDone = true
tasks[taskKey] = task
}
try runTask(taskKey: output)
}
}

struct BuildPath: Encodable, Hashable, CustomStringConvertible {
enum Component: Hashable, CustomStringConvertible {
case prefix(variable: String)
case constant(String)

var description: String {
switch self {
case .prefix(let variable): return "$\(variable)"
case .constant(let path): return path
}
}
}
fileprivate let components: [Component]

var description: String { self.components.map(\.description).joined(separator: "/") }

init(phony: String) {
self.components = [.constant(phony)]
}

init(prefix: String, _ tail: String...) {
self.components = [.prefix(variable: prefix)] + tail.map(Component.constant)
}

init(absolute: String) {
self.components = [.constant(absolute)]
}

private init(components: [Component]) {
self.components = components
}

func appending(path: String) -> BuildPath {
return BuildPath(components: self.components + [.constant(path)])
}

func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.description)
}
}
Loading