create-cmp-cli 0.2.0 → 0.3.1
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.
- package/README.md +65 -27
- package/bin/create-cmp.mjs +2 -1
- package/options.schema.json +5 -2
- package/package.json +2 -2
- package/src/commands/create.mjs +26 -3
- package/src/lib/toggle.mjs +2 -2
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/add-feature/SKILL.md +131 -0
- package/template/.claude/skills/add-repository/SKILL.md +106 -0
- package/template/.claude/skills/add-screen/SKILL.md +130 -0
- package/template/.github/workflows/verify.yml +30 -8
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/CHANGELOG.md +14 -0
- package/template/CLAUDE.md +70 -0
- package/template/CONTRIBUTING.md +39 -0
- package/template/README.md +103 -0
- package/template/composeApp/build.gradle.kts +15 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
- package/template/docs/ARCHITECTURE.md +51 -0
- package/template/docs/TESTING.md +64 -0
- package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
- package/template/docs/adr/template.md +17 -0
- package/template/gitignore +8 -1
- package/template/gradle/libs.versions.toml +2 -0
- package/template/manifest.json +11 -8
- package/template/qa/e2e/README.md +29 -0
- package/template/qa/e2e/smoke.yaml +35 -0
- package/template/qa/evidence/schema.json +56 -0
- package/template/qa/golden/home.json +15 -0
- package/template/qa/lib/inputs-hash.mjs +112 -0
- package/template/qa/lib/token-drift.mjs +94 -0
- package/template/qa/receipt-check.mjs +113 -0
- package/template/qa/refusal-demo.mjs +491 -0
- package/template/qa/scaffold-feature.mjs +488 -0
- package/template/qa/verify.mjs +467 -0
- package/template/specs/README.md +33 -0
- package/template/specs/app-base.spec.md +30 -0
- package/template/specs/home.spec.md +18 -0
- package/template/qa/appium/README.md +0 -23
- package/template/qa/appium/lib/appium-client.mjs +0 -225
- package/template/qa/appium/package.json +0 -8
- package/template/qa/appium/run-android-smoke.mjs +0 -39
- package/template/tests/appium/cmp/conftest.py +0 -96
- 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
|
+
}
|