create-cmp-cli 0.7.1 → 0.9.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.
- package/README.md +72 -11
- package/llms.txt +6 -2
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +8 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/registry.mjs +15 -2
- package/src/lib/tabs.mjs +91 -4
- package/src/lib/upgrade.mjs +49 -5
- package/src/scaffold.mjs +52 -1
- package/src/versions/candidates.json +4 -0
- package/src/versions/registry.json +88 -0
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +196 -48
- package/template/README.md +23 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +317 -34
- package/template/docs/TESTING.md +6 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/approvals.mjs +806 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +118 -9
- package/template/specs/app-base.spec.md +44 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package __PACKAGE__.conformance
|
|
2
|
+
|
|
3
|
+
import androidx.compose.material3.MaterialTheme
|
|
4
|
+
import androidx.compose.ui.test.ExperimentalTestApi
|
|
5
|
+
import androidx.compose.ui.test.assertHeightIsAtLeast
|
|
6
|
+
import androidx.compose.ui.test.assertWidthIsAtLeast
|
|
7
|
+
import androidx.compose.ui.test.hasTestTag
|
|
8
|
+
import androidx.compose.ui.test.hasText
|
|
9
|
+
import androidx.compose.ui.test.onNodeWithTag
|
|
10
|
+
import androidx.compose.ui.test.runComposeUiTest
|
|
11
|
+
import androidx.compose.ui.unit.dp
|
|
12
|
+
import __PACKAGE__.domain.model.DomainError
|
|
13
|
+
import __PACKAGE__.domain.model.Item
|
|
14
|
+
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
15
|
+
import __PACKAGE__.presentation.home.HomeScreen
|
|
16
|
+
import __PACKAGE__.presentation.home.HomeViewModel
|
|
17
|
+
import __PACKAGE__.testing.awaitNode
|
|
18
|
+
import __PACKAGE__.testing.fakes.FakeItemRepository
|
|
19
|
+
import kotlin.test.Test
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The COMP clauses (`specs/app-base.spec.md`) as executable checks — the component
|
|
23
|
+
* vocabulary's runtime contract, proven against the exemplar screen the same way the
|
|
24
|
+
* ARCH/SHELL conformance gates prove the architecture. Component substructure/tag
|
|
25
|
+
* stability is additionally pinned by the golden-tree baseline (`qa/golden/`); this class
|
|
26
|
+
* proves the STATE/A11Y contract every registry consumer inherits for free.
|
|
27
|
+
*/
|
|
28
|
+
@OptIn(ExperimentalTestApi::class)
|
|
29
|
+
class ComponentConformanceTest {
|
|
30
|
+
|
|
31
|
+
// SPEC: COMP-01
|
|
32
|
+
@Test
|
|
33
|
+
fun `a failed load is presented by ContentStateContainer with the screen-derived error tag`() = runComposeUiTest {
|
|
34
|
+
val repository = FakeItemRepository().apply { failure = DomainError.Network }
|
|
35
|
+
setContent {
|
|
36
|
+
MaterialTheme {
|
|
37
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
awaitNode(hasTestTag("home_error"))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// SPEC: COMP-01
|
|
44
|
+
@Test
|
|
45
|
+
fun `a zero-item load is presented by ContentStateContainer with the screen-derived empty tag`() = runComposeUiTest {
|
|
46
|
+
val repository = FakeItemRepository().apply { items = emptyList() }
|
|
47
|
+
setContent {
|
|
48
|
+
MaterialTheme {
|
|
49
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
awaitNode(hasTestTag("home_empty"))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// SPEC: COMP-02
|
|
56
|
+
@Test
|
|
57
|
+
fun `a recoverable error renders a retry control of at least 48dp`() = runComposeUiTest {
|
|
58
|
+
val repository = FakeItemRepository().apply { failure = DomainError.Network }
|
|
59
|
+
setContent {
|
|
60
|
+
MaterialTheme {
|
|
61
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
awaitNode(hasTestTag("home_retry"))
|
|
65
|
+
onNodeWithTag("home_retry")
|
|
66
|
+
.assertWidthIsAtLeast(48.dp)
|
|
67
|
+
.assertHeightIsAtLeast(48.dp)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SPEC: COMP-03
|
|
71
|
+
@Test
|
|
72
|
+
fun `every ListItemCard row clears the 48dp minimum pointer target`() = runComposeUiTest {
|
|
73
|
+
val repository = FakeItemRepository().apply {
|
|
74
|
+
items = listOf(Item(id = "1", title = "Row", subtitle = "sub"))
|
|
75
|
+
}
|
|
76
|
+
setContent {
|
|
77
|
+
MaterialTheme {
|
|
78
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
awaitNode(hasText("Row"))
|
|
82
|
+
onNodeWithTag("home_item_1").assertHeightIsAtLeast(48.dp)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.onFirst
|
|
|
10
10
|
import androidx.compose.ui.test.onNodeWithTag
|
|
11
11
|
import androidx.compose.ui.test.performClick
|
|
12
12
|
import androidx.compose.ui.test.runComposeUiTest
|
|
13
|
+
import __PACKAGE__.domain.model.DomainError
|
|
13
14
|
import __PACKAGE__.domain.model.Item
|
|
14
15
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
15
16
|
import __PACKAGE__.testing.awaitNode
|
|
@@ -47,16 +48,27 @@ class HomeScreenTest {
|
|
|
47
48
|
|
|
48
49
|
// SPEC: HOME-03
|
|
49
50
|
@Test
|
|
50
|
-
fun `shows
|
|
51
|
-
repository.
|
|
52
|
-
repository.failureMessage = "network down"
|
|
51
|
+
fun `shows presentation-mapped error copy when loading fails`() = runComposeUiTest {
|
|
52
|
+
repository.failure = DomainError.Network
|
|
53
53
|
|
|
54
54
|
setContent {
|
|
55
55
|
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
awaitNode(hasTestTag("home_error"))
|
|
59
|
-
onAllNodesWithText(
|
|
59
|
+
onAllNodesWithText(DomainError.Network.toUserMessage()).assertCountEquals(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SPEC: HOME-07
|
|
63
|
+
@Test
|
|
64
|
+
fun `shows the empty state when the repository returns no items`() = runComposeUiTest {
|
|
65
|
+
repository.items = emptyList()
|
|
66
|
+
|
|
67
|
+
setContent {
|
|
68
|
+
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
awaitNode(hasTestTag("home_empty"))
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
// SPEC: HOME-05
|
|
@@ -74,4 +86,24 @@ class HomeScreenTest {
|
|
|
74
86
|
waitUntil(timeoutMillis = 5_000) { clickedId != null }
|
|
75
87
|
kotlin.test.assertEquals("item-42", clickedId)
|
|
76
88
|
}
|
|
89
|
+
|
|
90
|
+
// SPEC: HOME-04
|
|
91
|
+
@Test
|
|
92
|
+
fun `tapping retry after a failure reloads and shows recovered items`() = runComposeUiTest {
|
|
93
|
+
repository.failure = DomainError.Network
|
|
94
|
+
val vm = viewModel()
|
|
95
|
+
|
|
96
|
+
setContent {
|
|
97
|
+
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = vm) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
awaitNode(hasTestTag("home_error"))
|
|
101
|
+
onNodeWithTag("home_retry", useUnmergedTree = true).assertExists()
|
|
102
|
+
|
|
103
|
+
repository.failure = null
|
|
104
|
+
repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
|
|
105
|
+
onNodeWithTag("home_retry").performClick()
|
|
106
|
+
|
|
107
|
+
awaitNode(hasText("Recovered"))
|
|
108
|
+
}
|
|
77
109
|
}
|
|
@@ -1,51 +1,334 @@
|
|
|
1
1
|
# Architecture
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Reading this document.** Every normative sentence below carries a tier tag.
|
|
4
|
+
> `[enforced: CLAUSE-ID]`: a named gate in `node qa/verify.mjs` fails the lane on violation.
|
|
5
|
+
> `[governed]`: the sentence lives inside a hash-bound human approval (`qa/approvals.json`);
|
|
6
|
+
> changing it without re-approval fails the `approvals` gate. `[advisory]`: a documented
|
|
7
|
+
> convention with no mechanical check yet. Every sentence is law, signed intent, or advice —
|
|
8
|
+
> and says which.
|
|
9
|
+
|
|
10
|
+
## 1. Purpose & quality goals
|
|
11
|
+
|
|
12
|
+
This app's purpose, audience, and shape are recorded in [`specs/intent.md`](../specs/intent.md)
|
|
13
|
+
— the root brief this document, the component registry, and the exemplar feature all trace
|
|
14
|
+
back to. The table below is the default quality-goal set a fresh scaffold ships with. The
|
|
15
|
+
genesis walk's architecture conversation is where a human promotes, demotes, or replaces
|
|
16
|
+
them for this app's actual priorities ("offline matters more than a11y for a field-work
|
|
17
|
+
app").
|
|
18
|
+
|
|
19
|
+
| Quality (ISO/IEC 25010) | Scenario | Backing |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Maintainability | An AI session adds a feature; the lane names any layer violation as a clause, not a style nit | `[enforced: ARCH-01..05]` |
|
|
22
|
+
| Reliability | A source fails; the failure crosses layers as a typed `DomainError`, never a raw exception, and the screen shows a mapped error state | `[enforced: ARCH-06/07/08]` |
|
|
23
|
+
| Reliability (offline) | Network drops mid-session; cached Room data still renders, UI shows degraded state | `[advisory]` today — `NetworkMonitor` and Room ship as infrastructure (§4, §7) but no repository wires the cache-first fallback yet; clause candidate |
|
|
24
|
+
| Interaction capability (a11y) | Every interactive element is perceivable by assistive tech and automation | `[enforced: SHELL-04 + A11y gates]` |
|
|
25
|
+
| Security | The debug inspector HTTP server (§3) never ships in a release build | `[advisory]` today — true by source-set placement (`androidDebug`), not yet gated; clause candidate (`DEBUG-01`) |
|
|
26
|
+
|
|
27
|
+
## 2. Constraints
|
|
28
|
+
|
|
29
|
+
- **The version set is frozen and moves as one set.** Kotlin, KSP, Compose Multiplatform, Room,
|
|
30
|
+
and AGP are pinned together in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml)
|
|
31
|
+
(currently Kotlin `2.2.20` / KSP `2.2.20-2.0.4` / Compose Multiplatform `1.10.3` / Room
|
|
32
|
+
`2.8.4` / AGP `8.7.3`; KSP **must** be `<kotlin>-<ksp>` or Room's KMP native compilation
|
|
33
|
+
breaks). Bump the set together via `npx create-cmp-cli upgrade` (proven-green sets), never
|
|
34
|
+
one library at a time — an isolated bump is how the native build gets `MainKt`/`Continuation`
|
|
35
|
+
link errors. `[advisory — no version-drift gate ships yet]`
|
|
36
|
+
- **Platform commitment:** Kotlin Multiplatform + Compose Multiplatform, one shared UI/logic
|
|
37
|
+
tree across Android and iOS (`composeApp/src/commonMain`). Android `minSdk 24` / `compileSdk
|
|
38
|
+
35`; iOS deployment target `16.0` (`iosApp/Podfile`). A `jvm("desktop")` target exists too —
|
|
39
|
+
see §4, it is harness infrastructure, not a shipped app target.
|
|
40
|
+
- **The harness conventions are the baseline** — Clean Architecture, hand-written fakes, a
|
|
41
|
+
verify-lane definition of done — recorded in
|
|
42
|
+
[`docs/adr/0001-adopt-the-create-cmp-harness-conventions.md`](./adr/0001-adopt-the-create-cmp-harness-conventions.md).
|
|
43
|
+
Deviating from a convention gets its own ADR, not a silent drift.
|
|
44
|
+
|
|
45
|
+
## 3. System context
|
|
4
46
|
|
|
5
47
|
```
|
|
6
|
-
|
|
7
|
-
│
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
48
|
+
User ──taps/reads──▶ This app (Android APK · iOS framework)
|
|
49
|
+
│ │ │
|
|
50
|
+
Firebase Room NetworkMonitor
|
|
51
|
+
(GitLive SDK: (on-device (platform connectivity,
|
|
52
|
+
auth/firestore/ SSOT — StateFlow<Boolean>)
|
|
53
|
+
functions/ AppDatabase,
|
|
54
|
+
storage) ItemDao)
|
|
55
|
+
|
|
56
|
+
Development-time only — never in a release build or on a user's device:
|
|
57
|
+
Debug inspector HTTP server QA harness
|
|
58
|
+
(androidDebug, loopback-only, (Maestro E2E, desktop preview
|
|
59
|
+
adb-forwarded) daemon) — drives the app
|
|
60
|
+
from the outside
|
|
17
61
|
```
|
|
18
62
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
| Integration | What | Where in the tree | Notes |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| Firebase | Auth / Firestore / Functions / Storage via the GitLive KMP SDK | `data/remote/FirebaseConfig.kt`, wired at `initKoin()`/`AppApplication`/`KoinHelper` | Emulator-backed in debug builds (`configureFirebaseEmulators()`); `google-services.json` ships as a placeholder — wire the real project before shipping. |
|
|
66
|
+
| Room | On-device SSOT — `AppDatabase`, `ItemDao` | `data/local/*.kt` | Registered in DI on every platform (`single<AppDatabase>`); the exemplar's `ItemRepositoryImpl` does not yet read/write it — see §1's offline row. |
|
|
67
|
+
| NetworkMonitor | Platform connectivity as `StateFlow<Boolean>` | `core/connectivity/NetworkMonitor.kt` (expect) + one `actual` per platform | Registered in DI (`single { NetworkMonitor(...) }`); not yet consumed by a repository — available, not wired. |
|
|
68
|
+
| Debug inspector HTTP server | Loopback-only (`ServerSocket`, never the LAN) structural/crash/DB inspection endpoint for the AI verification loop | `composeApp/src/androidDebug/kotlin/.../inspector/*.kt` | **Never compiled into `androidRelease`** — a separate no-op twin ships there (source-set placement, not a runtime flag). |
|
|
69
|
+
| QA harness | Maestro E2E flows + the desktop preview daemon | `qa/e2e/*.yaml`, `composeApp/src/desktopMain/.../inspector/PreviewHarness.kt` | Development/CI-time actors, never shipped to a device. |
|
|
70
|
+
|
|
71
|
+
## 4. Platform & deployment view
|
|
72
|
+
|
|
73
|
+
**Source-set map** (`composeApp/src/`):
|
|
74
|
+
|
|
75
|
+
| Source set | Role |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `commonMain` | Shared UI + logic — presentation, domain, data, di, core. The vast majority of the app lives here. |
|
|
78
|
+
| `commonTest` | Unit tests (kotlin-test + coroutines-test + Turbine), hand-written fakes (`testing/fakes/`). |
|
|
79
|
+
| `androidMain` | Android entry point (`AppApplication`), platform `actual`s. |
|
|
80
|
+
| `androidDebug` | Debug-only additions layered on `androidMain` — the inspector HTTP server, crash recorder, DB inspector. Never in `androidRelease`. |
|
|
81
|
+
| `androidRelease` | Release-only twins (currently a no-op inspector stub) that make the `androidDebug` additions compile out cleanly. |
|
|
82
|
+
| `iosMain` | iOS entry point (`MainViewController.kt`, `KoinHelper.kt`), platform `actual`s. |
|
|
83
|
+
| `desktopMain` | The JVM tier: dev-client window/hot-reload, the preview-render harness, and desktop DI — **harness infrastructure**, not a shipped app target (project ADR `0003`). |
|
|
84
|
+
| `desktopTest` | Conformance gates (this document's enforced clauses), Compose UI Tests, golden-tree structural baselines — the fast, device-free verification tier. |
|
|
85
|
+
|
|
86
|
+
**The expect/actual boundary.** Every shared declaration, with one `actual` per platform
|
|
87
|
+
that needs it. Each `actual` wraps that platform's own connectivity, persistence, or
|
|
88
|
+
automation API — open the file for the concrete mechanism. `AppDatabaseConstructor`'s
|
|
89
|
+
`actual` is generated by the Room KSP compiler plugin at build time, so no source file
|
|
90
|
+
appears for it:
|
|
91
|
+
|
|
92
|
+
<!-- cmp:generated expect-actual-table -->
|
|
93
|
+
| Declaration | commonMain (expect) | androidMain (actual) | iosMain (actual) | desktopMain (actual) |
|
|
94
|
+
|---|---|---|---|---|
|
|
95
|
+
| `AppDatabaseConstructor` | `data/local/AppDatabase.kt` | _(Room KSP-generated — no actual in source)_ | _(Room KSP-generated — no actual in source)_ | _(Room KSP-generated — no actual in source)_ |
|
|
96
|
+
| `Modifier.exposeTestTagsForAutomation()` | `presentation/components/TestTagAutomation.kt` | `presentation/components/TestTagAutomation.android.kt` | `presentation/components/TestTagAutomation.ios.kt` | `presentation/components/TestTagAutomation.desktop.kt` |
|
|
97
|
+
| `getDatabaseBuilder()` | `data/local/DatabaseBuilder.kt` | `data/local/DatabaseBuilder.android.kt` | `data/local/DatabaseBuilder.ios.kt` | `data/local/DatabaseBuilder.desktop.kt` |
|
|
98
|
+
| `NetworkMonitor` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.desktop.kt` |
|
|
99
|
+
<!-- /cmp:generated -->
|
|
100
|
+
|
|
101
|
+
**iOS topology:** the Kotlin framework (`composeApp`) is consumed by an Xcode project generated
|
|
102
|
+
from `iosApp/project.yml` (XcodeGen) with dependencies via CocoaPods (`iosApp/Podfile`,
|
|
103
|
+
deployment target `16.0`). `MainViewController.kt` bridges into `ContentView.swift`;
|
|
104
|
+
`KoinHelper.kt` starts the shared Koin graph from Swift. Build by opening
|
|
105
|
+
`iosApp/iosApp.xcworkspace` — never the `.xcodeproj` directly, or CocoaPods dependencies won't
|
|
106
|
+
resolve.
|
|
107
|
+
|
|
108
|
+
**Desktop's role:** the `jvm("desktop")` target is unconditional harness infrastructure —
|
|
109
|
+
present regardless of feature flags — hosting `desktopTest` (this document's gates) and,
|
|
110
|
+
if the dev-client feature is enabled, an interactive hot-reload window. It is not a shipped
|
|
111
|
+
release target. See project ADR
|
|
112
|
+
[`0003-jvm-desktop-target-is-harness-infrastructure.md`](./adr/0003-jvm-desktop-target-is-harness-infrastructure.md).
|
|
113
|
+
|
|
114
|
+
## 5. Building blocks — the layer model
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
118
|
+
│ presentation Screens (Compose) · ViewModels · the component │
|
|
119
|
+
│ registry (presentation/components/) │
|
|
120
|
+
│ └─ depends on domain only [enforced: ARCH-01] │
|
|
121
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
122
|
+
│ domain models · repository INTERFACES · use cases · │
|
|
123
|
+
│ AppResult/DomainError — imports nothing │
|
|
124
|
+
│ app-internal [enforced: ARCH-02] │
|
|
125
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
126
|
+
│ data local/ (Room: AppDatabase, ItemDao) │
|
|
127
|
+
│ remote/ (repository implementations, FirebaseConfig)│
|
|
128
|
+
│ never reaches into presentation or di │
|
|
129
|
+
│ [enforced: ARCH-09] │
|
|
130
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
131
|
+
│ core leaf utility code (connectivity, format) — │
|
|
132
|
+
│ importable by every layer above; imports domain │
|
|
133
|
+
│ at most, never presentation/data/di │
|
|
134
|
+
│ [enforced: ARCH-10] │
|
|
135
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
136
|
+
di/ wires data implementations to domain interfaces (Koin) —
|
|
137
|
+
the one place allowed to import both.
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Every arrow in that box is a cited rule, not a wish:
|
|
141
|
+
|
|
142
|
+
- `presentation → domain` only; ViewModels call **use cases**, never repositories directly
|
|
143
|
+
`[enforced: ARCH-01]`.
|
|
144
|
+
- `domain` is pure Kotlin — no Compose, no Koin, no platform types
|
|
145
|
+
`[enforced: ARCH-02]`.
|
|
146
|
+
- `data` implements domain's repository interfaces and never reaches upward into
|
|
147
|
+
`presentation` or `di` `[enforced: ARCH-09]`.
|
|
148
|
+
- `core` is leaf utility code, importable by every other layer; it imports `domain` at most,
|
|
149
|
+
never `presentation`/`data`/`di` `[enforced: ARCH-10]`.
|
|
150
|
+
- The typed error boundary between `data` and everything above it
|
|
151
|
+
`[enforced: ARCH-06/07/08 — see §7]`.
|
|
152
|
+
|
|
153
|
+
<!-- cmp:generated layer-file-inventory -->
|
|
154
|
+
- `presentation/` — commonMain: `App.kt`, `components/AppBottomBar.kt`, `components/AppButton.kt`, `components/AppHeader.kt`, `components/BaseScreen.kt`, `components/ContentStateContainer.kt`, `components/ContentUiState.kt`, `components/EmptyState.kt`, `components/ErrorState.kt`, `components/ListItemCard.kt`, `components/ScreenColumn.kt`, `components/Shimmer.kt`, `components/TestTagAutomation.kt`, `home/DetailScreen.kt`, `home/HomeScreen.kt`, `home/HomeViewModel.kt`, `navigation/AppNavHost.kt`, `navigation/AppShell.kt`, `navigation/AppTab.kt`, `navigation/NavInspectionHook.kt`, `navigation/Screen.kt`, `profile/ProfileScreen.kt`, `theme/DesignToken.kt`, `theme/Shape.kt`, `theme/Theme.kt`, `theme/Tokens.kt`, `theme/Typography.kt`; androidMain: `components/TestTagAutomation.android.kt`; iosMain: `components/TestTagAutomation.ios.kt`; desktopMain: `components/TestTagAutomation.desktop.kt`
|
|
155
|
+
- `domain/` — commonMain: `model/DomainError.kt`, `model/Item.kt`, `repository/ItemRepository.kt`, `result/AppResult.kt`, `usecase/GetItemsUseCase.kt`
|
|
156
|
+
- `data/` — commonMain: `AppResultCatching.kt`, `local/AppDatabase.kt`, `local/DatabaseBuilder.kt`, `local/ItemDao.kt`, `remote/FirebaseConfig.kt`, `remote/ItemRepositoryImpl.kt`; androidMain: `local/DatabaseBuilder.android.kt`; iosMain: `local/DatabaseBuilder.ios.kt`; desktopMain: `local/DatabaseBuilder.desktop.kt`
|
|
157
|
+
- `core/` — commonMain: `connectivity/NetworkMonitor.kt`, `format/Format.kt`; androidMain: `connectivity/NetworkMonitor.kt`; iosMain: `connectivity/NetworkMonitor.kt`; desktopMain: `connectivity/NetworkMonitor.desktop.kt`
|
|
158
|
+
- `di/` — commonMain: `AppModule.kt`; androidMain: `AndroidModule.kt`; desktopMain: `DesktopModule.kt`
|
|
159
|
+
<!-- /cmp:generated -->
|
|
160
|
+
|
|
161
|
+
## 6. Runtime view
|
|
162
|
+
|
|
163
|
+
**The UDF loop:** `Screen` collects `StateFlow<UiState>` from its ViewModel → user intent calls
|
|
164
|
+
a ViewModel function → the ViewModel invokes a use case → repository → sources → new immutable
|
|
165
|
+
`UiState` is emitted. No state lives in composables beyond UI-local concerns.
|
|
166
|
+
|
|
167
|
+
Three named scenarios ground that loop in what actually happens on this codebase:
|
|
168
|
+
|
|
169
|
+
1. **Cold start.** `AppApplication.onCreate()` (Android) / `KoinHelper.initKoin()` (iOS) starts
|
|
170
|
+
the Koin graph — DI modules register, `NetworkMonitor` and `AppDatabase` come online — then
|
|
171
|
+
the Compose entry point (`MainActivity`/`MainViewController`) composes `App()`, which themes
|
|
172
|
+
and hosts `AppNavHost()`; the NavHost's shell destination renders `AppShell` (bottom nav) with
|
|
173
|
+
the first tab's screen.
|
|
174
|
+
2. **Load with typed-failure handling.** A screen's `init { load() }` calls a use case, which
|
|
175
|
+
calls the repository. The repository's I/O runs inside `suspendRunCatching`
|
|
176
|
+
`[enforced: ARCH-08]`, which returns `AppResult.Success` or maps the failure to a typed
|
|
177
|
+
`DomainError` — never throws. The ViewModel folds the result into `ContentUiState`
|
|
178
|
+
(`Loading`/`Content`/`Empty`/`Error`) `[enforced: ARCH-07]` and the screen renders the
|
|
179
|
+
matching arm via `ContentStateContainer`. **Not yet wired:** a cache-first branch through
|
|
180
|
+
Room when `NetworkMonitor.isOnline` is false. Both pieces of infrastructure exist (§3, §7);
|
|
181
|
+
no repository reads them today — hence the offline goal's `[advisory]` status in §1.
|
|
182
|
+
3. **Navigate + process death.** `AppNavHost` (single-Activity/single-`ComposeUIViewController`)
|
|
183
|
+
owns the back stack; each screen's `ViewModel` (scoped via `viewModelOf`/Koin) survives
|
|
184
|
+
configuration change but not process death — a killed-and-restored process re-runs cold
|
|
185
|
+
start and reloads from the repository, the same path as scenario 1.
|
|
22
186
|
|
|
23
|
-
##
|
|
187
|
+
## 7. Crosscutting policies
|
|
24
188
|
|
|
25
|
-
|
|
26
|
-
function → the ViewModel invokes a use case → repository → sources → new immutable `UiState`
|
|
27
|
-
is emitted. No state lives in composables beyond UI-local concerns.
|
|
189
|
+
### Error handling `[enforced: ARCH-06/07/08]`
|
|
28
190
|
|
|
29
|
-
|
|
191
|
+
Failures cross layer boundaries as **typed results, never exceptions**:
|
|
30
192
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
193
|
+
- **`AppResult<T>`** (`domain/result/AppResult.kt`) is the boundary type: `Success(value)` or
|
|
194
|
+
`Failure(error: DomainError)`. One-shot repository operations return it; they never throw.
|
|
195
|
+
(Deliberately not `kotlin.Result` — its untyped `Throwable` would put raw exceptions right
|
|
196
|
+
back on the boundary.)
|
|
197
|
+
- **`DomainError`** (`domain/model/DomainError.kt`) is the typed failure vocabulary — KINDS
|
|
198
|
+
only (`Network`, `NotFound`, `Unexpected(cause)`), no message strings. Extend it with the
|
|
199
|
+
kinds your sources actually produce.
|
|
200
|
+
- **The repository implementation is the only translation point.** I/O runs inside
|
|
201
|
+
`suspendRunCatching` (`data/AppResultCatching.kt`), which maps infrastructure exceptions to
|
|
202
|
+
`DomainError` via its `mapError` classifier and **always rethrows `CancellationException`**
|
|
203
|
+
— swallowing cancellation breaks structured concurrency (a closed screen would render an
|
|
204
|
+
error instead of just stopping). `suspendRunCatching` is the data layer's *only* allowed
|
|
205
|
+
catch mechanism (enforced: ARCH-08); ad-hoc `try`/`catch` in `data/` fails the gate.
|
|
206
|
+
- **ViewModels contain no `try`/`catch`** (enforced: ARCH-07). They `when` over the
|
|
207
|
+
`AppResult` into a **sealed UiState** (`Loading` / `Content` / `Empty` / `Error`) —
|
|
208
|
+
impossible states are unrepresentable. User-facing error copy is mapped in presentation
|
|
209
|
+
from the `DomainError` kind (see the exemplar's `toUserMessage()`); a raw
|
|
210
|
+
`Throwable.message` never reaches the UI.
|
|
211
|
+
|
|
212
|
+
The sealed-UiState shape and the `toUserMessage()` placement are `[advisory]` convention,
|
|
213
|
+
carried by the exemplar and its tests, not gated.
|
|
214
|
+
|
|
215
|
+
### Threading (main-safety policy) `[advisory — THREAD-01 staged, not shipped]`
|
|
216
|
+
|
|
217
|
+
**Repositories are main-safe by delegation, not by ceremony.** Every I/O path the scaffold
|
|
218
|
+
ships is main-safe under its own library's contract, so the template injects no dispatcher:
|
|
219
|
+
|
|
220
|
+
- **Room suspend DAO calls** (`data/local/ItemDao.kt` — all `suspend fun`s): Room executes
|
|
221
|
+
suspending queries on its own background executor; calling them from `Dispatchers.Main` is
|
|
222
|
+
safe by Room's documented contract.
|
|
223
|
+
- **GitLive Firebase suspend APIs** (when you wire them into `data/remote/`): suspending
|
|
224
|
+
wrappers over the async native SDKs — main-safe by the SDK's contract.
|
|
225
|
+
- **The example source** (`data/remote/ItemRepositoryImpl.kt`): only `delay()` (a suspension,
|
|
226
|
+
not a block) and list construction.
|
|
227
|
+
|
|
228
|
+
The rule when you add a source that does NOT carry such a guarantee (JDBC, direct file I/O,
|
|
229
|
+
heavy parsing, any blocking call): inject a `CoroutineDispatcher` into the repository via
|
|
230
|
+
Koin (default `Dispatchers.IO`) and wrap the blocking work in `withContext(dispatcher)` —
|
|
231
|
+
injected, not hardcoded, so tests can pass a test dispatcher. Do **not** add `withContext`
|
|
232
|
+
around calls that are already main-safe by contract; it's dead weight that hides where the
|
|
233
|
+
real guarantee lives.
|
|
234
|
+
|
|
235
|
+
### DI `[advisory]`
|
|
236
|
+
|
|
237
|
+
One Koin module per concern: `repositoryModule` / `useCaseModule` / `viewModelModule`
|
|
238
|
+
(`di/AppModule.kt`, common to every platform), plus one platform module for platform-only
|
|
239
|
+
bindings (`AndroidModule.kt`, `DesktopModule.kt`; iOS wires its platform singletons inline in
|
|
240
|
+
`KoinHelper.kt`). **Constructor injection only** — see `GetItemsUseCase(repository:
|
|
241
|
+
ItemRepository)` and `HomeViewModel(getItems: GetItemsUseCase)`; no field/property injection,
|
|
242
|
+
no service-locator lookups inside domain or presentation code. Platform modules provide
|
|
243
|
+
`actual`-backed singletons (`NetworkMonitor`, `AppDatabase`) that common modules then depend on
|
|
244
|
+
via the domain-facing interface, not the concrete platform type.
|
|
245
|
+
|
|
246
|
+
### Logging `[advisory — no shared logger ships today]`
|
|
247
|
+
|
|
248
|
+
The scaffold ships **no cross-platform logging library**. The only logging present is Koin's
|
|
249
|
+
`androidLogger()` (DI diagnostics, Android only) and `android.util.Log` calls confined to
|
|
250
|
+
the debug-only `inspector` package (§3) — both Android-specific, neither usable from
|
|
251
|
+
`commonMain`. If you need cross-platform application logging, add a KMP logging library
|
|
252
|
+
(e.g. Kermit) behind a thin interface in `core/`, inject it via Koin, and never call a
|
|
253
|
+
platform logger directly from `commonMain`. Nothing enforces this yet; it is a documented
|
|
254
|
+
gap.
|
|
255
|
+
|
|
256
|
+
### expect/actual `[advisory]`
|
|
257
|
+
|
|
258
|
+
Shared code that needs a platform capability declares an `expect` in `commonMain` and one
|
|
259
|
+
`actual` per consuming platform — see §4's table (`NetworkMonitor`, `getDatabaseBuilder()`,
|
|
260
|
+
`AppDatabaseConstructor`, `Modifier.exposeTestTagsForAutomation()`). Convention, not a gate:
|
|
261
|
+
put the `expect` next to the domain-shaped contract it serves (`core/` or `data/local/`, not a
|
|
262
|
+
grab-bag `platform/` package), and keep the `actual`'s signature a mechanical mirror — logic
|
|
263
|
+
differences belong behind the shared type, not in divergent call sites.
|
|
264
|
+
|
|
265
|
+
### Persistence `[advisory]`
|
|
266
|
+
|
|
267
|
+
Room (`data/local/AppDatabase.kt`, `ItemDao.kt`) is the on-device single source of truth,
|
|
268
|
+
built via the shared `buildDatabase()` (`data/local/DatabaseBuilder.kt`, using
|
|
269
|
+
`BundledSQLiteDriver()` for Room-on-Kotlin/Native) and registered in DI on every platform.
|
|
270
|
+
It is wired but **not yet consumed**: `ItemRepositoryImpl` returns in-memory sample data,
|
|
271
|
+
not a Room-backed cache (see §1, §6). When you wire a real persistence path, the repository
|
|
272
|
+
stays the only place that talks to `AppDatabase`/`ItemDao` — domain never sees Room types;
|
|
273
|
+
map `ItemEntity` → the domain `Item` inside `data/` — and schema changes get a `version`
|
|
274
|
+
bump. `fallbackToDestructiveMigration` is a scaffold-stage convenience; replace it before
|
|
275
|
+
shipping user data you can't afford to lose.
|
|
276
|
+
|
|
277
|
+
### Design tokens `[enforced: ARCH-05]`
|
|
278
|
+
|
|
279
|
+
`presentation/theme/` (`Tokens.kt`, `Theme.kt`, `Typography.kt`, `Shape.kt`) is the only source
|
|
280
|
+
of design values — no hardcoded `Color(0x…)` literals outside it. The registry's own
|
|
281
|
+
components own the token call sites (declared once per component, correct everywhere it's
|
|
282
|
+
used), so a screen almost never touches a token directly.
|
|
283
|
+
|
|
284
|
+
### Automation reachability `[enforced: ARCH-04, ARCH-11, SHELL-04]`
|
|
285
|
+
|
|
286
|
+
Every screen root and interactive element is testTag-addressable
|
|
287
|
+
(`presentation/components/TestTagAutomation.kt` exposes tags to both platforms' UI-automation
|
|
288
|
+
layers) — either a literal `testTag`, or `screenTag =` wiring into a registry component, whose
|
|
289
|
+
derived tags (`<screenTag>_screen`/`_title`/`_loading`/`_error`/`_retry`/`_empty`) count as the
|
|
290
|
+
same automation-reachability. Loading is never hand-rolled (`ARCH-11`) — no screen outside
|
|
291
|
+
`presentation/components/` references `CircularProgressIndicator`/`LinearProgressIndicator`
|
|
292
|
+
directly; bind the loading arm to `ContentStateContainer` instead.
|
|
293
|
+
|
|
294
|
+
### Insets `[enforced: SHELL-03, SHELL-05]`
|
|
295
|
+
|
|
296
|
+
Insets are owned by `BaseScreen`/`AppShell` — new screens compose inside `BaseScreen` and never
|
|
297
|
+
re-solve edge-to-edge padding with a direct inset API call.
|
|
298
|
+
|
|
299
|
+
## 8. Decisions & glossary
|
|
300
|
+
|
|
301
|
+
<!-- cmp:generated adr-index -->
|
|
302
|
+
| ADR | Title | Status |
|
|
303
|
+
|---|---|---|
|
|
304
|
+
| [0001](./adr/0001-adopt-the-create-cmp-harness-conventions.md) | Adopt the create-cmp harness conventions | accepted |
|
|
305
|
+
| [0002](./adr/0002-maestro-over-appium-for-e2e.md) | Maestro over Appium for E2E | accepted |
|
|
306
|
+
| [0003](./adr/0003-jvm-desktop-target-is-harness-infrastructure.md) | The JVM desktop target is harness infrastructure | accepted |
|
|
307
|
+
| [0004](./adr/0004-fakes-not-mocks-for-unit-tests.md) | Fakes, not mocks, for unit tests | accepted |
|
|
308
|
+
<!-- /cmp:generated -->
|
|
309
|
+
|
|
310
|
+
<!-- cmp:generated glossary -->
|
|
311
|
+
_Domain glossary — seeded from the `## Glossary` section of [`specs/intent.md`](../specs/intent.md) once the genesis intent interview fills it in; empty on a fresh scaffold._
|
|
312
|
+
<!-- /cmp:generated -->
|
|
313
|
+
|
|
314
|
+
## The exemplar feature (`home` by default, configurable)
|
|
315
|
+
|
|
316
|
+
The **configured exemplar** — `exemplarFeature` in `qa/approvals.json`, `home` on a fresh
|
|
317
|
+
scaffold — is the **reference implementation** of every pattern above, including its tests
|
|
318
|
+
(`commonTest`). The genesis walk typically promotes your own first feature to exemplar (see
|
|
319
|
+
`CLAUDE.md`'s genesis section); `qa/scaffold-feature.mjs` then clones from *it*, and `home`
|
|
320
|
+
demotes to a regular feature. To add a feature, mirror the exemplar exactly:
|
|
34
321
|
|
|
35
322
|
1. Domain: model + repository interface + use case (+ tests).
|
|
36
323
|
2. Data: repository implementation (+ test through the domain contract).
|
|
37
|
-
3. Presentation: `<Feature>Screen`
|
|
38
|
-
`
|
|
324
|
+
3. Presentation: `<Feature>Screen` composed from the component vocabulary
|
|
325
|
+
(`presentation/components/*.kt` — `ScreenColumn` for the root, `AppHeader` for the
|
|
326
|
+
title, `ContentStateContainer` for the loading/content/empty/error dispatch,
|
|
327
|
+
`ListItemCard` for rows) + `<Feature>ViewModel` with a `StateFlow<ContentUiState<T>>`
|
|
328
|
+
(no `try`/`catch`, fold over `AppResult`, `List<E>.toContentState()` for the
|
|
329
|
+
empty/content split) (+ test using a fake from `testing/fakes/`).
|
|
39
330
|
4. DI: register in `di/AppModule.kt`.
|
|
40
331
|
5. Navigation: add the route in `presentation/navigation/`.
|
|
41
332
|
6. Run `node qa/verify.mjs` — done means PASS + committed receipt.
|
|
42
333
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
- **Theme tokens** (`presentation/theme/`) are the only source of design values — no hardcoded
|
|
46
|
-
colors/spacing/radii in screens.
|
|
47
|
-
- **testTags** on every screen root and interactive element (`TestTagAutomation` exposes them
|
|
48
|
-
to E2E tooling on both platforms).
|
|
49
|
-
- **Insets** are owned by `BaseScreen` — new screens compose inside it and never re-solve
|
|
50
|
-
edge-to-edge padding.
|
|
51
|
-
- Significant decisions get an ADR in [`docs/adr/`](./adr/) — see the template there.
|
|
334
|
+
Significant decisions get an ADR in [`docs/adr/`](./adr/) — see the template there.
|
package/template/docs/TESTING.md
CHANGED
|
@@ -24,10 +24,11 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
|
|
|
24
24
|
- **Frameworks:** `kotlin-test` assertions · `kotlinx-coroutines-test` (`runTest`,
|
|
25
25
|
`StandardTestDispatcher`) · **Turbine** for Flow/StateFlow.
|
|
26
26
|
- **Fakes, never mocks.** Every repository/source interface gets a hand-written fake in
|
|
27
|
-
`commonTest/…/testing/fakes/` — configurable (`
|
|
28
|
-
call-recording
|
|
27
|
+
`commonTest/…/testing/fakes/` — configurable (a typed `failure: DomainError?`, seeded data)
|
|
28
|
+
and call-recording; it returns `AppResult.Failure`, it never throws (the domain contract
|
|
29
|
+
doesn't). Mocking frameworks are banned: they're JVM-only in KMP and hide bad seams.
|
|
29
30
|
- **Style:** Arrange-Act-Assert; behavior-named backtick tests
|
|
30
|
-
(`` `emits
|
|
31
|
+
(`` `emits Content when the repository returns items` ``); one behavior per test; no shared
|
|
31
32
|
mutable state between tests.
|
|
32
33
|
- **ViewModels:** install a `StandardTestDispatcher` as Main (`@BeforeTest setMain` /
|
|
33
34
|
`@AfterTest resetMain`) because `viewModelScope` launches on Main; assert state with
|
|
@@ -38,8 +39,8 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
|
|
|
38
39
|
|
|
39
40
|
| You added | You also add |
|
|
40
41
|
|---|---|
|
|
41
|
-
| a ViewModel | a `*ViewModelTest` (states: loading,
|
|
42
|
-
| a use case | a `*UseCaseTest` (behavior + failure
|
|
42
|
+
| a ViewModel | a `*ViewModelTest` (sealed states: loading, content, empty, error, retry) |
|
|
43
|
+
| a use case | a `*UseCaseTest` (behavior + typed-failure passthrough) |
|
|
43
44
|
| a repository impl | a test through its DOMAIN interface |
|
|
44
45
|
| a screen | a testTag root (E2E reachable) |
|
|
45
46
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR-0002: Maestro over Appium for E2E
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** (scaffold date)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Device E2E is the least AI-load-bearing layer in this project's testing pyramid — the AI
|
|
9
|
+
collaborator's real verification instrument is the structural inspector (the preview loop
|
|
10
|
+
described in `CLAUDE.md`'s "UI feedback loop"), not black-box UI driving. That reframes what
|
|
11
|
+
the E2E layer should optimize for: least brittleness and cross-platform reach, not language
|
|
12
|
+
uniformity with the rest of the stack (which is Kotlin end to end everywhere else).
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
This project's sole E2E layer is Maestro YAML flows. Flows live in `qa/e2e/*.yaml`; the verify
|
|
17
|
+
lane's `e2eSmoke` step runs `maestro test` against them. Maestro is Apache-2.0, free to run
|
|
18
|
+
locally and in CI on both Android and iOS simulators — only the hosted Maestro Cloud device
|
|
19
|
+
farm is paid, and this project brings its own emulator/simulator, so it's never needed.
|
|
20
|
+
Selectors reference testTags exclusively (surfaced as resource-ids on Android and
|
|
21
|
+
accessibility identifiers on iOS via `TestTagAutomation`), never display text, keeping flows
|
|
22
|
+
l10n-stable.
|
|
23
|
+
|
|
24
|
+
## Consequences
|
|
25
|
+
|
|
26
|
+
- Auto-waits eliminate most flake; one YAML flow drives both platforms with no per-platform
|
|
27
|
+
driver code to maintain.
|
|
28
|
+
- Trade-off accepted: Maestro flows are not compile-checked. This project compensates by
|
|
29
|
+
keeping E2E thin (boot + a few critical journeys, see `qa/e2e/smoke.yaml`) and putting
|
|
30
|
+
compile-checked, interaction-level assertions in Compose UI Test instead
|
|
31
|
+
(`composeApp/src/desktopTest`).
|
|
32
|
+
- No Appium runner exists in this project — don't add one for a "just this one flow" need;
|
|
33
|
+
extend the Maestro flows or add a Compose UI Test instead.
|
|
34
|
+
|
|
35
|
+
## Related
|
|
36
|
+
|
|
37
|
+
- `docs/TESTING.md` — the test pyramid this project uses, Maestro's place in it.
|
|
38
|
+
- `CLAUDE.md` — "UI feedback loop", the primary AI verification instrument E2E is deliberately
|
|
39
|
+
thin relative to.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR-0003: The JVM desktop target is harness infrastructure
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** (scaffold date)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
This project's `jvm("desktop")` Gradle target (`composeApp/build.gradle.kts`) hosts the fast,
|
|
9
|
+
device-free verification tier — unit tests, the conformance gates this document's clauses are
|
|
10
|
+
backed by (`docs/ARCHITECTURE.md` §5/§7), Compose UI Tests, and golden-tree renders, all run
|
|
11
|
+
via `./gradlew :composeApp:desktopTest`. It sits alongside an optional interactive dev-client
|
|
12
|
+
feature (a hot-reload window, gated by feature flags at scaffold time) that happens to reuse
|
|
13
|
+
the same JVM target for its window and Compose Hot Reload wiring.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
The `jvm("desktop")` target is unconditional harness infrastructure in this project, present
|
|
18
|
+
regardless of which optional features were scaffolded, decoupled from the interactive
|
|
19
|
+
dev-client experience that merely reuses it. Only the window/hot-reload/foojay pieces specific
|
|
20
|
+
to interactive desktop development are feature-gated; the `kspDesktop` and `desktopTest`
|
|
21
|
+
dependencies that back the verification tier are unconditional.
|
|
22
|
+
|
|
23
|
+
## Consequences
|
|
24
|
+
|
|
25
|
+
- Disabling the dev-client feature never removes verification capability — only the
|
|
26
|
+
interactive desktop window and hot reload go away; `node qa/verify.mjs` and
|
|
27
|
+
`:composeApp:desktopTest` keep working exactly the same.
|
|
28
|
+
- The dev-client feature's footprint in the build file is limited to what it truly owns
|
|
29
|
+
(the window, hot-reload wiring); the harness's own test tier is never at risk of being
|
|
30
|
+
pruned by an unrelated feature toggle.
|
|
31
|
+
- Anyone editing the `jvm("desktop")` block in `composeApp/build.gradle.kts` should read the
|
|
32
|
+
surrounding comment first — it exists specifically to prevent this target being mistaken
|
|
33
|
+
for optional dev-client scaffolding and deleted along with it.
|
|
34
|
+
|
|
35
|
+
## Related
|
|
36
|
+
|
|
37
|
+
- `composeApp/build.gradle.kts`, the `jvm("desktop")` target comment — the in-repo record of
|
|
38
|
+
this decision.
|
|
39
|
+
- `docs/ARCHITECTURE.md` §4 ("Platform & deployment view") — desktop's documented role.
|