create-cmp-cli 0.11.0 → 0.13.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 +11 -9
- package/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +51 -6
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package __PACKAGE__.testing
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioManager
|
|
5
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Snapshot/restore of device audio state (ringer mode, Do Not Disturb) so audio-routing
|
|
9
|
+
* behavior can be asserted — "does the alert still sound when the phone is on silent?"
|
|
10
|
+
* is THE question that shipped broken repeatedly, because ringer mode only exists on a
|
|
11
|
+
* device and every JVM tier is deaf to it.
|
|
12
|
+
*
|
|
13
|
+
* Being honest about what instrumentation can and cannot control:
|
|
14
|
+
*
|
|
15
|
+
* CAN (this file):
|
|
16
|
+
* - Read and set the ringer mode. Setting SILENT/VIBRATE via AudioManager flips the
|
|
17
|
+
* device through DND on modern Android, so both knobs are driven through the shell
|
|
18
|
+
* (`cmd notification set_dnd`, `settings global zen_mode`) with the instrumentation's
|
|
19
|
+
* shell uid ([Shell.exec]) — no notification-policy grant dance, works on a stock emulator.
|
|
20
|
+
* - Read/set DND (zen mode) as a global: off / priority-only / total-silence / alarms-only.
|
|
21
|
+
* - Observe playback routing: which stream/usage an active player is on
|
|
22
|
+
* (AudioManager.activePlaybackConfigurations — see PlatformBehaviorSeamTest for the
|
|
23
|
+
* assertion shape: a sound with USAGE_ALARM is what survives a silenced ringer).
|
|
24
|
+
*
|
|
25
|
+
* CANNOT (manual tier — document the claim in your spec, verify it by hand per release):
|
|
26
|
+
* - OEM sound policy: whether a channel's sound reaches the alarm stream on a Samsung
|
|
27
|
+
* is Samsung's decision; assert your app plays with USAGE_ALARM, not that a given
|
|
28
|
+
* handset makes air move.
|
|
29
|
+
* - Physical volume keys / hardware mute switches, Bluetooth routing, and whether the
|
|
30
|
+
* speaker is audible — there is no API for "a human heard it".
|
|
31
|
+
* - Lock-screen rendering: whether a takeover actually draws over a locked, OEM-skinned
|
|
32
|
+
* screen stays manual. Doze delivery, by contrast, stopped being a manual rehearsal
|
|
33
|
+
* when [DozeControl] landed — force the idle state and watch the alarm arrive.
|
|
34
|
+
*
|
|
35
|
+
* Plain helpers, not a TestRule: state here is two scalars, and an explicit
|
|
36
|
+
* snapshot-in-@Before / restore-in-@After (or [withRingerMode]) keeps the mechanism
|
|
37
|
+
* visible in the test. Wrap it in a rule when a suite grows enough tests to justify one.
|
|
38
|
+
*/
|
|
39
|
+
object SystemState {
|
|
40
|
+
|
|
41
|
+
/** The restorable slice of device audio state. */
|
|
42
|
+
data class Snapshot(val ringerMode: Int, val zenMode: Int)
|
|
43
|
+
|
|
44
|
+
/** DND (zen) global values — `settings get global zen_mode`. */
|
|
45
|
+
const val ZEN_OFF = 0
|
|
46
|
+
const val ZEN_PRIORITY_ONLY = 1
|
|
47
|
+
const val ZEN_TOTAL_SILENCE = 2
|
|
48
|
+
const val ZEN_ALARMS_ONLY = 3
|
|
49
|
+
|
|
50
|
+
private val audio: AudioManager
|
|
51
|
+
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
|
52
|
+
.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
53
|
+
|
|
54
|
+
/** Capture the current ringer + DND state. Take it BEFORE the test mutates anything. */
|
|
55
|
+
fun snapshot(): Snapshot = Snapshot(ringerMode = audio.ringerMode, zenMode = readZen())
|
|
56
|
+
|
|
57
|
+
/** Put the device back exactly as [snapshot] found it — call from @After, always. */
|
|
58
|
+
fun restore(s: Snapshot) {
|
|
59
|
+
setZen(s.zenMode)
|
|
60
|
+
setRingerMode(s.ringerMode)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set the ringer mode (AudioManager.RINGER_MODE_NORMAL / _VIBRATE / _SILENT).
|
|
65
|
+
* SILENT is driven as alarms-only DND + the mode itself — on API 23+ that is what the
|
|
66
|
+
* volume-key gesture actually does, and it is the honest reproduction of "the user
|
|
67
|
+
* silenced the phone" (alarms are still allowed to sound; a total-silence test should
|
|
68
|
+
* say so explicitly via [setZen] with [ZEN_TOTAL_SILENCE]).
|
|
69
|
+
*/
|
|
70
|
+
fun setRingerMode(mode: Int) {
|
|
71
|
+
when (mode) {
|
|
72
|
+
AudioManager.RINGER_MODE_SILENT -> setZen(ZEN_ALARMS_ONLY)
|
|
73
|
+
else -> setZen(ZEN_OFF)
|
|
74
|
+
}
|
|
75
|
+
// Best-effort after the zen change; on emulators this lands reliably. Poll-check
|
|
76
|
+
// rather than trust: callers get the real mode back and can assume-skip if the
|
|
77
|
+
// device refused (an OEM with a hardware mute state may).
|
|
78
|
+
audio.ringerMode = mode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Set DND directly (one of the ZEN_* constants). Shell-driven; applies globally. */
|
|
82
|
+
fun setZen(zen: Int) {
|
|
83
|
+
val arg = when (zen) {
|
|
84
|
+
ZEN_PRIORITY_ONLY -> "priority"
|
|
85
|
+
ZEN_TOTAL_SILENCE -> "none"
|
|
86
|
+
ZEN_ALARMS_ONLY -> "alarms"
|
|
87
|
+
else -> "off"
|
|
88
|
+
}
|
|
89
|
+
Shell.exec("cmd notification set_dnd $arg")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The device's current DND global (ZEN_* value; unreadable/absent reads as off). */
|
|
93
|
+
fun readZen(): Int =
|
|
94
|
+
Shell.exec("settings get global zen_mode").trim().toIntOrNull() ?: ZEN_OFF
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run [block] with the ringer in [mode], restoring the full snapshot afterwards even
|
|
98
|
+
* when the block throws — the composable form for a single-assertion test:
|
|
99
|
+
*
|
|
100
|
+
* SystemState.withRingerMode(AudioManager.RINGER_MODE_SILENT) {
|
|
101
|
+
* // trigger the alert path, then assert a player is active with USAGE_ALARM
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
fun <T> withRingerMode(mode: Int, block: () -> T): T {
|
|
105
|
+
val before = snapshot()
|
|
106
|
+
return try {
|
|
107
|
+
setRingerMode(mode)
|
|
108
|
+
block()
|
|
109
|
+
} finally {
|
|
110
|
+
restore(before)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package __PACKAGE__.testing
|
|
2
|
+
|
|
3
|
+
import android.os.SystemClock
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device-clock manipulation for the on-device tier — the primitive that turns "the alarm
|
|
7
|
+
* ladder is unit-tested to the minute" into "we watched the alarm actually arrive".
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: scheduled delivery is the one claim the other helpers still could not
|
|
10
|
+
* close. [AlarmAsserts] proves REGISTRATION (the OS holds the alarm), unit tests prove the
|
|
11
|
+
* ladder's arithmetic, and yet "the notification arrives at 08:00" shipped untested every
|
|
12
|
+
* time — nobody waits two days inside a test run. Warping the device clock past the
|
|
13
|
+
* trigger time makes delivery observable in seconds: schedule for T, warp to T+ε, and the
|
|
14
|
+
* OS either fires the alarm or it doesn't. The same mechanism makes DST transitions and
|
|
15
|
+
* date rollovers ([withTimeZone]) a test input instead of a twice-a-year production
|
|
16
|
+
* surprise.
|
|
17
|
+
*
|
|
18
|
+
* EMULATOR-ONLY, NON-NEGOTIABLE — every entry point runs [assumeOnEmulator] first:
|
|
19
|
+
* warping a real phone's clock corrupts the owner's world, not just the test's — alarms
|
|
20
|
+
* re-fire or vanish, TLS certificates fall outside their validity window, auth tokens
|
|
21
|
+
* expire or refuse to, and messaging apps reorder history. A QA emulator is disposable
|
|
22
|
+
* state; a person's device never is. On a non-emulator the guard SKIPs (assumption
|
|
23
|
+
* violation), never fails — the suite stays honest about where it can run.
|
|
24
|
+
*
|
|
25
|
+
* Constraints the code can't show:
|
|
26
|
+
* - No root involved. `adb root` is refused on production (user-build) images, including
|
|
27
|
+
* the stock Play-image AVDs — but `cmd alarm set-time` / `set-timezone` work from the
|
|
28
|
+
* SHELL uid, which is exactly what [Shell.exec]'s UiAutomation channel provides.
|
|
29
|
+
* Time-warp proofs therefore run on any stock emulator, no special image.
|
|
30
|
+
* - Network time sync fights the warp: with `auto_time` on, the device re-corrects the
|
|
31
|
+
* clock underneath the test. Every warp is bracketed — sync off, warp, run, restore,
|
|
32
|
+
* sync back on — and the restore runs in `finally`, so a crashed block still leaves
|
|
33
|
+
* the device usable for the next test.
|
|
34
|
+
* - The restore is not "set the clock back to what it was": real time keeps passing
|
|
35
|
+
* while the block runs, and restoring the ORIGINAL instant would leave the clock
|
|
36
|
+
* slow by the block's duration (compounding across a suite). The restore target is
|
|
37
|
+
* `original + elapsed`, with elapsed measured on [SystemClock.elapsedRealtime] —
|
|
38
|
+
* the monotonic clock, immune to the warp itself.
|
|
39
|
+
* - Inside a warped block, bound your waits on monotonic time (`CountDownLatch.await`,
|
|
40
|
+
* [SystemClock.elapsedRealtime]), never on `System.currentTimeMillis()` — wall-clock
|
|
41
|
+
* deadlines computed before the warp are nonsense after it.
|
|
42
|
+
*/
|
|
43
|
+
object TimeWarp {
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SKIPs the test unless it is running on an emulator (`ro.kernel.qemu == 1`).
|
|
47
|
+
* Called by every warp entry point; call it yourself at the top of a warp test so
|
|
48
|
+
* the skip names the test, not a helper frame. See the header for why a real
|
|
49
|
+
* device is refused: a warped personal phone breaks the owner's alarms, tokens,
|
|
50
|
+
* and messaging — QA emulators only.
|
|
51
|
+
*/
|
|
52
|
+
fun assumeOnEmulator() {
|
|
53
|
+
Shell.assumeOnEmulator(
|
|
54
|
+
"TimeWarp runs only on emulators (ro.kernel.qemu != 1 here). Warping a real " +
|
|
55
|
+
"device's clock corrupts its owner's alarms, TLS validity, and auth " +
|
|
56
|
+
"tokens — run this suite on a stock QA AVD instead.",
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run [block] with the device wall clock set to [targetEpochMillis], restoring
|
|
62
|
+
* everything afterwards even when the block throws:
|
|
63
|
+
*
|
|
64
|
+
* 1. `auto_time` off — otherwise network time sync re-corrects mid-block;
|
|
65
|
+
* 2. `cmd alarm set-time target` — the warp (shell uid, root-free);
|
|
66
|
+
* 3. the block — schedule/await/assert;
|
|
67
|
+
* 4. finally: `set-time (original + elapsed)` then `auto_time` back on.
|
|
68
|
+
*
|
|
69
|
+
* The restore adds the block's real duration (monotonic-measured) to the original
|
|
70
|
+
* instant so the device clock never falls behind true time — see the header.
|
|
71
|
+
*/
|
|
72
|
+
fun <T> withWarpedClock(targetEpochMillis: Long, block: () -> T): T {
|
|
73
|
+
assumeOnEmulator()
|
|
74
|
+
val autoTimeBefore = readGlobal("auto_time")
|
|
75
|
+
val originalEpochMillis = System.currentTimeMillis()
|
|
76
|
+
val startElapsed = SystemClock.elapsedRealtime()
|
|
77
|
+
Shell.exec("settings put global auto_time 0")
|
|
78
|
+
try {
|
|
79
|
+
Shell.exec("cmd alarm set-time $targetEpochMillis")
|
|
80
|
+
return block()
|
|
81
|
+
} finally {
|
|
82
|
+
val elapsed = SystemClock.elapsedRealtime() - startElapsed
|
|
83
|
+
Shell.exec("cmd alarm set-time ${originalEpochMillis + elapsed}")
|
|
84
|
+
Shell.exec("settings put global auto_time ${autoTimeBefore ?: "1"}")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run [block] with the device in time zone [olsonId] (e.g. `"Pacific/Chatham"`,
|
|
90
|
+
* `"America/New_York"`), restoring the original zone and `auto_time_zone` afterwards
|
|
91
|
+
* even when the block throws. This is the DST/date-rollover assertion primitive:
|
|
92
|
+
* combine with [withWarpedClock] to place the device just before a transition and
|
|
93
|
+
* watch what a "tomorrow at 08:00" schedule actually does. No elapsed-time math here
|
|
94
|
+
* — a zone, unlike a clock, does not drift while the block runs.
|
|
95
|
+
*/
|
|
96
|
+
fun <T> withTimeZone(olsonId: String, block: () -> T): T {
|
|
97
|
+
assumeOnEmulator()
|
|
98
|
+
val autoZoneBefore = readGlobal("auto_time_zone")
|
|
99
|
+
val originalZone = Shell.exec("getprop persist.sys.timezone").trim()
|
|
100
|
+
Shell.exec("settings put global auto_time_zone 0")
|
|
101
|
+
try {
|
|
102
|
+
Shell.exec("cmd alarm set-timezone $olsonId")
|
|
103
|
+
return block()
|
|
104
|
+
} finally {
|
|
105
|
+
if (originalZone.isNotEmpty()) {
|
|
106
|
+
Shell.exec("cmd alarm set-timezone $originalZone")
|
|
107
|
+
}
|
|
108
|
+
Shell.exec("settings put global auto_time_zone ${autoZoneBefore ?: "1"}")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A global setting's current value, or null when unset — see [Shell.readSetting]. */
|
|
113
|
+
private fun readGlobal(key: String): String? = Shell.readSetting("global", key)
|
|
114
|
+
}
|
|
@@ -5,7 +5,7 @@ import __PACKAGE__.domain.repository.ItemRepository
|
|
|
5
5
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
6
6
|
import __PACKAGE__.presentation.home.HomeViewModel
|
|
7
7
|
// cmp:anchor di-imports
|
|
8
|
-
import org.koin.core.module.dsl.
|
|
8
|
+
import org.koin.core.module.dsl.viewModel
|
|
9
9
|
import org.koin.dsl.module
|
|
10
10
|
|
|
11
11
|
val repositoryModule = module {
|
|
@@ -19,7 +19,10 @@ val useCaseModule = module {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
val viewModelModule = module {
|
|
22
|
-
viewModelOf(
|
|
22
|
+
// Explicit factories only — never reflection-based viewModelOf (ARCH-14): it silently
|
|
23
|
+
// ignores constructor default parameter values, turning a compile-time wiring error
|
|
24
|
+
// into a runtime resolution crash. One get() per constructor dependency.
|
|
25
|
+
viewModel { HomeViewModel(get()) }
|
|
23
26
|
// cmp:anchor di-viewmodels
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -118,7 +118,7 @@ internal fun NavItem(
|
|
|
118
118
|
.clip(RoundedCornerShape(8.dp))
|
|
119
119
|
.clickable(onClick = onClick)
|
|
120
120
|
// a11y: guarantee the 48dp minimum touch target regardless of label width
|
|
121
|
-
// (the
|
|
121
|
+
// (the verify lane's a11y step flags anything smaller).
|
|
122
122
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
|
123
123
|
// Durable selection handle (tests/E2E select by testTag, never display text).
|
|
124
124
|
.semantics { testTag = navItemTag(label) }
|
|
@@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp
|
|
|
11
11
|
/**
|
|
12
12
|
* The filled call-to-action button: M3 `Button` with a 48 dp minimum touch target
|
|
13
13
|
* applied. Stock M3 buttons sit below that floor by default; wrapping them here clears
|
|
14
|
-
* WCAG 2.2 SC 2.5.8 and the
|
|
14
|
+
* WCAG 2.2 SC 2.5.8 and the verify lane's a11y bar once, for every call site.
|
|
15
15
|
*/
|
|
16
16
|
@Composable
|
|
17
17
|
fun AppPrimaryButton(
|
|
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|
|
13
13
|
* The registry's icon button: M3 `IconButton` held to the 48 dp touch-target floor.
|
|
14
14
|
*
|
|
15
15
|
* Stock M3 `IconButton` defaults to a 40x40 dp target — below WCAG 2.2 SC 2.5.8 and this
|
|
16
|
-
*
|
|
16
|
+
* verify lane's own a11y bar — so every raw use is a violation waiting to be measured
|
|
17
17
|
* (and historically why a text link masqueraded as a back button). This wrapper clears the
|
|
18
18
|
* floor once, by construction, the same way [AppPrimaryButton] does for filled buttons.
|
|
19
19
|
*
|
|
@@ -378,6 +378,64 @@ class ArchitectureConformanceTest {
|
|
|
378
378
|
)
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// SPEC: ARCH-13
|
|
382
|
+
@Test
|
|
383
|
+
fun `ARCH-13 ambient time is read only inside the designated time provider`() {
|
|
384
|
+
// The classic "inject the clock" rule. Ambient time reads make rendered structure
|
|
385
|
+
// and test evidence a function of WHEN you run them — a golden tree that passes at
|
|
386
|
+
// 23:00 and fails at 09:00 with no code change, an assertion that only reds out
|
|
387
|
+
// across a midnight boundary. Time is injected from one provider (a package ending
|
|
388
|
+
// in `.core.time`) so tests can pin it; everywhere else these APIs are banned.
|
|
389
|
+
// Scanned at reference level (imports AND fully-qualified inline calls), the same
|
|
390
|
+
// pragmatic granularity as the layer-boundary rules above. Covers kotlinx-datetime
|
|
391
|
+
// and kotlin.time (`Clock.System`), the JVM clock (`System.currentTimeMillis`),
|
|
392
|
+
// java.time (`LocalDate.now()` et al.), and the ambient zone
|
|
393
|
+
// (`TimeZone.currentSystemDefault()`) — the zone is time-of-run state too.
|
|
394
|
+
val ambientTime = Regex(
|
|
395
|
+
"""Clock\.System|System\.currentTimeMillis|LocalDate\.now\s*\(|LocalDateTime\.now\s*\(|""" +
|
|
396
|
+
"""LocalTime\.now\s*\(|Instant\.now\s*\(|TimeZone\.currentSystemDefault\s*\("""
|
|
397
|
+
)
|
|
398
|
+
val offenders = sources(commonMain)
|
|
399
|
+
.filterNot { it.path.replace(File.separatorChar, '/').contains("/core/time/") }
|
|
400
|
+
.filter { file -> nonCommentLines(file).any { ambientTime.containsMatchIn(it) } }
|
|
401
|
+
.map { it.path }
|
|
402
|
+
if (offenders.isNotEmpty()) fail(
|
|
403
|
+
violation(
|
|
404
|
+
"ARCH-13", "ambient time APIs (Clock.System, System.currentTimeMillis, *.now(), " +
|
|
405
|
+
"TimeZone.currentSystemDefault()) are called only inside the designated time " +
|
|
406
|
+
"provider (a `core/time` package) — everywhere else, time is an injected value.",
|
|
407
|
+
offenders,
|
|
408
|
+
"inject a clock/time provider (constructor parameter, wired in di/) that lives in " +
|
|
409
|
+
"`core/time`, and read the current moment through it — never ambiently. Tests then " +
|
|
410
|
+
"pin the provider instead of inheriting the wall clock.",
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// SPEC: ARCH-14
|
|
416
|
+
@Test
|
|
417
|
+
fun `ARCH-14 ViewModels are registered with explicit viewModel factories - viewModelOf is banned`() {
|
|
418
|
+
// Koin's reflection-based `viewModelOf(::X)` silently ignores Kotlin default
|
|
419
|
+
// parameter values: it resolves EVERY constructor parameter from the graph, so a
|
|
420
|
+
// ViewModel that compiles and previews fine crashes at runtime resolution the
|
|
421
|
+
// first time a defaulted parameter matters (the default masked a dependency the
|
|
422
|
+
// graph never registered). An explicit `viewModel { X(get(), …) }` factory states
|
|
423
|
+
// each dependency and fails at compile time instead.
|
|
424
|
+
val offenders = sources(commonMain)
|
|
425
|
+
.filter { file -> nonCommentLines(file).any { it.contains("viewModelOf") } }
|
|
426
|
+
.map { it.path }
|
|
427
|
+
if (offenders.isNotEmpty()) fail(
|
|
428
|
+
violation(
|
|
429
|
+
"ARCH-14", "ViewModels are registered with explicit `viewModel { … }` factories — " +
|
|
430
|
+
"reflection-based `viewModelOf` is banned (it silently ignores constructor " +
|
|
431
|
+
"default parameter values; failures move from compile time to runtime resolution).",
|
|
432
|
+
offenders,
|
|
433
|
+
"replace `viewModelOf(::XViewModel)` with `viewModel { XViewModel(get(), …) }` " +
|
|
434
|
+
"(import org.koin.core.module.dsl.viewModel), one get() per constructor dependency.",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
381
439
|
// SPEC: SHELL-05
|
|
382
440
|
@Test
|
|
383
441
|
fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
|
|
@@ -21,6 +21,7 @@ app").
|
|
|
21
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
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
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
|
+
| Reliability (platform behavior) | A feature that alarms or notifies actually alerts on a real device — the claim crosses the process boundary instead of stopping at a green JVM | instrumented tier (`androidInstrumentedTest`, lane step `androidChecks`); platform-behavior spec clauses cite instrumented tests, never desktop ones |
|
|
24
25
|
| Interaction capability (a11y) | Every interactive element is perceivable by assistive tech and automation | `[enforced: SHELL-04 + A11y gates]` |
|
|
25
26
|
| 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
|
|
|
@@ -79,6 +80,7 @@ app").
|
|
|
79
80
|
| `androidMain` | Android entry point (`AppApplication`), platform `actual`s. |
|
|
80
81
|
| `androidDebug` | Debug-only additions layered on `androidMain` — the inspector HTTP server, crash recorder, DB inspector. Never in `androidRelease`. |
|
|
81
82
|
| `androidRelease` | Release-only twins (currently a no-op inspector stub) that make the `androidDebug` additions compile out cleanly. |
|
|
83
|
+
| `androidInstrumentedTest` | The on-device behavior tier: platform facts no JVM test can see (alarms, notifications, PendingIntent identity, audio routing) asserted in the app's real process — generic helpers in `testing/`, exemplar in `PlatformBehaviorSeamTest`. Runs via the lane's `androidChecks` step (`connectedDebugAndroidTest`; SKIPs without a device). See `docs/TESTING.md` §"The instrumented tier". |
|
|
82
84
|
| `iosMain` | iOS entry point (`MainViewController.kt`, `KoinHelper.kt`), platform `actual`s. |
|
|
83
85
|
| `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
86
|
| `desktopTest` | Conformance gates (this document's enforced clauses), Compose UI Tests, golden-tree structural baselines — the fast, device-free verification tier. |
|
|
@@ -180,7 +182,7 @@ Three named scenarios ground that loop in what actually happens on this codebase
|
|
|
180
182
|
Room when `NetworkMonitor.isOnline` is false. Both pieces of infrastructure exist (§3, §7);
|
|
181
183
|
no repository reads them today — hence the offline goal's `[advisory]` status in §1.
|
|
182
184
|
3. **Navigate + process death.** `AppNavHost` (single-Activity/single-`ComposeUIViewController`)
|
|
183
|
-
owns the back stack; each screen's `ViewModel` (
|
|
185
|
+
owns the back stack; each screen's `ViewModel` (a Koin `viewModel { }` factory) survives
|
|
184
186
|
configuration change but not process death — a killed-and-restored process re-runs cold
|
|
185
187
|
start and reloads from the repository, the same path as scenario 1.
|
|
186
188
|
|
|
@@ -232,7 +234,22 @@ injected, not hardcoded, so tests can pass a test dispatcher. Do **not** add `wi
|
|
|
232
234
|
around calls that are already main-safe by contract; it's dead weight that hides where the
|
|
233
235
|
real guarantee lives.
|
|
234
236
|
|
|
235
|
-
###
|
|
237
|
+
### Time (inject the clock) `[enforced: ARCH-13]`
|
|
238
|
+
|
|
239
|
+
**Ambient time reads are banned outside the designated time provider.** `Clock.System`,
|
|
240
|
+
`System.currentTimeMillis`, `LocalDate.now()` / `LocalDateTime.now()` / `LocalTime.now()` /
|
|
241
|
+
`Instant.now()`, and `TimeZone.currentSystemDefault()` may be referenced only inside a
|
|
242
|
+
`core/time` package (the one place allowed to touch the wall clock; a fresh scaffold ships
|
|
243
|
+
no time usage at all, so the package appears with your first time-dependent feature).
|
|
244
|
+
Everywhere else, the current moment arrives as an **injected value** — a clock/time-provider
|
|
245
|
+
interface declared in `core/time` and wired through Koin like any other dependency.
|
|
246
|
+
|
|
247
|
+
The rationale is determinism of evidence: an ambient time read makes rendered structure and
|
|
248
|
+
test results a function of *when* you run them — a golden tree that passes at 23:00 and
|
|
249
|
+
fails at 09:00 with no code change, a "today" grouping that flips across a midnight
|
|
250
|
+
boundary, a defaulted `Clock.System` constructor parameter that no test can pin. Injected
|
|
251
|
+
time gives tests one seam to freeze, and gives every screen's rendered output a single
|
|
252
|
+
source of "now" the harness can control.
|
|
236
253
|
|
|
237
254
|
One Koin module per concern: `repositoryModule` / `useCaseModule` / `viewModelModule`
|
|
238
255
|
(`di/AppModule.kt`, common to every platform), plus one platform module for platform-only
|
|
@@ -243,6 +260,21 @@ no service-locator lookups inside domain or presentation code. Platform modules
|
|
|
243
260
|
`actual`-backed singletons (`NetworkMonitor`, `AppDatabase`) that common modules then depend on
|
|
244
261
|
via the domain-facing interface, not the concrete platform type.
|
|
245
262
|
|
|
263
|
+
**ViewModels are registered with explicit `viewModel { … }` factories; reflection-based
|
|
264
|
+
`viewModelOf` is disallowed** `[enforced: ARCH-14]`. Koin's `viewModelOf` silently ignores
|
|
265
|
+
Kotlin constructor default parameter values — a ViewModel that compiles and previews fine
|
|
266
|
+
crashes at runtime resolution the first time a defaulted parameter matters (the default was
|
|
267
|
+
masking a dependency the graph never registered). An explicit factory states every
|
|
268
|
+
dependency (`viewModel { XViewModel(get(), …) }`) and fails at compile time instead.
|
|
269
|
+
|
|
270
|
+
Shared-vs-per-destination ViewModel scope is an **intent** decision no static rule can make
|
|
271
|
+
for you `[advisory]`: a screen that default-instantiates its own ViewModel gets a fresh
|
|
272
|
+
instance per destination, so state silently resets on re-entry. State meant to be shared
|
|
273
|
+
across screens must be scoped explicitly — register it once and resolve the same instance
|
|
274
|
+
at each destination (e.g. a `single { … }`-scoped holder, or a factory resolved against a
|
|
275
|
+
shared owner) rather than letting each screen construct its own. Decide the scope when you
|
|
276
|
+
introduce the ViewModel, not after the reset surprises you.
|
|
277
|
+
|
|
246
278
|
### Logging `[advisory — no shared logger ships today]`
|
|
247
279
|
|
|
248
280
|
The scaffold ships **no cross-platform logging library**. The only logging present is Koin's
|
|
@@ -274,6 +306,13 @@ map `ItemEntity` → the domain `Item` inside `data/` — and schema changes get
|
|
|
274
306
|
bump. `fallbackToDestructiveMigration` is a scaffold-stage convenience; replace it before
|
|
275
307
|
shipping user data you can't afford to lose.
|
|
276
308
|
|
|
309
|
+
Room's exported schema history (`composeApp/schemas/`) is **append-only**
|
|
310
|
+
`[enforced: schemaHistory lane step]`: every version below the current highest is a frozen
|
|
311
|
+
record of a database that shipped, and migrations are validated against those exact bytes.
|
|
312
|
+
The lane fails if a historical `<version>.json` is rewritten or deleted (`git checkout --
|
|
313
|
+
<file>` restores it); only the current highest version — the live, in-progress schema — may
|
|
314
|
+
change between commits.
|
|
315
|
+
|
|
277
316
|
### Design tokens `[enforced: ARCH-05]`
|
|
278
317
|
|
|
279
318
|
`presentation/theme/` (`Tokens.kt`, `Theme.kt`, `Typography.kt`, `Shape.kt`) is the only source
|
package/template/docs/TESTING.md
CHANGED
|
@@ -9,6 +9,7 @@ copy their shape.
|
|
|
9
9
|
| Conformance gates (ARCH clauses) | `composeApp/src/desktopTest/…/conformance` | same task |
|
|
10
10
|
| Screen behavior — Compose UI Test (spec-cited) | `composeApp/src/desktopTest/…/presentation` | same task |
|
|
11
11
|
| Golden trees (structure) | `qa/golden/` + `HomeGoldenTreeTest` | same task |
|
|
12
|
+
| Instrumented behavior (platform facts) | `composeApp/src/androidInstrumentedTest` | `./gradlew :composeApp:connectedDebugAndroidTest` (device attached) |
|
|
12
13
|
<!-- >>> cmp:feature e2e -->
|
|
13
14
|
| E2E smoke (few) | `qa/e2e/*.yaml` (Maestro) | `maestro test qa/e2e/smoke.yaml` |
|
|
14
15
|
<!-- <<< cmp:feature e2e -->
|
|
@@ -47,6 +48,82 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
|
|
|
47
48
|
Never delete, weaken, or `@Ignore` a failing test to get green. Fix the behavior — or if the
|
|
48
49
|
test is genuinely wrong, change it and say so explicitly in your PR/summary.
|
|
49
50
|
|
|
51
|
+
## The instrumented tier — platform behavior
|
|
52
|
+
|
|
53
|
+
This seam exists because platform behavior escapes every desktop tier. Alarms,
|
|
54
|
+
notifications, lock-screen/full-screen intents, notification channels, PendingIntent
|
|
55
|
+
identity, audio routing, process death, and runtime permissions are OS facts:
|
|
56
|
+
`desktopTest` runs on a JVM, golden trees pin structure, the conformance suite is static,
|
|
57
|
+
and the E2E smoke taps UI without asserting anything about the shade or the alarm table.
|
|
58
|
+
A feature whose whole point is "the phone alerts" can ship fully green from every other
|
|
59
|
+
tier and never alert — that class of defect escaped to production repeatedly before this
|
|
60
|
+
tier existed. When your feature touches alarms, notifications, or locks, its behavior
|
|
61
|
+
test lives here.
|
|
62
|
+
|
|
63
|
+
**What belongs here:** claims only the OS can witness — a notification actually reached
|
|
64
|
+
the shade, a channel holds the importance the feature needs, N logical alarms occupy N
|
|
65
|
+
PendingIntent slots, the alert path plays with `USAGE_ALARM` on a silenced ringer, the
|
|
66
|
+
app survives process death, a permission-gated path degrades correctly.
|
|
67
|
+
|
|
68
|
+
**What does not:** logic (→ `commonTest`), rendered structure (→ golden trees),
|
|
69
|
+
architecture rules (→ conformance), UI journeys (→ E2E flows). If a JVM test can prove
|
|
70
|
+
it, a JVM test is where it goes — this tier is the most expensive seat in the house.
|
|
71
|
+
|
|
72
|
+
**Cost model:** needs a device/emulator, so it runs at checkpoint cadence — the lane's
|
|
73
|
+
`androidChecks` step (`connectedDebugAndroidTest`) SKIPs honestly when no device is
|
|
74
|
+
attached and runs in `local`/`ci` when one is, with the `release` profile adding the
|
|
75
|
+
release-APK smoke on top. It is not an inner-loop tier; don't reach for it per-edit.
|
|
76
|
+
|
|
77
|
+
**The helpers** (`androidInstrumentedTest/…/testing/`), small and composable — see
|
|
78
|
+
`PlatformBehaviorSeamTest` for the exemplar shape:
|
|
79
|
+
|
|
80
|
+
- `NotificationAsserts` — bounded-poll wait for a posted notification (id/tag or
|
|
81
|
+
predicate), channel-exists with an importance floor, full-screen-intent capability
|
|
82
|
+
(API-aware).
|
|
83
|
+
- `AlarmAsserts` — the OS alarm table (`dumpsys alarm`) parsed per-package;
|
|
84
|
+
alarm-registered and N-distinct-alarms assertions (the PendingIntent-identity
|
|
85
|
+
collision, caught mechanically).
|
|
86
|
+
- `SystemState` — snapshot/restore of ringer mode and DND so audio-routing claims are
|
|
87
|
+
testable; its header is honest about what instrumentation cannot control (OEM sound
|
|
88
|
+
policy, "a human heard it") — those stay a documented manual tier.
|
|
89
|
+
|
|
90
|
+
### Runtime state control — put the system into the state the claim is about
|
|
91
|
+
|
|
92
|
+
The observation helpers above answer "what did the OS do?"; this organ family answers
|
|
93
|
+
the prior question — "can the test even reach the state the claim is about?" Most
|
|
94
|
+
unprovable mobile claims are unprovable exactly because the state is hard to reach: you
|
|
95
|
+
would have to wait hours for Doze, ship to a user who denies the permission, drop the
|
|
96
|
+
network by hand, or hope the OS reclaims your process while you watch. Each organ
|
|
97
|
+
reaches one such state on demand — emulator-only (every entry point SKIPs on a real
|
|
98
|
+
device), root-free on stock user-build images (every shell command verified from the
|
|
99
|
+
shell uid), and bracket-shaped (`withX { }` — snapshot, act, restore in `finally`) so
|
|
100
|
+
organs compose:
|
|
101
|
+
|
|
102
|
+
- `TimeWarp` — the device clock and time zone ("the 08:00 alarm actually arrives", DST).
|
|
103
|
+
- `DozeControl` — forced light/deep idle; composed with TimeWarp it proves
|
|
104
|
+
`setExactAndAllowWhileIdle` does what its name promises (the flagship exemplar in
|
|
105
|
+
`RuntimeStateSeamTest`).
|
|
106
|
+
- `PermissionControl` — the denied state (the fresh-install default) as a test input;
|
|
107
|
+
one-way grants. Its header documents the trap that shapes it: revoking a held runtime
|
|
108
|
+
permission kills the holding process — this seam's own process.
|
|
109
|
+
- `ProcessControl` — real OS-driven activity destruction and saved-state rebuild
|
|
110
|
+
(don't-keep-activities — flipped live via the same binder call the Developer-options
|
|
111
|
+
toggle uses, because `settings put global always_finish_activities` alone is read only
|
|
112
|
+
at boot; verified on API 35); honest about why in-process "kill my own process" is
|
|
113
|
+
structurally impossible under instrumentation.
|
|
114
|
+
- `NetworkControl` — offline (airplane mode) and per-transport wifi/data brackets; the
|
|
115
|
+
path to proving `core/connectivity/NetworkMonitor` tracks the world.
|
|
116
|
+
- `ConfigControl` — dark mode, font scale, per-app locale (API 33+); configuration
|
|
117
|
+
change as the other state-loss lever.
|
|
118
|
+
|
|
119
|
+
What device-state control cannot reproduce, honestly: the path INTO a state (real Doze's
|
|
120
|
+
descent ladder and maintenance windows — forced idle teleports and holds), OEM policy
|
|
121
|
+
(battery managers, sound routing, skinned lock screens), app-standby buckets, real-network
|
|
122
|
+
character (bandwidth, captive portals, flaky RSSI — emulator transports are the host's
|
|
123
|
+
connection wearing costumes), true cold-process start under instrumentation, and anything
|
|
124
|
+
whose proof is a human's senses. Each organ's header states its own gaps; a green test is
|
|
125
|
+
evidence for exactly the state the organ produced, nothing wider.
|
|
126
|
+
|
|
50
127
|
<!-- >>> cmp:feature e2e -->
|
|
51
128
|
## E2E
|
|
52
129
|
|
|
@@ -70,3 +147,91 @@ asserts are for static post-navigation elements only.
|
|
|
70
147
|
attached. It writes the evidence receipt to `qa/evidence/latest.json`; **commit the receipt
|
|
71
148
|
with your change.** SKIPped steps are recorded honestly — green-with-gaps is visible, never
|
|
72
149
|
silent.
|
|
150
|
+
|
|
151
|
+
**`--fast` is the lane's inner loop.** `node qa/verify.mjs --fast` runs the resolved
|
|
152
|
+
profile minus the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`,
|
|
153
|
+
`androidChecks`, `releaseSmoke`) — unconditionally, device attached or not. It exists
|
|
154
|
+
because device/release evidence is the scarce, slow tier (R8 compile, emulator, Maestro,
|
|
155
|
+
instrumented runner): batch it at the checkpoint, don't pay for it on every small edit.
|
|
156
|
+
The rest of the profile runs cheaply, three ways:
|
|
157
|
+
|
|
158
|
+
- **Unchanged pure-Node steps are reused, not re-run.** `specCoverage`, `approvals`,
|
|
159
|
+
`componentStories`, `reachability`, and `archDoc` are pure functions of files on disk;
|
|
160
|
+
a fast run reuses each one's last PASS when a content hash of its declared inputs is
|
|
161
|
+
unchanged, shown as `⚡ name: CACHED (unchanged since …)`. Only a PASS is ever reused —
|
|
162
|
+
a FAIL or SKIP always re-runs — and the cache
|
|
163
|
+
(`composeApp/build/.cmp-step-cache.json`) is a gitignored cache, never evidence: the
|
|
164
|
+
full lane never reads it (it always executes every step; it only writes entries so the
|
|
165
|
+
next fast run benefits).
|
|
166
|
+
- **Gradle's up-to-date checks stand.** The full lane forces test execution with
|
|
167
|
+
`--rerun` (evidence integrity — a receipt must attest tests that ran). A fast receipt
|
|
168
|
+
is already declared non-evidence, so fast mode omits the flag and unchanged test tasks
|
|
169
|
+
cost nothing.
|
|
170
|
+
- **Unit tests are scoped to the change.** Changed `.kt` files (git diff + untracked)
|
|
171
|
+
map to `--tests "*<feature>*"` filters, reported honestly in the step line and the
|
|
172
|
+
receipt. Broad-impact changes — build files, DI, theme/tokens, shared components,
|
|
173
|
+
`qa/` itself, anything outside `composeApp/src` — disable filtering and run the whole
|
|
174
|
+
suite, as does any uncertain case (no git, unmappable change): fail open, never fail
|
|
175
|
+
silent. A filtered fast run can miss a cross-feature regression by design; the full,
|
|
176
|
+
unfiltered suite at the checkpoint is what decides done.
|
|
177
|
+
|
|
178
|
+
A fast run is mechanically unable to claim done — its receipt records `"mode": "fast"`,
|
|
179
|
+
derives **no** evidence rung, and `qa/receipt-check.mjs` (the Stop hook, CI, pre-push)
|
|
180
|
+
refuses it by name. Iterate on `--fast`; run the full lane once, deliberately, when the
|
|
181
|
+
change is done.
|
|
182
|
+
|
|
183
|
+
**Watch mode makes the inner loop resident.** `node qa/watch.mjs` watches
|
|
184
|
+
`composeApp/src`, `specs/`, and `qa/` and re-runs `node qa/verify.mjs --fast` (as a
|
|
185
|
+
subprocess — it inherits every fast-mode economy above for free) on every save, debounced
|
|
186
|
+
so a multi-file save storm triggers one run and changes landing mid-run coalesce into
|
|
187
|
+
exactly one follow-up. Each run re-prints the step table with any failing step's reason
|
|
188
|
+
verbatim — the errors-on-save loop an IDE gives a human, for the agent. What it is **not**:
|
|
189
|
+
evidence. It runs the fast tier only, so nothing it produces can satisfy the done-gate,
|
|
190
|
+
and every run says so in a standing footer; the checkpoint remains one deliberate full
|
|
191
|
+
`node qa/verify.mjs` run. Coordination: it never launches while a verify lane
|
|
192
|
+
(`.cmp-lane-in-progress`) or a preview-daemon render (`.cmp-render-in-progress`) holds the
|
|
193
|
+
project — it waits and coalesces, so watch mode, the preview daemon, and the lane can all
|
|
194
|
+
be resident on one project without two Gradle invocations ever colliding. `--once` runs a
|
|
195
|
+
single coordinated pass for scripting; `--json` emits one JSON object per run for
|
|
196
|
+
programmatic consumption.
|
|
197
|
+
|
|
198
|
+
## One device, one driver
|
|
199
|
+
|
|
200
|
+
The machine typically has ONE Android device/emulator, and it is the scarcest, slowest,
|
|
201
|
+
most fragile resource the harness touches — two concurrent drivers produce wedged adbd,
|
|
202
|
+
`device offline` while `adb devices` looks fine, crossed app state, and false reds that
|
|
203
|
+
have nothing to do with the app. So device evidence is **batched, never an inner loop**:
|
|
204
|
+
the lane sequences its device steps (`tokenDrift` live tier, `e2eSmoke`, `androidChecks`,
|
|
205
|
+
`releaseSmoke`) once, last — don't hand-run `connectedDebugAndroidTest`, `maestro test`,
|
|
206
|
+
or `adb install` mid-task to "check something".
|
|
207
|
+
|
|
208
|
+
Mechanically, the first device step takes a **machine-global lease** on the device's adb
|
|
209
|
+
serial (`<os tmpdir>/create-cmp/device-leases/<serial>.json` — `qa/lib/device-lease.mjs`
|
|
210
|
+
documents the contract), held until the lane exits. It is machine-global on purpose: two
|
|
211
|
+
different projects (a scratch app in /tmp, the real one) share the same emulator, and
|
|
212
|
+
per-project markers cannot see each other.
|
|
213
|
+
|
|
214
|
+
**Contention is a SKIP, never a FAIL** — nothing is broken; another run legitimately holds
|
|
215
|
+
the device, and the reason names it: `held by "verify lane e2eSmoke" (pid 4711,
|
|
216
|
+
/tmp/scratch-x, 2m ago)`. A SKIPped device step does not buy its rung, so contention
|
|
217
|
+
visibly *lowers* the evidence level (L2 falls back to L1) instead of corrupting the run —
|
|
218
|
+
re-run when the holder finishes to earn the full rung. To see who holds a device:
|
|
219
|
+
`cat "${TMPDIR:-/tmp}/create-cmp/device-leases/<serial>.json"`. A crashed holder never
|
|
220
|
+
wedges the machine: a lease whose pid is dead, or older than 30 minutes, is silently
|
|
221
|
+
reclaimed by the next run. The live inspector tier (`connect_live`,
|
|
222
|
+
`navigate_and_inspect`) checks the same lease and refuses to drive a leased device by
|
|
223
|
+
naming the holder instead of failing with a mysterious transport error.
|
|
224
|
+
|
|
225
|
+
## The evidence ladder
|
|
226
|
+
|
|
227
|
+
Every PASS receipt names its rung (`evidenceLevel` in the receipt; derived by
|
|
228
|
+
`qa/lib/evidence-level.mjs` from which steps actually ran and PASSed — never declared, and
|
|
229
|
+
a SKIPped step never buys a rung: an unsigned-keystore `releaseSmoke` SKIP is not L3). The
|
|
230
|
+
rung is the coarse grade; the per-step list stays the fine print. A FAILed lane has no rung.
|
|
231
|
+
|
|
232
|
+
| Rung | What it proves | What it does NOT prove |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| **L0 scaffold** | Stamp-time green: the build compiles and the unit tests pass. | Nothing about conformance, rendered structure, a11y, or the release variant — and nothing on a device. |
|
|
235
|
+
| **L1 desktop** | Full static + JVM evidence: build, unit tests, conformance, golden trees, a11y, release COMPILE, and the pure-Node gates. | That the app runs on a device at all — no APK was installed or driven; platform behavior (alarms, notifications) is invisible from this rung. |
|
|
236
|
+
| **L2 device** | L1 plus executed on-device evidence: the debug APK installed and driven (`e2eSmoke`), instrumented platform assertions (`androidChecks`), and/or live token drift. | That the release variant behaves (R8 differs from debug — that is L3's job), nor that alarms/notifications actually land unless an instrumented behavior test asserts them. |
|
|
237
|
+
| **L3 release** | L2 plus `releaseSmoke` PASSed: the signed release APK installed and driven on a device. | Real-backend behavior (the emulator/dev backend is a documented tier boundary — see the instrumented-tier section) and store-review compliance. |
|
|
@@ -22,6 +22,14 @@ compose-hot-reload = "1.1.1"
|
|
|
22
22
|
coil = "3.1.0"
|
|
23
23
|
androidx-core = "1.15.0"
|
|
24
24
|
androidx-activity = "1.10.1"
|
|
25
|
+
# ── Android instrumentation tier (androidInstrumentedTest). The alarm table, the
|
|
26
|
+
# ── notification shade, channels, and audio routing are OS facts no JVM test can see —
|
|
27
|
+
# ── this tier is the only evidence that crosses the process boundary onto a real device.
|
|
28
|
+
androidx-test-runner = "1.6.2"
|
|
29
|
+
androidx-test-core = "1.6.1"
|
|
30
|
+
androidx-test-ext-junit = "1.2.1"
|
|
31
|
+
androidx-uiautomator = "2.3.0"
|
|
32
|
+
junit4 = "4.13.2"
|
|
25
33
|
|
|
26
34
|
[libraries]
|
|
27
35
|
# Compose Multiplatform
|
|
@@ -82,6 +90,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
|
|
|
82
90
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
|
83
91
|
android-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.3" }
|
|
84
92
|
|
|
93
|
+
# Android instrumentation (androidInstrumentedTest — the on-device behavior tier)
|
|
94
|
+
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
|
95
|
+
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
|
|
96
|
+
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
|
|
97
|
+
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
|
|
98
|
+
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
|
99
|
+
|
|
85
100
|
# Test
|
|
86
101
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
|
87
102
|
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
|