Compare commits

...
Author SHA1 Message Date
Rsclub2_2 ec7ea8ea1d test: render every ImageComposeScene on the AWT event thread
The rare CI hang is a lock-order deadlock in Compose Multiplatform 1.8.2,
shown by the thread dump of run 40299943329:

- the test thread renders the scene and holds the FlushCoroutineDispatcher
  runLock while LazyListState.scrollToItem measures and wants the
  SnapshotStateObserver's observedScopeMapsLock;
- the AWT event thread sends the global snapshot notifications and holds
  observedScopeMapsLock in drainChanges while a re-read derived state wakes
  the scene's snapshotFlow collector, whose dispatch wants runLock.

The application renders on the event thread, where both monitors are simply
re-entered. The tests now do the same: onEventThread builds, renders and
closes each scene there and rethrows what the block throws.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
2026-10-26 11:02:17 +01:00
Rsclub2_2 05874d6ddb test: print all threads when a test times out
The rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a
monitor in SnapshotStateObserver.observeReads (line 643 is the monitorenter
of observedScopeMapsLock in Compose 1.8.2). The timed-out thread's stack
shows where it waits but not which thread holds the lock. With the thread
dump enabled, JUnit prints every thread to the test's standard output, which
ends up in the XML report of the test-results artifact.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
2026-10-26 10:29:18 +01:00
dependabot[bot] 834f4b131a build(deps-dev): bump brace-expansion from 1.1.15 to 1.1.21 in /website (#155)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.15 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.15...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:22:26 +01:00
dependabot[bot] 1868f21c06 build(deps-dev): bump liquidjs from 10.27.1 to 10.30.0 in /website (#154)
Bumps [liquidjs](https://github.com/harttle/liquidjs) from 10.27.1 to 10.30.0.
- [Release notes](https://github.com/harttle/liquidjs/releases)
- [Changelog](https://github.com/harttle/liquidjs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.30.0)

---
updated-dependencies:
- dependency-name: liquidjs
  dependency-version: 10.30.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:36 +01:00
dependabot[bot] 9d433f9ba4 build(deps): bump markdown-it from 14.3.0 to 14.3.3 in /website (#153)
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.0 to 14.3.3.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/14.3.3/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.0...14.3.3)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.3.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:19 +01:00
dependabot[bot] f4f046c04f build(deps): bump js-yaml in /website (#152)
Bumps  and [js-yaml](https://github.com/nodeca/js-yaml). These dependencies needed to be updated together.

Updates `js-yaml` from 3.15.0 to 3.15.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2)

Updates `js-yaml` from 4.3.0 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.15.2
  dependency-type: indirect
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:06 +01:00
Rsclub2_2 8fc3b9f424 fix: resolve the open CodeQL alerts (#156)
Strip HTML tags and XML comments until the text no longer changes, so that
nested input cannot reassemble a tag or comment after a single pass
(js/incomplete-multi-character-sanitization in website/.eleventy.js and
website/scripts/validate-version-info.js).

Limit the GITHUB_TOKEN of the PR compile check and the test version-XML
workflow to read access (actions/missing-workflow-permissions).

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
2026-10-26 10:13:10 +01:00
Rsclub2_2andClaude Opus 5.5 f165f7d303 fix(aur): build the source packages' launcher with Temurin (#150)
The kst4contest and kst4contest-git packages built jpackage's launcher
with Arch's openjdk, which links libapplauncher.so against the shared
libstdc++. That build aborts now and then on exit with "pure virtual
method called" in a static destructor (SIGABRT, core dump), the failure
Debian describes for non-static libstdc++ builds. Temurin links
libstdc++ statically, like the CI-built packages.

Both PKGBUILDs now fetch a pinned Temurin 21.0.13+9 through
source_x86_64, so aur-publish.yml's rewrite of the sha256sums= line
leaves it alone, and point the Gradle toolchain at it alone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:21:37 +02:00
Rsclub2_2andClaude Opus 5.5 738ae2125e ci: print full stacks of failed tests and keep reports on a cancel (#151)
Nightly #303 named the hanging test, SelectedStationMessageUiVisual-
RenderingTest at scene.render(), but the short exception format showed
only that line, not the call that blocked below it. The job timeout
cancels the run, and the test reports were kept only on failure().

Failed tests now print their full stack, causes included, and the
reports are uploaded on cancelled() as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:15:56 +02:00
Rsclub2_2andClaude Opus 5.5 af8affda8c ci: fail hanging tests by name instead of holding the job (#149)
Nightly #302 and the PR check for #147 hung in :app-desktop:test for
over an hour, and the log named only the task. Thirteen reruns of the
same commit passed, so the hang is rare and still unidentified.

Every test now gets a two-minute JUnit timeout in a separate thread, so
even an uninterruptible hang fails with the test's name and the stack
where it stood. The test jobs are capped at 20 minutes for a hang
outside any test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:47:25 +02:00
22 changed files with 210 additions and 40 deletions

No files matched your search

+6 -1
View File
@@ -32,6 +32,9 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
@@ -108,8 +111,10 @@ jobs:
exit 1 exit 1
fi fi
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
# classes that did finish carry the timed-out tests' full stacks.
- name: Keep the test reports on a failure - name: Keep the test reports on a failure
if: failure() if: failure() || cancelled()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: test-results name: test-results
+9 -1
View File
@@ -6,6 +6,9 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -155,6 +158,9 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
@@ -283,8 +289,10 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 14 retention-days: 14
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
# classes that did finish carry the timed-out tests' full stacks.
- name: Keep the test reports on a failure - name: Keep the test reports on a failure
if: failure() if: failure() || cancelled()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: test-results name: test-results
+6 -1
View File
@@ -24,6 +24,9 @@ jobs:
test: test:
name: Test (Java 21, headless) name: Test (Java 21, headless)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
@@ -127,8 +130,10 @@ jobs:
exit 1 exit 1
fi fi
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
# classes that did finish carry the timed-out tests' full stacks.
- name: Keep the test reports on a failure - name: Keep the test reports on a failure
if: failure() if: failure() || cancelled()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: test-results name: test-results
@@ -3,6 +3,10 @@ name: TEST publish-version-xml (local only)
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
issues: read
jobs: jobs:
publish-version-xml: publish-version-xml:
name: Publish Version Info XML via WebDAV name: Publish Version Info XML via WebDAV
@@ -55,6 +55,7 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
} }
val filters = StationFilterState() val filters = StationFilterState()
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface( Surface(
@@ -90,12 +91,13 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
} }
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions { private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
override fun panelSelection(): ChatMember? = null override fun panelSelection(): ChatMember? = null
@@ -0,0 +1,37 @@
package kst4contest.view.compose
import java.awt.EventQueue
import java.lang.reflect.InvocationTargetException
/*
* An ImageComposeScene must live on the AWT event thread, the thread the application renders
* on. Compose Multiplatform 1.8.2 sends the global snapshot notifications there
* (GlobalSnapshotManager), and doing so takes two locks in the opposite order from a render
* on any other thread:
*
* - the render holds the scene's FlushCoroutineDispatcher runLock while a task it runs
* (here LazyListState.scrollToItem) measures and wants the SnapshotStateObserver's
* observedScopeMapsLock;
* - the event thread holds observedScopeMapsLock in drainChanges while a derived state it
* re-reads wakes a snapshotFlow collector of that scene, whose dispatch wants runLock.
*
* Both waited forever in CI runs 40252077018, 40252089738 and 40299943329; the thread dump of
* the last one shows both stacks. On one thread the two monitors are simply re-entered, which
* is why the application never hangs this way.
*/
/**
* Runs [block] on the AWT event thread, waits for it and returns its result. Whatever
* [block] throws is rethrown here unwrapped, so a failed assertion still reads as one.
*/
internal fun <T> onEventThread(block: () -> T): T {
if (EventQueue.isDispatchThread()) return block()
var result: Result<T>? = null
try {
EventQueue.invokeAndWait { result = runCatching(block) }
} catch (wrapped: InvocationTargetException) {
throw wrapped.cause ?: wrapped
}
return result!!.getOrThrow()
}
@@ -84,6 +84,7 @@ class SelectedStationMessageUiVisualRenderingTest {
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked"))) replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
} }
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface( Surface(
@@ -111,12 +112,13 @@ class SelectedStationMessageUiVisualRenderingTest {
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun messageTable() = DataTableState( private fun messageTable() = DataTableState(
columns = listOf( columns = listOf(
@@ -87,6 +87,7 @@ class StationWorkspaceVisualRenderingTest {
RowKeys.byReference<ChatMessage>(), RowKeys.byReference<ChatMessage>(),
) )
return onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = 1366, width = 1366,
height = scenario.height, height = scenario.height,
@@ -139,12 +140,13 @@ class StationWorkspaceVisualRenderingTest {
} }
} }
return try { try {
scene.render().encodeToData()!!.bytes scene.render().encodeToData()!!.bytes
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun member(callSign: String, category: Int) = ChatMember().apply { private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign this.callSign = callSign
@@ -32,6 +32,7 @@ class TopPriorityCandidatesWindowTest {
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */ /** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? { private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null var picked: String? = null
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) { TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign picked = it.displayCallSign
@@ -44,6 +45,7 @@ class TopPriorityCandidatesWindowTest {
} finally { } finally {
scene.close() scene.close()
} }
}
return picked return picked
} }
@@ -78,6 +80,7 @@ class TopPriorityCandidatesWindowTest {
@Test @Test
fun `the list renders with candidates and without`() { fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking -> listOf(emptyList(), twoCandidates).forEach { ranking ->
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
} }
@@ -88,6 +91,7 @@ class TopPriorityCandidatesWindowTest {
} }
} }
} }
}
/** /**
* A double click picks the candidate under it, and picks the right one. * A double click picks the candidate under it, and picks the right one.
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapViewport import kst4contest.view.map.MapViewport
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
@@ -71,6 +72,7 @@ class ComposeStationMapDrawingTest {
workedGrossFields: Set<String> = emptySet(), workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true, darkMode: Boolean = true,
): BufferedImage { ): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap( ComposeStationMap(
markers = markers, markers = markers,
@@ -89,11 +91,12 @@ class ComposeStationMapDrawingTest {
) )
} }
try { try {
return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
} finally { } finally {
scene.close() scene.close()
} }
} }
}
private fun countColour(image: BufferedImage, rgb: Int): Int { private fun countColour(image: BufferedImage, rgb: Int): Int {
var count = 0 var count = 0
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -33,6 +34,7 @@ class ComposeStationMapLayoutTest {
) )
private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) { private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = heightPx, height = heightPx,
@@ -59,6 +61,7 @@ class ComposeStationMapLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f) fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f)
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -25,6 +26,7 @@ class PathAnalysisDetailsLayoutTest {
private val narrowWidthPx = 120 private val narrowWidthPx = 120
private fun renderAt(widthPx: Int, result: PathAnalysisResult?) { private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = 600, height = 600,
@@ -42,6 +44,7 @@ class PathAnalysisDetailsLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders when the column is narrower than the assessment label`() { fun `renders when the column is narrower than the assessment label`() {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -31,6 +32,7 @@ class PathAnalysisDetailsWidthTest {
private val assessmentBackground = 0x33383E private val assessmentBackground = 0x33383E
private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage { private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
PathAnalysisDetails( PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
@@ -40,11 +42,12 @@ class PathAnalysisDetailsWidthTest {
} }
try { try {
val png = scene.render().encodeToData()!!.bytes val png = scene.render().encodeToData()!!.bytes
return ImageIO.read(ByteArrayInputStream(png)) ImageIO.read(ByteArrayInputStream(png))
} finally { } finally {
scene.close() scene.close()
} }
} }
}
/** The smallest x at which the assessment background appears, or -1 when it does not. */ /** The smallest x at which the assessment background appears, or -1 when it does not. */
private fun assessmentLeftEdge(image: BufferedImage): Int { private fun assessmentLeftEdge(image: BufferedImage): Int {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
@@ -18,6 +19,7 @@ import org.junit.jupiter.api.Test
class PathProfileChartLayoutTest { class PathProfileChartLayoutTest {
private fun renderAt(widthPx: Int, heightPx: Int) { private fun renderAt(widthPx: Int, heightPx: Int) {
onEventThread {
val scene = ImageComposeScene( val scene = ImageComposeScene(
width = widthPx, width = widthPx,
height = heightPx, height = heightPx,
@@ -31,6 +33,7 @@ class PathProfileChartLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at the height the window gives it`() = renderAt(800, 210) fun `renders at the height the window gives it`() = renderAt(800, 210)
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp
import kst4contest.view.compose.SplitterOrientation import kst4contest.view.compose.SplitterOrientation
import kst4contest.view.compose.SplitterPane import kst4contest.view.compose.SplitterPane
import kst4contest.view.compose.SplitterState import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
@@ -88,6 +89,7 @@ class StationMapBodyLayoutTest {
} }
private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) { private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
onEventThread {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
Body(pathAnalysisVisible) Body(pathAnalysisVisible)
} }
@@ -97,6 +99,7 @@ class StationMapBodyLayoutTest {
scene.close() scene.close()
} }
} }
}
@Test @Test
fun `renders at the window's minimum size`() = renderAt(900, 600) fun `renders at the window's minimum size`() = renderAt(900, 600)
+22
View File
@@ -141,6 +141,28 @@ subprojects {
} }
testUserHome.mkdirs() testUserHome.mkdirs()
} }
/*
* A test that never returns must fail by name instead of holding the job until
* GitHub kills it after six hours: Nightly #302 hung in :app-desktop:test for over
* an hour, and the log named only the task. The limit is far above the slowest test
* (under four seconds). SEPARATE_THREAD also ends a test whose thread ignores
* interruption, such as a busy loop or a blocked native call, and the failure's
* cause carries that thread's stack, so it shows where the test stood.
*/
systemProperty("junit.jupiter.execution.timeout.default", "2m")
systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD")
/*
* The timed-out thread's stack shows where a test waits, not who it waits for: the
* rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a monitor
* that another thread holds. On a timeout JUnit prints all threads to the test's
* standard output, which lands in its XML report (the test-results artifact).
*/
systemProperty("junit.jupiter.execution.timeout.threaddump.enabled", "true")
// The default short format prints only the first frame of each cause, which for a
// timed-out test is the line it hung in but not the call that blocked there.
testLogging {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
finalizedBy(checkTestUserHome) finalizedBy(checkTestUserHome)
} }
+19 -3
View File
@@ -7,12 +7,22 @@ arch=('x86_64')
url="https://github.com/HamRadioOnline/kst4contest" url="https://github.com/HamRadioOnline/kst4contest"
license=('GPL-3.0-only') license=('GPL-3.0-only')
depends=('gst-plugins-base' 'gst-plugins-good') depends=('gst-plugins-base' 'gst-plugins-good')
makedepends=('java-environment=21' 'git') makedepends=('git')
provides=('kst4contest') provides=('kst4contest')
conflicts=('kst4contest' 'kst4contest-bin') conflicts=('kst4contest' 'kst4contest-bin')
source=("kst4contest::git+https://github.com/HamRadioOnline/kst4contest.git") source=("kst4contest::git+https://github.com/HamRadioOnline/kst4contest.git")
sha256sums=('SKIP') sha256sums=('SKIP')
# The launcher is built with Temurin, not with the system JDK. Arch's openjdk links
# jpackage's launcher library against the shared libstdc++, and that build aborts
# with "pure virtual method called" (SIGABRT, core dump) in a static destructor when
# the application exits; Temurin links libstdc++ statically, like the CI packages.
# The _x86_64 arrays are deliberate: aur-publish.yml rewrites the plain sha256sums=
# line on every release and would drop a second entry there.
_temurin=21.0.13+9
source_x86_64=("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-${_temurin/+/%2B}/OpenJDK21U-jdk_x64_linux_hotspot_${_temurin/+/_}.tar.gz")
sha256sums_x86_64=('d7f5d5cf865a2902b70ff549b97586a7d3f234e016f041ab447ea740c7d66d1c')
pkgver() { pkgver() {
cd "${srcdir}/kst4contest" cd "${srcdir}/kst4contest"
BASE=$(grep -m1 '^version=' gradle.properties \ BASE=$(grep -m1 '^version=' gradle.properties \
@@ -23,7 +33,7 @@ pkgver() {
build() { build() {
cd "${srcdir}/kst4contest" cd "${srcdir}/kst4contest"
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export JAVA_HOME="${srcdir}/jdk-${_temurin}"
export PATH="${JAVA_HOME}/bin:${PATH}" export PATH="${JAVA_HOME}/bin:${PATH}"
# A Gradle home inside $srcdir keeps the build independent of whatever the # A Gradle home inside $srcdir keeps the build independent of whatever the
@@ -31,7 +41,13 @@ build() {
# cache, every dependency added since the last local build broke the package. # cache, every dependency added since the last local build broke the package.
export GRADLE_USER_HOME="${srcdir}/gradle-home" export GRADLE_USER_HOME="${srcdir}/gradle-home"
./gradlew --no-daemon -S :app-desktop:createDistributable # The Java toolchain, and with it jpackage, would otherwise pick any JDK 21 it
# finds in /usr/lib/jvm, the system one included.
./gradlew --no-daemon -S \
-Porg.gradle.java.installations.auto-detect=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.paths="${JAVA_HOME}" \
:app-desktop:createDistributable
# package() reads dist/KST4Contest. Application name, icon and the JDK # package() reads dist/KST4Contest. Application name, icon and the JDK
# module list come from app-desktop/build.gradle.kts. # module list come from app-desktop/build.gradle.kts.
+18 -3
View File
@@ -7,16 +7,25 @@ arch=('x86_64')
url="https://github.com/HamRadioOnline/kst4contest" url="https://github.com/HamRadioOnline/kst4contest"
license=('GPL-3.0-only') license=('GPL-3.0-only')
depends=('gst-plugins-base' 'gst-plugins-good') depends=('gst-plugins-base' 'gst-plugins-good')
makedepends=('java-environment=21')
provides=('kst4contest') provides=('kst4contest')
conflicts=('kst4contest-bin' 'kst4contest-git') conflicts=('kst4contest-bin' 'kst4contest-git')
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/HamRadioOnline/kst4contest/archive/refs/tags/v${pkgver}.tar.gz") source=("${pkgname}-${pkgver}.tar.gz::https://github.com/HamRadioOnline/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914') sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914')
# The launcher is built with Temurin, not with the system JDK. Arch's openjdk links
# jpackage's launcher library against the shared libstdc++, and that build aborts
# with "pure virtual method called" (SIGABRT, core dump) in a static destructor when
# the application exits; Temurin links libstdc++ statically, like the CI packages.
# The _x86_64 arrays are deliberate: aur-publish.yml rewrites the plain sha256sums=
# line on every release and would drop a second entry there.
_temurin=21.0.13+9
source_x86_64=("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-${_temurin/+/%2B}/OpenJDK21U-jdk_x64_linux_hotspot_${_temurin/+/_}.tar.gz")
sha256sums_x86_64=('d7f5d5cf865a2902b70ff549b97586a7d3f234e016f041ab447ea740c7d66d1c')
build() { build() {
cd "${srcdir}/kst4contest-${pkgver}" cd "${srcdir}/kst4contest-${pkgver}"
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export JAVA_HOME="${srcdir}/jdk-${_temurin}"
export PATH="${JAVA_HOME}/bin:${PATH}" export PATH="${JAVA_HOME}/bin:${PATH}"
# A Gradle home inside $srcdir keeps the build independent of whatever the # A Gradle home inside $srcdir keeps the build independent of whatever the
@@ -24,7 +33,13 @@ build() {
# cache, every dependency added since the last local build broke the package. # cache, every dependency added since the last local build broke the package.
export GRADLE_USER_HOME="${srcdir}/gradle-home" export GRADLE_USER_HOME="${srcdir}/gradle-home"
./gradlew --no-daemon -S :app-desktop:createDistributable # The Java toolchain, and with it jpackage, would otherwise pick any JDK 21 it
# finds in /usr/lib/jvm, the system one included.
./gradlew --no-daemon -S \
-Porg.gradle.java.installations.auto-detect=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.paths="${JAVA_HOME}" \
:app-desktop:createDistributable
# package() reads dist/KST4Contest. Application name, icon and the JDK # package() reads dist/KST4Contest. Application name, icon and the JDK
# module list come from app-desktop/build.gradle.kts, so this PKGBUILD no # module list come from app-desktop/build.gradle.kts, so this PKGBUILD no
+18 -3
View File
@@ -32,11 +32,26 @@ const MANUAL_PAGE_ORDER = {
] ]
}; };
// Removes HTML tags until none are left, so that nested input such as
// "<<b>script>" cannot reassemble a tag after a single pass.
function stripHtmlTags(value) {
let previous;
let current = value;
do {
previous = current;
current = current.replace(/<[^>]+>/g, "");
} while (current !== previous);
return current;
}
function markdownToPlainText(value) { function markdownToPlainText(value) {
return (value || "") const withoutLinks = (value || "")
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
.replace(/<[^>]+>/g, "")
return stripHtmlTags(withoutLinks)
.replace(/[*_~`]/g, "") .replace(/[*_~`]/g, "")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
+19 -19
View File
@@ -7,15 +7,15 @@
"": { "": {
"name": "kst4contest-website", "name": "kst4contest-website",
"version": "0.1.0", "version": "0.1.0",
"engines": {
"node": ">=18.19.1"
},
"dependencies": { "dependencies": {
"markdown-it": "^14.3.0", "markdown-it": "^14.3.3",
"markdown-it-anchor": "^9.2.0" "markdown-it-anchor": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@11ty/eleventy": "^3.0.0" "@11ty/eleventy": "^3.0.0"
},
"engines": {
"node": ">=18.19.1"
} }
}, },
"node_modules/@11ty/dependency-tree": { "node_modules/@11ty/dependency-tree": {
@@ -397,9 +397,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -831,9 +831,9 @@
} }
}, },
"node_modules/gray-matter/node_modules/js-yaml": { "node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.15.0", "version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1026,9 +1026,9 @@
} }
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1098,9 +1098,9 @@
} }
}, },
"node_modules/liquidjs": { "node_modules/liquidjs": {
"version": "10.27.1", "version": "10.30.0",
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.1.tgz", "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.0.tgz",
"integrity": "sha512-ylE+1q2kSef1UxAyxqbyuWM3FRWS1v48JK1Y3CoW3bD6TSNXZh0+GsVnihujEpKyR+Jejx2aRAFfC3AHm9rElg==", "integrity": "sha512-Fw4wA+8CZsJkaeLEIlPE3AoponfjEZhj7FQvYNcAEcg7jNRik7mEMoaco382FBdrYlykVf6w4vClPNctz2OYjg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1136,9 +1136,9 @@
} }
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.3.0", "version": "14.3.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
+1 -1
View File
@@ -15,7 +15,7 @@
"@11ty/eleventy": "^3.0.0" "@11ty/eleventy": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"markdown-it": "^14.3.0", "markdown-it": "^14.3.3",
"markdown-it-anchor": "^9.2.0" "markdown-it-anchor": "^9.2.0"
} }
} }
+18 -3
View File
@@ -28,14 +28,29 @@ function extractTag(xml, tagName) {
return match ? match[1].trim() : null; return match ? match[1].trim() : null;
} }
// Applies the removal until nothing changes, so that nested input such as
// "<!-<!---->-->" cannot leave a fresh comment behind after a single pass.
function removeUntilStable(value, pattern) {
let previous;
let current = value;
do {
previous = current;
current = current.replace(pattern, "");
} while (current !== previous);
return current;
}
function assertWellFormedStructure(xml) { function assertWellFormedStructure(xml) {
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) { if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
fail("the XML contains an unescaped ampersand"); fail("the XML contains an unescaped ampersand");
} }
const withoutCommentsAndDeclaration = xml const withoutCommentsAndDeclaration = removeUntilStable(
.replace(/<!--[\s\S]*?-->/g, "") xml,
.replace(/<\?[\s\S]*?\?>/g, ""); /<!--[\s\S]*?-->|<\?[\s\S]*?\?>/g
);
const tagPattern = const tagPattern =
/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g; /<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;