Skip to content

Commit 1e7b687

Browse files
committed
Migrate kotaml to Kotlin Toolchain with KMP checks and coverage reporting
1 parent a087b1a commit 1e7b687

109 files changed

Lines changed: 1609 additions & 4560 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Keep source and wrapper line endings consistent on every CI host.
2+
* text=auto eol=lf
3+
*.bat text eol=crlf

‎.github/dependabot.yml‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,3 @@ updates:
1818
all:
1919
patterns:
2020
- "*"
21-
- package-ecosystem: "npm"
22-
directory: "/kotlin-js-store"
23-
schedule:
24-
interval: "weekly"
25-
open-pull-requests-limit: 0
26-
groups:
27-
all:
28-
patterns:
29-
- "*"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env kotlinr
2+
3+
/** Build the Pages site from completed Kover and Dokka tasks; no network or dependencies.
4+
* Usage: kotlinr .github/scripts/build-pages.main.kts <tasks-dir> <output-dir> <commit-sha> [ISO-instant]
5+
*/
6+
7+
import java.io.File
8+
import java.time.Instant
9+
import java.util.Locale
10+
import javax.xml.XMLConstants
11+
import javax.xml.parsers.DocumentBuilderFactory
12+
import org.w3c.dom.Element
13+
14+
data class Coverage(val covered: Long, val missed: Long) {
15+
val total = Math.addExact(covered, missed)
16+
val percent: Double? = if (total == 0L) null else covered.toDouble() * 100 / total
17+
val label: String = percent?.let { String.format(Locale.ROOT, "%.2f%%", it) } ?: "n/a"
18+
}
19+
20+
fun escape(value: String): String = value.replace("&", "&amp;").replace("<", "&lt;")
21+
.replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;")
22+
23+
fun counters(file: File): Map<String, Coverage> {
24+
val factory = DocumentBuilderFactory.newInstance().apply {
25+
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
26+
setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "")
27+
setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "")
28+
isXIncludeAware = false
29+
isExpandEntityReferences = false
30+
}
31+
val report = factory.newDocumentBuilder().parse(file).documentElement
32+
require(report.tagName == "report") { "Expected a Kover report: $file" }
33+
// Only report totals: package/class/method counters would count the same code repeatedly.
34+
val children = (0 until report.childNodes.length).mapNotNull { report.childNodes.item(it) as? Element }
35+
return listOf("LINE", "BRANCH").associateWith { type ->
36+
val node = children.singleOrNull { it.tagName == "counter" && it.getAttribute("type") == type }
37+
requireNotNull(node) { "Missing or duplicate $type total in $file" }
38+
val covered = node.getAttribute("covered").toLong()
39+
val missed = node.getAttribute("missed").toLong()
40+
require(covered >= 0 && missed >= 0) { "Negative $type counter in $file" }
41+
Coverage(covered, missed)
42+
}
43+
}
44+
45+
fun badge(label: String, coverage: Coverage, provenance: String): String {
46+
val left = label.length * 7 + 16
47+
val right = 68
48+
val width = left + right
49+
val color = when {
50+
coverage.percent == null -> "#6b7280"
51+
coverage.percent >= 90 -> "#16803c"
52+
coverage.percent >= 75 -> "#996000"
53+
else -> "#b91c1c"
54+
}
55+
return """<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="24" role="img" aria-label="${escape("$label: ${coverage.label}")}">
56+
<title>${escape("$label: ${coverage.label}; $provenance")}</title>
57+
<rect width="$width" height="24" rx="4" fill="#374151"/>
58+
<path d="M$left 0H${width - 4}Q$width 0 $width 4V20Q$width 24 ${width - 4} 24H$left Z" fill="$color"/>
59+
<g fill="white" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">
60+
<text x="${left / 2}" y="16">${escape(label)}</text>
61+
<text x="${left + right / 2}" y="16">${coverage.label}</text>
62+
</g>
63+
</svg>
64+
""".trimIndent()
65+
}
66+
67+
fun page(title: String, provenance: String, body: String): String = """
68+
<!doctype html>
69+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
70+
<title>${escape(title)}</title><style>
71+
body{font:16px/1.6 system-ui,sans-serif;max-width:850px;margin:3rem auto;padding:0 1rem;color:#17212b}
72+
a{color:#0759a5}table{border-collapse:collapse}td,th{padding:.5rem 1rem;text-align:left;border-bottom:1px solid #ddd}
73+
</style></head><body><h1>${escape(title)}</h1>$provenance$body</body></html>
74+
""".trimIndent() + "\n"
75+
76+
require(args.size in 3..4) { "Usage: build-pages.main.kts <tasks-dir> <output-dir> <commit-sha> [ISO-instant]" }
77+
val tasks = File(args[0])
78+
val output = File(args[1])
79+
val sha = args[2]
80+
require(Regex("[0-9a-f]{40}").matches(sha)) { "Expected full commit SHA" }
81+
val generated = args.getOrNull(3)?.let(Instant::parse) ?: Instant.now()
82+
val modules = listOf("kotaml", "kotaml-json")
83+
val reports = modules.associateWith { module -> counters(tasks.resolve("_${module}_koverReport@kover/coverage.xml")) }
84+
for (module in modules) {
85+
for (path in listOf("_${module}_koverReport@kover/html", "_${module}_dokkaHtml@dokka/html")) {
86+
require(tasks.resolve("$path/index.html").isFile) { "Missing report: $path/index.html" }
87+
require(tasks.resolve(path).walkTopDown().none { java.nio.file.Files.isSymbolicLink(it.toPath()) }) {
88+
"Pages artifacts must not contain symbolic links: $path"
89+
}
90+
}
91+
}
92+
require(!output.exists() || output.isDirectory && output.listFiles()!!.isEmpty()) { "Output must be empty: $output" }
93+
check(output.isDirectory || output.mkdirs())
94+
val provenance = "<p>Commit <a href=\"https://github.com/Heapy/kotaml/commit/$sha\"><code>${sha.take(12)}</code></a> · Generated <time>$generated</time> (UTC)</p>"
95+
val badges = output.resolve("badges").apply { mkdirs() }
96+
for ((module, totals) in reports) {
97+
val destination = output.resolve("coverage/$module").apply { mkdirs() }
98+
check(tasks.resolve("_${module}_koverReport@kover/html").copyRecursively(destination.resolve("report")))
99+
tasks.resolve("_${module}_koverReport@kover/coverage.xml").copyTo(destination.resolve("coverage.xml"))
100+
val rows = listOf("LINE" to "lines", "BRANCH" to "branches").joinToString("\n") { (type, name) ->
101+
val coverage = totals.getValue(type)
102+
badges.resolve("$module-$name.svg").writeText(badge("$module JVM $name", coverage, "$sha; $generated"))
103+
println("$module $name: ${coverage.label} (${coverage.covered}/${coverage.total})")
104+
"<tr><th>$name</th><td>${coverage.label}</td><td>${coverage.covered} / ${coverage.total}</td></tr>"
105+
}
106+
destination.resolve("index.html").writeText(page("$module JVM coverage", provenance, """
107+
<p>Coverage from JVM tests. Branch coverage does not measure every combination of conditions.</p>
108+
<table><thead><tr><th>Metric</th><th>Coverage</th><th>Covered / total</th></tr></thead><tbody>$rows</tbody></table>
109+
<p><a href="report/index.html">Browse the Kover report</a> · <a href="coverage.xml">XML report</a> · <a href="../../index.html">All reports</a></p>
110+
""".trimIndent()))
111+
check(tasks.resolve("_${module}_dokkaHtml@dokka/html").copyRecursively(output.resolve("api/$module")))
112+
}
113+
val links = modules.joinToString("\n") { module ->
114+
"<li><strong>$module</strong>: <a href=\"coverage/$module/\">JVM coverage</a> · <a href=\"api/$module/\">KMP API documentation</a></li>"
115+
}
116+
output.resolve("index.html").writeText(page("kotaml documentation and coverage", provenance, "<ul>$links</ul>"))
117+
output.resolve(".nojekyll").writeText("")
118+
println("Pages site: ${output.absolutePath}")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env kotlinr
2+
3+
/**
4+
* KTC 0.13 NativeTestTask executes tvOS/watchOS binaries directly on the host.
5+
* Link with KTC, then run in the matching runtime through simctl (as Gradle does).
6+
* Usage: kotlinr .github/scripts/test-apple-simulators.main.kts
7+
*/
8+
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
9+
10+
import java.io.File
11+
import java.util.UUID
12+
import java.util.concurrent.TimeUnit
13+
import kotlinx.serialization.json.*
14+
15+
val root = File(".").canonicalFile
16+
val logs = root.resolve("build/simulator-tests").apply { mkdirs() }
17+
18+
fun run(command: List<String>, name: String, timeoutMinutes: Long = 30): String {
19+
val log = logs.resolve("$name.log")
20+
val process = ProcessBuilder(command).directory(root).redirectErrorStream(true).redirectOutput(log).start()
21+
try {
22+
check(process.waitFor(timeoutMinutes, TimeUnit.MINUTES)) { "Timed out: $command; see $log" }
23+
val output = log.readText()
24+
check(process.exitValue() == 0) { "Failed (${process.exitValue()}): $command\n${output.takeLast(12000)}" }
25+
return output.trim()
26+
} finally {
27+
if (process.isAlive) {
28+
process.descendants().use { children -> children.forEach { it.destroyForcibly() } }
29+
process.destroyForcibly().waitFor()
30+
}
31+
}
32+
}
33+
34+
check(System.getProperty("os.name").startsWith("Mac")) { "Apple simulators require macOS" }
35+
val runtimes = Json.parseToJsonElement(run(listOf("xcrun", "simctl", "list", "runtimes", "-j"), "runtimes"))
36+
.jsonObject.getValue("runtimes").jsonArray.map { it.jsonObject }
37+
val targets = listOf("tvOS" to "TvosSimulatorArm64", "watchOS" to "WatchosSimulatorArm64")
38+
// Validate both runtimes before expensive compilation. Never silently skip simulator tests.
39+
val selected = targets.associate { (platform, target) ->
40+
val runtime = runtimes.filter {
41+
it["isAvailable"]?.jsonPrimitive?.booleanOrNull == true &&
42+
it.getValue("identifier").jsonPrimitive.content.contains(".$platform-")
43+
}.maxByOrNull {
44+
it.getValue("version").jsonPrimitive.content.split('.').joinToString(".") { part -> part.padStart(4, '0') }
45+
} ?: error("No available $platform simulator runtime. Install with: xcodebuild -downloadPlatform $platform")
46+
target to runtime
47+
}
48+
49+
val tasks = selected.keys.flatMap { target ->
50+
listOf("kotaml", "kotaml-json").map { module -> ":$module:link${target}TestDebug" }
51+
}
52+
println("Linking tvOS/watchOS tests with Kotlin Toolchain")
53+
run(listOf(root.resolve("kotlin").path, "task") + tasks, "link")
54+
for ((target, runtime) in selected) {
55+
val runtimeId = runtime.getValue("identifier").jsonPrimitive.content
56+
val deviceType = runtime.getValue("supportedDeviceTypes").jsonArray.first().jsonObject
57+
.getValue("identifier").jsonPrimitive.content
58+
val device = run(listOf("xcrun", "simctl", "create", "kotaml-tests-${UUID.randomUUID()}", deviceType, runtimeId), "$target-create")
59+
require(Regex("[A-Fa-f0-9-]{36}").matches(device)) { "Invalid simulator identifier: $device" }
60+
try {
61+
for (module in listOf("kotaml", "kotaml-json")) {
62+
val directory = root.resolve("build/tasks/_${module}_link${target}TestDebug")
63+
// dSYM bundles also contain a file named *.kexe; only the top-level file is executable.
64+
val binary = directory.listFiles().orEmpty().single { it.isFile && it.extension == "kexe" }
65+
println("Testing $module on $runtimeId")
66+
// Standalone console tests need no booted UI or paired watch; simctl supplies DYLD_ROOT_PATH.
67+
val result = run(listOf("xcrun", "simctl", "spawn", "--standalone", device, binary.path), "$module-$target", 10)
68+
println(result.lineSequence().filter { it.contains("Tests:") || it.contains("All tests passed") }.joinToString("\n"))
69+
}
70+
} finally {
71+
// Delete only the disposable device created by this script.
72+
run(listOf("xcrun", "simctl", "delete", device), "$target-delete", 2)
73+
}
74+
}
75+
println("tvOS and watchOS simulator tests passed for both modules")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env kotlinr
2+
3+
/** Regression checks for Kover totals, empty metrics, invalid input and Pages packaging. */
4+
import java.io.File
5+
import java.nio.file.Files
6+
import javax.xml.parsers.DocumentBuilderFactory
7+
8+
val temporary = Files.createTempDirectory("kotaml-pages-test-").toFile()
9+
val tasks = temporary.resolve("tasks")
10+
val generator = File(".github/scripts/build-pages.main.kts").absolutePath
11+
val sha = "0123456789abcdef0123456789abcdef01234567"
12+
val date = "2026-10-13T12:00:00Z"
13+
14+
fun xml(line: String = """<counter type="LINE" covered="3" missed="1"/>""", branch: String = """<counter type="BRANCH" covered="0" missed="0"/>""") = """
15+
<report name="fixture"><package name="nested"><counter type="LINE" covered="999" missed="0"/></package>$line$branch</report>
16+
""".trimIndent()
17+
18+
fun run(name: String, success: Boolean = true): File {
19+
val output = temporary.resolve(name)
20+
val process = ProcessBuilder("kotlinr", generator, tasks.path, output.path, sha, date)
21+
.redirectErrorStream(true).start()
22+
val log = process.inputStream.bufferedReader().readText()
23+
val exit = process.waitFor()
24+
check((exit == 0) == success) { "$name: unexpected exit $exit\n$log" }
25+
if (!success) check(!output.exists()) { "Invalid input created a partial site" }
26+
return output
27+
}
28+
29+
try {
30+
for (module in listOf("kotaml", "kotaml-json")) {
31+
val coverage = tasks.resolve("_${module}_koverReport@kover").apply { mkdirs() }
32+
coverage.resolve("coverage.xml").writeText(xml())
33+
for (type in listOf("koverReport@kover", "dokkaHtml@dokka")) {
34+
val html = tasks.resolve("_${module}_$type/html").apply { mkdirs() }
35+
html.resolve("index.html").writeText("<html><body>$module $type</body></html>")
36+
}
37+
}
38+
val output = run("valid")
39+
val svg = output.resolve("badges/kotaml-lines.svg")
40+
check("75.00%" in svg.readText() && sha in svg.readText() && date in svg.readText())
41+
check("n/a" in output.resolve("badges/kotaml-branches.svg").readText())
42+
output.resolve("badges").listFiles()!!.forEach {
43+
check(DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(it).documentElement.tagName == "svg")
44+
}
45+
check("3 / 4" in output.resolve("coverage/kotaml/index.html").readText())
46+
check(output.resolve("coverage/kotaml/report/index.html").isFile)
47+
check(output.resolve("api/kotaml-json/index.html").isFile)
48+
check(output.resolve("coverage/kotaml/coverage.xml").isFile)
49+
val report = tasks.resolve("_kotaml_koverReport@kover/coverage.xml")
50+
report.writeText(xml(line = ""))
51+
run("missing-total", success = false)
52+
report.writeText(xml(line = """<counter type="LINE" covered="-1" missed="1"/>"""))
53+
run("negative-total", success = false)
54+
report.writeText(xml(branch = """<counter type="BRANCH" covered="1" missed="2"/>"""))
55+
val thirds = run("rounding")
56+
check("33.33%" in thirds.resolve("badges/kotaml-branches.svg").readText())
57+
tasks.resolve("_kotaml-json_dokkaHtml@dokka/html/index.html").delete()
58+
run("missing-docs", success = false)
59+
println("Pages generator tests passed")
60+
} finally {
61+
temporary.deleteRecursively()
62+
}

0 commit comments

Comments
 (0)