test: stream seed progress and add a docker exec timeout (#69)

The integration seeder gave no visible progress and could hang
indefinitely. A `docker compose exec` for the admin-user step wedged on
a transient Docker-on-macOS flake, and `DockerExec.run` waited on it
with no timeout, blocking the whole UI test suite forever with no output
(stdout was block-buffered, so the existing phase prints never flushed).

- Line-buffer the seeder's stdout so phase progress streams live instead
  of being dumped all at once when stdout is a pipe.
- Add numbered "[n/7] <phase> (Ns elapsed)" headers for a clear progress
  and timing signal.
- Add a 60s timeout to `docker compose exec` and retry the admin-user
  step, so a hung exec fails fast and recovers instead of wedging the
  suite.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/69
This commit is contained in:
Stefan Hausotte 2026-06-09 12:55:45 +02:00 committed by secana
parent b1942df58d
commit 2a679140e6
4 changed files with 40 additions and 3 deletions

View file

@ -4,6 +4,7 @@ enum DockerExec {
static func run( static func run(
composeFile: String, service: String, composeFile: String, service: String,
command: [String], command: [String],
timeout: TimeInterval = 60,
) throws { ) throws {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@ -16,8 +17,21 @@ enum DockerExec {
process.standardError = stderrPipe process.standardError = stderrPipe
process.standardOutput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice
let didExit = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in didExit.signal() }
try process.run() try process.run()
process.waitUntilExit()
// Enforce a timeout so a hung `docker compose exec` (a known Docker-on-macOS flake)
// fails fast and can be retried, instead of blocking the whole seed indefinitely.
if didExit.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
_ = didExit.wait(timeout: .now() + 5)
throw SeedError.dockerExecTimedOut(
command: command.joined(separator: " "),
seconds: timeout,
)
}
if process.terminationStatus != 0 { if process.terminationStatus != 0 {
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()

View file

@ -3,6 +3,10 @@ import Foundation
@main @main
struct ForgejeSeed { struct ForgejeSeed {
static func main() async throws { static func main() async throws {
// Line-buffer stdout so seed progress streams live instead of being held in a
// block buffer (and dumped all at once) when stdout is a pipe rather than a TTY.
setvbuf(stdout, nil, _IOLBF, 0)
let args = CommandLine.arguments let args = CommandLine.arguments
guard args.count >= 3 else { guard args.count >= 3 else {
throw SeedError.missingArguments throw SeedError.missingArguments

View file

@ -3,6 +3,7 @@ import Foundation
enum SeedError: LocalizedError { enum SeedError: LocalizedError {
case missingArguments case missingArguments
case dockerExecFailed(command: String, exitCode: Int32, stderr: String) case dockerExecFailed(command: String, exitCode: Int32, stderr: String)
case dockerExecTimedOut(command: String, seconds: TimeInterval)
case retryExhausted(operation: String, attempts: Int) case retryExhausted(operation: String, attempts: Int)
var errorDescription: String? { var errorDescription: String? {
@ -11,6 +12,8 @@ enum SeedError: LocalizedError {
"Usage: forgejo-seed <base-url> <service-name> [compose-file]" "Usage: forgejo-seed <base-url> <service-name> [compose-file]"
case let .dockerExecFailed(command, exitCode, stderr): case let .dockerExecFailed(command, exitCode, stderr):
"Docker exec failed (\(exitCode)): \(command)\n\(stderr)" "Docker exec failed (\(exitCode)): \(command)\n\(stderr)"
case let .dockerExecTimedOut(command, seconds):
"Docker exec timed out after \(Int(seconds))s: \(command)"
case let .retryExhausted(operation, attempts): case let .retryExhausted(operation, attempts):
"\(operation) failed after \(attempts) attempts" "\(operation) failed after \(attempts) attempts"
} }

View file

@ -25,14 +25,30 @@ struct Seeder {
allowSelfSignedCertificates: true, allowSelfSignedCertificates: true,
) )
let start = Date()
let total = 7
func step(_ number: Int, _ label: String) {
let elapsed = Int(Date().timeIntervalSince(start))
print("\n[\(number)/\(total)] \(label) (\(elapsed)s elapsed)")
}
step(1, "Creating admin user")
try await withRetry(maxAttempts: 3, delay: .seconds(2), operation: "create admin user") {
try createAdminUser() try createAdminUser()
}
step(2, "Creating API users")
try await createAPIUsers(adminClient: adminClient) try await createAPIUsers(adminClient: adminClient)
step(3, "Creating repositories")
try await createRepositories(adminClient: adminClient) try await createRepositories(adminClient: adminClient)
step(4, "Creating issues, files, and metadata")
try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient) try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(5, "Creating pull requests")
try await createPullRequests(testbotClient: testbotClient) try await createPullRequests(testbotClient: testbotClient)
step(6, "Setting pull request metadata")
try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient) try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(7, "Creating API token")
try await createAPIToken(adminClient: adminClient) try await createAPIToken(adminClient: adminClient)
print("\nSeed data created successfully.") print("\nSeed data created successfully (\(Int(Date().timeIntervalSince(start)))s total).")
} }
// MARK: - Phase 1: Create users // MARK: - Phase 1: Create users