From c42ed9552ebf848b342951ff81811ff99e48cd2d Mon Sep 17 00:00:00 2001 From: Stefan Hausotte Date: Mon, 23 Mar 2026 19:07:55 +0100 Subject: [PATCH] test: improve test runtime Reviewed-on: https://codeberg.org/secana/Forji/pulls/24 Co-authored-by: Stefan Hausotte Co-committed-by: Stefan Hausotte --- .../MergedInstanceReadOnlyUITests.swift | 166 +++++++++++++++++ .../ForjiUITests/MergedInstanceUITests.swift | 45 ----- .../PullRequestMutatingUITests.swift | 17 +- integration/docker-compose.yml | 18 ++ justfile | 170 +++++++++++------- 5 files changed, 289 insertions(+), 127 deletions(-) create mode 100644 Forji/ForjiUITests/MergedInstanceReadOnlyUITests.swift diff --git a/Forji/ForjiUITests/MergedInstanceReadOnlyUITests.swift b/Forji/ForjiUITests/MergedInstanceReadOnlyUITests.swift new file mode 100644 index 0000000..a1d50a4 --- /dev/null +++ b/Forji/ForjiUITests/MergedInstanceReadOnlyUITests.swift @@ -0,0 +1,166 @@ +import XCTest + +final class MergedInstanceReadOnlyUITests: XCTestCase, UITestNavigating { + + static var sharedApp: XCUIApplication! + static var sharedServerURL: String! + + override class func setUp() { + super.setUp() + + guard let url = ForgejoUITestBase.resolveTestServerURL(), + !url.isEmpty + else { return } + sharedServerURL = url + + guard let url2 = resolveSecondServerURL(), !url2.isEmpty else { return } + + let app = XCUIApplication() + + // Reset data and add first instance + app.launchArguments = ["-dev_skipAutoLogin", "true", "-dev_resetData", "true"] + app.launch() + app.terminate() + + addInstance(app: app, serverURL: url) + addInstance(app: app, serverURL: url2) + + // Relaunch to instance list and enter merged mode + app.launchArguments = ["-dev_skipAutoLogin", "true"] + app.launch() + + let allRow = app.buttons["all-instances-row"] + guard allRow.waitForExistence(timeout: 10) else { + XCTFail("All Instances row not found in class setUp") + return + } + allRow.tap() + + let reposTab = app.tabBars.buttons["Repositories"] + guard reposTab.waitForExistence(timeout: 20) else { + XCTFail("Merged HomeView did not load in class setUp") + return + } + sharedApp = app + } + + override class func tearDown() { + sharedApp = nil + sharedServerURL = nil + super.tearDown() + } + + var app: XCUIApplication! { Self.sharedApp } + var serverURL: String! { Self.sharedServerURL } + + override func setUpWithError() throws { + continueAfterFailure = true + + guard Self.sharedServerURL != nil, Self.sharedApp != nil else { + throw XCTSkip("Integration server not running or merged setup failed") + } + + XCUIDevice.shared.orientation = .portrait + + // Ensure we're on the Repositories tab + let reposTab = app.tabBars.buttons["Repositories"] + if reposTab.exists && reposTab.isHittable { + reposTab.tap() + } + } + + func navigateToRepoDetail(_ repoName: String = "test-repo") { + app.tabBars.buttons["Repositories"].tap() + let repoCell = app.staticTexts[repoName].firstMatch + XCTAssertTrue(repoCell.waitForExistence(timeout: 10)) + repoCell.tap() + } + + // MARK: - Tests + + @MainActor + func testMergedViewLoads() throws { + let reposTab = app.tabBars.buttons["Repositories"] + XCTAssertTrue(reposTab.waitForExistence(timeout: 5), "Merged view should be loaded") + } + + @MainActor + func testMergedIssuesShowInstanceBadges() throws { + app.tabBars.buttons["Issues"].tap() + + let badge = app.staticTexts.matching(identifier: "instance-badge") + XCTAssertTrue( + badge.firstMatch.waitForExistence(timeout: 15), + "Instance badges should appear in merged issues view" + ) + } + + @MainActor + func testMergedPullRequestsShowInstanceBadges() throws { + app.tabBars.buttons["Pull Requests"].tap() + + let badge = app.staticTexts.matching(identifier: "instance-badge") + XCTAssertTrue( + badge.firstMatch.waitForExistence(timeout: 15), + "Instance badges should appear in merged PRs view" + ) + } + + @MainActor + func testMergedNotificationsShowItems() throws { + app.tabBars.buttons["Notifications"].tap() + + let navTitle = app.navigationBars["Notifications"] + XCTAssertTrue(navTitle.waitForExistence(timeout: 10), "Notifications tab should load in merged mode") + } + + // MARK: - Helpers + + private static func addInstance( + app: XCUIApplication, + serverURL url: String, + username: String = "testadmin", + password: String = "admin1234" + ) { + app.launchArguments = [ + "-dev_serverURL", url, + "-dev_username", username, + "-dev_password", password, + "-dev_skipAutoLogin", "true", + ] + app.launch() + + let addButton = app.buttons["instance-add-button"] + guard addButton.waitForExistence(timeout: 15) else { + XCTFail("Instance list did not appear for \(url)") + return + } + addButton.tap() + + app.swipeUp() + + let loginButton = app.buttons["login-button"] + guard loginButton.waitForExistence(timeout: 5) else { + XCTFail("Login button not found for \(url)") + return + } + loginButton.tap() + + let reposTab = app.tabBars.buttons["Repositories"] + guard reposTab.waitForExistence(timeout: 20) else { + XCTFail("HomeView did not appear after login for \(url)") + return + } + } + + private static func resolveSecondServerURL() -> String? { + let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "") + .replacingOccurrences(of: " ", with: "_") + let specificPath = "/tmp/forgejo_test_url2_\(deviceName).txt" + let genericPath = "/tmp/forgejo_test_url2.txt" + + return ((try? String(contentsOfFile: specificPath, encoding: .utf8)) + ?? (try? String(contentsOfFile: genericPath, encoding: .utf8))) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } +} diff --git a/Forji/ForjiUITests/MergedInstanceUITests.swift b/Forji/ForjiUITests/MergedInstanceUITests.swift index 3d428b0..ee4bd75 100644 --- a/Forji/ForjiUITests/MergedInstanceUITests.swift +++ b/Forji/ForjiUITests/MergedInstanceUITests.swift @@ -106,51 +106,6 @@ final class MergedInstanceUITests: ForgejoUITestBase { XCTAssertFalse(allRow.exists, "All Instances row should not appear with single instance") } - @MainActor - func testMergedViewLoads() throws { - try setupTwoInstances() - enterMergedMode() - } - - @MainActor - func testMergedIssuesShowInstanceBadges() throws { - try setupTwoInstances() - enterMergedMode() - - app.tabBars.buttons["Issues"].tap() - - let badge = app.staticTexts.matching(identifier: "instance-badge") - XCTAssertTrue( - badge.firstMatch.waitForExistence(timeout: 15), - "Instance badges should appear in merged issues view", - ) - } - - @MainActor - func testMergedPullRequestsShowInstanceBadges() throws { - try setupTwoInstances() - enterMergedMode() - - app.tabBars.buttons["Pull Requests"].tap() - - let badge = app.staticTexts.matching(identifier: "instance-badge") - XCTAssertTrue( - badge.firstMatch.waitForExistence(timeout: 15), - "Instance badges should appear in merged PRs view", - ) - } - - @MainActor - func testMergedNotificationsShowItems() throws { - try setupTwoInstances() - enterMergedMode() - - app.tabBars.buttons["Notifications"].tap() - - let navTitle = app.navigationBars["Notifications"] - XCTAssertTrue(navTitle.waitForExistence(timeout: 10), "Notifications tab should load in merged mode") - } - @MainActor func testDisconnectFromMergedMode() throws { try setupTwoInstances() diff --git a/Forji/ForjiUITests/PullRequestMutatingUITests.swift b/Forji/ForjiUITests/PullRequestMutatingUITests.swift index 8f54af8..c0a5750 100644 --- a/Forji/ForjiUITests/PullRequestMutatingUITests.swift +++ b/Forji/ForjiUITests/PullRequestMutatingUITests.swift @@ -160,22 +160,9 @@ final class PullRequestMutatingUITests: ForgejoUITestBase { XCTAssertTrue(mergeConfirm.waitForExistence(timeout: 5)) mergeConfirm.tap() - // Verify merge succeeded: sheet should dismiss - let mergeSheetTitle = app.staticTexts["Merge Pull Request"] - if mergeSheetTitle.waitForExistence(timeout: 10) { - let errorAlert = app.alerts.firstMatch - if errorAlert.exists { - let alertText = errorAlert.staticTexts.allElementsBoundByIndex - .map(\.label).joined(separator: " | ") - XCTFail("\(method) merge failed with error: \(alertText)") - } else { - XCTFail("\(method) merge sheet still visible without error alert") - } - return - } - + // Verify merge succeeded: wait for PR detail to reappear (sheet dismissed) XCTAssertTrue( - app.staticTexts["pr-detail-title"].waitForExistence(timeout: 10), + app.staticTexts["pr-detail-title"].waitForExistence(timeout: 15), "Should return to PR detail after successful \(method) merge" ) } diff --git a/integration/docker-compose.yml b/integration/docker-compose.yml index 53d60de..9c8d8b4 100644 --- a/integration/docker-compose.yml +++ b/integration/docker-compose.yml @@ -33,6 +33,24 @@ services: retries: 12 start_period: 10s + forgejo-3: + image: codeberg.org/forgejo/forgejo:14 + ports: + - "13003:3000" + environment: + - FORGEJO__security__INSTALL_LOCK=true + - FORGEJO__service__DISABLE_REGISTRATION=true + - FORGEJO__server__ROOT_URL=http://localhost:13003 + volumes: + - forgejo-3-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + volumes: forgejo-1-data: forgejo-2-data: + forgejo-3-data: diff --git a/justfile b/justfile index 8376789..77c2e02 100644 --- a/justfile +++ b/justfile @@ -2,11 +2,14 @@ set shell := ["bash", "-eo", "pipefail", "-c"] default_destination := "platform=iOS Simulator,name=iPhone 17 Pro" default_destination_b := "platform=iOS Simulator,name=iPhone Air" +default_destination_c := "platform=iOS Simulator,name=iPhone 17 Pro Max" compose_file := "integration/docker-compose.yml" base_url_1 := "http://localhost:13001" base_url_2 := "http://localhost:13002" -readonly_classes := "LoginUITests CommitHistoryUITests PaginationUITests RepositoryUITests IssueUITests PullRequestUITests OverviewCreateUITests" -mutating_classes := "RepositoryMutatingUITests IssueMutatingUITests PullRequestMutatingUITests OverviewCreateMutatingUITests HomeScreenUITests NotificationsUITests PermissionUITests MergedInstanceUITests" +base_url_3 := "http://localhost:13003" +readonly_classes := "LoginUITests CommitHistoryUITests PaginationUITests RepositoryUITests IssueUITests PullRequestUITests OverviewCreateUITests HomeScreenUITests PermissionUITests MergedInstanceReadOnlyUITests" +mutating_a_classes := "MergedInstanceUITests IssueMutatingUITests NotificationsUITests OverviewCreateMutatingUITests" +mutating_b_classes := "PullRequestMutatingUITests RepositoryMutatingUITests" # List all available tasks default: @@ -92,8 +95,8 @@ seed: #!/usr/bin/env bash set -eo pipefail COMPOSE_FILE="{{compose_file}}" - BASE_URL_1="{{base_url_1}}" - BASE_URL_2="{{base_url_2}}" + BASE_URLS=("{{base_url_1}}" "{{base_url_2}}" "{{base_url_3}}") + SERVICES=(forgejo-1 forgejo-2 forgejo-3) SNAPSHOT_FILE="integration/.forgejo-seed-snapshot.tar.gz" HASH_FILE="integration/.forgejo-seed-hash" SETUP_HASH=$(find integration/forgejo-seed/Sources -name '*.swift' -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}') @@ -111,34 +114,43 @@ seed: } if [ -f "$SNAPSHOT_FILE" ] && [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$SETUP_HASH" ]; then echo "Restoring from seed snapshot..." - CONTAINER_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) - CONTAINER_2=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-2) - docker cp "$SNAPSHOT_FILE" "$CONTAINER_1:/tmp/snapshot.tar.gz" & - docker cp "$SNAPSHOT_FILE" "$CONTAINER_2:/tmp/snapshot.tar.gz" & + for svc in "${SERVICES[@]}"; do + CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") + docker cp "$SNAPSHOT_FILE" "$CID:/tmp/snapshot.tar.gz" & + done wait - docker exec "$CONTAINER_1" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & - docker exec "$CONTAINER_2" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & + for svc in "${SERVICES[@]}"; do + CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") + docker exec "$CID" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & + done wait - docker compose -f "$COMPOSE_FILE" restart forgejo-1 forgejo-2 - wait_for_instance "$BASE_URL_1" & - wait_for_instance "$BASE_URL_2" & + docker compose -f "$COMPOSE_FILE" restart "${SERVICES[@]}" + for url in "${BASE_URLS[@]}"; do + wait_for_instance "$url" & + done wait - echo "Snapshot restored to both instances." + echo "Snapshot restored to all instances." else echo "Seeding test data..." - swift run --package-path integration/forgejo-seed forgejo-seed "$BASE_URL_1" forgejo-1 + swift run --package-path integration/forgejo-seed forgejo-seed "${BASE_URLS[0]}" forgejo-1 echo "Snapshotting seed data..." - CONTAINER_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) - docker exec "$CONTAINER_1" sh -c "cd / && tar czf /tmp/snapshot.tar.gz data" - docker cp "$CONTAINER_1:/tmp/snapshot.tar.gz" "$SNAPSHOT_FILE" + CID_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) + docker exec "$CID_1" sh -c "cd / && tar czf /tmp/snapshot.tar.gz data" + docker cp "$CID_1:/tmp/snapshot.tar.gz" "$SNAPSHOT_FILE" echo "$SETUP_HASH" > "$HASH_FILE" - echo "Restoring snapshot to instance 2..." - CONTAINER_2=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-2) - docker cp "$SNAPSHOT_FILE" "$CONTAINER_2:/tmp/snapshot.tar.gz" - docker exec "$CONTAINER_2" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" - docker compose -f "$COMPOSE_FILE" restart forgejo-2 - wait_for_instance "$BASE_URL_2" - echo "Instance 2 seeded from snapshot." + echo "Restoring snapshot to other instances..." + for svc in "${SERVICES[@]:1}"; do + CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") + docker cp "$SNAPSHOT_FILE" "$CID:/tmp/snapshot.tar.gz" + docker exec "$CID" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & + done + wait + docker compose -f "$COMPOSE_FILE" restart "${SERVICES[@]:1}" + for url in "${BASE_URLS[@]:1}"; do + wait_for_instance "$url" & + done + wait + echo "All instances seeded." fi # Run read-only UI tests (assumes build + seed + URL files are set up) @@ -153,15 +165,30 @@ test-readonly destination=default_destination: -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination}}' \ - -parallel-testing-enabled NO \ + -parallel-testing-enabled YES \ $ARGS 2>&1 | xcbeautify -# Run mutating UI tests (assumes build + seed + URL files are set up) -test-mutating destination=default_destination_b: +# Run mutating group A: MergedInstanceUITests (needs 2 instances) +test-mutating-a destination=default_destination_b: #!/usr/bin/env bash set -eo pipefail ARGS="" - for cls in {{mutating_classes}}; do + for cls in {{mutating_a_classes}}; do + ARGS="$ARGS -only-testing:ForjiUITests/$cls" + done + xcodebuild test-without-building \ + -project Forji/Forji.xcodeproj \ + -scheme Forji \ + -destination '{{destination}}' \ + -parallel-testing-enabled NO \ + $ARGS 2>&1 | xcbeautify + +# Run mutating group B: all non-merged mutating tests +test-mutating-b destination=default_destination_c: + #!/usr/bin/env bash + set -eo pipefail + ARGS="" + for cls in {{mutating_b_classes}}; do ARGS="$ARGS -only-testing:ForjiUITests/$cls" done xcodebuild test-without-building \ @@ -172,44 +199,51 @@ test-mutating destination=default_destination_b: $ARGS 2>&1 | xcbeautify # Run full UI integration test suite (requires Docker) -test-ui destination_a=default_destination destination_b=default_destination_b: +test-ui destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: #!/usr/bin/env bash set -eo pipefail cleanup() { just docker-down } trap cleanup EXIT - just sim-boot '{{destination_a}}' '{{destination_b}}' & + just sim-boot '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' & just docker-up & - just build-for-testing '{{destination_a}}' '{{destination_b}}' & + just build-for-testing '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' & wait just seed - just _write-url-files '{{destination_a}}' '{{destination_b}}' - LOG_A="/tmp/forji_test_group_a.log" - LOG_B="/tmp/forji_test_group_b.log" - EXIT_A=0 - EXIT_B=0 - echo "Starting read-only tests..." - just test-readonly '{{destination_a}}' > "$LOG_A" 2>&1 & - PID_A=$! - echo "Starting mutating tests..." - just test-mutating '{{destination_b}}' > "$LOG_B" 2>&1 & - PID_B=$! - wait $PID_A || EXIT_A=$? - wait $PID_B || EXIT_B=$? + just _write-url-files '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' + LOG_RO="/tmp/forji_test_readonly.log" + LOG_MA="/tmp/forji_test_mutating_a.log" + LOG_MB="/tmp/forji_test_mutating_b.log" + EXIT_RO=0 + EXIT_MA=0 + EXIT_MB=0 + echo "Starting read-only tests on {{destination_a}}..." + just test-readonly '{{destination_a}}' > "$LOG_RO" 2>&1 & + PID_RO=$! + echo "Starting mutating-a (merged) tests on {{destination_b}}..." + just test-mutating-a '{{destination_b}}' > "$LOG_MA" 2>&1 & + PID_MA=$! + echo "Starting mutating-b (rest) tests on {{destination_c}}..." + just test-mutating-b '{{destination_c}}' > "$LOG_MB" 2>&1 & + PID_MB=$! + wait $PID_RO || EXIT_RO=$? + wait $PID_MA || EXIT_MA=$? + wait $PID_MB || EXIT_MB=$? echo "=== Read-only tests output ===" - cat "$LOG_A" + cat "$LOG_RO" echo "" - echo "=== Mutating tests output ===" - cat "$LOG_B" - rm -f "$LOG_A" "$LOG_B" - if [ "$EXIT_A" -ne 0 ]; then - echo "Read-only tests failed (exit code $EXIT_A)." - fi - if [ "$EXIT_B" -ne 0 ]; then - echo "Mutating tests failed (exit code $EXIT_B)." - fi - if [ "$EXIT_A" -ne 0 ] || [ "$EXIT_B" -ne 0 ]; then + echo "=== Mutating-A (merged) tests output ===" + cat "$LOG_MA" + echo "" + echo "=== Mutating-B (rest) tests output ===" + cat "$LOG_MB" + rm -f "$LOG_RO" "$LOG_MA" "$LOG_MB" + FAILED=0 + if [ "$EXIT_RO" -ne 0 ]; then echo "Read-only tests failed (exit $EXIT_RO)."; FAILED=1; fi + if [ "$EXIT_MA" -ne 0 ]; then echo "Mutating-A tests failed (exit $EXIT_MA)."; FAILED=1; fi + if [ "$EXIT_MB" -ne 0 ]; then echo "Mutating-B tests failed (exit $EXIT_MB)."; FAILED=1; fi + if [ "$FAILED" -ne 0 ]; then echo "Integration tests FAILED." exit 1 fi @@ -231,9 +265,9 @@ test-one filter="" destination=default_destination: just docker-down } trap cleanup EXIT - just sim-boot '{{destination}}' '{{destination}}' & + just sim-boot '{{destination}}' '{{destination}}' '{{destination}}' & just docker-up & - just build-for-testing '{{destination}}' '{{destination}}' & + just build-for-testing '{{destination}}' '{{destination}}' '{{destination}}' & wait just seed just _write-url-file '{{destination}}' '{{base_url_1}}' true @@ -246,21 +280,22 @@ test-one filter="" destination=default_destination: -only-testing:"ForjiUITests/$FILTER" 2>&1 | xcbeautify [private] -sim-boot destination_a=default_destination destination_b=default_destination_b: +sim-boot destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: #!/usr/bin/env bash set -eo pipefail - SIM_A="$(echo '{{destination_a}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" - SIM_B="$(echo '{{destination_b}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" - xcrun simctl boot "$SIM_A" 2>/dev/null || true - xcrun simctl boot "$SIM_B" 2>/dev/null || true + for dest in '{{destination_a}}' '{{destination_b}}' '{{destination_c}}'; do + SIM="$(echo "$dest" | sed -n 's/.*name=\([^,]*\).*/\1/p')" + xcrun simctl boot "$SIM" 2>/dev/null || true + done [private] -build-for-testing destination_a=default_destination destination_b=default_destination_b: +build-for-testing destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: xcodebuild build-for-testing \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination_a}}' \ - -destination '{{destination_b}}' 2>&1 | xcbeautify + -destination '{{destination_b}}' \ + -destination '{{destination_c}}' 2>&1 | xcbeautify [private] _write-url-file destination url fallback="false": @@ -285,8 +320,9 @@ _write-url2-file destination url fallback="false": fi [private] -_write-url-files destination_a=default_destination destination_b=default_destination_b: +_write-url-files destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: just _write-url-file '{{destination_a}}' '{{base_url_1}}' true just _write-url-file '{{destination_b}}' '{{base_url_2}}' - just _write-url2-file '{{destination_a}}' '{{base_url_2}}' + just _write-url-file '{{destination_c}}' '{{base_url_3}}' + just _write-url2-file '{{destination_a}}' '{{base_url_2}}' true just _write-url2-file '{{destination_b}}' '{{base_url_1}}'