diff --git a/CHANGELOG.md b/CHANGELOG.md index b460fba..2434f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- NetworkMock: an operation can now simulate a network failure instead of returning a response, + two ways — **deterministically**, by selecting Timeout or Connection Refused in the operation + sheet's picker page (a new "Simulate Failure" section, alongside the response variants); or + **probabilistically**, via a new `x-devview.failureRate` (0.0–1.0) OpenAPI extension field, + which independently rolls on every otherwise-mocked request to that operation (an operation + left on `Network` passthrough is never affected). Each failure kind mirrors the exception a + real Ktor engine throws for the equivalent condition (`HttpRequestTimeoutException` for + Timeout, a connection-level `kotlinx.io.IOException` for Connection Refused), so existing app + error handling exercises the same code path. `OperationMockState` gains a `Failure(kind: + FailureKind)` variant (**breaking**: exhaustive `when` blocks over `OperationMockState` need a + new branch); `Operation` gains `failureRate: Double?`; `NetworkMockConfig` gains an injectable + `random: Random` for deterministic tests; `MockColorScheme` gains a `failure: StatusColors` + slot (**breaking**: new required constructor parameter). (`devview-networkmock-core`, + `devview-networkmock-ktor`, `devview-networkmock`, #88, #95) - NetworkMock: mocked responses now serve the `Content-Type` derived from the spec's declared media type (`responses..content.`, previously always hardcoded to `application/json`) plus any additional headers declared on `responses..headers` — a diff --git a/devview-networkmock-core/CLAUDE.md b/devview-networkmock-core/CLAUDE.md index 88bdcb8..d96758f 100644 --- a/devview-networkmock-core/CLAUDE.md +++ b/devview-networkmock-core/CLAUDE.md @@ -34,7 +34,8 @@ Single test class: | `MockMatch` | Returned by `findMatchingMock()`; carries `OperationKey` + resolved `Operation` — kept unrenamed, see naming note below | | `OperationDescriptor` | Static `(key, config)` pair for an operation; used by the UI layer. Does not carry response variants — see below | | `NetworkMockState` | Persisted state: `globalMockingEnabled`, `operationStates: Map`, `lastModified` | -| `OperationMockState` | Sealed interface: `Network` (pass-through) or `Mock(statusCode: Int, exampleName: String)` | +| `OperationMockState` | Sealed interface: `Network` (pass-through), `Mock(statusCode: Int, exampleName: String)`, or `Failure(kind: FailureKind)` (deterministic simulated network failure) | +| `FailureKind` | `@Serializable enum`: `TIMEOUT` / `CONNECTION_REFUSED` — mirrors what a real Ktor engine throws for the equivalent condition | **Naming note**: `MockResponse` and `MockMatch` are deliberately *not* renamed to OpenAPI vocabulary — they model DevView's own runtime mocking behavior (a served response, a request-to-operation match), which OpenAPI has no concept of. Everything that models something the spec itself describes uses OpenAPI terms (`ApiSpec`, `Operation`, `OperationKey`). @@ -102,7 +103,7 @@ There is no environment axis and no manifest file. `servers[]` lists every base | `network_mock_schema_version` | Int | Gates the one-shot pre-0.2.0 migration | | `network_mock_operation_{compositeKey}` | String (JSON) | `OperationMockState` per operation | -`OperationMockState` is serialized as `{"type":"network"}` or `{"type":"mock","statusCode":200,"exampleName":"default"}` (discriminator field `type`). +`OperationMockState` is serialized as `{"type":"network"}`, `{"type":"mock","statusCode":200,"exampleName":"default"}`, or `{"type":"failure","kind":"timeout"}` (discriminator field `type`). Each operation is stored under its own key, so updating one operation never overwrites another. @@ -114,6 +115,8 @@ Each operation is stored under its own key, so updating one operation never over **`NetworkMockInitializer.initialize()` is `@Composable`** even though it is a process-level singleton. It uses `remember` internally so that the repo objects are tied to the Composition. Subsequent calls are early-returned no-ops (`if (stateRepository != null) return`). +**`Operation.failureRate` has no spec-wide default**, unlike `delayMs`. `DevViewExtension.failureRate` is parsed at the document root too but deliberately unused there — see the doc comment on `Operation.failureRate` for why (an operation-level-only knob is a much narrower blast radius than "some percentage of everything fails"). + **`MockConfigRepository` caches** the parsed `MockConfiguration` in `cachedConfig` after the first successful load. Tests verify this with a recording resource loader that asserts each spec file is read exactly once. **Response variant discovery is opt-in, not eager.** `OperationDescriptor` carries only `key` and `config` — no response list — because loading it requires real I/O (reading and decoding each `externalValue` file) on top of the one-time spec parse. Nothing in this module calls `discoverResponseFiles`/`loadMockResponse` automatically; `devview-networkmock`'s main operation list is built from parsed spec metadata alone, and only calls discovery for one operation when that operation's detail screen opens. diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index e43d492..69c85b5 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -43,6 +43,13 @@ package com.worldline.devview.networkmock.core.model { property public java.util.List servers; } + @kotlinx.serialization.Serializable public enum FailureKind { + method @InaccessibleFromKotlin public String getDisplayName(); + property public String displayName; + enum_constant @kotlinx.serialization.SerialName("connection_refused") public static final com.worldline.devview.networkmock.core.model.FailureKind CONNECTION_REFUSED; + enum_constant @kotlinx.serialization.SerialName("timeout") public static final com.worldline.devview.networkmock.core.model.FailureKind TIMEOUT; + } + @kotlin.jvm.JvmInline @kotlinx.serialization.Serializable public final value class HttpMethod { ctor @KotlinOnly public HttpMethod(String value); method @InaccessibleFromKotlin public String getValue(); @@ -135,7 +142,7 @@ package com.worldline.devview.networkmock.core.model { } @androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class Operation { - ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version); + ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); method public String component1(); method public String component2(); method public String component3(); @@ -143,14 +150,17 @@ package com.worldline.devview.networkmock.core.model { method public java.util.Map? component5(); method public Long? component6(); method public String? component7(); - method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version); + method public Double? component8(); + method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); method @InaccessibleFromKotlin public Long? getDelayMs(); + method @InaccessibleFromKotlin public Double? getFailureRate(); method @InaccessibleFromKotlin public String getName(); method @InaccessibleFromKotlin public String getOperationId(); method @InaccessibleFromKotlin public String getPath(); method @InaccessibleFromKotlin public java.util.Map? getQueryParameters(); method @InaccessibleFromKotlin public String? getVersion(); property public Long? delayMs; + property public Double? failureRate; property public com.worldline.devview.networkmock.core.model.HttpMethod method; property public String name; property public String operationId; @@ -200,6 +210,16 @@ package com.worldline.devview.networkmock.core.model { property public abstract String displayName; } + @androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("failure") @kotlinx.serialization.Serializable public static final class OperationMockState.Failure implements com.worldline.devview.networkmock.core.model.OperationMockState { + ctor public OperationMockState.Failure(com.worldline.devview.networkmock.core.model.FailureKind kind); + method public com.worldline.devview.networkmock.core.model.FailureKind component1(); + method public com.worldline.devview.networkmock.core.model.OperationMockState.Failure copy(optional com.worldline.devview.networkmock.core.model.FailureKind kind); + method @InaccessibleFromKotlin public String getDisplayName(); + method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.FailureKind getKind(); + property public String displayName; + property public com.worldline.devview.networkmock.core.model.FailureKind kind; + } + @androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("mock") @kotlinx.serialization.Serializable public static final class OperationMockState.Mock implements com.worldline.devview.networkmock.core.model.OperationMockState { ctor public OperationMockState.Mock(int statusCode, String exampleName); method public int component1(); diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt index 2bd13a0..b2ddfa6 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt @@ -74,6 +74,11 @@ public data class ApiSpec( * [com.worldline.devview.networkmock.core.repository.RequestMatcher]). The pattern is not * currently configurable; non-standard (header- or query-versioned) APIs simply get * `null` here. + * @property failureRate Probability (0.0–1.0) that an otherwise-mocked request to this + * operation independently fails instead, from the operation-level `x-devview.failureRate` + * extension. `null` (the default) means every request behaves normally. Unlike [delayMs], + * this has no spec-wide default on [ApiSpec] — "some percentage of everything fails" is a + * much blunter tool than "this specific flaky endpoint fails sometimes". * @see ApiSpec * @see com.worldline.devview.networkmock.core.repository.RequestMatcher */ @@ -86,7 +91,8 @@ public data class Operation( val method: HttpMethod, val queryParameters: Map? = null, val delayMs: Long? = null, - val version: String? = null + val version: String? = null, + val failureRate: Double? = null ) /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt index cfa5c08..cb34984 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt @@ -77,9 +77,10 @@ public data class NetworkMockState( /** * Represents the mocking state for a single API operation. * - * Each operation is either passing traffic through to the actual network or returning a - * specific mock response. The two variants are represented as distinct types, eliminating - * any ambiguous state combinations that existed in a previous boolean-flag approach. + * Each operation is either passing traffic through to the actual network, returning a + * specific mock response, or simulating a network failure. The variants are represented as + * distinct types, eliminating any ambiguous state combinations that existed in a previous + * boolean-flag approach. * * ## Variants * @@ -87,6 +88,7 @@ public data class NetworkMockState( * |---------|----------|---------------| * | [Network] | All requests pass through to the actual network (default) | `"Network"` | * | [Mock] | Requests return the selected response variant | `"$statusCode - $exampleName"` | + * | [Failure] | Requests fail with the selected [FailureKind] | e.g. `"Timeout"` | * * @see NetworkMockState */ @@ -100,6 +102,7 @@ public sealed interface OperationMockState { * * - [Network]: always `"Network"` * - [Mock]: `"$statusCode - $exampleName"` (e.g. `"200 - default"`) + * - [Failure]: the selected [FailureKind]'s own display name (e.g. `"Timeout"`) */ public val displayName: String @@ -135,4 +138,45 @@ public sealed interface OperationMockState { public data class Mock(val statusCode: Int, val exampleName: String) : OperationMockState { override val displayName: String get() = "$statusCode - $exampleName" } + + /** + * The operation will deterministically simulate a network failure instead of returning + * any response — every request to it fails the same way, the same way [Mock] always + * serves the same response. + * + * See #95's `x-devview.failureRate` for the complementary *probabilistic* failure — + * this variant answers "make this endpoint always fail right now", that one answers + * "make this endpoint flaky, the way a real degraded service is." + * + * @property kind The kind of network failure to simulate + * @see FailureKind + */ + @Immutable + @Serializable + @SerialName("failure") + public data class Failure(val kind: FailureKind) : OperationMockState { + override val displayName: String get() = kind.displayName + } +} + +/** + * A network failure mode an operation can be configured to simulate deterministically via + * [OperationMockState.Failure]. + * + * Each kind mirrors the exception a real Ktor HTTP engine (OkHttp on Android, Darwin on iOS) + * throws for the equivalent real condition, so an app's existing error handling for that + * condition exercises the same code path against the simulated failure as it would against + * the real one. See `NetworkMockPlugin`'s `simulatedFailure` in `devview-networkmock-ktor`. + * + * @property displayName Human-readable name for UI display (e.g. `"Timeout"`) + */ +@Serializable +public enum class FailureKind(public val displayName: String) { + /** Mirrors `io.ktor.client.plugins.HttpRequestTimeoutException` — the request timed out. */ + @SerialName("timeout") + TIMEOUT(displayName = "Timeout"), + + /** Mirrors a connection-level I/O failure (e.g. connection refused, host unreachable). */ + @SerialName("connection_refused") + CONNECTION_REFUSED(displayName = "Connection Refused") } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt index 2d8d1da..8af9359 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt @@ -137,8 +137,10 @@ internal data class ComponentsObject( ) /** - * The `x-devview` Specification Extension object (see #94). Read at both the document root - * (spec-wide default delay) and per-operation (overrides the document default). + * The `x-devview` Specification Extension object (see #94). [delayMs] is read at both the + * document root (spec-wide default delay) and per-operation (overrides the document default). + * [failureRate] is operation-level only (see [com.worldline.devview.networkmock.core.model.Operation.failureRate]) — + * it is ignored if declared at the document root. */ @Serializable -internal data class DevViewExtension(val delayMs: Long? = null) +internal data class DevViewExtension(val delayMs: Long? = null, val failureRate: Double? = null) diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt index 2fb05b4..d7a0dd8 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt @@ -87,7 +87,8 @@ internal object OpenApiParser { method = method, queryParameters = queryParameters, delayMs = rawOperation.xDevview?.delayMs, - version = versionPattern.find(input = path)?.groupValues?.get(index = 1) + version = versionPattern.find(input = path)?.groupValues?.get(index = 1), + failureRate = rawOperation.xDevview?.failureRate ) responseIndex[operationId] = context.resolveResponseIndex( diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index 59e33f9..9bb550d 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -370,6 +370,37 @@ class MockConfigRepositoryTest { withoutOwnDelay?.delayMs shouldBe 200 } + @Test + fun `x-devview failureRate is parsed as an operation-level field with no spec-wide default`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "x-devview": { "failureRate": 0.5 }, + "paths": { + "/api/flaky": { + "get": { + "operationId": "flaky", + "x-devview": { "failureRate": 0.1 }, + "responses": {} + } + }, + "/api/steady": { + "get": { "operationId": "steady", "responses": {} } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + val operations = config.specs[0].operations.associateBy { it.operationId } + + // Unlike delayMs, a document-root failureRate is not a spec-wide default. + operations.getValue("flaky").failureRate shouldBe 0.1 + operations.getValue("steady").failureRate shouldBe null + } + @Test fun `operation version is extracted from a v-n path segment`() = runTest { val cases = mapOf( diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt index e0f6f0a..74b32f9 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import app.cash.turbine.test import com.worldline.devview.networkmock.core.fixtures.MockTestData import com.worldline.devview.networkmock.core.fixtures.ThrowingPreferencesDataStore +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.OperationKey import com.worldline.devview.networkmock.core.model.OperationMockState import com.worldline.devview.test.FakePreferencesDataStore @@ -114,6 +115,20 @@ class MockStateRepositoryTest { operationState shouldBe OperationMockState.Network } + @Test + fun `setOperationMockState persists failure state for an operation`() = runTest { + val repository = createRepository() + + repository.setOperationMockState( + key = key(operationId = "getUser"), + state = OperationMockState.Failure(kind = FailureKind.TIMEOUT) + ) + + val operationState = repository.getState().getOperationState(key = key(operationId = "getUser")) + operationState.shouldBeInstanceOf() + operationState.kind shouldBe FailureKind.TIMEOUT + } + @Test fun `setOperationMockState is reflected in observeState`() = runTest { val repository = createRepository() diff --git a/devview-networkmock-ktor/CLAUDE.md b/devview-networkmock-ktor/CLAUDE.md index 0cbfb7a..89baa5e 100644 --- a/devview-networkmock-ktor/CLAUDE.md +++ b/devview-networkmock-ktor/CLAUDE.md @@ -24,7 +24,7 @@ val client = HttpClient(OkHttp) { ``` - `NetworkMockPlugin` — the `HttpClientPlugin` singleton (`NetworkMockPlugin.kt`) -- `NetworkMockConfig` — DSL receiver; exposes `mockRepository` and `stateRepository` as nullable vars (`NetworkMockConfig.kt`) +- `NetworkMockConfig` — DSL receiver; exposes `mockRepository` and `stateRepository` as nullable vars, plus `random: Random` (defaults to `Random.Default`) used for the `x-devview.failureRate` roll — override in tests to pin the outcome (`NetworkMockConfig.kt`) - `MockHttpClientCall` — public subclass of `HttpClientCall` that wraps synthetic request/response data without touching the network (`NetworkMockPlugin.kt`) ## Interception Flow @@ -38,9 +38,10 @@ The plugin hooks into Ktor's `HttpSend` phase during `install`: 5. If no match → real network. 6. If matched, `currentState.getOperationState(match.key)` is read: - `OperationMockState.Network` or `null` → real network. - - `OperationMockState.Mock(statusCode, exampleName)` → load that declared response variant via `mockRepository.loadMockResponse(key, statusCode, exampleName)`. + - `OperationMockState.Failure(kind)` → throws immediately via `simulatedFailure(kind, request)` — `HttpRequestTimeoutException` for `TIMEOUT`, `kotlinx.io.IOException` for `CONNECTION_REFUSED`. No response is loaded; this is the one path where the plugin deliberately throws. + - `OperationMockState.Mock(statusCode, exampleName)` → if `match.config.failureRate != null` and `plugin.config.random.nextDouble() < failureRate`, throws the same way as `Failure(CONNECTION_REFUSED)` before ever attempting to load a response. Otherwise loads the declared response variant via `mockRepository.loadMockResponse(key, statusCode, exampleName)`. 7. On a successful load, `createMockHttpClientCall(...)` builds a `MockHttpClientCall` with `HttpResponseData` (HTTP/1.1, `MockResponse.contentType` as `Content-Type` merged with any `MockResponse.headers`, `ByteReadChannel` body) and returns it — **no network call is made**. -8. On any failure (variant not declared in the spec, exception) → falls back to real network and logs; never throws. +8. On any failure loading a declared mock (variant not declared in the spec, exception) → falls back to real network and logs. Simulated failures (step 6) are the deliberate exception to "never throws" — see step 6. ## Non-obvious Patterns and Constraints diff --git a/devview-networkmock-ktor/api/api.txt b/devview-networkmock-ktor/api/api.txt index ebb2e0e..749649d 100644 --- a/devview-networkmock-ktor/api/api.txt +++ b/devview-networkmock-ktor/api/api.txt @@ -9,10 +9,13 @@ package com.worldline.devview.networkmock.ktor.plugin { public final class NetworkMockConfig { ctor public NetworkMockConfig(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.repository.MockConfigRepository? getMockRepository(); + method @InaccessibleFromKotlin public kotlin.random.Random getRandom(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.repository.MockStateRepository? getStateRepository(); method @InaccessibleFromKotlin public void setMockRepository(com.worldline.devview.networkmock.core.repository.MockConfigRepository?); + method @InaccessibleFromKotlin public void setRandom(kotlin.random.Random); method @InaccessibleFromKotlin public void setStateRepository(com.worldline.devview.networkmock.core.repository.MockStateRepository?); property public com.worldline.devview.networkmock.core.repository.MockConfigRepository? mockRepository; + property public kotlin.random.Random random; property public com.worldline.devview.networkmock.core.repository.MockStateRepository? stateRepository; } diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt index db2ab9e..539d7c2 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -1,5 +1,6 @@ package com.worldline.devview.networkmock.ktor.plugin +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.NetworkMockState import com.worldline.devview.networkmock.core.model.OperationMockState import com.worldline.devview.networkmock.core.repository.MockConfigRepository @@ -10,6 +11,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -20,9 +22,12 @@ import io.ktor.http.headersOf import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import kotlin.random.Random import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import kotlinx.io.IOException class NetworkMockPluginTest { @@ -216,6 +221,156 @@ class NetworkMockPluginTest { // endregion + // region Failure simulation + + @Test + fun returnsFailure_whenEndpointStateIsFailureTimeout() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Failure(kind = FailureKind.TIMEOUT) + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + assertFailsWith { + client.get(urlString = "https://staging.api.example.com/api/users/42") + } + } + + @Test + fun returnsFailure_whenEndpointStateIsFailureConnectionRefused() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Failure(kind = FailureKind.CONNECTION_REFUSED) + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + assertFailsWith { + client.get(urlString = "https://staging.api.example.com/api/users/42") + } + } + + @Test + fun probabilisticFailure_throwsWhenRandomRollHitsTheConfiguredRate() = runTest { + val resources = flakySpecResources(failureRate = 0.5) + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state), + // 0.0 < 0.5 -> always "hits" the configured rate. + random = FixedRandom(value = 0.0) + ) + + assertFailsWith { + client.get(urlString = "https://staging.api.example.com/api/users/42") + } + } + + @Test + fun probabilisticFailure_servesMockWhenRandomRollMissesTheConfiguredRate() = runTest { + val resources = flakySpecResources(failureRate = 0.5) + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state), + // 0.99 >= 0.5 -> always "misses" the configured rate. + random = FixedRandom(value = 0.99) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe """{"id":1,"name":"Alice"}""" + } + + @Test + fun probabilisticFailure_doesNotApply_whenEndpointStateIsNetwork() = runTest { + // The roll only applies to otherwise-mocked requests - see the plugin's own doc note. + val resources = flakySpecResources(failureRate = 1.0) + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf("example-getUser" to OperationMockState.Network) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state), + random = FixedRandom(value = 0.0) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + /** A spec with `x-devview.failureRate` declared on `getUser`, backed by its response file. */ + private fun flakySpecResources(failureRate: Double): Map { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "x-devview": { "failureRate": $failureRate }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/files/networkmocks/responses/getUser-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + return mapOf( + KtorPluginTestData.SPEC_PATH to spec, + "files/networkmocks/responses/getUser-200.json" to """{"id":1,"name":"Alice"}""" + ) + } + + /** A [Random] pinned to always return [value] from [nextDouble], for deterministic rolls. */ + private class FixedRandom(private val value: Double) : Random() { + override fun nextBits(bitCount: Int): Int = 0 + override fun nextDouble(): Double = value + } + + // endregion + // region Non-matching requests pass through @Test @@ -454,11 +609,15 @@ class NetworkMockPluginTest { private fun buildClient( engine: MockEngine, configRepository: MockConfigRepository, - stateRepository: MockStateRepository + stateRepository: MockStateRepository, + random: Random? = null ): HttpClient = HttpClient(engine = engine) { install(plugin = NetworkMockPlugin) { mockRepository = configRepository this.stateRepository = stateRepository + if (random != null) { + this.random = random + } } } diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt index d479bd9..4bdcc6e 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt @@ -3,6 +3,7 @@ package com.worldline.devview.networkmock.ktor.plugin import com.worldline.devview.networkmock.core.NetworkMockInitializer import com.worldline.devview.networkmock.core.repository.MockConfigRepository import com.worldline.devview.networkmock.core.repository.MockStateRepository +import kotlin.random.Random /** * Configuration class for the [NetworkMockPlugin]. @@ -57,6 +58,15 @@ public class NetworkMockConfig { */ public var stateRepository: MockStateRepository? = null + /** + * The random source used to roll against an operation's declared `x-devview.failureRate` + * (see [com.worldline.devview.networkmock.core.model.Operation.failureRate]). + * + * Overridable so tests can pin the outcome deterministically — a fake source that always + * "hits" or always "misses" — rather than relying on [Random.Default]'s real randomness. + */ + public var random: Random = Random.Default + /** * Resolves the [MockConfigRepository] to use, falling back to * [NetworkMockInitializer.requireConfigRepository] if not explicitly set. diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt index d8f3369..1f57fdd 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt @@ -1,11 +1,13 @@ package com.worldline.devview.networkmock.ktor.plugin import co.touchlab.kermit.Logger +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.NetworkMockState import com.worldline.devview.networkmock.core.model.OperationMockState import io.ktor.client.HttpClient import io.ktor.client.call.HttpClientCall import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.plugin import io.ktor.client.request.HttpRequest @@ -31,6 +33,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.io.IOException private val logger = Logger.withTag(tag = "DevViewNetworkMock") @@ -59,6 +62,9 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * - **Path Parameters**: Supports path parameters like `/users/{userId}` * - **Multiple Hosts**: Can mock different hosts (staging, production, etc.) * - **State Persistence**: Mock configuration persists across app restarts + * - **Failure Simulation**: An operation can deterministically simulate a network failure + * (see [com.worldline.devview.networkmock.core.model.OperationMockState.Failure]), or fail a + * configurable percentage of the time via `x-devview.failureRate` * * ## How It Works * 1. Plugin intercepts every HTTP request using Ktor's `HttpSend` mechanism @@ -117,8 +123,10 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * * ## Error Handling * The plugin fails gracefully — if configuration cannot be loaded, a response - * file is missing, or any exception occurs, it falls back to the actual network - * and logs the reason. + * file is missing, or any exception occurs while loading a declared mock, it falls back to the + * actual network and logs the reason. The one deliberate exception: a simulated failure (a + * `Failure` state, or a `failureRate` roll) throws intentionally, mirroring what a real network + * failure looks like to the app — that's the point, not an error to recover from. * * ## Thread Safety * The plugin is thread-safe. Multiple requests can be intercepted concurrently @@ -143,6 +151,7 @@ public val NetworkMockPlugin: HttpClientPlugin NETWORK (operation set to pass-through)" } execute(requestBuilder = requestBuilder) } + is OperationMockState.Failure -> { + logger.d { "$method $path -> FAILURE (${endpointState.kind}, forced)" } + throw simulatedFailure(kind = endpointState.kind, requestData = request) + } is OperationMockState.Mock -> { + val failureRate = mockMatch.config.failureRate + if (failureRate != null && random.nextDouble() < failureRate) { + logger.d { + "$method $path -> FAILURE (probabilistic, rate=$failureRate)" + } + throw simulatedFailure( + kind = FailureKind.CONNECTION_REFUSED, + requestData = request + ) + } + @Suppress("TooGenericExceptionCaught") try { val mockResponse = mockRepository.loadMockResponse( @@ -239,6 +263,26 @@ public val NetworkMockPlugin: HttpClientPlugin HttpRequestTimeoutException(request = requestData) + FailureKind.CONNECTION_REFUSED -> + IOException("Connection refused (simulated by DevView NetworkMock)") + } + /** * Creates a mock [HttpClientCall] without making an actual network request. * diff --git a/devview-networkmock/CLAUDE.md b/devview-networkmock/CLAUDE.md index 9cac3cd..07c2aef 100644 --- a/devview-networkmock/CLAUDE.md +++ b/devview-networkmock/CLAUDE.md @@ -141,7 +141,12 @@ composable (not the ViewModel — see `PreviewSheetState` below): `onSelectResponse` and dismisses the sheet. Each `MockItem` also has an eye-icon preview toggle (`isMarkedForPreview`/`onToggleMarkedForPreview`) that marks it *without* dismissing; once ≥1 response is marked, a "Preview .../Compare 2 responses" button appears and switches - to the preview page. + to the preview page. Below the grouped responses, a "SIMULATE FAILURE" sticky header plus one + `FailureItem` per `FailureKind` (built the same way as `NetworkItem`/`MockItem`, reusing the + shared `MockItemContent` — see `MockItem.kt`); tapping one calls `onSelectFailure` and + dismisses the sheet the same way a response row does. If the operation declares + `x-devview.failureRate`, a read-only `Text` row under that header shows the configured rate — + there is no in-UI editing for it, the field is spec-authored. - **Preview page** (`MockResponsePreviewPage.kt`, replaces the pre-sheet `NetworkMockEndpointPreviewBottomSheet.kt`): same diff-rendering body as before, now reached via a back arrow instead of a close button — going back returns to the picker page without clearing the marks. diff --git a/devview-networkmock/api/api.txt b/devview-networkmock/api/api.txt index 57c29a0..86c41d1 100644 --- a/devview-networkmock/api/api.txt +++ b/devview-networkmock/api/api.txt @@ -77,7 +77,7 @@ package com.worldline.devview.networkmock.theme { } @androidx.compose.runtime.Immutable public final class MockColorScheme { - ctor public MockColorScheme(com.worldline.devview.networkmock.theme.StatusColors informational, com.worldline.devview.networkmock.theme.StatusColors successful, com.worldline.devview.networkmock.theme.StatusColors redirection, com.worldline.devview.networkmock.theme.StatusColors clientError, com.worldline.devview.networkmock.theme.StatusColors serverError, com.worldline.devview.networkmock.theme.StatusColors unknown, com.worldline.devview.networkmock.theme.StatusColors network); + ctor public MockColorScheme(com.worldline.devview.networkmock.theme.StatusColors informational, com.worldline.devview.networkmock.theme.StatusColors successful, com.worldline.devview.networkmock.theme.StatusColors redirection, com.worldline.devview.networkmock.theme.StatusColors clientError, com.worldline.devview.networkmock.theme.StatusColors serverError, com.worldline.devview.networkmock.theme.StatusColors unknown, com.worldline.devview.networkmock.theme.StatusColors network, com.worldline.devview.networkmock.theme.StatusColors failure); method public com.worldline.devview.networkmock.theme.StatusColors component1(); method public com.worldline.devview.networkmock.theme.StatusColors component2(); method public com.worldline.devview.networkmock.theme.StatusColors component3(); @@ -85,9 +85,11 @@ package com.worldline.devview.networkmock.theme { method public com.worldline.devview.networkmock.theme.StatusColors component5(); method public com.worldline.devview.networkmock.theme.StatusColors component6(); method public com.worldline.devview.networkmock.theme.StatusColors component7(); - method public com.worldline.devview.networkmock.theme.MockColorScheme copy(optional com.worldline.devview.networkmock.theme.StatusColors informational, optional com.worldline.devview.networkmock.theme.StatusColors successful, optional com.worldline.devview.networkmock.theme.StatusColors redirection, optional com.worldline.devview.networkmock.theme.StatusColors clientError, optional com.worldline.devview.networkmock.theme.StatusColors serverError, optional com.worldline.devview.networkmock.theme.StatusColors unknown, optional com.worldline.devview.networkmock.theme.StatusColors network); + method public com.worldline.devview.networkmock.theme.StatusColors component8(); + method public com.worldline.devview.networkmock.theme.MockColorScheme copy(optional com.worldline.devview.networkmock.theme.StatusColors informational, optional com.worldline.devview.networkmock.theme.StatusColors successful, optional com.worldline.devview.networkmock.theme.StatusColors redirection, optional com.worldline.devview.networkmock.theme.StatusColors clientError, optional com.worldline.devview.networkmock.theme.StatusColors serverError, optional com.worldline.devview.networkmock.theme.StatusColors unknown, optional com.worldline.devview.networkmock.theme.StatusColors network, optional com.worldline.devview.networkmock.theme.StatusColors failure); method public operator com.worldline.devview.networkmock.theme.StatusColors get(com.worldline.devview.networkmock.core.model.StatusCodeFamily family); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getClientError(); + method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getFailure(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getInformational(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getNetwork(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getRedirection(); @@ -95,6 +97,7 @@ package com.worldline.devview.networkmock.theme { method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getSuccessful(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.theme.StatusColors getUnknown(); property public com.worldline.devview.networkmock.theme.StatusColors clientError; + property public com.worldline.devview.networkmock.theme.StatusColors failure; property public com.worldline.devview.networkmock.theme.StatusColors informational; property public com.worldline.devview.networkmock.theme.StatusColors network; property public com.worldline.devview.networkmock.theme.StatusColors redirection; @@ -163,6 +166,7 @@ package com.worldline.devview.networkmock.viewmodel { method public void reloadConfiguration(); method public void resetAllToNetwork(); method public void setGlobalMockingEnabled(boolean enabled); + method public void setOperationFailureState(com.worldline.devview.networkmock.core.model.OperationKey key, com.worldline.devview.networkmock.core.model.FailureKind kind); method public void setOperationMockState(com.worldline.devview.networkmock.core.model.OperationKey key, com.worldline.devview.networkmock.core.model.MockResponse? response); property public kotlinx.coroutines.flow.StateFlow sheetState; property public kotlinx.coroutines.flow.StateFlow uiState; diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt index 023d112..bc1b06d 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.HttpMethod import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.core.model.Operation @@ -125,6 +126,7 @@ class NetworkMockOperationSheetTest { private fun ComposeUiTest.setPickerPage( currentState: OperationMockState, onSelectResponse: (MockResponse?) -> Unit = {}, + onSelectFailure: (FailureKind) -> Unit = {}, onOpenPreview: () -> Unit = {}, onClose: () -> Unit = {} ) { @@ -151,6 +153,7 @@ class NetworkMockOperationSheetTest { ), markedForPreview = marked, onSelectResponse = onSelectResponse, + onSelectFailure = onSelectFailure, onTogglePreview = { response -> marked = marked.transition(response = response) }, onOpenPreview = onOpenPreview, onClose = onClose diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt index 9b64dee..1e84982 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close @@ -43,9 +44,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.worldline.devview.networkmock.components.ErrorState +import com.worldline.devview.networkmock.components.FailureItem import com.worldline.devview.networkmock.components.LoadingState import com.worldline.devview.networkmock.components.MockItem import com.worldline.devview.networkmock.components.NetworkItem +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.core.model.OperationMockState import com.worldline.devview.networkmock.core.model.StatusCodeFamily @@ -73,6 +76,7 @@ import kotlinx.coroutines.launch * @param sheetState The current [OperationSheetState] from [com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel.sheetState]. * @param onDismissRequest Called when the sheet should close (row tap, swipe, tap outside, close button). * @param onSelectResponse Called with the tapped response (or `null` for "no mock") when a row is selected. + * @param onSelectFailure Called with the tapped [FailureKind] when a failure-simulation row is selected. * @param modifier [Modifier] to be applied to the [ModalBottomSheet]. */ @Composable @@ -80,6 +84,7 @@ internal fun NetworkMockOperationSheet( sheetState: OperationSheetState, onDismissRequest: () -> Unit, onSelectResponse: (MockResponse?) -> Unit, + onSelectFailure: (FailureKind) -> Unit, modifier: Modifier = Modifier ) { val modalSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -146,6 +151,10 @@ internal fun NetworkMockOperationSheet( onSelectResponse(response) onClose() }, + onSelectFailure = { kind -> + onSelectFailure(kind) + onClose() + }, onTogglePreview = { response -> markedForPreview = markedForPreview.transition(response = response) }, @@ -173,6 +182,7 @@ internal fun OperationPickerPage( content: OperationSheetState.Content, markedForPreview: PreviewSheetState, onSelectResponse: (MockResponse?) -> Unit, + onSelectFailure: (FailureKind) -> Unit, onTogglePreview: (MockResponse) -> Unit, onOpenPreview: () -> Unit, onClose: () -> Unit, @@ -187,8 +197,11 @@ internal fun OperationPickerPage( it.statusCode == currentState.statusCode && it.exampleName == currentState.exampleName } + is OperationMockState.Failure -> null OperationMockState.Network -> null } + val selectedFailureKind = (endpoint.currentState as? OperationMockState.Failure)?.kind + val failureRate = endpoint.descriptor.config.failureRate Column( modifier = modifier.fillMaxWidth() @@ -243,6 +256,42 @@ internal fun OperationPickerPage( } } } + + stickyHeader(key = "header_simulate_failure") { + Surface { + Column { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + text = "SIMULATE FAILURE", + style = MaterialTheme.typography.labelLarge + ) + if (failureRate != null) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .testTag(tag = "operation_sheet_failure_rate"), + text = "Also configured to fail ${(failureRate * 100).toInt()}% " + + "of mocked requests on its own (x-devview.failureRate)", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + items(items = FailureKind.entries, key = { "failure_item_${it.name}" }) { kind -> + FailureItem( + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag(tag = "failure_item_${kind.name}"), + kind = kind, + selected = selectedFailureKind == kind, + onClick = { onSelectFailure(kind) } + ) + } } if (markedForPreview is PreviewSheetState.HasResponse) { @@ -337,6 +386,7 @@ private fun NetworkMockOperationSheetPickerPreview( ), markedForPreview = PreviewSheetState.Hidden, onSelectResponse = {}, + onSelectFailure = {}, onTogglePreview = {}, onOpenPreview = {}, onClose = {} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index 385ed9a..6f1c36e 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -124,17 +124,22 @@ public fun NetworkMockScreen( ) if (sheetState != OperationSheetState.Hidden) { + val openKey = (sheetState as? OperationSheetState.Content) + ?.operationUiModel + ?.descriptor + ?.key NetworkMockOperationSheet( sheetState = sheetState, onDismissRequest = viewModel::closeSheet, onSelectResponse = { response -> - val openKey = (sheetState as? OperationSheetState.Content) - ?.operationUiModel - ?.descriptor - ?.key if (openKey != null) { viewModel.setOperationMockState(key = openKey, response = response) } + }, + onSelectFailure = { kind -> + if (openKey != null) { + viewModel.setOperationFailureState(key = openKey, kind = kind) + } } ) } @@ -194,7 +199,8 @@ private fun ContentState( val mockedOperations by remember(key1 = uiState.specs) { derivedStateOf { uiState.specs.sumOf { spec -> - spec.operations.count { it.currentState is OperationMockState.Mock } + // "Mocked" means anything that isn't plain pass-through — Mock and Failure both count. + spec.operations.count { it.currentState !is OperationMockState.Network } } } } @@ -521,7 +527,10 @@ private fun OperationUiModel.matches( val matchesVersion = version == null || config.version == version val matchesMethod = methods.isEmpty() || config.method in methods val matchesMockState = mockStates.isEmpty() || when (currentState) { + // Failure counts as "Mocked" for this filter — like Mock, it's a deliberately + // configured non-default state, distinct only from plain pass-through. is OperationMockState.Mock -> MockStateFilter.MOCKED in mockStates + is OperationMockState.Failure -> MockStateFilter.MOCKED in mockStates OperationMockState.Network -> MockStateFilter.NETWORK in mockStates } return matchesQuery && matchesVersion && matchesMethod && matchesMockState diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt index 6badd60..e631ca0 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt @@ -53,6 +53,7 @@ internal fun EndpointCard( ) { val railColor = when (val state = endpoint.currentState) { is OperationMockState.Mock -> state.containerColor + is OperationMockState.Failure -> state.containerColor OperationMockState.Network -> Color.Transparent } Row( diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt index c8b898e..ec72bb1 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt @@ -32,6 +32,7 @@ internal fun EndpointStateChip( modifier: Modifier = Modifier, label: String = when (endpointMockState) { is OperationMockState.Mock -> endpointMockState.statusCode.toString() + is OperationMockState.Failure -> endpointMockState.displayName OperationMockState.Network -> endpointMockState.displayName }, chipTestTag: String = "endpoint_state_chip", diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt index 08c24c3..9489d98 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt @@ -21,10 +21,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.core.model.OperationMockState import com.worldline.devview.networkmock.preview.MockResponsePreviewParameterProvider @@ -60,7 +63,9 @@ internal fun MockItem( ) { MockItemContent( modifier = modifier, - statusCode = mockResponse.statusCode, + icon = iconForStatusCode(statusCode = mockResponse.statusCode), + contentColor = contentColorForStatusCode(statusCode = mockResponse.statusCode), + containerColor = containerColorForStatusCode(statusCode = mockResponse.statusCode), label = mockResponse.displayName, selected = selected, onClick = onClick, @@ -77,12 +82,41 @@ internal fun NetworkItem( modifier: Modifier = Modifier, selected: Boolean = false ) { + val state = OperationMockState.Network MockItemContent( modifier = modifier, - statusCode = null, - label = OperationMockState.Network.displayName, + icon = state.icon, + contentColor = state.contentColor, + containerColor = state.containerColor, + label = state.displayName, + selected = selected, + onClick = onClick, + isMarkedForPreview = false, + onToggleMarkedForPreview = null, + previewToggleTestTag = "mock_item_preview_toggle" + ) +} + +/** + * A selectable row simulating a deterministic network failure — see + * [com.worldline.devview.networkmock.core.model.OperationMockState.Failure]. Has nothing to + * preview, same as [NetworkItem]. + */ +@Composable +internal fun FailureItem( + kind: FailureKind, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + val state = OperationMockState.Failure(kind = kind) + MockItemContent( + modifier = modifier, + icon = state.icon, + contentColor = state.contentColor, + containerColor = state.containerColor, + label = state.displayName, selected = selected, - isNetwork = true, onClick = onClick, isMarkedForPreview = false, onToggleMarkedForPreview = null, @@ -92,38 +126,17 @@ internal fun NetworkItem( @Composable private fun MockItemContent( - statusCode: Int?, + icon: ImageVector, + contentColor: Color, + containerColor: Color, label: String, selected: Boolean, onClick: () -> Unit, isMarkedForPreview: Boolean, onToggleMarkedForPreview: (() -> Unit)?, previewToggleTestTag: String, - modifier: Modifier = Modifier, - isNetwork: Boolean = false + modifier: Modifier = Modifier ) { - val (icon, contentColor, containerColor) = when (isNetwork) { - true -> { - val state = OperationMockState.Network - Triple( - first = state.icon, - second = state.contentColor, - third = state.containerColor - ) - } - - false -> { - requireNotNull(value = statusCode) { - "Status code must not be null for non-network items" - } - Triple( - first = iconForStatusCode(statusCode = statusCode), - second = contentColorForStatusCode(statusCode = statusCode), - third = containerColorForStatusCode(statusCode = statusCode) - ) - } - } - Row( modifier = Modifier .fillMaxWidth() @@ -217,6 +230,22 @@ private fun NetworkItemPreview( } } +@Preview(locale = "en") +@Composable +private fun FailureItemPreview( + @PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean +) { + MaterialTheme { + Surface { + FailureItem( + kind = FailureKind.TIMEOUT, + selected = selected, + onClick = {} + ) + } + } +} + @Preview(locale = "en") @Composable private fun MockItemSelectedPreview( diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/theme/MockColorScheme.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/theme/MockColorScheme.kt index 16e4c12..e6c0bce 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/theme/MockColorScheme.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/theme/MockColorScheme.kt @@ -55,6 +55,10 @@ public data class StatusColors(public val container: Color, public val content: * @property serverError Colors for [StatusCodeFamily.SERVER_ERROR] (5xx). * @property unknown Colors for [StatusCodeFamily.UNKNOWN]. * @property network Colors for the network pass-through state (no mock active). + * @property failure Colors for a simulated network failure (see + * [com.worldline.devview.networkmock.core.model.OperationMockState.Failure]) — deliberately + * distinct from [clientError]/[serverError], since a failure is DevView breaking the + * connection, not the mocked API returning an error status. * * @see StatusColors * @see LocalMockColorScheme @@ -67,7 +71,8 @@ public data class MockColorScheme( public val clientError: StatusColors, public val serverError: StatusColors, public val unknown: StatusColors, - public val network: StatusColors + public val network: StatusColors, + public val failure: StatusColors ) { /** * Returns the [StatusColors] for the given [family]. @@ -113,6 +118,10 @@ public data class MockColorScheme( network = StatusColors( container = Color(color = 0xFFABC4ED), content = Color(color = 0xFF0D1F3A) + ), + failure = StatusColors( + container = Color(color = 0xFFF6D186), + content = Color(color = 0xFF4D3300) ) ) @@ -147,6 +156,10 @@ public data class MockColorScheme( network = StatusColors( container = Color(color = 0xFF6290DD), content = Color(color = 0xFF0D1F3A) + ), + failure = StatusColors( + container = Color(color = 0xFFE6B84D), + content = Color(color = 0xFF4D3300) ) ) } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt index 4b3cfaf..a33a258 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.WifiOff import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -77,6 +78,7 @@ internal fun OperationUiModel.Companion.fake( internal val OperationMockState.icon: ImageVector get() = when (this) { is OperationMockState.Mock -> iconForStatusCode(statusCode = statusCode) + is OperationMockState.Failure -> Icons.Rounded.WifiOff OperationMockState.Network -> Icons.Rounded.Wifi } @@ -94,6 +96,7 @@ internal val OperationMockState.contentColor: Color @ReadOnlyComposable get() = when (this) { is OperationMockState.Mock -> contentColorForStatusCode(statusCode = statusCode) + is OperationMockState.Failure -> rememberMockColorScheme().failure.content OperationMockState.Network -> rememberMockColorScheme().network.content } @@ -107,6 +110,7 @@ internal val OperationMockState.containerColor: Color @ReadOnlyComposable get() = when (this) { is OperationMockState.Mock -> containerColorForStatusCode(statusCode = statusCode) + is OperationMockState.Failure -> rememberMockColorScheme().failure.container OperationMockState.Network -> rememberMockColorScheme().network.container } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt index b09c0ea..c060b2f 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt @@ -3,6 +3,7 @@ package com.worldline.devview.networkmock.viewmodel import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.MockConfiguration import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.core.model.OperationDescriptor @@ -298,6 +299,26 @@ public class NetworkMockViewModel( } } + /** + * Sets an operation to deterministically simulate a network failure of the given [kind]. + * + * Every request to the operation fails the same way, the same way [setOperationMockState] + * makes an operation always serve the same response — the two are mutually exclusive + * states, so selecting a failure kind here replaces any previously-selected mock response. + * + * @param key The [OperationKey] identifying the spec and operation + * @param kind The kind of network failure to simulate + * @see OperationMockState.Failure + */ + public fun setOperationFailureState(key: OperationKey, kind: FailureKind) { + viewModelScope.launch { + stateRepository.setOperationMockState( + key = key, + state = OperationMockState.Failure(kind = kind) + ) + } + } + /** * Resets all operation mocks to use actual network. * diff --git a/docs/guides/theming.md b/docs/guides/theming.md index 202121e..412e5d4 100644 --- a/docs/guides/theming.md +++ b/docs/guides/theming.md @@ -77,8 +77,9 @@ signal. ## Network Mock Status Colors Like the Console Logger's `LogColorScheme`, the Network Mock module's per-status-family colors -(2xx green, 4xx/5xx red, etc.) are **not** derived from `MaterialTheme.colorScheme` — a mocked -2xx response needs to read as "success" regardless of your app's brand colors. `MockColorScheme.Light` +(2xx green, 4xx/5xx red, etc.), the network pass-through state, and simulated-failure state are +**not** derived from `MaterialTheme.colorScheme` — a mocked 2xx response needs to read as +"success" regardless of your app's brand colors. `MockColorScheme.Light` and `MockColorScheme.Dark` are two complete, hand-tuned palettes chosen for contrast in each theme. Provide the palette where you already configure your app's `MaterialTheme`, so it switches diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index 11b7add..5829b6d 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -131,19 +131,51 @@ Refs resolve one level deep — a referenced component's own `$ref` (if any) is ### x-devview extension -Vanilla OpenAPI has no field for response delay simulation, so it lives under the standard `x-`-prefixed [Specification Extensions](https://spec.openapis.org/oas/v3.1.0#specification-extensions) mechanism: +Vanilla OpenAPI has no field for response delay simulation or failure injection, so both live under the standard `x-`-prefixed [Specification Extensions](https://spec.openapis.org/oas/v3.1.0#specification-extensions) mechanism: ```yaml x-devview: - delayMs: 200 # document root — spec-wide default + delayMs: 200 # document root — spec-wide default + failureRate: 0.1 # ignored at the document root, see below paths: /users/{userId}: get: x-devview: - delayMs: 500 # per-operation — overrides the document default + delayMs: 500 # per-operation — overrides the document default + failureRate: 0.1 # per-operation only — 10% of otherwise-mocked requests fail ``` +`failureRate`, unlike `delayMs`, has **no spec-wide default** — a document-root `failureRate` is +parsed but ignored. "Some percentage of everything fails" is a much blunter tool than "this +specific flaky endpoint fails sometimes", so it's deliberately operation-level only. See +[Simulating failures](#simulating-failures). + +## Simulating failures + +An operation can be made to fail instead of returning a response, two ways: + +- **Deterministically**, by selecting a failure kind in the operation sheet's picker page (a + `Failure` row alongside the response variants) — every request to that operation fails the + same way until the selection changes, the same way [`Mock`](#datastore-schema) always serves + the same response. +- **Probabilistically**, via the spec's `x-devview.failureRate` (0.0–1.0) — each request to an + otherwise-mocked operation independently rolls against the configured rate. This only applies + when the operation would otherwise serve a mock response; an operation left on `Network` + passthrough is never affected, keeping real network traffic untouched by default. + +Two failure kinds are supported, each mirroring the exception a real Ktor engine (OkHttp on +Android, Darwin on iOS) throws for the equivalent real condition, so an app's existing error +handling exercises the same code path: + +| Kind | Mirrors | +|---|---| +| Timeout | `io.ktor.client.plugins.HttpRequestTimeoutException` | +| Connection Refused | a connection-level `kotlinx.io.IOException` | + +The probabilistic roll uses an injectable `Random` (`NetworkMockConfig.random`, defaulting to +`Random.Default`) — override it in tests to pin the outcome deterministically. + ## Caching & Reload `MockConfigRepository` parses every configured spec once and caches the result — subsequent @@ -165,7 +197,9 @@ State is persisted via `MockStateRepository`: | `network_mock_schema_version` | Int | Gates the one-shot pre-0.2.0 migration below | | `network_mock_operation_{compositeKey}` | String (JSON) | Per-operation state | -`OperationMockState` is serialized as `{"type":"network"}` (pass-through) or `{"type":"mock","statusCode":200,"exampleName":"default"}`. +`OperationMockState` is serialized as `{"type":"network"}` (pass-through), +`{"type":"mock","statusCode":200,"exampleName":"default"}`, or +`{"type":"failure","kind":"timeout"}` / `{"type":"failure","kind":"connection_refused"}`. **Upgrading from a pre-0.2.0 release**: the operation-state key shape changed (`{groupId}-{environmentId}-{endpointId}` → `{specId}-{operationId}`), and so did the `Mock` payload (a response file name → `(statusCode, exampleName)`). On first launch after upgrading, every `network_mock_endpoint_*` entry from the old shape is wiped once — this is disabled-by-default developer-tooling state, not user data, so previously-selected mocks are reset rather than translated. The global mocking toggle is unaffected. See the [migration guide](../guides/migrating-to-openapi.md) for converting an existing `mocks.json`. diff --git a/docs/modules/networkmock-ktor.md b/docs/modules/networkmock-ktor.md index 4cf01d6..a05b8a5 100644 --- a/docs/modules/networkmock-ktor.md +++ b/docs/modules/networkmock-ktor.md @@ -46,8 +46,13 @@ For every outgoing request, the plugin: 4. If no match → sends the real request. 5. If matched, reads the operation's `OperationMockState`: - `Network` or `null` → sends the real request. - - `Mock(statusCode, exampleName)` → loads that declared response variant and returns a synthetic response. -6. On any error (undeclared variant, missing file, exception) → falls back to the real network and logs the reason. **The plugin never throws.** + - `Failure(kind)` → throws immediately, simulating that failure kind (see below). No response is loaded. + - `Mock(statusCode, exampleName)`: + - If the operation declares `x-devview.failureRate` and the configured `Random` rolls below it → throws a simulated connection failure instead, same as `Failure(CONNECTION_REFUSED)`. + - Otherwise, loads that declared response variant and returns a synthetic response. +6. On any error loading a declared mock (undeclared variant, missing file, exception) → falls back to the real network and logs the reason. + +Simulated failures (deterministic `Failure` states and the probabilistic `failureRate` roll) are the one case where **the plugin does throw** — this is deliberate: it's mirroring a real network failure, not an internal error to recover from. See [Simulating failures](networkmock-core.md#simulating-failures) for which exception each failure kind throws. Mock responses are returned with HTTP/1.1 status, `Content-Type` set from the response's declared media type (defaulting to `application/json`), any additional headers declared on `responses..headers`, and the response body as the content. See [Response headers and content type](networkmock-core.md#response-headers-and-content-type). diff --git a/docs/modules/networkmock-ui.md b/docs/modules/networkmock-ui.md index a58e878..a7cacaf 100644 --- a/docs/modules/networkmock-ui.md +++ b/docs/modules/networkmock-ui.md @@ -22,7 +22,7 @@ The main screen shows a global mock toggle at the top, followed by a scrollable A bottom sheet over the operation list — opened by tapping a row, not a navigation destination — showing every discovered mock response for that operation, grouped by status code family (2xx, 4xx, 5xx, etc.). Two pages: -- **Picker page** (opens first): a header showing the operation's name, method, and path (wraps instead of truncating, no version badge — same anatomy as the list row above, minus its state chip: the active response is already marked below, so the header doesn't repeat it), then a "No mock" row to route the operation to the actual network, plus one row per response variant. Tapping a row activates it and dismisses the sheet. Each response row also has a preview toggle (an eye icon) that marks it without dismissing the sheet — marking one response reveals a "Preview `statusCode - exampleName`" button at the bottom; marking a second changes it to "Compare 2 responses". Tapping that button opens the preview page. +- **Picker page** (opens first): a header showing the operation's name, method, and path (wraps instead of truncating, no version badge — same anatomy as the list row above, minus its state chip: the active response is already marked below, so the header doesn't repeat it), then a "No mock" row to route the operation to the actual network, plus one row per response variant. Tapping a row activates it and dismisses the sheet. Each response row also has a preview toggle (an eye icon) that marks it without dismissing the sheet — marking one response reveals a "Preview `statusCode - exampleName`" button at the bottom; marking a second changes it to "Compare 2 responses". Tapping that button opens the preview page. Below the response variants, a "Simulate Failure" section lists one row per failure kind (Timeout, Connection Refused) — tapping one deterministically fails every request to that operation until changed. If the spec declares `x-devview.failureRate`, a read-only note under the section header shows the configured probabilistic rate. See [Simulating failures](networkmock-core.md#simulating-failures). - **Preview page**: shows the marked response's body, or — when two are marked — a diff between them (a side-by-side or inline diff, LCS-based, collapsing long unchanged runs). A back arrow returns to the picker page without losing the marks. ## Theming diff --git a/docs/modules/networkmock-workflows.md b/docs/modules/networkmock-workflows.md index a4e20b0..69dc26b 100644 --- a/docs/modules/networkmock-workflows.md +++ b/docs/modules/networkmock-workflows.md @@ -114,6 +114,30 @@ paths: delayMs: 500 # overrides the 200ms default for this operation only ``` +## Simulating a network failure + +**Deterministically** — every request to the operation fails the same way until you change it: + +1. Open DevView → Network Mock → tap your operation. +2. Scroll past the response variants to "Simulate Failure" and tap Timeout or Connection Refused. +3. The operation's state chip reflects the selected failure. Tap "No mock" (or select a response) to stop simulating it. + +**Probabilistically** — a percentage of requests fail on their own, the rest behave normally: + +```yaml +paths: + /v1/users/{userId}: + get: + x-devview: + failureRate: 0.1 # 10% of requests to this operation fail, independently, each time +``` + +This only rolls for requests that would otherwise be mocked — an operation left on `Network` +passthrough is never affected. If the operation's picker page shows a configured failure rate, +that's this field — it's read-only in the UI; edit the spec to change it. See +[Simulating failures](networkmock-core.md#simulating-failures) for the exact exception each +failure kind throws. + ## Resetting all mocks - **UI**: Open DevView → Network Mock → tap the restore icon in the top toolbar.