create-cmp-cli 0.8.0 → 0.10.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 +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- 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 +213 -47
- package/template/README.md +32 -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/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -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/brand/BrandMark.kt +75 -0
- 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 +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -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 +22 -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 +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- 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 +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- 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 +372 -34
- package/template/docs/TESTING.md +13 -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/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -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 +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -1,51 +1,389 @@
|
|
|
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
|
|
46
|
+
|
|
47
|
+
```
|
|
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
|
|
61
|
+
```
|
|
62
|
+
|
|
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
|
|
4
115
|
|
|
5
116
|
```
|
|
6
|
-
|
|
7
|
-
│ presentation Screens (Compose) · ViewModels
|
|
8
|
-
│
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
│
|
|
12
|
-
|
|
13
|
-
│
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
17
138
|
```
|
|
18
139
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- `
|
|
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.
|
|
186
|
+
|
|
187
|
+
## 7. Crosscutting policies
|
|
188
|
+
|
|
189
|
+
### Error handling `[enforced: ARCH-06/07/08]`
|
|
190
|
+
|
|
191
|
+
Failures cross layer boundaries as **typed results, never exceptions**:
|
|
192
|
+
|
|
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.
|
|
22
214
|
|
|
23
|
-
|
|
215
|
+
### Threading (main-safety policy) `[advisory — THREAD-01 staged, not shipped]`
|
|
24
216
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
is emitted. No state lives in composables beyond UI-local concerns.
|
|
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:
|
|
28
219
|
|
|
29
|
-
|
|
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.
|
|
30
227
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
### UI-first construction `[advisory, with one enforced hazard gate: ARCH-12]`
|
|
285
|
+
|
|
286
|
+
Screens are built **UI-first**: a stateless composable over a plain state value, rendered in the
|
|
287
|
+
preview gallery before any ViewModel or data layer exists. The pattern has four parts, and the
|
|
288
|
+
fourth is a gate:
|
|
289
|
+
|
|
290
|
+
1. **Stateless screen** — `XScreen(state, onEvent)`; it owns no data and calls no VM.
|
|
291
|
+
2. **Preview seam** — the screen's state parameter defaults to a `sampleX` fixture declared in the
|
|
292
|
+
same file, so the preview registry renders it with zero DI. This is what lets a screen exist —
|
|
293
|
+
and be judged, styled, and approved — before its vertical slice is built.
|
|
294
|
+
3. **Wiring obligation** — the sample default is scaffolding, not a destination. The screen ends up
|
|
295
|
+
driven by a VM-backed `XRoute` wrapper (the actual nav destination; `SHELL-05` inspects both
|
|
296
|
+
suffixes), and the slice follows the exemplar layer-for-layer.
|
|
297
|
+
4. **The hazard → enforced.** Sample data must never leak into production wiring: a `sample*`
|
|
298
|
+
symbol is referenced only in its declaring file (the seam), the preview registry/stories, and
|
|
299
|
+
tests. `ARCH-12` fails the lane on any other `commonMain` reference — a nav host resolving
|
|
300
|
+
entities from sample data is the proven failure mode this exists to stop.
|
|
301
|
+
|
|
302
|
+
This pattern is why the genesis walk can lock the design system and distill the component registry
|
|
303
|
+
*after* real screens exist — the visual artifacts are extracted from the product, not guessed ahead
|
|
304
|
+
of it.
|
|
305
|
+
|
|
306
|
+
### Component vocabulary `[governed — registry membership is judgment]`
|
|
307
|
+
|
|
308
|
+
`presentation/components/` is the design-system registry — the shared vocabulary screens compose
|
|
309
|
+
from. It is **distilled from real screens, not authored before them**: build the screens, then
|
|
310
|
+
promote the primitives that pass the bar. What earns a place is **judgment, not a reuse count** —
|
|
311
|
+
govern a composable when it (1) encodes a *design-system decision* (how the product presents
|
|
312
|
+
something — a progress ring, a metric tile, a chip) rather than *how one screen arranges its data*;
|
|
313
|
+
(2) is a *stable, obvious* shape, not a speculative one; (3) would cause visible inconsistency if
|
|
314
|
+
each screen reinvented it; or (4) centralizes a cross-cutting concern worth enforcing once (a11y,
|
|
315
|
+
tokens — as `AppButton` does the 48 dp target). Keep it **feature-local** when it is a one-screen
|
|
316
|
+
composition, carries feature/domain logic, or genuinely differs from its neighbors. Reuse count is
|
|
317
|
+
a *signal* (≥2 real uses is strong evidence), never the rule — a single-use stable primitive is
|
|
318
|
+
still a component; a five-times-duplicated set of *different* shapes is not.
|
|
319
|
+
|
|
320
|
+
Two guardrails are first-class:
|
|
321
|
+
|
|
322
|
+
- **Do not force reuse.** Cramming genuinely-different composables into one over-parameterized
|
|
323
|
+
component (leading slot + trailing slot + toggle + chevron + value…) is a *worse* antipattern
|
|
324
|
+
than duplication — god-components with leaky parameter lists and hidden coupling. "Both are rows"
|
|
325
|
+
is not a reason to unify; only near-identical shape **and** behavior is. **When in doubt, keep
|
|
326
|
+
them separate.**
|
|
327
|
+
- **A domain-named component is a smell.** A composable named for a domain concept (`MacroTag`) is
|
|
328
|
+
a feature decision in design-system clothes — generalize it to a real primitive (`Tag` = label +
|
|
329
|
+
value + colour) or keep it local. Never admit domain vocabulary to the registry. Brand marks
|
|
330
|
+
(`presentation/brand/`) are their own category, governed separately.
|
|
331
|
+
|
|
332
|
+
Any conformance gate here flags **only** true near-identical duplication or re-invention of an
|
|
333
|
+
existing registry component — never "you have two rows, unify them" (that would push toward the
|
|
334
|
+
antipattern above), and it never *forces* a promotion. Division of labor: the **agent makes the
|
|
335
|
+
rubric calls** — classifies each screen composable, promotes or keeps local, with reasoning — and
|
|
336
|
+
the **human governs at the Components approval**, which is exactly what that gate exists for.
|
|
337
|
+
Nothing the agent promotes is law until the approval signs it.
|
|
338
|
+
|
|
339
|
+
### Automation reachability `[enforced: ARCH-04, ARCH-11, SHELL-04]`
|
|
340
|
+
|
|
341
|
+
Every screen root and interactive element is testTag-addressable
|
|
342
|
+
(`presentation/components/TestTagAutomation.kt` exposes tags to both platforms' UI-automation
|
|
343
|
+
layers) — either a literal `testTag`, or `screenTag =` wiring into a registry component, whose
|
|
344
|
+
derived tags (`<screenTag>_screen`/`_title`/`_loading`/`_error`/`_retry`/`_empty`) count as the
|
|
345
|
+
same automation-reachability. Loading is never hand-rolled (`ARCH-11`) — no screen outside
|
|
346
|
+
`presentation/components/` references `CircularProgressIndicator`/`LinearProgressIndicator`
|
|
347
|
+
directly; bind the loading arm to `ContentStateContainer` instead.
|
|
348
|
+
|
|
349
|
+
### Insets `[enforced: SHELL-03, SHELL-05]`
|
|
350
|
+
|
|
351
|
+
Insets are owned by `BaseScreen`/`AppShell` — new screens compose inside `BaseScreen` and never
|
|
352
|
+
re-solve edge-to-edge padding with a direct inset API call.
|
|
353
|
+
|
|
354
|
+
## 8. Decisions & glossary
|
|
355
|
+
|
|
356
|
+
<!-- cmp:generated adr-index -->
|
|
357
|
+
| ADR | Title | Status |
|
|
358
|
+
|---|---|---|
|
|
359
|
+
| [0001](./adr/0001-adopt-the-create-cmp-harness-conventions.md) | Adopt the create-cmp harness conventions | accepted |
|
|
360
|
+
| [0002](./adr/0002-maestro-over-appium-for-e2e.md) | Maestro over Appium for E2E | accepted |
|
|
361
|
+
| [0003](./adr/0003-jvm-desktop-target-is-harness-infrastructure.md) | The JVM desktop target is harness infrastructure | accepted |
|
|
362
|
+
| [0004](./adr/0004-fakes-not-mocks-for-unit-tests.md) | Fakes, not mocks, for unit tests | accepted |
|
|
363
|
+
<!-- /cmp:generated -->
|
|
364
|
+
|
|
365
|
+
<!-- cmp:generated glossary -->
|
|
366
|
+
_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._
|
|
367
|
+
<!-- /cmp:generated -->
|
|
368
|
+
|
|
369
|
+
## The exemplar feature (`home` by default, configurable)
|
|
370
|
+
|
|
371
|
+
The **configured exemplar** — `exemplarFeature` in `qa/approvals.json`, `home` on a fresh
|
|
372
|
+
scaffold — is the **reference implementation** of every pattern above, including its tests
|
|
373
|
+
(`commonTest`). The genesis walk typically promotes your own first feature to exemplar (see
|
|
374
|
+
`CLAUDE.md`'s genesis section); `qa/scaffold-feature.mjs` then clones from *it*, and `home`
|
|
375
|
+
demotes to a regular feature. To add a feature, mirror the exemplar exactly:
|
|
34
376
|
|
|
35
377
|
1. Domain: model + repository interface + use case (+ tests).
|
|
36
378
|
2. Data: repository implementation (+ test through the domain contract).
|
|
37
|
-
3. Presentation: `<Feature>Screen`
|
|
38
|
-
`
|
|
379
|
+
3. Presentation: `<Feature>Screen` composed from the component vocabulary
|
|
380
|
+
(`presentation/components/*.kt` — `ScreenColumn` for the root, `AppHeader` for the
|
|
381
|
+
title, `ContentStateContainer` for the loading/content/empty/error dispatch,
|
|
382
|
+
`ListItemCard` for rows) + `<Feature>ViewModel` with a `StateFlow<ContentUiState<T>>`
|
|
383
|
+
(no `try`/`catch`, fold over `AppResult`, `List<E>.toContentState()` for the
|
|
384
|
+
empty/content split) (+ test using a fake from `testing/fakes/`).
|
|
39
385
|
4. DI: register in `di/AppModule.kt`.
|
|
40
386
|
5. Navigation: add the route in `presentation/navigation/`.
|
|
41
387
|
6. Run `node qa/verify.mjs` — done means PASS + committed receipt.
|
|
42
388
|
|
|
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.
|
|
389
|
+
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
|
|
|
@@ -53,6 +54,13 @@ Maestro flows (`qa/e2e/*.yaml`) cover boot + bottom-nav — install the free CLI
|
|
|
53
54
|
(`curl -fsSL "https://get.maestro.mobile.dev" | bash`). Selectors go by **testTag** (`id:` —
|
|
54
55
|
surfaced as resource-ids via `TestTagAutomation`), never by display text. One flow per
|
|
55
56
|
journey, spec-clause cited; keep the E2E tip small — behavior belongs in unit tests.
|
|
57
|
+
|
|
58
|
+
**Settle rule:** an assertion that follows an interaction triggering an async state change
|
|
59
|
+
(typing a search query, a toggle that persists, a load) must be an `extendedWaitUntil`, not
|
|
60
|
+
a bare `assertVisible` — the ViewModel round-trip passes through a brief Loading arm that a
|
|
61
|
+
lane-loaded emulator stretches, and asserting into that transition is a false red (a real
|
|
62
|
+
one: a search assert that passed standalone failed in-lane behind a 33s type gap). Bare
|
|
63
|
+
asserts are for static post-navigation elements only.
|
|
56
64
|
<!-- <<< cmp:feature e2e -->
|
|
57
65
|
|
|
58
66
|
## The verify lane
|
|
@@ -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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ADR-0004: Fakes, not mocks, for unit tests
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** (scaffold date)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Every repository/source interface in this project's domain layer (`domain/repository/*.kt`)
|
|
9
|
+
needs a test double for ViewModel and use-case unit tests — see `FakeItemRepository`
|
|
10
|
+
(`composeApp/src/commonTest/.../testing/fakes/`), used by `HomeViewModelTest` and
|
|
11
|
+
`GetItemsUseCaseTest`. Mocking frameworks (MockK, Mockito) are the conventional default for
|
|
12
|
+
this in JVM-only Android projects, but this project is Kotlin Multiplatform: `commonTest` runs
|
|
13
|
+
on Android, iOS (native), and desktop (JVM) targets from one source set, and the bytecode-proxy
|
|
14
|
+
mechanism mocking frameworks rely on doesn't exist on Kotlin/Native. A mock-based test suite
|
|
15
|
+
would either be JVM-only (silently untested on iOS) or need a second, divergent native-only
|
|
16
|
+
test strategy.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
This project uses **hand-written fakes**, never mocking frameworks, for every domain-layer
|
|
21
|
+
interface under test. A fake lives in `testing/fakes/` (commonTest), implements the real
|
|
22
|
+
interface, and is configurable in the shape the exemplar establishes: a typed
|
|
23
|
+
`failure: DomainError?` to force the error path, seeded data for the success path, and
|
|
24
|
+
call-recording where a test needs to assert an interaction happened. A fake returns
|
|
25
|
+
`AppResult.Failure` when configured to fail — it never throws, because the domain contract it
|
|
26
|
+
implements (`domain/repository/*.kt`, ARCH-06) doesn't either.
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
- One test double works identically on every target `commonTest` runs on — no JVM-only
|
|
31
|
+
behavior gap between what Android/desktop CI proves and what iOS actually ships.
|
|
32
|
+
- Fakes are slightly more code up front than a one-line `every { } returns` mock stub, but
|
|
33
|
+
they read as real (if simplified) implementations, which makes failure-path tests
|
|
34
|
+
(`failure = DomainError.Network`) as readable as success-path tests instead of relying on
|
|
35
|
+
stubbing syntax a reviewer has to decode.
|
|
36
|
+
- No mocking-framework dependency to keep on the frozen version set (`gradle/libs.versions.toml`)
|
|
37
|
+
or track for Kotlin/Native compatibility on every version bump.
|
|
38
|
+
- The cost: a new repository interface needs its fake hand-written before its consumers can be
|
|
39
|
+
tested — there is no "just mock it inline" escape hatch. This is intentional: an interface
|
|
40
|
+
too awkward to fake by hand is usually an interface with the wrong shape.
|
|
41
|
+
|
|
42
|
+
## Related
|
|
43
|
+
|
|
44
|
+
- `CLAUDE.md` — "Testing" section: "**Hand-written fakes** in `testing/fakes/` — never mocking
|
|
45
|
+
frameworks", the contract this ADR records the reasoning for.
|
|
46
|
+
- `docs/TESTING.md` — "Fakes, never mocks" under Unit conventions.
|
|
47
|
+
- `composeApp/src/commonTest/.../testing/fakes/FakeItemRepository.kt` — the exemplar fake this
|
|
48
|
+
ADR's shape is drawn from.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "cmp-approvals/1",
|
|
3
|
+
"exemplarFeature": "home",
|
|
4
|
+
"artifacts": [
|
|
5
|
+
{
|
|
6
|
+
"artifact": "intent",
|
|
7
|
+
"status": "unreviewed",
|
|
8
|
+
"hash": null,
|
|
9
|
+
"approvedAt": null
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"artifact": "design-system",
|
|
13
|
+
"status": "unreviewed",
|
|
14
|
+
"hash": null,
|
|
15
|
+
"approvedAt": null
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"artifact": "architecture",
|
|
19
|
+
"status": "unreviewed",
|
|
20
|
+
"hash": null,
|
|
21
|
+
"approvedAt": null
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"artifact": "components",
|
|
25
|
+
"status": "unreviewed",
|
|
26
|
+
"hash": null,
|
|
27
|
+
"approvedAt": null
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"artifact": "exemplar-feature",
|
|
31
|
+
"status": "unreviewed",
|
|
32
|
+
"hash": null,
|
|
33
|
+
"approvedAt": null
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"artifact": "exemplar-spec",
|
|
37
|
+
"status": "unreviewed",
|
|
38
|
+
"hash": null,
|
|
39
|
+
"approvedAt": null
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|