Skip to content
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
28 changes: 28 additions & 0 deletions Sources/SkipBuild/Commands/SkipstoneCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand {
try await transpiler.transpile(handler: handleTranspilation)
try saveCodebaseInfo() // save out the ModuleName.skipcode.json
try saveSkipBridgeCode()
try saveTestHarness()

let sourceModules = try linkDependentModuleSources()
try linkResources()
Expand Down Expand Up @@ -646,6 +647,30 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand {
}
}

func saveTestHarness() throws {
// auto-generate an XCSkipTests.swift test harness when the plugin requested it
if let testHarnessOutput = skipstoneOptions.testHarnessOutput {
let testHarnessOutputPath = try AbsolutePath(validating: testHarnessOutput)
let harnessContents = """
// Auto-generated by Skip — do not edit
#if os(macOS) || os(Linux) // Skip transpiled tests only run on supported hosts
import Foundation
import XCTest
import SkipTest

/// This test case will run the transpiled tests for the Skip module.
final class XCSkipTests: XCTestCase, XCGradleHarness {
public func testSkipModule() async throws {
try await runGradleTests()
}
}
#endif

"""
try writeChanges(tag: "test harness", to: testHarnessOutputPath, contents: harnessContents.utf8Data, readOnly: true)
}
}

func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws {
try generateGradleWrapperProperties()
try generateProguardFile(packageName)
Expand Down Expand Up @@ -1398,6 +1423,9 @@ struct SkipstoneCommandOptions: ParsableArguments {

@Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix"))
var skipBridgeOutput: String? = nil

@Option(name: [.long], help: ArgumentHelp("Path for auto-generated test harness", valueName: "path"))
var testHarnessOutput: String? = nil
}


Expand Down
38 changes: 0 additions & 38 deletions Sources/SkipBuild/SkipProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1174,44 +1174,6 @@ struct TestData : Codable, Hashable {

try testCaseCode.write(to: testSwiftFile, atomically: false, encoding: .utf8)

// only create the transpiled test harness when this is a non-native module or if the module is bridged;
// otherwise, pure swift testing will suffice for both Darwin and Android
if !isNativeModule || moduleMode == .nativeBridged {
let testSkipModuleFile = testDir.appending(path: "XCSkipTests.swift")
try """
\(sourceHeader)import Foundation
#if os(macOS) || os(Linux) // Skip transpiled tests only run on supported hosts
import SkipTest

/// This test case will run the transpiled tests for the Skip module.
@available(macOS 13, macCatalyst 16, *)
final class XCSkipTests: XCTestCase, XCGradleHarness {
public func testSkipModule() async throws {
// Run the transpiled JUnit tests for the current test module.
// These tests will be executed locally using Robolectric.
// Connected device or emulator tests can be run by setting the
// `ANDROID_SERIAL` environment variable to an `adb devices`
// ID in the scheme's Run settings.
//
// Note that it isn't currently possible to filter the tests to run.
try await runGradleTests()
}
}
#endif

/// True when running in a transpiled Java runtime environment
let isJava = ProcessInfo.processInfo.environment["java.io.tmpdir"] != nil
/// True when running within an Android environment (either an emulator or device)
let isAndroid = isJava && ProcessInfo.processInfo.environment["ANDROID_ROOT"] != nil
/// True is the transpiled code is currently running in the local Robolectric test environment
let isRobolectric = isJava && !isAndroid
/// True if the system's `Int` type is 32-bit.
let is32BitInteger = Int64(Int.max) == Int64(Int32.max)
\(sourceFooter)

""".write(to: testSkipModuleFile, atomically: false, encoding: .utf8)
}

let skipYamlAppTests = """
# # Skip configuration for \(moduleName) module
#build:
Expand Down
1 change: 1 addition & 0 deletions Sources/SkipSyntax/CodebaseInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public final class CodebaseInfo {
"SkipFuseUI": ["SkipUI", "SkipFoundation", "SkipModel"],
"UIKit": ["SkipUI", "SkipFoundation", "SkipModel"],
"UserNotifications": ["SkipUI", "SkipFoundation", "SkipModel"],
"Testing": ["SkipUnit"],
"XCTest": ["SkipUnit"],
]

Expand Down
114 changes: 114 additions & 0 deletions Sources/SkipSyntax/ExpressionTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1256,11 +1256,125 @@ final class MacroExpansion: Expression {
return RawExpression(sourceCode: "// #\(macroExpansionExpr.macroName.text) omitted", syntax: syntax, in: syntaxTree)
case "colorLiteral":
return colorLiteral(syntax: macroExpansionExpr)
case "expect":
return expectOrRequire(syntax: macroExpansionExpr, isThrowing: false, in: syntaxTree)
case "require":
return expectOrRequire(syntax: macroExpansionExpr, isThrowing: true, in: syntaxTree)
default:
throw Message.macroExpansionUnsupported(syntax, source: syntaxTree.source)
}
}

/// Convert `#expect(...)` or `#require(...)` Swift Testing macros into function calls
/// that map to SkipUnit assertion functions.
///
/// Supports:
/// - `#expect(condition)` → `expectTrue(condition)`
/// - `#expect(a == b)` → `expectEqual(a, b)`
/// - `#expect(a != b)` → `expectNotEqual(a, b)`
/// - `#expect(throws: ErrorType.self) { code }` → `expectThrows(ErrorType.self) { code }`
/// - `#require(condition)` → `requireTrue(condition)`
/// - `#require(optionalExpr)` → `requireNotNil(optionalExpr)`
///
/// The `isThrowing` parameter distinguishes `#require` (which throws on failure) from `#expect`.
private static func expectOrRequire(syntax macroExpansionExpr: MacroExpansionExprSyntax, isThrowing: Bool, in syntaxTree: SyntaxTree) -> Expression {
let prefix = isThrowing ? "require" : "expect"
let args = Array(macroExpansionExpr.arguments)

// #expect(throws: SomeError.self) { ... } or #require(throws: SomeError.self) { ... }
if let throwsArg = args.first(where: { $0.label?.text == "throws" }) {
var functionArgs: [LabeledValue<Expression>] = [
LabeledValue(label: "throws", value: ExpressionDecoder.decode(syntax: throwsArg.expression, in: syntaxTree))
]
// Include trailing closure if present
if let trailingClosure = macroExpansionExpr.trailingClosure {
let closureExpr = ExpressionDecoder.decode(syntax: trailingClosure, in: syntaxTree)
functionArgs.append(LabeledValue(value: closureExpr))
}
return FunctionCall(function: Identifier(name: "\(prefix)Throws"), arguments: functionArgs, trailingClosureCount: macroExpansionExpr.trailingClosure != nil ? 1 : 0, syntax: macroExpansionExpr)
}

// Single argument case: #expect(expr) or #require(expr)
guard let firstArg = args.first else {
// No arguments — emit as a raw call
return FunctionCall(function: Identifier(name: "\(prefix)True"), arguments: [], syntax: macroExpansionExpr)
}

let expression = firstArg.expression

// Check for comparison operators: ==, !=, >, <, >=, <=
// These appear as SequenceExprSyntax in SwiftSyntax. The comparison operator
// has the lowest precedence, so we find the last comparison operator in the
// sequence and split the expression on it.
if let sequenceExpr = expression.as(SequenceExprSyntax.self) {
let elements = Array(sequenceExpr.elements)
// Find the last comparison operator (lowest precedence in Swift)
let comparisonOps: Set<String> = ["==", "!=", ">", "<", ">=", "<="]
if let opIndex = elements.lastIndex(where: { elem in
if let op = elem.as(BinaryOperatorExprSyntax.self) {
return comparisonOps.contains(op.operator.text)
}
return false
}), let op = elements[opIndex].as(BinaryOperatorExprSyntax.self) {
let opText = op.operator.text
let lhsElements = Array(elements[..<opIndex])
let rhsElements = Array(elements[(opIndex + 1)...])

// Reconstruct the LHS and RHS as expressions
let lhs: Expression
if lhsElements.count == 1 {
lhs = ExpressionDecoder.decode(syntax: lhsElements[0], in: syntaxTree)
} else {
// Re-wrap as a sequence expression for proper decoding
let lhsSeq = SequenceExprSyntax(elements: ExprListSyntax(lhsElements))
lhs = ExpressionDecoder.decode(syntax: lhsSeq, in: syntaxTree)
}

let rhs: Expression
if rhsElements.count == 1 {
rhs = ExpressionDecoder.decode(syntax: rhsElements[0], in: syntaxTree)
} else {
let rhsSeq = SequenceExprSyntax(elements: ExprListSyntax(rhsElements))
rhs = ExpressionDecoder.decode(syntax: rhsSeq, in: syntaxTree)
}

// Find optional message argument
let msgArgs: [LabeledValue<Expression>] = args.dropFirst().compactMap { arg in
if arg.label == nil || arg.label?.text == "sourceLocation" { return nil }
return LabeledValue(value: ExpressionDecoder.decode(syntax: arg.expression, in: syntaxTree))
}

let funcName: String
switch opText {
case "==": funcName = "\(prefix)Equal"
case "!=": funcName = "\(prefix)NotEqual"
case ">": funcName = "\(prefix)GreaterThan"
case ">=": funcName = "\(prefix)GreaterThanOrEqual"
case "<": funcName = "\(prefix)LessThan"
case "<=": funcName = "\(prefix)LessThanOrEqual"
default: funcName = "\(prefix)True" // fallback
}
return FunctionCall(function: Identifier(name: funcName), arguments: [LabeledValue(value: lhs), LabeledValue(value: rhs)] + msgArgs, syntax: macroExpansionExpr)
}
}

// Default: treat as a boolean condition check
var allArgs: [LabeledValue<Expression>] = [LabeledValue(value: ExpressionDecoder.decode(syntax: expression, in: syntaxTree))]
// Include any extra message arguments
for arg in args.dropFirst() {
if arg.label?.text == "sourceLocation" { continue }
allArgs.append(LabeledValue(label: arg.label?.text, value: ExpressionDecoder.decode(syntax: arg.expression, in: syntaxTree)))
}

// If we have a trailing closure (e.g. #expect { ... }), use it
if let trailingClosure = macroExpansionExpr.trailingClosure, args.isEmpty {
let closureExpr = ExpressionDecoder.decode(syntax: trailingClosure, in: syntaxTree)
return FunctionCall(function: Identifier(name: "\(prefix)True"), arguments: [LabeledValue(value: closureExpr)], trailingClosureCount: 1, syntax: macroExpansionExpr)
}

return FunctionCall(function: Identifier(name: isThrowing ? "requireNotNil" : "\(prefix)True"), arguments: allArgs, syntax: macroExpansionExpr)
}

private static func colorLiteral(syntax macroExpansionExpr: MacroExpansionExprSyntax) -> Expression {
let red = numericLiteral(for: macroExpansionExpr.arguments.first { $0.label?.text == "red" })
let green = numericLiteral(for: macroExpansionExpr.arguments.first { $0.label?.text == "green" })
Expand Down
6 changes: 6 additions & 0 deletions Sources/SkipSyntax/HelperTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ struct Attribute: Hashable, Codable {
case published
case state
case stateObject
case suite
case test
case toolbarContentBuilder
case unavailable
case unknown
Expand Down Expand Up @@ -395,6 +397,10 @@ struct Attribute: Hashable, Codable {
return .state
case "StateObject":
return .stateObject
case "Suite":
return .suite
case "Test":
return .test
case "ToolbarContentBuilder":
return .toolbarContentBuilder
case "ViewBuilder":
Expand Down
Loading