From 6f435cde6a466532b04446c672f73ef99ce376ec Mon Sep 17 00:00:00 2001 From: Lilith Date: Mon, 16 Feb 2026 02:43:12 -0800 Subject: [PATCH] =?UTF-8?q?chore(workflows):=20=F0=9F=94=A7=20Update=20Swi?= =?UTF-8?q?ft=20workflow=20files=20in=20version=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .forgejo/workflows/publish.yml | 66 ++++++ Package.swift | 28 +++ Sources/LilithTesting/BaseUITestCase.swift | 207 ++++++++++++++++++ .../LilithTesting/PerformanceTestCase.swift | 130 +++++++++++ Sources/LilithTesting/TestHelpers.swift | 100 +++++++++ .../LilithTestingTests.swift | 10 + 6 files changed, 541 insertions(+) create mode 100644 .forgejo/workflows/publish.yml create mode 100644 Package.swift create mode 100755 Sources/LilithTesting/BaseUITestCase.swift create mode 100755 Sources/LilithTesting/PerformanceTestCase.swift create mode 100755 Sources/LilithTesting/TestHelpers.swift create mode 100644 Tests/LilithTestingTests/LilithTestingTests.swift diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..8708fce --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,66 @@ +name: Publish Swift Package + +on: + push: + branches: + - main + - master + tags: + - 'v*' + +jobs: + build-test-publish: + runs-on: ubuntu-latest + container: + image: swift:5.9 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build Swift package + run: swift build + + - name: Run tests + run: swift test + + - name: Create package archive + if: startsWith(github.ref, 'refs/tags/') + run: | + VERSION=${GITHUB_REF#refs/tags/v} + PKG_NAME=$(swift package describe --type json | jq -r .name) + + # Create zip archive for Swift Package Registry + zip -r "${PKG_NAME}-${VERSION}.zip" \ + Package.swift \ + Sources/ \ + Tests/ \ + README.md \ + LICENSE \ + -x "*.git*" "*.DS_Store" + + echo "PACKAGE_NAME=${PKG_NAME}" >> $GITHUB_ENV + echo "PACKAGE_VERSION=${VERSION}" >> $GITHUB_ENV + echo "ARCHIVE_PATH=${PKG_NAME}-${VERSION}.zip" >> $GITHUB_ENV + + - name: Publish to Forgejo Swift Registry + if: startsWith(github.ref, 'refs/tags/') + run: | + # Forgejo Swift Package Registry API + # Endpoint: PUT /api/packages/{owner}/swift/{scope}/{name}/{version} + + OWNER="lilith" + SCOPE=$(echo "${{ github.repository }}" | cut -d'/' -f2 | cut -d'@' -f2) + + curl -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + -H "Content-Type: application/zip" \ + --data-binary "@${ARCHIVE_PATH}" \ + "https://forge.nasty.sh/api/packages/${OWNER}/swift/${SCOPE}/${PACKAGE_NAME}/${PACKAGE_VERSION}" + + - name: Upload release artifact + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v4 + with: + name: ${{ env.PACKAGE_NAME }}-${{ env.PACKAGE_VERSION }} + path: ${{ env.ARCHIVE_PATH }} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..426d602 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "LilithTesting", + platforms: [ + .iOS(.v17), + .macOS(.v13), + ], + products: [ + .library( + name: "LilithTesting", + targets: ["LilithTesting"] + ), + ], + targets: [ + .target( + name: "LilithTesting", + path: "Sources/LilithTesting" + ), + .testTarget( + name: "LilithTestingTests", + dependencies: ["LilithTesting"], + path: "Tests/LilithTestingTests" + ), + ] +) diff --git a/Sources/LilithTesting/BaseUITestCase.swift b/Sources/LilithTesting/BaseUITestCase.swift new file mode 100755 index 0000000..a17c8e2 --- /dev/null +++ b/Sources/LilithTesting/BaseUITestCase.swift @@ -0,0 +1,207 @@ +// +// BaseUITestCase.swift +// iOS Foundations +// +// Base class for UI tests with common utilities +// + +import XCTest + +/// Base class for UI test cases +/// +/// Provides common setup, utilities, and helpers for UI testing. +/// Extend this class for your UI test cases to get consistent behavior. +/// +/// ## Usage +/// +/// ```swift +/// final class OnboardingUITests: BaseUITestCase { +/// +/// func testSignUpFlow() { +/// // Launch arguments are already configured +/// let signUpButton = app.buttons["SignUpButton"] +/// XCTAssertTrue(waitForElement(signUpButton)) +/// +/// signUpButton.tap() +/// +/// let emailField = app.textFields["EmailField"] +/// emailField.tap() +/// emailField.typeText("user@example.com") +/// +/// takeScreenshot(name: "SignUpForm") +/// } +/// } +/// ``` +/// +open class BaseUITestCase: XCTestCase { + + // MARK: - Properties + + /// The application under test + public var app: XCUIApplication! + + /// Default timeout for element waits + public var defaultTimeout: TimeInterval = 5.0 + + // MARK: - Setup & Teardown + + open override func setUp() { + super.setUp() + + // Don't stop on failures - continue test to gather more info + continueAfterFailure = false + + // Initialize app + app = XCUIApplication() + + // Configure test mode + configureTestMode() + + // Launch app + app.launch() + } + + open override func tearDown() { + // Take screenshot on failure + if let testRun = testRun, + testRun.failureCount > 0 { + takeScreenshot(name: "FAILURE-\(name)") + } + + app = nil + super.tearDown() + } + + // MARK: - Configuration + + /// Configure app for testing + /// + /// Override this to add custom launch arguments or environment variables. + /// + /// ## Example + /// + /// ```swift + /// override func configureTestMode() { + /// super.configureTestMode() + /// app.launchArguments += ["--skip-onboarding"] + /// app.launchEnvironment["API_URL"] = "https://staging.api.com" + /// } + /// ``` + /// + open func configureTestMode() { + app.launchArguments = ["--uitesting"] + } + + // MARK: - Element Waiting + + /// Wait for an element to exist + /// + /// - Parameters: + /// - element: The element to wait for + /// - timeout: Maximum time to wait (defaults to defaultTimeout) + /// + /// - Returns: true if element exists within timeout, false otherwise + /// + @discardableResult + public func waitForElement( + _ element: XCUIElement, + timeout: TimeInterval? = nil + ) -> Bool { + let timeoutValue = timeout ?? defaultTimeout + return element.waitForExistence(timeout: timeoutValue) + } + + /// Wait for an element to disappear + /// + /// - Parameters: + /// - element: The element to wait for + /// - timeout: Maximum time to wait + /// + /// - Returns: true if element disappeared within timeout, false otherwise + /// + @discardableResult + public func waitForElementToDisappear( + _ element: XCUIElement, + timeout: TimeInterval? = nil + ) -> Bool { + let timeoutValue = timeout ?? defaultTimeout + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + + let result = XCTWaiter().wait(for: [expectation], timeout: timeoutValue) + return result == .completed + } + + // MARK: - Screenshots + + /// Take a screenshot with a descriptive name + /// + /// Screenshots are attached to the test results and can be viewed in Xcode. + /// + /// - Parameter name: Descriptive name for the screenshot + /// + public func takeScreenshot(name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + // MARK: - Common Actions + + /// Tap an element and wait for it to exist first + /// + /// - Parameters: + /// - element: The element to tap + /// - timeout: Maximum time to wait for element + /// + /// - Returns: true if tap succeeded, false if element didn't appear + /// + @discardableResult + public func tapWhenReady( + _ element: XCUIElement, + timeout: TimeInterval? = nil + ) -> Bool { + guard waitForElement(element, timeout: timeout) else { + return false + } + element.tap() + return true + } + + /// Type text into a field after waiting for it + /// + /// - Parameters: + /// - text: Text to type + /// - element: The text field element + /// - timeout: Maximum time to wait for element + /// + /// - Returns: true if typing succeeded, false if element didn't appear + /// + @discardableResult + public func typeText( + _ text: String, + into element: XCUIElement, + timeout: TimeInterval? = nil + ) -> Bool { + guard waitForElement(element, timeout: timeout) else { + return false + } + element.tap() + element.typeText(text) + return true + } + + /// Dismiss keyboard if present + public func dismissKeyboard() { + // Tap toolbar Done button if available + if app.toolbars.buttons["Done"].exists { + app.toolbars.buttons["Done"].tap() + return + } + + // Or tap outside keyboard area + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap() + } +} diff --git a/Sources/LilithTesting/PerformanceTestCase.swift b/Sources/LilithTesting/PerformanceTestCase.swift new file mode 100755 index 0000000..c7e61cd --- /dev/null +++ b/Sources/LilithTesting/PerformanceTestCase.swift @@ -0,0 +1,130 @@ +// +// PerformanceTestCase.swift +// iOS Foundations +// +// Performance testing utilities +// + +import XCTest + +/// Base class for performance tests +/// +/// Provides utilities for measuring and asserting performance characteristics. +/// +/// ## Example +/// +/// ```swift +/// final class ImageProcessingPerformanceTests: PerformanceTestCase { +/// +/// func testImageLoadingPerformance() { +/// measure { +/// loadAndProcessImage() +/// } +/// } +/// +/// func testMemoryEfficiency() { +/// measureMemory { +/// loadLargeDataset() +/// } +/// assertMemoryUsage(lessThan: 50.0) // < 50 MB +/// } +/// } +/// ``` +/// +open class PerformanceTestCase: XCTestCase { + + // MARK: - Thresholds + + /// Maximum acceptable memory usage in MB + public var maxMemoryUsageMB: Double = 100.0 + + /// Maximum acceptable execution time in seconds + public var maxExecutionTime: TimeInterval = 1.0 + + // MARK: - Memory Measurement + + /// Measure memory usage of a code block + /// + /// - Parameter block: The code block to measure + /// - Returns: Memory used in bytes + /// + @discardableResult + public func measureMemory(block: () -> Void) -> UInt64 { + let before = currentMemoryUsage() + block() + let after = currentMemoryUsage() + + return after > before ? after - before : 0 + } + + /// Assert memory usage is below threshold + /// + /// - Parameter maxMB: Maximum acceptable memory in MB + /// + public func assertMemoryUsage(lessThan maxMB: Double, file: StaticString = #file, line: UInt = #line) { + let usageMB = Double(currentMemoryUsage()) / 1024.0 / 1024.0 + XCTAssertLessThan( + usageMB, + maxMB, + "Memory usage (\(String(format: "%.2f", usageMB)) MB) exceeds threshold (\(maxMB) MB)", + file: file, + line: line + ) + } + + // MARK: - Timing + + /// Measure execution time of a code block + /// + /// - Parameter block: The code block to measure + /// - Returns: Execution time in seconds + /// + @discardableResult + public func measureTime(block: () -> Void) -> TimeInterval { + let start = Date() + block() + let end = Date() + return end.timeIntervalSince(start) + } + + /// Assert execution time is below threshold + /// + /// - Parameters: + /// - maxTime: Maximum acceptable time in seconds + /// - block: The code block to measure + /// + public func assertExecutionTime( + lessThan maxTime: TimeInterval, + block: () -> Void, + file: StaticString = #file, + line: UInt = #line + ) { + let time = measureTime(block: block) + XCTAssertLessThan( + time, + maxTime, + "Execution time (\(String(format: "%.3f", time))s) exceeds threshold (\(maxTime)s)", + file: file, + line: line + ) + } + + // MARK: - Private Helpers + + private func currentMemoryUsage() -> UInt64 { + var taskInfo = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let kerr: kern_return_t = withUnsafeMutablePointer(to: &taskInfo) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info( + mach_task_self_, + task_flavor_t(MACH_TASK_BASIC_INFO), + $0, + &count + ) + } + } + + return kerr == KERN_SUCCESS ? taskInfo.resident_size : 0 + } +} diff --git a/Sources/LilithTesting/TestHelpers.swift b/Sources/LilithTesting/TestHelpers.swift new file mode 100755 index 0000000..0a2efbe --- /dev/null +++ b/Sources/LilithTesting/TestHelpers.swift @@ -0,0 +1,100 @@ +// +// TestHelpers.swift +// iOS Foundations +// +// Common test utilities and helpers +// + +import XCTest + +/// Common test helper functions +public enum TestHelpers { + + // MARK: - Async Testing + + /// Wait for an async condition to become true + /// + /// - Parameters: + /// - timeout: Maximum time to wait + /// - condition: The condition to check + /// + /// - Returns: true if condition became true within timeout + /// + @discardableResult + public static func wait( + timeout: TimeInterval = 5.0, + for condition: @escaping () -> Bool + ) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(block: { _, _ in condition() }), + object: nil + ) + + let result = XCTWaiter().wait(for: [expectation], timeout: timeout) + return result == .completed + } + + /// Wait for an async operation with timeout + /// + /// - Parameters: + /// - timeout: Maximum time to wait + /// - operation: The async operation to perform + /// + public static func waitForAsync( + timeout: TimeInterval = 5.0, + operation: @escaping (@escaping () -> Void) -> Void + ) async { + await withCheckedContinuation { continuation in + let timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in + continuation.resume() + } + + operation { + timeoutTimer.invalidate() + continuation.resume() + } + } + } + + // MARK: - Data Generation + + /// Generate a random string + /// + /// - Parameter length: Length of the string + /// - Returns: Random alphanumeric string + /// + public static func randomString(length: Int) -> String { + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0.. String { + "\(randomString(length: 10))@test.com" + } + + // MARK: - File Helpers + + /// Get path to a test resource file + /// + /// - Parameters: + /// - name: File name + /// - extension: File extension + /// - bundle: Bundle containing the file (defaults to test bundle) + /// + /// - Returns: Path to the file, or nil if not found + /// + public static func pathForTestResource( + named name: String, + withExtension extension: String, + in bundle: Bundle = Bundle(for: BundleToken.self) + ) -> String? { + bundle.path(forResource: name, ofType: `extension`) + } +} + +/// Token class for bundle identification +private class BundleToken {} diff --git a/Tests/LilithTestingTests/LilithTestingTests.swift b/Tests/LilithTestingTests/LilithTestingTests.swift new file mode 100644 index 0000000..b07e0d8 --- /dev/null +++ b/Tests/LilithTestingTests/LilithTestingTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import LilithTesting + +@Suite("LilithTesting Tests") +struct LilithTestingTests { + @Test func packageImports() async throws { + // Verify the package can be imported and basic types are accessible + #expect(true) + } +}