create-cmp-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +65 -27
  2. package/bin/create-cmp.mjs +2 -1
  3. package/options.schema.json +5 -2
  4. package/package.json +2 -2
  5. package/src/commands/create.mjs +26 -3
  6. package/src/lib/toggle.mjs +2 -2
  7. package/template/.claude/settings.json +12 -0
  8. package/template/.claude/skills/add-feature/SKILL.md +131 -0
  9. package/template/.claude/skills/add-repository/SKILL.md +106 -0
  10. package/template/.claude/skills/add-screen/SKILL.md +130 -0
  11. package/template/.github/workflows/verify.yml +30 -8
  12. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  13. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  14. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  15. package/template/.gradle/8.11.1/gc.properties +0 -0
  16. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  17. package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
  18. package/template/.gradle/vcs-1/gc.properties +0 -0
  19. package/template/CHANGELOG.md +14 -0
  20. package/template/CLAUDE.md +70 -0
  21. package/template/CONTRIBUTING.md +39 -0
  22. package/template/README.md +103 -0
  23. package/template/composeApp/build.gradle.kts +15 -8
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
  30. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
  31. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
  32. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
  33. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
  34. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
  35. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
  36. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
  37. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
  38. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
  39. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
  40. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
  41. package/template/docs/ARCHITECTURE.md +51 -0
  42. package/template/docs/TESTING.md +64 -0
  43. package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
  44. package/template/docs/adr/template.md +17 -0
  45. package/template/gitignore +4 -0
  46. package/template/gradle/libs.versions.toml +2 -0
  47. package/template/manifest.json +11 -8
  48. package/template/qa/e2e/README.md +29 -0
  49. package/template/qa/e2e/smoke.yaml +35 -0
  50. package/template/qa/evidence/schema.json +56 -0
  51. package/template/qa/golden/home.json +15 -0
  52. package/template/qa/lib/inputs-hash.mjs +105 -0
  53. package/template/qa/lib/token-drift.mjs +94 -0
  54. package/template/qa/receipt-check.mjs +113 -0
  55. package/template/qa/refusal-demo.mjs +491 -0
  56. package/template/qa/scaffold-feature.mjs +488 -0
  57. package/template/qa/verify.mjs +467 -0
  58. package/template/specs/README.md +33 -0
  59. package/template/specs/app-base.spec.md +30 -0
  60. package/template/specs/home.spec.md +18 -0
  61. package/template/qa/appium/README.md +0 -23
  62. package/template/qa/appium/lib/appium-client.mjs +0 -225
  63. package/template/qa/appium/package.json +0 -8
  64. package/template/qa/appium/run-android-smoke.mjs +0 -39
  65. package/template/tests/appium/cmp/conftest.py +0 -96
  66. package/template/tests/appium/cmp/test_smoke.py +0 -17
@@ -0,0 +1,37 @@
1
+ package __PACKAGE__.domain.usecase
2
+
3
+ import __PACKAGE__.domain.model.Item
4
+ import __PACKAGE__.testing.fakes.FakeItemRepository
5
+ import kotlin.test.Test
6
+ import kotlin.test.assertEquals
7
+ import kotlin.test.assertFailsWith
8
+ import kotlinx.coroutines.test.runTest
9
+
10
+ /**
11
+ * The exemplar use-case test. Use cases are pure business actions with no framework
12
+ * dependencies, so their tests are the simplest in the pyramid: fake in, behavior out.
13
+ */
14
+ class GetItemsUseCaseTest {
15
+
16
+ private val repository = FakeItemRepository()
17
+ private val getItems = GetItemsUseCase(repository)
18
+
19
+ @Test
20
+ fun `returns the repository's items`() = runTest {
21
+ val expected = listOf(
22
+ Item(id = "1", title = "First", subtitle = "a"),
23
+ Item(id = "2", title = "Second", subtitle = "b"),
24
+ )
25
+ repository.items = expected
26
+
27
+ assertEquals(expected, getItems())
28
+ assertEquals(1, repository.getItemsCallCount)
29
+ }
30
+
31
+ @Test
32
+ fun `propagates repository failures to the caller`() = runTest {
33
+ repository.shouldFail = true
34
+
35
+ assertFailsWith<IllegalStateException> { getItems() }
36
+ }
37
+ }
@@ -0,0 +1,104 @@
1
+ package __PACKAGE__.presentation.home
2
+
3
+ import app.cash.turbine.test
4
+ import __PACKAGE__.domain.model.Item
5
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
6
+ import __PACKAGE__.testing.fakes.FakeItemRepository
7
+ import kotlin.test.AfterTest
8
+ import kotlin.test.BeforeTest
9
+ import kotlin.test.Test
10
+ import kotlin.test.assertEquals
11
+ import kotlin.test.assertNotNull
12
+ import kotlin.test.assertNull
13
+ import kotlin.test.assertTrue
14
+ import kotlinx.coroutines.Dispatchers
15
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
16
+ import kotlinx.coroutines.test.StandardTestDispatcher
17
+ import kotlinx.coroutines.test.resetMain
18
+ import kotlinx.coroutines.test.runTest
19
+ import kotlinx.coroutines.test.setMain
20
+
21
+ /**
22
+ * The exemplar ViewModel test — the pattern every generated ViewModel test follows:
23
+ * - Arrange/Act/Assert with behavior-named backtick tests, one behavior per test.
24
+ * - A [StandardTestDispatcher] installed as Main (viewModelScope launches on Main),
25
+ * so coroutines run under the test scheduler's virtual time.
26
+ * - Turbine (`state.test { … }`) for StateFlow assertions.
27
+ * - Hand-written fakes from `testing/fakes` — never mocks.
28
+ */
29
+ @OptIn(ExperimentalCoroutinesApi::class)
30
+ class HomeViewModelTest {
31
+
32
+ private val dispatcher = StandardTestDispatcher()
33
+ private val repository = FakeItemRepository()
34
+
35
+ @BeforeTest
36
+ fun setUp() {
37
+ Dispatchers.setMain(dispatcher)
38
+ }
39
+
40
+ @AfterTest
41
+ fun tearDown() {
42
+ Dispatchers.resetMain()
43
+ }
44
+
45
+ private fun viewModel() = HomeViewModel(GetItemsUseCase(repository))
46
+
47
+ // SPEC: HOME-01
48
+ @Test
49
+ fun `starts in loading state`() = runTest(dispatcher) {
50
+ viewModel().state.test {
51
+ assertTrue(awaitItem().isLoading, "initial state should be loading")
52
+ }
53
+ }
54
+
55
+ @Test
56
+ fun `emits items when repository succeeds`() = runTest(dispatcher) {
57
+ repository.items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
58
+
59
+ viewModel().state.test {
60
+ assertTrue(awaitItem().isLoading)
61
+
62
+ val loaded = awaitItem()
63
+ assertEquals(false, loaded.isLoading)
64
+ assertEquals(listOf("First"), loaded.items.map { it.title })
65
+ assertNull(loaded.errorMessage)
66
+ }
67
+ }
68
+
69
+ @Test
70
+ fun `emits error message when repository fails`() = runTest(dispatcher) {
71
+ repository.shouldFail = true
72
+ repository.failureMessage = "network down"
73
+
74
+ viewModel().state.test {
75
+ assertTrue(awaitItem().isLoading)
76
+
77
+ val failed = awaitItem()
78
+ assertEquals(false, failed.isLoading)
79
+ assertTrue(failed.items.isEmpty())
80
+ assertEquals("network down", failed.errorMessage)
81
+ }
82
+ }
83
+
84
+ // SPEC: HOME-04
85
+ @Test
86
+ fun `reload after failure clears the error and loads items`() = runTest(dispatcher) {
87
+ repository.shouldFail = true
88
+ val viewModel = viewModel()
89
+
90
+ viewModel.state.test {
91
+ assertTrue(awaitItem().isLoading)
92
+ assertNotNull(awaitItem().errorMessage, "first load should fail")
93
+
94
+ repository.shouldFail = false
95
+ repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
96
+ viewModel.load()
97
+
98
+ assertTrue(awaitItem().isLoading, "reload should show loading again")
99
+ val recovered = awaitItem()
100
+ assertNull(recovered.errorMessage)
101
+ assertEquals(listOf("Recovered"), recovered.items.map { it.title })
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,29 @@
1
+ package __PACKAGE__.testing.fakes
2
+
3
+ import __PACKAGE__.domain.model.Item
4
+ import __PACKAGE__.domain.repository.ItemRepository
5
+
6
+ /**
7
+ * Hand-written fake — the template's testing convention (no mocking frameworks: they are
8
+ * JVM-only in KMP, and interface-driven fakes keep the architecture honest).
9
+ *
10
+ * The pattern every fake follows:
11
+ * - configurable behavior (`items`, `shouldFail`) so a test arranges its scenario,
12
+ * - recorded interactions (`getItemsCallCount`) so a test can assert usage,
13
+ * - implements the DOMAIN interface, never a concrete data source.
14
+ */
15
+ class FakeItemRepository : ItemRepository {
16
+
17
+ var items: List<Item> = emptyList()
18
+ var shouldFail: Boolean = false
19
+ var failureMessage: String = "fake failure"
20
+
21
+ var getItemsCallCount: Int = 0
22
+ private set
23
+
24
+ override suspend fun getItems(): List<Item> {
25
+ getItemsCallCount++
26
+ if (shouldFail) throw IllegalStateException(failureMessage)
27
+ return items
28
+ }
29
+ }
@@ -0,0 +1,64 @@
1
+ package __PACKAGE__.conformance
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.ui.semantics.SemanticsActions
5
+ import androidx.compose.ui.semantics.SemanticsNode
6
+ import androidx.compose.ui.semantics.SemanticsProperties
7
+ import androidx.compose.ui.semantics.getOrNull
8
+ import androidx.compose.ui.test.ExperimentalTestApi
9
+ import androidx.compose.ui.test.hasText
10
+ import androidx.compose.ui.test.onRoot
11
+ import androidx.compose.ui.test.runComposeUiTest
12
+ import __PACKAGE__.domain.model.Item
13
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
14
+ import __PACKAGE__.presentation.home.HomeScreen
15
+ import __PACKAGE__.presentation.home.HomeViewModel
16
+ import __PACKAGE__.testing.awaitNode
17
+ import __PACKAGE__.testing.fakes.FakeItemRepository
18
+ import kotlin.test.Test
19
+ import kotlin.test.fail
20
+
21
+ /**
22
+ * A11y gate — SPEC: SHELL-04. Every interactive node must be perceivable by assistive
23
+ * technology and automation: a testTag, text, or contentDescription. Runs on the JVM tier;
24
+ * the verify lane's `a11y` step. (Deeper checks — touch-target size, contrast — come with
25
+ * the live-tier inspector audit.)
26
+ */
27
+ @OptIn(ExperimentalTestApi::class)
28
+ class A11yConformanceTest {
29
+
30
+ // SPEC: SHELL-04
31
+ @Test
32
+ fun `every clickable on Home is perceivable`() = runComposeUiTest {
33
+ val repository = FakeItemRepository().apply {
34
+ items = listOf(Item(id = "1", title = "A11y row", subtitle = "sub"))
35
+ }
36
+ setContent {
37
+ MaterialTheme {
38
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
39
+ }
40
+ }
41
+ awaitNode(hasText("A11y row"))
42
+
43
+ val offenders = mutableListOf<String>()
44
+ fun walk(node: SemanticsNode) {
45
+ val clickable = node.config.getOrNull(SemanticsActions.OnClick) != null
46
+ if (clickable) {
47
+ val perceivable =
48
+ node.config.getOrNull(SemanticsProperties.TestTag) != null ||
49
+ !node.config.getOrNull(SemanticsProperties.Text).isNullOrEmpty() ||
50
+ !node.config.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() ||
51
+ node.children.any { !it.config.getOrNull(SemanticsProperties.Text).isNullOrEmpty() }
52
+ if (!perceivable) offenders += "node@${node.id} (bounds=${node.boundsInRoot})"
53
+ }
54
+ node.children.forEach(::walk)
55
+ }
56
+ walk(onRoot(useUnmergedTree = true).fetchSemanticsNode())
57
+
58
+ if (offenders.isNotEmpty()) fail(
59
+ "[SHELL-04] Interactive nodes without testTag/text/contentDescription:\n " +
60
+ offenders.joinToString("\n ") +
61
+ "\n Fix: give each clickable a testTag or visible text/contentDescription.",
62
+ )
63
+ }
64
+ }
@@ -0,0 +1,143 @@
1
+ package __PACKAGE__.conformance
2
+
3
+ import java.io.File
4
+ import kotlin.test.Test
5
+ import kotlin.test.fail
6
+
7
+ /**
8
+ * The conformance gates — `specs/app-base.spec.md`'s ARCH clauses as executable checks.
9
+ * These enforce the architecture mechanically: an AI (or human) that drifts from the
10
+ * contract gets a named clause violation with the offending files, not a style nit.
11
+ *
12
+ * Deliberately dependency-free (plain source scanning) rather than Konsist: the template's
13
+ * moat is a frozen, lockstep-safe version set, and Konsist would add a kotlin-compiler
14
+ * pin to track. Swap in Konsist if you want richer queries — keep the clause ids.
15
+ *
16
+ * Runs on the JVM tier (`:composeApp:desktopTest`), invoked by the verify lane's
17
+ * `conformance` step.
18
+ */
19
+ class ArchitectureConformanceTest {
20
+
21
+ private val commonMain = File("src/commonMain/kotlin")
22
+ private val commonTest = File("src/commonTest/kotlin")
23
+
24
+ private fun sources(root: File): List<File> =
25
+ root.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList()
26
+
27
+ private fun imports(file: File): List<String> =
28
+ file.readLines().filter { it.trimStart().startsWith("import ") }.map { it.trim() }
29
+
30
+ private fun under(file: File, segment: String): Boolean =
31
+ file.path.replace(File.separatorChar, '/').contains("/$segment/")
32
+
33
+ private fun violation(clause: String, rule: String, offenders: List<String>, fix: String): String =
34
+ "[$clause] $rule\n Offending: ${offenders.joinToString("\n ")}\n Fix: $fix"
35
+
36
+ // SPEC: ARCH-01
37
+ @Test
38
+ fun `ARCH-01 presentation never imports the data layer`() {
39
+ val offenders = sources(commonMain)
40
+ .filter { under(it, "presentation") }
41
+ .filter { file -> imports(file).any { it.startsWith("import __PACKAGE__.data.") } }
42
+ .map { it.path }
43
+ if (offenders.isNotEmpty()) fail(
44
+ violation(
45
+ "ARCH-01", "presentation depends on domain only — it never imports the data layer.",
46
+ offenders,
47
+ "depend on a domain interface (domain/repository) and let di/ wire the data implementation.",
48
+ )
49
+ )
50
+ }
51
+
52
+ // SPEC: ARCH-02
53
+ @Test
54
+ fun `ARCH-02 domain is pure - no app layers, no frameworks`() {
55
+ val banned = listOf(
56
+ "import __PACKAGE__.presentation.", "import __PACKAGE__.data.", "import __PACKAGE__.di.",
57
+ "import androidx.compose.", "import org.koin.",
58
+ )
59
+ val offenders = sources(commonMain)
60
+ .filter { under(it, "domain") }
61
+ .filter { file -> imports(file).any { imp -> banned.any { imp.startsWith(it) } } }
62
+ .map { it.path }
63
+ if (offenders.isNotEmpty()) fail(
64
+ violation(
65
+ "ARCH-02", "domain imports nothing app-internal and no UI/DI frameworks.",
66
+ offenders,
67
+ "move framework-touching code out to presentation/data; domain stays pure Kotlin.",
68
+ )
69
+ )
70
+ }
71
+
72
+ // SPEC: ARCH-03
73
+ @Test
74
+ fun `ARCH-03 every ViewModel has a test`() {
75
+ val testNames = sources(commonTest).map { it.name }.toSet()
76
+ val offenders = sources(commonMain)
77
+ .filter { it.name.endsWith("ViewModel.kt") }
78
+ .filterNot { "${it.name.removeSuffix(".kt")}Test.kt" in testNames }
79
+ .map { it.path }
80
+ if (offenders.isNotEmpty()) fail(
81
+ violation(
82
+ "ARCH-03", "every ViewModel has a corresponding *ViewModelTest in commonTest.",
83
+ offenders,
84
+ "add the test mirroring HomeViewModelTest (Turbine + fakes; loading/success/error paths).",
85
+ )
86
+ )
87
+ }
88
+
89
+ // SPEC: ARCH-04
90
+ @Test
91
+ fun `ARCH-04 every Screen composable declares a testTag`() {
92
+ val offenders = sources(commonMain)
93
+ .filter { it.name != "Screen.kt" && it.name.endsWith("Screen.kt") }
94
+ .filterNot { under(it, "components") || under(it, "navigation") }
95
+ .filterNot { it.readText().contains("testTag") }
96
+ .map { it.path }
97
+ if (offenders.isNotEmpty()) fail(
98
+ violation(
99
+ "ARCH-04", "every screen is automation-reachable: *Screen files declare at least one testTag.",
100
+ offenders,
101
+ "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the screen's key nodes.",
102
+ )
103
+ )
104
+ }
105
+
106
+ // SPEC: ARCH-05
107
+ @Test
108
+ fun `ARCH-05 no hardcoded Color literals outside the theme`() {
109
+ val colorLiteral = Regex("""Color\(0x""")
110
+ val offenders = sources(commonMain)
111
+ .filterNot { under(it, "theme") }
112
+ .filter { colorLiteral.containsMatchIn(it.readText()) }
113
+ .map { it.path }
114
+ if (offenders.isNotEmpty()) fail(
115
+ violation(
116
+ "ARCH-05", "design colors come from the token catalog, never Color(0x…) literals.",
117
+ offenders,
118
+ "add/use a token in presentation/theme instead of the literal.",
119
+ )
120
+ )
121
+ }
122
+
123
+ // SPEC: SHELL-03
124
+ @Test
125
+ fun `SHELL-03 insets are owned by BaseScreen - screens never touch inset APIs`() {
126
+ val insetApi = Regex(
127
+ "WindowInsets|safeDrawingPadding|safeContentPadding|systemBarsPadding|" +
128
+ "statusBarsPadding|navigationBarsPadding|imePadding"
129
+ )
130
+ val offenders = sources(commonMain)
131
+ .filterNot { under(it, "components") }
132
+ .filterNot { under(it, "navigation") }
133
+ .filter { insetApi.containsMatchIn(it.readText()) }
134
+ .map { it.path }
135
+ if (offenders.isNotEmpty()) fail(
136
+ violation(
137
+ "SHELL-03", "window insets are solved once, in BaseScreen/AppShell — screens never call inset APIs directly.",
138
+ offenders,
139
+ "compose your content inside BaseScreen (or the shell) and remove the direct inset call.",
140
+ )
141
+ )
142
+ }
143
+ }
@@ -0,0 +1,78 @@
1
+ package __PACKAGE__.presentation.home
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.ui.test.ExperimentalTestApi
5
+ import androidx.compose.ui.test.hasText
6
+ import androidx.compose.ui.test.onRoot
7
+ import androidx.compose.ui.test.runComposeUiTest
8
+ import __PACKAGE__.domain.model.Item
9
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
10
+ import __PACKAGE__.testing.StructuralTree
11
+ import __PACKAGE__.testing.awaitNode
12
+ import __PACKAGE__.testing.fakes.FakeItemRepository
13
+ import java.io.File
14
+ import kotlin.test.Test
15
+ import kotlin.test.fail
16
+
17
+ /**
18
+ * Golden-tree structural baseline — SPEC: HOME-06.
19
+ *
20
+ * Renders Home headlessly with FIXED fake data and diffs the semantics structure against the
21
+ * committed baseline (`qa/golden/home.json`). No pixels, no flake: a failure means the
22
+ * screen's STRUCTURE changed.
23
+ *
24
+ * Unintended drift → fix your change. Intended drift → regenerate the baseline explicitly
25
+ * and declare it in your summary/PR:
26
+ *
27
+ * UPDATE_GOLDEN=1 ./gradlew :composeApp:desktopTest --tests "*GoldenTree*"
28
+ */
29
+ @OptIn(ExperimentalTestApi::class)
30
+ class HomeGoldenTreeTest {
31
+
32
+ private val baseline = File("../qa/golden/home.json")
33
+
34
+ // Fixed dataset — golden renders must be deterministic; never use live/random data here.
35
+ private val goldenItems = listOf(
36
+ Item(id = "1", title = "Golden first", subtitle = "Structural baseline row one"),
37
+ Item(id = "2", title = "Golden second", subtitle = "Structural baseline row two"),
38
+ )
39
+
40
+ // SPEC: HOME-06
41
+ @Test
42
+ fun `home structure matches the committed golden tree`() = runComposeUiTest {
43
+ val repository = FakeItemRepository().apply { items = goldenItems }
44
+
45
+ setContent {
46
+ MaterialTheme {
47
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
48
+ }
49
+ }
50
+ awaitNode(hasText("Golden first"))
51
+
52
+ val rendered = StructuralTree.serialize(onRoot(useUnmergedTree = true).fetchSemanticsNode())
53
+
54
+ if (System.getenv("UPDATE_GOLDEN") == "1") {
55
+ baseline.parentFile.mkdirs()
56
+ baseline.writeText(rendered)
57
+ return@runComposeUiTest
58
+ }
59
+
60
+ if (!baseline.exists()) fail(
61
+ "[HOME-06] Golden baseline missing (qa/golden/home.json). " +
62
+ "Generate it explicitly: UPDATE_GOLDEN=1 ./gradlew :composeApp:desktopTest --tests \"*GoldenTree*\"",
63
+ )
64
+
65
+ val expected = baseline.readText()
66
+ if (rendered != expected) {
67
+ val diffAt = rendered.zip(expected).indexOfFirst { (a, b) -> a != b }
68
+ .let { if (it == -1) minOf(rendered.length, expected.length) else it }
69
+ fail(
70
+ "[HOME-06] Home's rendered structure drifted from qa/golden/home.json (first diff at char $diffAt).\n" +
71
+ "If this drift is UNINTENDED: fix your change.\n" +
72
+ "If it is the intended change: regenerate with UPDATE_GOLDEN=1 and declare it.\n" +
73
+ "--- rendered (excerpt) ---\n${rendered.substring(maxOf(0, diffAt - 120), minOf(rendered.length, diffAt + 240))}\n" +
74
+ "--- baseline (excerpt) ---\n${expected.substring(maxOf(0, diffAt - 120), minOf(expected.length, diffAt + 240))}",
75
+ )
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,77 @@
1
+ package __PACKAGE__.presentation.home
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.ui.test.ExperimentalTestApi
5
+ import androidx.compose.ui.test.assertCountEquals
6
+ import androidx.compose.ui.test.hasTestTag
7
+ import androidx.compose.ui.test.hasText
8
+ import androidx.compose.ui.test.onAllNodesWithText
9
+ import androidx.compose.ui.test.onFirst
10
+ import androidx.compose.ui.test.onNodeWithTag
11
+ import androidx.compose.ui.test.performClick
12
+ import androidx.compose.ui.test.runComposeUiTest
13
+ import __PACKAGE__.domain.model.Item
14
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
15
+ import __PACKAGE__.testing.awaitNode
16
+ import __PACKAGE__.testing.fakes.FakeItemRepository
17
+ import kotlin.test.Test
18
+
19
+ /**
20
+ * Durable screen tests — first-party Compose UI Test, spec-cited, testTag selectors.
21
+ * These are the long-lived regression layer (TESTING-ARCHITECTURE tier 3b): each test
22
+ * verifies a clause from `specs/home.spec.md`, one behavior per test.
23
+ */
24
+ @OptIn(ExperimentalTestApi::class)
25
+ class HomeScreenTest {
26
+
27
+ private val repository = FakeItemRepository()
28
+
29
+ private fun viewModel() = HomeViewModel(GetItemsUseCase(repository))
30
+
31
+ // SPEC: HOME-02
32
+ @Test
33
+ fun `renders loaded items with title and subtitle`() = runComposeUiTest {
34
+ repository.items = listOf(
35
+ Item(id = "1", title = "First title", subtitle = "First subtitle"),
36
+ Item(id = "2", title = "Second title", subtitle = "Second subtitle"),
37
+ )
38
+
39
+ setContent {
40
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
41
+ }
42
+
43
+ awaitNode(hasText("First title"))
44
+ onAllNodesWithText("Second subtitle").assertCountEquals(1)
45
+ onNodeWithTag("home_title", useUnmergedTree = true).assertExists()
46
+ }
47
+
48
+ // SPEC: HOME-03
49
+ @Test
50
+ fun `shows the error message when loading fails`() = runComposeUiTest {
51
+ repository.shouldFail = true
52
+ repository.failureMessage = "network down"
53
+
54
+ setContent {
55
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
56
+ }
57
+
58
+ awaitNode(hasTestTag("home_error"))
59
+ onAllNodesWithText("network down").assertCountEquals(1)
60
+ }
61
+
62
+ // SPEC: HOME-05
63
+ @Test
64
+ fun `tapping an item reports its id for navigation`() = runComposeUiTest {
65
+ repository.items = listOf(Item(id = "item-42", title = "Tap me", subtitle = "sub"))
66
+ var clickedId: String? = null
67
+
68
+ setContent {
69
+ MaterialTheme { HomeScreen(onItemClick = { clickedId = it }, viewModel = viewModel()) }
70
+ }
71
+
72
+ awaitNode(hasText("Tap me"))
73
+ onAllNodesWithText("Tap me").onFirst().performClick()
74
+ waitUntil(timeoutMillis = 5_000) { clickedId != null }
75
+ kotlin.test.assertEquals("item-42", clickedId)
76
+ }
77
+ }
@@ -0,0 +1,79 @@
1
+ package __PACKAGE__.presentation.navigation
2
+
3
+ import androidx.compose.material.icons.Icons
4
+ import androidx.compose.material.icons.filled.Home
5
+ import androidx.compose.material3.MaterialTheme
6
+ import androidx.compose.material3.Text
7
+ import androidx.compose.ui.Modifier
8
+ import androidx.compose.ui.semantics.semantics
9
+ import androidx.compose.ui.semantics.testTag
10
+ import androidx.compose.ui.test.ExperimentalTestApi
11
+ import androidx.compose.ui.test.assertIsDisplayed
12
+ import androidx.compose.ui.test.hasTestTag
13
+ import androidx.compose.ui.test.onNodeWithTag
14
+ import androidx.compose.ui.test.onNodeWithText
15
+ import androidx.compose.ui.test.performClick
16
+ import androidx.compose.ui.test.runComposeUiTest
17
+ import __PACKAGE__.testing.awaitNode
18
+ import kotlin.test.Test
19
+
20
+ /**
21
+ * Durable shell tests — first-party Compose UI Test, spec-cited, testTag selectors.
22
+ * Verifies `specs/app-base.spec.md`'s SHELL-01/SHELL-02 clauses directly against [AppShell]
23
+ * with trivial in-test tabs (no ViewModel/DI wiring — the shell itself is under test), so no
24
+ * scaffold flag combination (in particular `--no-e2e`, which removes qa/e2e) orphans these
25
+ * clauses. `qa/e2e/smoke.yaml` remains the on-device citation of the same clauses — being
26
+ * cited by both a durable JVM test and the E2E flow is expected, not redundant.
27
+ */
28
+ @OptIn(ExperimentalTestApi::class)
29
+ class AppShellTest {
30
+
31
+ private fun testTabs(): List<AppTab> = listOf(
32
+ AppTab(
33
+ label = "Tab One",
34
+ icon = Icons.Filled.Home,
35
+ content = {
36
+ Text("Tab one content", modifier = Modifier.semantics { testTag = "tab_one_content" })
37
+ },
38
+ ),
39
+ AppTab(
40
+ label = "Tab Two",
41
+ icon = Icons.Filled.Home,
42
+ content = {
43
+ Text("Tab two content", modifier = Modifier.semantics { testTag = "tab_two_content" })
44
+ },
45
+ ),
46
+ )
47
+
48
+ // SPEC: SHELL-01
49
+ @Test
50
+ fun `first tab renders inside the shell with the bottom nav visible on launch`() = runComposeUiTest {
51
+ setContent {
52
+ MaterialTheme { AppShell(tabs = testTabs()) }
53
+ }
54
+
55
+ awaitNode(hasTestTag("tab_one_content"))
56
+ onNodeWithTag("tab_one_content").assertIsDisplayed()
57
+ onNodeWithTag("app_bottom_nav").assertIsDisplayed()
58
+ }
59
+
60
+ // SPEC: SHELL-02
61
+ @Test
62
+ fun `tapping another tab renders its screen and keeps the bottom nav visible`() = runComposeUiTest {
63
+ setContent {
64
+ MaterialTheme { AppShell(tabs = testTabs()) }
65
+ }
66
+
67
+ awaitNode(hasTestTag("tab_one_content"))
68
+
69
+ onNodeWithText("Tab Two").performClick()
70
+ awaitNode(hasTestTag("tab_two_content"))
71
+ onNodeWithTag("tab_two_content").assertIsDisplayed()
72
+ onNodeWithTag("app_bottom_nav").assertIsDisplayed()
73
+
74
+ onNodeWithText("Tab One").performClick()
75
+ awaitNode(hasTestTag("tab_one_content"))
76
+ onNodeWithTag("tab_one_content").assertIsDisplayed()
77
+ onNodeWithTag("app_bottom_nav").assertIsDisplayed()
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import androidx.compose.ui.test.ComposeUiTest
4
+ import androidx.compose.ui.test.ExperimentalTestApi
5
+ import androidx.compose.ui.test.SemanticsMatcher
6
+
7
+ /**
8
+ * Waits until at least one node matches. Portable helper (the upstream
9
+ * waitUntilAtLeastOneExists is not exposed on this CMP version's desktop test artifact).
10
+ */
11
+ @OptIn(ExperimentalTestApi::class)
12
+ fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher, timeoutMillis: Long = 5_000) {
13
+ waitUntil(timeoutMillis = timeoutMillis) {
14
+ onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
15
+ }
16
+ }