test: improve test runtime

Reviewed-on: https://codeberg.org/secana/Forji/pulls/24
Co-authored-by: Stefan Hausotte <stefan.hausotte@gmx.de>
Co-committed-by: Stefan Hausotte <stefan.hausotte@gmx.de>
This commit is contained in:
Stefan Hausotte 2026-03-23 19:07:55 +01:00 committed by secana
parent 253f3e88d1
commit c42ed9552e
5 changed files with 289 additions and 127 deletions

View file

@ -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) }
}
}

View file

@ -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()

View file

@ -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"
)
}

View file

@ -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:

170
justfile
View file

@ -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}}'