Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,23 @@ jobs:
- name: Run tests
if: ${{ !matrix.xvfb }}
env:
GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=512m -Xss32m -Dfile.encoding=UTF-8
run: >-
./gradlew ${{ matrix.target }}
--stacktrace
--no-daemon
--no-parallel
--no-configuration-cache
--build-cache
-Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8"
-Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Xss32m -Dfile.encoding=UTF-8"
-Dorg.gradle.workers.max=1
-Dkotlin.daemon.enabled=false

# Run tests that require UI interaction (like JVM desktop Compose UI tests) using xvfb-run
# to provide a virtual framebuffer in the headless environment, preventing graphic environment exceptions.
- name: Run tests (with xvfb)
if: ${{ matrix.xvfb }}
env:
GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=512m -Xss32m -Dfile.encoding=UTF-8
run: >-
xvfb-run --auto-servernum
./gradlew ${{ matrix.target }}
Expand All @@ -124,9 +123,8 @@ jobs:
--no-parallel
--no-configuration-cache
--build-cache
-Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8"
-Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Xss32m -Dfile.encoding=UTF-8"
-Dorg.gradle.workers.max=1
-Dkotlin.daemon.enabled=false

- name: Upload test reports
if: always()
Expand Down
7 changes: 6 additions & 1 deletion datacapture/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/build
/build

# Generated at Karma startup by karma.config.d/compose-resources-testing.js and
# karma.config.d/skiko-js-reexport.js
karma.config.d/compose-resources-testing-setup.js
karma.config.d/skiko-await-setup.mjs
17 changes: 17 additions & 0 deletions datacapture/karma.config.d/chrome-stack-size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
;(function (config) {
// kotlin-fhirpath's generated sealed-interface dispatch (see MoreSealedInterfaces.kt) compiles
// to deeply nested JS that can exceed V8's default call-stack limit at runtime, surfacing as
// "RangeError: Maximum call stack size exceeded" during FHIRPath evaluation. That's swallowed by
// FhirPathService.evaluate()'s runCatching, so it looks like wrong/empty results downstream
// rather than a crash. Chrome's V8 stack size varies by build; raise it explicitly so evaluation
// isn't at the mercy of whatever headless Chrome happens to be installed.
config.set({
customLaunchers: Object.assign({}, config.customLaunchers, {
ChromeHeadlessBigStack: {
base: 'ChromeHeadless',
flags: ['--js-flags=--stack-size=8192'],
},
}),
browsers: ['ChromeHeadlessBigStack'],
})
})(config)
19 changes: 19 additions & 0 deletions datacapture/karma.config.d/compose-resources-testing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const fs = require('fs')
const path = require('path')

const composeResourcesTestingSetupFile = path.join(__dirname, 'compose-resources-testing-setup.js')
fs.writeFileSync(composeResourcesTestingSetupFile, 'window.composeResourcesTesting = true;\n')

config.set({
files: [composeResourcesTestingSetupFile].concat(config.files).concat([
{
pattern: path.join(__dirname, 'kotlin', 'composeResources', '**', '*'),
included: false,
served: true,
watched: false,
},
]),
proxies: Object.assign({}, config.proxies, {
'/composeResources/': '/base/kotlin/composeResources/',
}),
})
11 changes: 11 additions & 0 deletions datacapture/karma.config.d/mocha-timeout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Mocha's default 2000ms per-test timeout is too tight for a Compose/Skia render under load
// (many tests sharing one browser tab/GC). Confirmed flaky on both wasmJsBrowserTest and
// jsBrowserTest: passes reliably alone, intermittently exceeds 2000ms when run with the full
// suite. Raise the timeout instead of just tolerating occasional CI flakes.
config.set({
client: Object.assign({}, config.client, {
mocha: Object.assign({}, (config.client && config.client.mocha) || {}, {
timeout: 10000,
}),
}),
})
34 changes: 34 additions & 0 deletions datacapture/karma.config.d/skiko-js-reexport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
;(function (config, __dirname) {
var fs = require('fs')
var path = require('path')

// The Kotlin/JS `js` target's Skiko bridge (js-reexport-symbols.mjs) loads skiko.wasm and
// assigns its exports onto `window` as bare globals. Nothing require()s this .mjs file, and
// it's absent from the Karma "files" list by default, so Compose UI tests fail with
// "ReferenceError: org_jetbrains_skia_... is not defined" the moment they touch the canvas.
var kotlinDir = path.join(__dirname, 'kotlin')
var skikoReexportPath = path.join(kotlinDir, 'js-reexport-symbols.mjs')

if (fs.existsSync(skikoReexportPath)) {
// skiko.wasm instantiation kicked off by js-reexport-symbols.mjs is asynchronous.
// Without gating Mocha's run on it, tests race the WASM load and fail with
// "TypeError: org_jetbrains_skia_... is not a function". A global `before()` hook
// (registered during Mocha's collection phase, before any suite runs) closes that race.
var skikoAwaitSetupPath = path.join(kotlinDir, 'skiko-await-setup.mjs')
fs.writeFileSync(
skikoAwaitSetupPath,
'import { api } from "./js-reexport-symbols.mjs"\n' +
'before(function () {\n' +
' return api.awaitSkiko\n' +
'})\n',
)

config.set({
files: [skikoReexportPath, skikoAwaitSetupPath].concat(config.files),
preprocessors: Object.assign({}, config.preprocessors, {
[skikoReexportPath]: ['webpack', 'sourcemap'],
[skikoAwaitSetupPath]: ['webpack', 'sourcemap'],
}),
})
}
})(config, __dirname)
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ internal class QuestionnaireViewModel(state: Map<String, Any>, config: DataCaptu
)
.apply {
if (parentIdPrefix.isNotEmpty()) {
id = "${parentIdPrefix}${questionnaireItem.linkId}"
id = "${parentIdPrefix}${questionnaireItem.linkId.value}"
}
}
add(question)
Expand Down Expand Up @@ -1120,20 +1120,20 @@ internal class QuestionnaireViewModel(state: Map<String, Any>, config: DataCaptu
if (!questionnaireItem.isRepeatedGroup) {
// Case 2: Questions nested under a question (not a repeated group)
if (parentIdPrefix.isEmpty()) {
"${index}_${question.item.questionnaireItem.linkId}_"
"${index}_${question.item.questionnaireItem.linkId.value}_"
} else {
"${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_"
"${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId.value}_"
}
} else {
// Case 3: Build hierarchical ID prefix for nested repeated groups
"${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_"
"${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId.value}_"
}

if (questionnaireItem.isRepeatedGroup) {
// Case 3
add(
QuestionnaireAdapterItem.RepeatedGroupHeader(
id = "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}",
id = "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId.value}",
index = index,
onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } },
responses = nestedResponseItemList,
Expand All @@ -1155,7 +1155,7 @@ internal class QuestionnaireViewModel(state: Map<String, Any>, config: DataCaptu
if (questionnaireItem.isRepeatedGroup) {
add(
QuestionnaireAdapterItem.RepeatedGroupAddButton(
id = "${parentIdPrefix}${question.item.questionnaireItem.linkId}_add_btn",
id = "${parentIdPrefix}${question.item.questionnaireItem.linkId.value}_add_btn",
item = question.item,
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.gm_calendar_today_24
import kotlin_fhir_data_capture.datacapture.generated.resources.select_date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
Expand All @@ -80,7 +79,7 @@ internal fun DateFieldItem(
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val localDateTimeFormatter = getLocalDateTimeFormatter()

var dateInputDisplay by
Expand Down Expand Up @@ -140,7 +139,7 @@ internal fun DateFieldItem(
text = formattedText,
selection = TextRange(dateInputFormat.pattern.length),
)
postDelayedNewDateInput(DateInput(formattedText, localDate), HANDLE_INPUT_DEBOUNCE_TIME)
postDelayedNewDateInput(DateInput(formattedText, localDate), handleInputDebounceTime)
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview

internal const val EDIT_TEXT_FIELD_TEST_TAG = "text_input_edit_text"
internal const val UNIT_TEXT_TEST_TAG = "unit_text_view"
internal const val HANDLE_INPUT_DEBOUNCE_TIME = 500L
internal var handleInputDebounceTime = 500L

@Composable
internal fun EditTextFieldItem(modifier: Modifier, textFieldState: EditTextFieldState) {
Expand Down Expand Up @@ -183,7 +183,7 @@ internal data class EditTextFieldState(
coroutineScope.launch {
snapshotFlow { inputText }
.drop(1) // Drops the initial value emitted by snapshotFlow
.debounce(HANDLE_INPUT_DEBOUNCE_TIME)
.debounce(handleInputDebounceTime)
.collectLatest { handleTextInputChange(it) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.ohs.fhir.datacapture.extensions.displayString
import dev.ohs.fhir.datacapture.extensions.elementValue
import dev.ohs.fhir.datacapture.extensions.itemAnswerOptionImage
import dev.ohs.fhir.datacapture.extensions.optionExclusive
import dev.ohs.fhir.datacapture.extensions.toAnnotatedString
Expand Down Expand Up @@ -139,7 +138,7 @@ internal fun OptionDialogSelect(
) {
itemsIndexed(
choiceOptions,
key = { _, row -> row.key() },
key = { index, row -> row.key(index) },
contentType = { _, row -> row::class.simpleName },
) { index, optionSelectRow ->
val label = optionSelectRow.option.displayString.toAnnotatedString()
Expand Down Expand Up @@ -229,7 +228,7 @@ internal fun OptionDialogSelect(
if (otherOptionRowSelected) {
itemsIndexed(
otherOptionEditTexts,
key = { _, option -> option.key() },
key = { index, option -> option.key(index) },
contentType = { _, _ -> OptionSelectRow.OtherEditText },
) { index, option ->
Row(
Expand Down Expand Up @@ -376,9 +375,12 @@ internal sealed class OptionSelectRow {
/** "Add Another" other field button. Only used in multi-select when [OtherRow] is selected. */
object OtherAddAnother : OptionSelectRow()

fun key() =
// `index` disambiguates `Option` rows instead of relying on the answer option's `toString()`,
// which on Kotlin/JS can collapse distinct FHIR element values to the same "[object Object]"
// string and cause LazyColumn key collisions.
fun key(index: Int) =
when (this) {
is Option -> "option_${option.item.elementValue}"
is Option -> "option_$index"
is OtherRow -> "other_row"
is OtherEditText -> "other_edit_$id"
OtherAddAnother -> "add_another"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import dev.ohs.fhir.model.r4.QuestionnaireResponse
import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.add_24px
import kotlin_fhir_data_capture.datacapture.generated.resources.add_repeated_group_item
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
Expand All @@ -48,7 +47,7 @@ internal const val ADD_REPEATED_GROUP_BUTTON_TAG = "addRepeatedGroupButton"

@Composable
internal fun RepeatedGroupAddButtonItem(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val questionText =
remember(questionnaireViewItem) { questionnaireViewItem.questionText?.toString() ?: "" }
val isQuestionnaireViewItemRepeatedGroup =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.delete
import kotlin_fhir_data_capture.datacapture.generated.resources.delete_24px
import kotlin_fhir_data_capture.datacapture.generated.resources.repeated_group_title
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
Expand All @@ -52,7 +51,7 @@ internal fun RepeatedGroupHeaderItem(
repeatedGroupHeader: QuestionnaireAdapterItem.RepeatedGroupHeader,
enabled: Boolean = true,
) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val indexNumber = remember(repeatedGroupHeader.index) { "${repeatedGroupHeader.index + 1}" }
val title = remember(repeatedGroupHeader.title) { repeatedGroupHeader.title }
val color =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ import kotlin_fhir_data_capture.datacapture.generated.resources.upload_file
import kotlin_fhir_data_capture.datacapture.generated.resources.upload_photo
import kotlin_fhir_data_capture.datacapture.generated.resources.upload_video
import kotlin_fhir_data_capture.datacapture.generated.resources.uploaded
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
Expand All @@ -101,7 +100,7 @@ internal object AttachmentViewFactory : QuestionnaireItemViewFactory {

@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val validationResult =
remember(questionnaireViewItem.validationResult) { questionnaireViewItem.validationResult }
var errorMessage by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,12 @@ import dev.ohs.fhir.datacapture.views.components.DropDownAnswerOption
import dev.ohs.fhir.datacapture.views.components.Header
import dev.ohs.fhir.datacapture.views.components.MediaItem
import dev.ohs.fhir.datacapture.views.components.MultiAutoCompleteTextItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

internal object AutoCompleteViewFactory : QuestionnaireItemViewFactory {
@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val canHaveMultipleAnswers =
remember(questionnaireViewItem.questionnaireItem) {
questionnaireViewItem.questionnaireItem.repeats?.value ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import dev.ohs.fhir.model.r4.QuestionnaireResponse
import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.no
import kotlin_fhir_data_capture.datacapture.generated.resources.yes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource

Expand All @@ -53,7 +52,7 @@ internal const val NO_CHOICE_RADIO_BUTTON_TAG = "no_radio_button"
internal object BooleanChoiceViewFactory : QuestionnaireItemViewFactory {
@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val readOnly =
remember(questionnaireViewItem) {
questionnaireViewItem.questionnaireItem.readOnly?.value ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,14 @@ import dev.ohs.fhir.datacapture.views.components.Header
import dev.ohs.fhir.datacapture.views.components.MediaItem
import dev.ohs.fhir.datacapture.views.isAnswerOptionSelected
import dev.ohs.fhir.model.r4.Questionnaire
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

internal const val CHECKBOX_OPTION_TAG = "checkbox_group_option"

internal object CheckBoxGroupViewFactory : QuestionnaireItemViewFactory {
@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val readOnly =
remember(questionnaireViewItem) {
questionnaireViewItem.questionnaireItem.readOnly?.value ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.date_format_validation_error_msg
import kotlin_fhir_data_capture.datacapture.generated.resources.required_text_and_new_line
import kotlin_fhir_data_capture.datacapture.generated.resources.time
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
Expand All @@ -68,7 +67,7 @@ internal object DateTimeViewFactory : QuestionnaireItemViewFactory {

@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val localDateTimeFormatter = getLocalDateTimeFormatter()
val itemReadOnly =
remember(questionnaireViewItem.questionnaireItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import kotlin.time.ExperimentalTime
import kotlin_fhir_data_capture.datacapture.generated.resources.Res
import kotlin_fhir_data_capture.datacapture.generated.resources.date_format_validation_error_msg
import kotlin_fhir_data_capture.datacapture.generated.resources.required_text_and_new_line
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
Expand All @@ -64,7 +63,7 @@ internal object DateViewFactory : QuestionnaireItemViewFactory {

@Composable
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
val coroutineScope = rememberCoroutineScope()
val localDateTimeFormatter = getLocalDateTimeFormatter()
val isReadOnly =
remember(questionnaireViewItem) {
Expand Down
Loading
Loading