Skip to content

Commit d1805f3

Browse files
CedNaruclaude
andcommitted
Fix export test jobs for all three OSes
Follow-up to the earlier export-test work, fixing the per-OS failures CI surfaced: - tests_macos: terminate the previously broken exclude_filter string (an unterminated quote made Godot swallow export_path -> "preset.5.options nonexistent"), and keep export_path as ./export/tests.dmg. The macOS workflow mounts that .dmg (hdiutil attach tests.dmg) and copies the .app out, so the export must produce a .dmg (an earlier attempt to switch to .app broke that workflow step). - Windows: prepareHostExportTemplates ensured BOTH the debug and release host templates unconditionally, but each export job only downloads the template for its own target (debug for "dev tests", release for "release tests"), causing "Could not find Windows <target> export template". ensure now skips a template that isn't present instead of failing the prepare step; the export only needs the template matching its target. - Linux: run the test executable under `stdbuf -oL -eL` so its stdout/stderr is line-buffered. Godot block-buffers when piped, so on a 30-min timeout the buffered tail (where the exported run actually stalls) was lost, leaving the log truncated at "Starting JVM ...". This surfaces the real progress/stall point in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8969761 commit d1805f3

6 files changed

Lines changed: 186 additions & 197 deletions

File tree

harness/tests/build.gradle.kts

Lines changed: 112 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -52,59 +52,121 @@ kotlin.sourceSets.main {
5252
kotlin.srcDirs("otherSourceDir")
5353
}
5454

55+
fun bundledBinDirectories(): List<File> = listOf(
56+
projectDir.resolve("../../../../bin"),
57+
projectDir.resolve("bin"),
58+
rootProject.layout.projectDirectory.asFile.resolve("bin"),
59+
)
5560

61+
fun resolveBundledBinaries(): List<File> =
62+
bundledBinDirectories().flatMap { directory -> (directory.listFiles() ?: emptyArray()).toList() }
63+
64+
fun provideEditorExecutable(): File =
65+
resolveBundledBinaries()
66+
.also { println("[${it.joinToString()}]") }
67+
.firstOrNull { it.name.startsWith("godot.") && it.name.contains("editor") && !it.name.contains("console") }
68+
.also { println("Godot executable selected: $it") }
69+
?: error("Could not find editor executable")
70+
71+
fun currentExportTarget(): String = when {
72+
HostManager.hostIsLinux -> "tests_linux"
73+
HostManager.hostIsMac -> "tests_macos"
74+
HostManager.hostIsMingw -> "tests_windows"
75+
else -> throw IllegalStateException("Unsupported OS for exporting")
76+
}
5677

57-
tasks {
58-
fun currentExportTarget(): String = when {
59-
HostManager.hostIsLinux -> "tests_linux"
60-
HostManager.hostIsMac -> "tests_macos"
61-
HostManager.hostIsMingw -> "tests_windows"
62-
else -> throw IllegalStateException("Unsupported OS for exporting")
63-
}
64-
65-
fun resolveBundledBinaries(): List<File> =
66-
listOf(
67-
projectDir.resolve("../../../../bin"),
68-
projectDir.resolve("bin"),
69-
rootProject.layout.projectDirectory.asFile.resolve("bin"),
70-
)
71-
.flatMap { directory -> (directory.listFiles() ?: emptyArray()).toList() }
78+
fun File.ensureEmptyDirectory() {
79+
deleteRecursively()
80+
mkdirs()
81+
}
7282

73-
fun findWindowsBundledBinary(target: String, consoleWrapper: Boolean): File? {
74-
val expectedPrefix = "godot.windows.template_${target}.x86_64.jvm."
83+
fun findWindowsBundledBinary(target: String, consoleWrapper: Boolean): File? {
84+
val expectedPrefix = "godot.windows.template_${target}.x86_64.jvm."
7585

76-
return resolveBundledBinaries().firstOrNull { file ->
77-
file.name.startsWith(expectedPrefix) && when {
78-
consoleWrapper -> file.name.endsWith(".console.exe")
79-
else -> file.name.endsWith(".exe") && !file.name.endsWith(".console.exe")
80-
}
86+
return resolveBundledBinaries().firstOrNull { file ->
87+
file.name.startsWith(expectedPrefix) && when {
88+
consoleWrapper -> file.name.endsWith(".console.exe")
89+
else -> file.name.endsWith(".exe") && !file.name.endsWith(".console.exe")
8190
}
8291
}
92+
}
8393

84-
fun ensureWindowsExportTemplate(target: String, consoleWrapper: Boolean = false) {
85-
if (!HostManager.hostIsMingw) {
86-
return
87-
}
94+
fun ensureWindowsExportTemplate(target: String, consoleWrapper: Boolean = false) {
95+
if (!HostManager.hostIsMingw) {
96+
return
97+
}
8898

89-
val destinationName = buildString {
90-
append("godot.windows.template_${target}.x86_64")
91-
if (consoleWrapper) {
92-
append(".console")
93-
}
94-
append(".exe")
95-
}
96-
val destination = projectDir.resolve(destinationName)
97-
if (destination.exists()) {
98-
return
99+
val destinationName = buildString {
100+
append("godot.windows.template_${target}.x86_64")
101+
if (consoleWrapper) {
102+
append(".console")
99103
}
104+
append(".exe")
105+
}
106+
val destination = projectDir.resolve(destinationName)
107+
if (destination.exists()) {
108+
return
109+
}
100110

101-
val source = findWindowsBundledBinary(target, consoleWrapper) ?: throw IllegalStateException(
102-
"Could not find Windows ${target} export ${if (consoleWrapper) "console wrapper" else "template"} in the local bin directories."
111+
// Each CI export job only provides the template for the target it builds
112+
// (debug for "dev tests", release for "release tests"). If the other target's
113+
// template isn't present, skip it instead of failing the whole prepare step -
114+
// the export only uses the template matching its target.
115+
val source = findWindowsBundledBinary(target, consoleWrapper)
116+
if (source == null) {
117+
logger.lifecycle(
118+
"Skipping Windows ${target} export ${if (consoleWrapper) "console wrapper" else "template"}: " +
119+
"not present in the local bin directories.",
103120
)
121+
return
122+
}
104123

105-
source.copyTo(destination, overwrite = true)
124+
source.copyTo(destination, overwrite = true)
125+
}
126+
127+
fun findExportedExecutable(): File? {
128+
val exportedFiles = projectDir.resolve("export").listFiles()?.toList().orEmpty()
129+
println("Test executables: [${exportedFiles.joinToString()}]")
130+
exportedFiles.forEach { it.setExecutable(true) }
131+
132+
val exportedExecutable = when {
133+
HostManager.hostIsMingw -> exportedFiles.firstOrNull { it.name.endsWith(".console.exe") }
134+
HostManager.hostIsMac -> exportedFiles.firstOrNull { it.name.endsWith(".app") }
135+
else -> exportedFiles.firstOrNull { it.name.contains("x86_64") }
136+
} ?: exportedFiles.firstOrNull { it.name.endsWith(".exe") }
137+
138+
return if (exportedExecutable?.name?.endsWith(".app") == true) {
139+
exportedExecutable.resolve("Contents/MacOS").listFiles()?.firstOrNull()
140+
} else {
141+
exportedExecutable
106142
}
143+
}
144+
145+
fun requireExportedExecutable(): File =
146+
findExportedExecutable()
147+
?: error("No exported test executable found in ${projectDir.resolve("export")}. Run exportDebug or exportRelease first.")
148+
149+
fun registerExportTask(name: String, exportFlag: String, description: String) = tasks.register<Exec>(name) {
150+
group = "verification"
151+
this.description = description
152+
dependsOn("importResources", "prepareHostExportTemplates")
153+
154+
environment("JAVA_HOME", System.getProperty("java.home"))
155+
workingDir = projectDir
156+
157+
doFirst {
158+
projectDir.resolve("export").ensureEmptyDirectory()
159+
}
160+
161+
commandLine(
162+
provideEditorExecutable().absolutePath,
163+
"--headless",
164+
exportFlag,
165+
currentExportTarget(),
166+
)
167+
}
107168

169+
tasks {
108170
val prepareHostExportTemplates = register("prepareHostExportTemplates") {
109171
group = "verification"
110172
description = "Ensures export presets can find the host export templates in a project-local path."
@@ -143,42 +205,9 @@ tasks {
143205
)
144206
}
145207
}
146-
val exportDebug by registering(Exec::class) {
147-
group = "verification"
148-
description = "Exports the tests for the current host OS in debug mode"
149-
dependsOn(importResources, prepareHostExportTemplates)
150-
151-
environment("JAVA_HOME", System.getProperty("java.home"))
152-
workingDir = projectDir
153-
154-
projectDir.resolve("export").deleteRecursively()
155-
projectDir.resolve("export").mkdirs()
156-
157-
commandLine(
158-
provideEditorExecutable().absolutePath,
159-
"--headless",
160-
"--export-debug",
161-
currentExportTarget(),
162-
)
163-
}
164-
val exportRelease by registering(Exec::class) {
165-
group = "verification"
166-
description = "Exports the tests for the current host OS in release mode"
167-
dependsOn(importResources, prepareHostExportTemplates)
168-
169-
environment("JAVA_HOME", System.getProperty("java.home"))
170-
workingDir = projectDir
208+
val exportDebug = registerExportTask("exportDebug", "--export-debug", "Exports the tests for the current host OS in debug mode")
209+
val exportRelease = registerExportTask("exportRelease", "--export-release", "Exports the tests for the current host OS in release mode")
171210

172-
projectDir.resolve("export").deleteRecursively()
173-
projectDir.resolve("export").mkdirs()
174-
175-
commandLine(
176-
provideEditorExecutable().absolutePath,
177-
"--headless",
178-
"--export-release",
179-
currentExportTarget(),
180-
)
181-
}
182211
register<Exec>("runGDTests") {
183212
group = "verification"
184213
description = "Runs GDUnit tests from the source Godot project."
@@ -207,47 +236,11 @@ tasks {
207236
group = "verification"
208237
description = "Runs GDUnit tests from the exported package."
209238

210-
val hasExistingExport = projectDir.resolve("export")
211-
.listFiles()
212-
?.any { exportedFile ->
213-
exportedFile.name.endsWith(".exe") ||
214-
exportedFile.name.endsWith(".app") ||
215-
exportedFile.name.contains("x86_64")
216-
} == true
217-
if (!hasExistingExport) {
218-
dependsOn(exportDebug)
219-
}
220-
221239
setupTestExecution {
222-
val executable = projectDir
223-
.resolve("export")
224-
.listFiles()
225-
?.also {
226-
println("Test executables: [${it.joinToString()}]")
227-
it.forEach { file -> file.setExecutable(true) }
228-
}
229-
?.let { exportedFiles ->
230-
when {
231-
HostManager.hostIsMingw -> exportedFiles.firstOrNull { it.name.endsWith(".console.exe") }
232-
HostManager.hostIsMac -> exportedFiles.firstOrNull { it.name.endsWith(".app") }
233-
else -> exportedFiles.firstOrNull { it.name.contains("x86_64") }
234-
} ?: exportedFiles.firstOrNull { it.name.endsWith(".exe") }
235-
}
236-
?.let { exportedExecutable ->
237-
if (exportedExecutable.name.endsWith(".app")) {
238-
exportedExecutable.resolve("Contents/MacOS").listFiles()?.firstOrNull()
239-
} else {
240-
exportedExecutable
241-
}
242-
}
243-
?.absolutePath
244-
245240
TestExecutionCommand(
246-
executable = executable ?: "no_test_executable_found",
241+
executable = requireExportedExecutable().absolutePath,
247242
useProjectPathOverride = false,
248-
// Exported builds cannot use GdUnitCmdTool (it pulls in editor-only code).
249-
// Use our minimal runner instead. See test_runner/README.md.
250-
scriptArgs = listOf("-s", "res://test_runner/ExportTestMain.gd"),
243+
scriptArgs = emptyList(),
251244
)
252245
}
253246
}
@@ -292,6 +285,13 @@ fun Exec.setupTestExecution(commandProvider: () -> TestExecutionCommand) {
292285
append(runtimeArgs.joinToString(" ", transform = ::windowsQuote))
293286
},
294287
)
288+
} else if (HostManager.hostIsLinux) {
289+
// Force line-buffered stdout/stderr so CI shows progress in real time.
290+
// Godot block-buffers when piped, so on a timeout-kill the buffered tail
291+
// (where an exported run actually stalls) is otherwise lost.
292+
this@setupTestExecution.commandLine(
293+
"stdbuf", "-oL", "-eL", command.executable, *runtimeArgs.toTypedArray(),
294+
)
295295
} else {
296296
this@setupTestExecution.commandLine(command.executable, *runtimeArgs.toTypedArray())
297297
}
@@ -304,17 +304,3 @@ fun windowsQuote(argument: String): String {
304304
}
305305
return "\"$argument\""
306306
}
307-
308-
fun provideEditorExecutable(): File = (
309-
listOf(
310-
projectDir.resolve("../../../../bin"),
311-
projectDir.resolve("bin"),
312-
rootProject.layout.projectDirectory.asFile.resolve("bin"),
313-
)
314-
.flatMap { (it.listFiles() ?: arrayOf()).toList() }
315-
.also {
316-
println("[${it.joinToString()}]")
317-
}
318-
.firstOrNull { it.name.startsWith("godot.") && it.name.contains("editor") && !it.name.contains("console") }
319-
.also{ println("Godot executable selected: $it")}
320-
?: throw Exception("Could not find editor executable"))

harness/tests/export_presets.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,7 @@ dedicated_server=false
918918
custom_features=""
919919
export_filter="all_resources"
920920
include_filter="*.json"
921-
exclude_filter="addons/gdUnit4/*.tscn,
921+
exclude_filter="addons/gdUnit4/*.tscn"
922922
export_path="./export/tests.dmg"
923923
patches=PackedStringArray()
924924
patch_delta_encoding=false

harness/tests/project.godot

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ config_version=5
1212

1313
config/name="Godot Kotlin Tests"
1414
run/main_scene="uid://cnqp52df0ln8e"
15+
run/main_loop_type="ExportTestMain"
1516
config/features=PackedStringArray("4.6")
1617
config/icon="res://icon.png"
1718

0 commit comments

Comments
 (0)