diff --git a/.github/workflows/ci_actions_test.yml b/.github/workflows/ci_actions_test.yml index 2883e39c34..e567ff70e0 100644 --- a/.github/workflows/ci_actions_test.yml +++ b/.github/workflows/ci_actions_test.yml @@ -36,4 +36,7 @@ jobs: run: ./gradlew :ci-actions:issue-assign:test - name: Test the CI "issue-create-comment" action - run: ./gradlew :ci-actions:issue-create-comment:test \ No newline at end of file + run: ./gradlew :ci-actions:issue-create-comment:test + + - name: Test the CI "compose_stability" action + run: ./gradlew :ci-actions:compose-stability:test \ No newline at end of file diff --git a/.github/workflows/compose_stability.yml b/.github/workflows/compose_stability.yml new file mode 100644 index 0000000000..d6f0f2e43d --- /dev/null +++ b/.github/workflows/compose_stability.yml @@ -0,0 +1,43 @@ +name: Composables stability + +on: + push: + branches: + - main + pull_request: + +jobs: + compose_stability: + runs-on: ubuntu-latest + steps: + - name: Checkout GIT + uses: actions/checkout@v4 + + - name: Setup Java SDK + uses: actions/setup-java@v4 + with: + distribution: 'adopt' + java-version: '18' + + - name: Enable Gradle Wrapper caching (optimization) + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Compose compiler report (Jetpack Compose) + run: ./gradlew assembleDemo -PcomposeCompilerReports=true + + - name: Analyze the report (Ivy) + run: ./gradlew :ci-actions:compose-stability:run + + - name: Upload the Compose Stability report + if: always() + uses: actions/upload-artifact@v4 + with: + name: compose-stability-report + path: ci-actions/compose-stability/ivy-compose-stability-report.txt \ No newline at end of file diff --git a/.gitignore b/.gitignore index 808b8405cb..7c8fc0e274 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ lint/reports/ # JS node_modules/* +/ci-actions/compose-stability/ivy-compose-stability-report.txt diff --git a/buildSrc/src/main/kotlin/ivy.compose.gradle.kts b/buildSrc/src/main/kotlin/ivy.compose.gradle.kts index 85d67a23fd..c4feb4adb3 100644 --- a/buildSrc/src/main/kotlin/ivy.compose.gradle.kts +++ b/buildSrc/src/main/kotlin/ivy.compose.gradle.kts @@ -26,6 +26,24 @@ android { } } +@Suppress("MaximumLineLength", "MaxLineLength") +tasks.withType().configureEach { + kotlinOptions { + if (project.findProperty("composeCompilerReports") == "true") { + freeCompilerArgs += listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir.absolutePath}/compose_compiler" + ) + } + if (project.findProperty("composeCompilerMetrics") == "true") { + freeCompilerArgs += listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir.absolutePath}/compose_compiler" + ) + } + } +} + dependencies { implementation(libs.bundles.compose) diff --git a/ci-actions/compose-stability/build.gradle.kts b/ci-actions/compose-stability/build.gradle.kts new file mode 100644 index 0000000000..d2011ae4ec --- /dev/null +++ b/ci-actions/compose-stability/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("ivy.script") + application +} + +application { + mainClass = "ivy.automate.compose.stability.MainKt" +} + +dependencies { + implementation(projects.ciActions.base) +} diff --git a/ci-actions/compose-stability/ivy-compose-stability-baseline.txt b/ci-actions/compose-stability/ivy-compose-stability-baseline.txt new file mode 100644 index 0000000000..e013b9faa1 --- /dev/null +++ b/ci-actions/compose-stability/ivy-compose-stability-baseline.txt @@ -0,0 +1,280 @@ +com.ivy.design.api.ivyContext +com.ivy.design.l0_system.Gradient.Companion.black +com.ivy.design.l0_system.UI. +com.ivy.design.l0_system.UI. +com.ivy.design.l0_system.UI. +com.ivy.design.l0_system.style +com.ivy.design.utils.densityScope +com.ivy.design.utils.rememberInteractionSource +com.ivy.design.utils.toDensityDp +com.ivy.design.utils.windowInsets +com.ivy.design.utils.navigationBarInsets +com.ivy.design.utils.keyboardOnlyWindowInsets +com.ivy.design.utils.keyboardVisibleState +com.ivy.legacy.ivyWalletCtx +com.ivy.legacy.rootView +com.ivy.legacy.rootActivity +com.ivy.legacy.rootScreen +com.ivy.frp.view.FRP +com.ivy.wallet.ui.theme.pureBlur +com.ivy.wallet.ui.theme.mediumBlur +com.ivy.wallet.ui.theme.gradientExpenses +com.ivy.wallet.ui.theme.Gradient.Companion.black +com.ivy.legacy.legacy.ui.theme.components.DateTimeRow +com.ivy.wallet.ui.theme.components.getCustomIconIdS +com.ivy.wallet.ui.theme.components.ReorderModalSingleType +com.ivy.wallet.ui.theme.components.ReorderModal +com.ivy.wallet.ui.theme.components.WrapContentRow +com.ivy.wallet.ui.theme.components.charts.linechart.IvyLineChart +com.ivy.wallet.ui.theme.components.charts.linechart.IvyChart +com.ivy.wallet.ui.theme.modal.BufferModal +com.ivy.wallet.ui.theme.modal.IconsRow +com.ivy.wallet.ui.theme.modal.ChoosePeriodModal +com.ivy.wallet.ui.theme.modal.IntervalFromToDate +com.ivy.wallet.ui.theme.modal.IvyModal +com.ivy.wallet.ui.theme.modal.ModalBackHandling +com.ivy.wallet.ui.theme.modal.AddModalBackHandling +com.ivy.wallet.ui.theme.modal.modalPreviewActionRowHeight +com.ivy.wallet.ui.theme.modal.LoanModal +com.ivy.wallet.ui.theme.modal.AccountsRow +com.ivy.wallet.ui.theme.modal.LoanRecordModal +com.ivy.wallet.ui.theme.modal.AccountsRow +com.ivy.wallet.ui.theme.modal.MonthPickerModal +com.ivy.wallet.ui.theme.modal.RecurringRuleModal +com.ivy.wallet.ui.theme.modal.OneTime +com.ivy.wallet.ui.theme.modal.MultipleTimes +com.ivy.wallet.ui.theme.modal.DateRow +com.ivy.wallet.ui.theme.modal.edit.AccountModal +com.ivy.wallet.ui.theme.modal.edit.AmountModal +com.ivy.wallet.ui.theme.modal.edit.circleButtonModifier +com.ivy.wallet.ui.theme.modal.edit.CategoryModal +com.ivy.wallet.ui.theme.modal.edit.ChooseCategoryModal +com.ivy.wallet.ui.theme.modal.edit.CategoryPicker +com.ivy.legacy.ui.component.IncomeExpensesCards +com.ivy.legacy.ui.component.edit.TransactionDateTime +com.ivy.wallet.ui.edit.core.DueDate +com.ivy.wallet.ui.edit.core.DueDateCard +com.ivy.wallet.ui.edit.core.EditBottomSheet +com.ivy.wallet.ui.edit.core.SheetHeader +com.ivy.wallet.ui.edit.core.AccountsRow +com.ivy.wallet.ui.edit.core.Title +com.ivy.wallet.ui.edit.core.Suggestions +com.ivy.wallet.ui.edit.core.Toolbar +com.ivy.legacy.ui.component.transaction.HistoryDateDivider +com.ivy.legacy.ui.component.transaction.TransactionHeaderRow +com.ivy.legacy.ui.component.transaction.TransferHeader +com.ivy.legacy.ui.component.transaction.TypeAmountCurrency +com.ivy.legacy.ui.component.transaction.category +com.ivy.legacy.ui.component.transaction.account +com.ivy.legacy.utils.windowInsets +com.ivy.legacy.utils.statusBarInset +com.ivy.legacy.utils.navigationBarInset +com.ivy.legacy.utils.navigationBarInsets +com.ivy.legacy.utils.keyboardOnlyWindowInsets +com.ivy.legacy.utils.densityScope +com.ivy.legacy.utils.rememberInteractionSource +com.ivy.legacy.utils.toDensityPx +com.ivy.legacy.utils.toDensityDp +com.ivy.legacy.utils.toDensityDp +com.ivy.legacy.utils.formatLocalTime +com.ivy.legacy.utils.rememberSwipeListenerState +com.ivy.legacy.utils.keyboardVisibleState +com.ivy.legacy.utils.UiText.asString +com.ivy.navigation.navigation +com.ivy.navigation.screenScopedViewModel +com.ivy.common.ui.rememberScrollPositionListState +com.ivy.data.datastore.datastore +com.ivy.domain.features.BoolFeature.asEnabledState +com.ivy.loans.loan.LoanViewModel.uiState +com.ivy.loans.loan.LoanViewModel.getReorderModalVisible +com.ivy.loans.loan.LoanViewModel.getLoanModalData +com.ivy.loans.loan.LoanViewModel.getLoans +com.ivy.loans.loan.LoanViewModel.getBaseCurrencyCode +com.ivy.loans.loan.LoanViewModel.getSelectedAccount +com.ivy.loans.loan.LoanViewModel.getAccounts +com.ivy.loans.loan.LoanViewModel.getPaidOffLoanVisibility +com.ivy.loans.loandetails.LoanDetailsViewModel.uiState +com.ivy.importdata.csv.ImportUI +com.ivy.importdata.csv.CSVRow +com.ivy.importdata.csv.CSVViewModel.uiState +com.ivy.importdata.csv.CSVViewModel.continueEnabled +com.ivy.importdata.csv.CSVViewModel.importantFields +com.ivy.importdata.csv.CSVViewModel.transferFields +com.ivy.importdata.csv.CSVViewModel.optionalFields +com.ivy.settings.SettingsViewModel.uiState +com.ivy.settings.SettingsViewModel.getCurrencyCode +com.ivy.settings.SettingsViewModel.getName +com.ivy.settings.SettingsViewModel.getCurrentTheme +com.ivy.settings.SettingsViewModel.getLockApp +com.ivy.settings.SettingsViewModel.getShowNotifications +com.ivy.settings.SettingsViewModel.getHideCurrentBalance +com.ivy.settings.SettingsViewModel.getHideIncome +com.ivy.settings.SettingsViewModel.getTreatTransfersAsIncomeExpense +com.ivy.settings.SettingsViewModel.getStartDateOfMonth +com.ivy.settings.SettingsViewModel.getProgressState +com.ivy.home.HomeLazyColumn +com.ivy.home.HomeViewModel.uiState +com.ivy.home.HomeViewModel.getTheme +com.ivy.home.HomeViewModel.getName +com.ivy.home.HomeViewModel.getPeriod +com.ivy.home.HomeViewModel.getBaseData +com.ivy.home.HomeViewModel.getHistory +com.ivy.home.HomeViewModel.getStats +com.ivy.home.HomeViewModel.getBalance +com.ivy.home.HomeViewModel.getBuffer +com.ivy.home.HomeViewModel.getUpcoming +com.ivy.home.HomeViewModel.getOverdue +com.ivy.home.HomeViewModel.getCustomerJourneyCards +com.ivy.home.HomeViewModel.getHideBalance +com.ivy.home.HomeViewModel.getExpanded +com.ivy.home.HomeViewModel.getHideIncome +com.ivy.releases.ReleasesViewModel.uiState +com.ivy.transaction.UI +com.ivy.transaction.EditTransactionViewModel.uiState +com.ivy.transaction.EditTransactionViewModel.getTransactionType +com.ivy.transaction.EditTransactionViewModel.getInitialTitle +com.ivy.transaction.EditTransactionViewModel.getTitleSuggestions +com.ivy.transaction.EditTransactionViewModel.getCurrency +com.ivy.transaction.EditTransactionViewModel.getDescription +com.ivy.transaction.EditTransactionViewModel.getDateTime +com.ivy.transaction.EditTransactionViewModel.getDueDate +com.ivy.transaction.EditTransactionViewModel.getAccounts +com.ivy.transaction.EditTransactionViewModel.getCategories +com.ivy.transaction.EditTransactionViewModel.getAccount +com.ivy.transaction.EditTransactionViewModel.getToAccount +com.ivy.transaction.EditTransactionViewModel.getCategory +com.ivy.transaction.EditTransactionViewModel.getAmount +com.ivy.transaction.EditTransactionViewModel.getHasChanges +com.ivy.transaction.EditTransactionViewModel.getDisplayLoanHelper +com.ivy.transaction.EditTransactionViewModel.getBackgroundProcessingStarted +com.ivy.transaction.EditTransactionViewModel.getCustomExchangeRateState +com.ivy.features.FeaturesViewModel.uiState +com.ivy.features.FeaturesViewModel.getFeatures +com.ivy.balance.BalanceViewModel.uiState +com.ivy.piechart.PieChart +com.ivy.piechart.PieChartStatisticViewModel.uiState +com.ivy.piechart.PieChartStatisticViewModel.getTransactionType +com.ivy.piechart.PieChartStatisticViewModel.getPeriod +com.ivy.piechart.PieChartStatisticViewModel.getBaseCurrency +com.ivy.piechart.PieChartStatisticViewModel.getTotalAmount +com.ivy.piechart.PieChartStatisticViewModel.getCategoryAmounts +com.ivy.piechart.PieChartStatisticViewModel.getSelectedCategory +com.ivy.piechart.PieChartStatisticViewModel.getAccountIdFilterList +com.ivy.piechart.PieChartStatisticViewModel.getShowCloseButtonOnly +com.ivy.piechart.PieChartStatisticViewModel.getFilterExcluded +com.ivy.piechart.PieChartStatisticViewModel.getTransactions +com.ivy.piechart.PieChartStatisticViewModel.getChoosePeriodModal +com.ivy.contributors.ContributorsViewModel.uiState +com.ivy.search.SearchViewModel.uiState +com.ivy.transactions.TransactionsViewModel.uiState +com.ivy.transactions.TransactionsViewModel.getPeriod +com.ivy.transactions.TransactionsViewModel.getBaseCurrency +com.ivy.transactions.TransactionsViewModel.getAccount +com.ivy.transactions.TransactionsViewModel.getCurrency +com.ivy.transactions.TransactionsViewModel.getCategories +com.ivy.transactions.TransactionsViewModel.getAccounts +com.ivy.transactions.TransactionsViewModel.getCategory +com.ivy.transactions.TransactionsViewModel.getBalance +com.ivy.transactions.TransactionsViewModel.getBalanceBaseCurrency +com.ivy.transactions.TransactionsViewModel.getIncome +com.ivy.transactions.TransactionsViewModel.getExpenses +com.ivy.transactions.TransactionsViewModel.getInitWithTransactions +com.ivy.transactions.TransactionsViewModel.getTreatTransfersAsIncomeExpense +com.ivy.transactions.TransactionsViewModel.getUpcomingExpenses +com.ivy.transactions.TransactionsViewModel.getUpcoming +com.ivy.transactions.TransactionsViewModel.getUpcomingExpanded +com.ivy.transactions.TransactionsViewModel.getUpcomingIncome +com.ivy.transactions.TransactionsViewModel.getHistory +com.ivy.transactions.TransactionsViewModel.getOverdue +com.ivy.transactions.TransactionsViewModel.getOverdueExpanded +com.ivy.transactions.TransactionsViewModel.getOverdueIncome +com.ivy.transactions.TransactionsViewModel.getOverdueExpenses +com.ivy.transactions.TransactionsViewModel.getAccountNameConfirmation +com.ivy.transactions.TransactionsViewModel.getEnableDeletionButton +com.ivy.transactions.TransactionsViewModel.getSkipAllModalVisible +com.ivy.transactions.TransactionsViewModel.getDeleteModal1Visible +com.ivy.transactions.TransactionsViewModel.getChoosePeriodModal +com.ivy.accounts.AccountsViewModel.uiState +com.ivy.accounts.AccountsViewModel.getBaseCurrency +com.ivy.accounts.AccountsViewModel.getAccountsData +com.ivy.accounts.AccountsViewModel.getTotalBalanceWithExcluded +com.ivy.accounts.AccountsViewModel.getTotalBalanceWithExcludedText +com.ivy.accounts.AccountsViewModel.getTotalBalanceWithoutExcluded +com.ivy.accounts.AccountsViewModel.getTotalBalanceWithoutExcludedText +com.ivy.accounts.AccountsViewModel.getReorderVisible +com.ivy.budgets.BudgetModal +com.ivy.budgets.CategoriesRow +com.ivy.budgets.UI +com.ivy.budgets.BudgetViewModel.uiState +com.ivy.budgets.BudgetViewModel.getBaseCurrency +com.ivy.budgets.BudgetViewModel.getTimeRange +com.ivy.budgets.BudgetViewModel.getCategories +com.ivy.budgets.BudgetViewModel.getAccounts +com.ivy.budgets.BudgetViewModel.getBudgets +com.ivy.budgets.BudgetViewModel.getReorderModalVisible +com.ivy.budgets.BudgetViewModel.getCategoryBudgetsTotal +com.ivy.budgets.BudgetViewModel.getAppBudgetMax +com.ivy.budgets.BudgetViewModel.getBudgetModalData +com.ivy.exchangerates.ExchangeRatesViewModel.uiState +com.ivy.attributions.AttributionsViewModel.uiState +com.ivy.categories.SortModal +com.ivy.categories.CategoriesViewModel.uiState +com.ivy.categories.CategoriesViewModel.getBaseCurrency +com.ivy.categories.CategoriesViewModel.getCategories +com.ivy.categories.CategoriesViewModel.getReorderModalVisible +com.ivy.categories.CategoriesViewModel.getCategoryModalData +com.ivy.categories.CategoriesViewModel.getSortOrder +com.ivy.categories.CategoriesViewModel.getSortModalVisible +com.ivy.planned.edit.UI +com.ivy.planned.edit.EditPlannedViewModel.uiState +com.ivy.planned.edit.EditPlannedViewModel.getCurrency +com.ivy.planned.edit.EditPlannedViewModel.getCategories +com.ivy.planned.edit.EditPlannedViewModel.getAccounts +com.ivy.planned.edit.EditPlannedViewModel.getTransactionType +com.ivy.planned.edit.EditPlannedViewModel.getStartDate +com.ivy.planned.edit.EditPlannedViewModel.getIntervalN +com.ivy.planned.edit.EditPlannedViewModel.getIntervalType +com.ivy.planned.edit.EditPlannedViewModel.getOneTime +com.ivy.planned.edit.EditPlannedViewModel.getInitialTitle +com.ivy.planned.edit.EditPlannedViewModel.getDescription +com.ivy.planned.edit.EditPlannedViewModel.getAccount +com.ivy.planned.edit.EditPlannedViewModel.getCategory +com.ivy.planned.edit.EditPlannedViewModel.getAmount +com.ivy.planned.edit.EditPlannedViewModel.getCategoryModalVisibility +com.ivy.planned.edit.EditPlannedViewModel.getDescriptionModalVisibility +com.ivy.planned.edit.EditPlannedViewModel.getDeleteTransactionModalVisibility +com.ivy.planned.edit.EditPlannedViewModel.getTransactionTypeModalVisibility +com.ivy.planned.edit.EditPlannedViewModel.getAmountModalVisibility +com.ivy.planned.edit.EditPlannedViewModel.getCategoryModalData +com.ivy.planned.edit.EditPlannedViewModel.getAccountModalData +com.ivy.planned.edit.EditPlannedViewModel.getRecurringRuleModalData +com.ivy.planned.edit.RecurringRule +com.ivy.planned.edit.RecurringRuleCard +com.ivy.planned.list.RuleTextRow +com.ivy.planned.list.PlannedPaymentsViewModel.uiState +com.ivy.planned.list.PlannedPaymentsViewModel.getCurrency +com.ivy.planned.list.PlannedPaymentsViewModel.getCategories +com.ivy.planned.list.PlannedPaymentsViewModel.getAccounts +com.ivy.planned.list.PlannedPaymentsViewModel.getOneTimePlannedPayment +com.ivy.planned.list.PlannedPaymentsViewModel.getRecurringPlannedPayment +com.ivy.planned.list.PlannedPaymentsViewModel.getOneTimeExpenses +com.ivy.planned.list.PlannedPaymentsViewModel.getOneTimeIncome +com.ivy.planned.list.PlannedPaymentsViewModel.getRecurringExpenses +com.ivy.planned.list.PlannedPaymentsViewModel.getRecurringIncome +com.ivy.planned.list.PlannedPaymentsViewModel.getRecurringPaymentsExpanded +com.ivy.planned.list.PlannedPaymentsViewModel.getOneTimePaymentsExpanded +com.ivy.onboarding.components.Suggestions +com.ivy.onboarding.steps.OnboardingAccounts +com.ivy.onboarding.steps.Accounts +com.ivy.onboarding.steps.OnboardingCategories +com.ivy.onboarding.steps.Categories +com.ivy.onboarding.viewmodel.OnboardingViewModel.uiState +com.ivy.reports.FilterOverlay +com.ivy.reports.TypeFilter +com.ivy.reports.TypeFilterCheckbox +com.ivy.reports.PeriodFilter +com.ivy.reports.AccountsFilter +com.ivy.reports.CategoriesFilter +com.ivy.reports.AmountFilter +com.ivy.reports.KeywordsFilter +com.ivy.reports.ReportViewModel.uiState \ No newline at end of file diff --git a/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/Main.kt b/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/Main.kt new file mode 100644 index 0000000000..5de64a15a6 --- /dev/null +++ b/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/Main.kt @@ -0,0 +1,178 @@ +package ivy.automate.compose.stability + +import arrow.core.Either +import arrow.core.identity +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.right +import ivy.automate.compose.stability.model.ComposableArgument +import ivy.automate.compose.stability.model.FullyQualifiedName +import ivy.automate.compose.stability.model.UnstableComposable +import java.io.File +import kotlin.system.exitProcess + +const val OutputReportFileName = "ivy-compose-stability-report.txt" +const val ComposeReportFolderName = "compose_compiler" +const val BaselineArg = "generateBaseline" +const val BaselineFileName = "ivy-compose-stability-baseline.txt" + +fun main(args: Array) { + val shouldGenerateBaseline = BaselineArg in args + val baselineComposables = readBaseline() + val unstableComposables = findComposeReportFolders() + .flatMap { reportFolder -> + unstableComposables(reportFolder).fold( + ifRight = ::identity, + ifLeft = { error -> + println(error) + emptyList() + } + ) + }.toList() + .filter { + shouldGenerateBaseline || it.fullyQualifiedName !in baselineComposables + } + val ivyReportTxt = buildIvyReport(unstableComposables) + createReportFile(ivyReportTxt) + println(ivyReportTxt) + if (!shouldGenerateBaseline) { + if (unstableComposables.isNotEmpty()) { + println("ERROR: ${unstableComposables.size} unstable composables found. Fix them!") + exitProcess(1) + } else { + println("SUCCESS!") + } + } else { + generateBaseline(unstableComposables) + println("Baseline generated.") + println("Check: ${File(BaselineFileName).absolutePath}") + } +} + +private fun findComposeReportFolders(): Sequence { + val rootDirRelativePath = "../../" + return File(rootDirRelativePath).walk() + .filter { it.isDirectory && it.name == ComposeReportFolderName } +} + +private fun unstableComposables( + reportFolder: File +): Either> = either { + val files = reportFolder.listFiles() + ?: raise("Empty report folder '${reportFolder.absoluteFile}'") + val composablesTxt = files.firstOrNull { + it.name.endsWith("composables.txt") + }?.readText() ?: raise("Couldn't find '*composables.txt' in '${reportFolder.absoluteFile}'") + val composablesCsv = files.firstOrNull { + it.name.endsWith("composables.csv") + } ?: raise("Couldn't find '*composables.csv' in '${reportFolder.absoluteFile}'") + + parseUnstableComposables(composablesCsv).bind().map { + it.copy( + unstableArguments = it.findUnstableArguments(composablesTxt).toSet() + ) + } +} + +@Suppress("MagicNumber") +private fun parseUnstableComposables( + composablesCsv: File +): Either> = + catch({ + composablesCsv.readText() + .split("\n") // rows + .drop(1) // drop the header + .filter { it.isNotBlank() } + .map { row -> + val values = row.split(",") + val fullyQualifiedName = values[0] + val name = values[1] + val skippable = values[3].toInt() == 1 + val restartable = values[4].toInt() == 1 + + UnstableComposable( + fullyQualifiedName = fullyQualifiedName, + name = name, + restartable = restartable, + skippable = skippable, + unstableArguments = emptySet(), + ) + } + .filter { !it.restartable || !it.skippable } + .right() + }) { + Either.Left("CSV parse error for '${composablesCsv.path}': ${it.message}") + } + +private fun UnstableComposable.findUnstableArguments( + composablesTxt: String +): List { + val composableFunction = composablesTxt.split(")\n").firstOrNull { funTxt -> + "fun $name(" in funTxt + } ?: return emptyList() + return composableFunction.split("\n") + .drop(1) // drop the signature + .mapNotNull { paramTxt -> + try { + if ("unstable" in paramTxt) { + val words = paramTxt.split(" ") + .filter { it.isNotBlank() } + ComposableArgument( + name = words[1].dropLast(1), // drop the ":" + type = words[2] + ) + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +private fun buildIvyReport( + unstableComposables: List +): String = buildString { + append("-----------") + append("\n") + append("UNSTABLE COMPOSABLES:") + append("\n") + unstableComposables.forEachIndexed { index, composable -> + append("@Composable ${composable.fullyQualifiedName}") + append("(restartable = ${composable.restartable}, skippable = ${composable.skippable}):\n") + composable.unstableArguments.forEach { arg -> + append("-unstable ") + append("\"${arg.name}: ${arg.type}\"") + append("\n") + } + if (index != unstableComposables.lastIndex) { + append("\n") + } + } + append("-----------\n") + append("[CONCLUSION]\n") + append("Unstable Composables: ${unstableComposables.size}") +} + +private fun createReportFile(report: String) { + val reportFile = File(OutputReportFileName) + reportFile.writeText(report) +} + +private fun generateBaseline(unstableComposables: List) { + val baselineContent = unstableComposables.joinToString(separator = "\n") { + it.fullyQualifiedName + } + val baselineFile = File(BaselineFileName) + baselineFile.writeText(baselineContent) +} + +private fun readBaseline(): Set { + return try { + val baselineFile = File(BaselineFileName) + baselineFile.readText().split("\n").toSet() + } catch (e: Exception) { + emptySet() + } +} diff --git a/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/model/UnstableComposable.kt b/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/model/UnstableComposable.kt new file mode 100644 index 0000000000..8319ec9aea --- /dev/null +++ b/ci-actions/compose-stability/src/main/kotlin/ivy/automate/compose/stability/model/UnstableComposable.kt @@ -0,0 +1,16 @@ +package ivy.automate.compose.stability.model + +typealias FullyQualifiedName = String + +data class UnstableComposable( + val fullyQualifiedName: FullyQualifiedName, + val name: String, + val skippable: Boolean, + val restartable: Boolean, + val unstableArguments: Set +) + +data class ComposableArgument( + val name: String, + val type: String +) \ No newline at end of file diff --git a/docs/CI-Troubleshooting.md b/docs/CI-Troubleshooting.md index fb6342f14a..674f8c23a5 100644 --- a/docs/CI-Troubleshooting.md +++ b/docs/CI-Troubleshooting.md @@ -54,4 +54,14 @@ If this job is failing this means that your changes break an existing unit test. **To run the Unit tests locally:** ``` ./gradlew testDebugUnitTest -``` \ No newline at end of file +``` + +## Compose Stability + +This GitHub Action checks whether your `@Composable` functions are stable (i.e. "restartable" and "skippable"). If it fails it means that some of your composables are unstable. That causes unnecessary recompositions which can lead to lost frames and laggy UI/UX especially when animation or scrolling. You must fix that! To fix it, open the failing working and see the output from report - it tells you which `@Composable` functions are unstable and what parameters cause that. + +**Compose Stability baseline** (not recommended) +``` +./scripts/composeStabilityBaseline.sh +``` +Do that only if the failure is in legacy code. If the script is failing, open it and execute the commands inside it manually. diff --git a/scripts/composeStabilityBaseline.sh b/scripts/composeStabilityBaseline.sh new file mode 100755 index 0000000000..e0bd4f5f9a --- /dev/null +++ b/scripts/composeStabilityBaseline.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# ------------------- +# 0. ROOT DIRECTORY CHECK +# ------------------- + +# Check if the script is being run from the root directory of the repo +if [ ! -f "settings.gradle.kts" ]; then + echo "ERROR:" + echo "Please run this script from the root directory of the repo." + exit 1 +fi + + +./gradlew :ci-actions:compose-stability:run --args='generateBaseline' || exit 0 +git add ci-actions/compose-stability/ivy-compose-stability-baseline.txt || exit 0 +git commit -m "Add Compose Stability baseline" || exit 0 +echo "Compose Stability baseline added." +echo "[SUCCESS] Compose Stability baseline committed. Do 'git push'." \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c8accd862c..10a667bd79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ dependencyResolutionManagement { rootProject.name = "IvyWallet" include(":app") include(":ci-actions:base") +include(":ci-actions:compose-stability") include(":ci-actions:issue-assign") include(":ci-actions:issue-create-comment") include(":screen:accounts")