Merge feat/observability-error-logging: Stage 16A error logging, crash capture, and bug reporting (1475/1475 tests passing)
This commit is contained in:
commit
6389acd2ae
|
|
@ -1,3 +1,13 @@
|
|||
<!--
|
||||
MD011 (no-reversed-links) is disabled for this file.
|
||||
|
||||
This PRD uses `(descriptive text)[cite: n]` source-citation markers, which are
|
||||
syntactically indistinguishable from a reversed Markdown link. They are not
|
||||
links and must not be "corrected" — running `markdownlint --fix` on this file
|
||||
will silently rewrite them into malformed links and destroy the citations.
|
||||
-->
|
||||
<!-- markdownlint-disable MD011 -->
|
||||
|
||||
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
||||
|
||||
## 1. Executive Summary & Vision
|
||||
|
|
@ -52,6 +62,7 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
|
|||
|
||||
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
|
||||
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
|
||||
- **Observability & Support:** Structured logging (`KcLogger`) with five severities and a bounded in-memory evidence buffer; global uncaught-error capture (`KcErrorReporter`) across framework, platform, and zone channels; operator bug reporting (`KcBugReportService`) that attaches the recent log tail; mandatory credential redaction (`KcRedactor`) on all logged context so API tokens and secrets can never reach a log or a submitted report.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -76,7 +87,12 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
|
|||
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] |
|
||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
|
||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
|
||||
| **Stage 10B** | Square Catalog & Inventory Sync | ✅ Merged |
|
||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ✅ Merged |
|
||||
| **Stage 10E** | `SquareSyncController`, `square()`/`multi()` factories, ID fix | ✅ Merged |
|
||||
| **Stage 10F** | Square Sync UI (`SquareSyncStatusPanel`, Integrations pages) | ✅ Merged |
|
||||
| **Stage 16A** | **Error Logging, Crash Capture & Bug Reporting** | ✅ Merged |
|
||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | 🔄 **Next Stage** |
|
||||
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
|
||||
| **Stage 16B** | Secure Credential Storage & Auth Guard | ⏳ Planned — Production blocker |
|
||||
| **Stage 16C** | Retry/Rate Limiting & Remote Diagnostics | ⏳ Planned — Staging blocker |
|
||||
|
|
|
|||
|
|
@ -6,12 +6,52 @@
|
|||
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
||||
- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout
|
||||
- merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55`
|
||||
- active branch: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting
|
||||
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
||||
- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); dart analyze clean across all packages
|
||||
- total tests on branch: 1475/1475 passing (+85 new: 56 core + 10 design_system + 10 kell_web + 9 kell_mobile); dart analyze --fatal-infos clean across all packages
|
||||
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
|
||||
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
|
||||
|
||||
## Slice tracker
|
||||
|
||||
### feat/observability-error-logging
|
||||
|
||||
- status: ready for merge
|
||||
- branch: `feat/observability-error-logging`
|
||||
- stage: 16A — Error logging, crash reporting, and bug reporting
|
||||
- date: 2026-08-29
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- closes audit findings: P3.4 (structured logging), P3.5 (crash reporting), and the "No logging infrastructure" / "No crash reporting" architectural risks in `PROJECT_STATE_AUDIT.md` §3.4
|
||||
- files changed:
|
||||
- `core/lib/src/observability/kc_log_level.dart` — new `KcLogLevel` enum (debug/info/warning/error/fatal) with `severity`, `label`, `isAtLeast()`, `fromString()`
|
||||
- `core/lib/src/observability/kc_log_record.dart` — new immutable `KcLogRecord` (level, source, message, timestamp, unmodifiable context, error, stackTrace); `format()`, `toJson()`, `isFailure`
|
||||
- `core/lib/src/observability/kc_redactor.dart` — new `KcRedactor`; `isSensitiveKey()`, `redactMap()` (recurses nested maps/lists), `maskValue()`; 14 sensitive key fragments
|
||||
- `core/lib/src/observability/kc_log_sink.dart` — new `KcLogSink` contract; `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer with `recent()`/`recordsAtLeast()`/`clear()`), `KcMultiLogSink` (fault-tolerant fan-out)
|
||||
- `core/lib/src/observability/kc_logger.dart` — new `KcLogger` facade; level helpers, threshold filtering, automatic context redaction, `forSource()`, `KcLogger.silent()`; never throws
|
||||
- `core/lib/src/observability/kc_error_reporter.dart` — new `KcErrorReporter`; `install()` wires `FlutterError.onError` + `PlatformDispatcher.instance.onError`, `runGuarded()` wraps `runApp`, `restore()` for test isolation
|
||||
- `core/lib/src/observability/kc_bug_report.dart` — new `KcBugSeverity` enum and `KcBugReport` model with redacted diagnostics, `hasFailureEvidence`, `toJson()`, `toMarkdown()`
|
||||
- `core/lib/src/observability/kc_bug_report_service.dart` — new `KcBugReportResult`, `KcBugReportSink` contract, `KcInMemoryBugReportSink`, and `KcBugReportService` (validation, log-tail evidence, graceful sink-failure handling)
|
||||
- `core/lib/src/observability/kc_observability.dart` — new `KcObservability` bundle; `standard()` and `silent()` factories guarantee logger and bug report service share one evidence buffer
|
||||
- `core/lib/core.dart` — barrel updated with all 9 observability exports
|
||||
- `design_system/lib/src/widgets/kc_bug_report_dialog.dart` — new `KcBugReportDraft` and `KcBugReportDialog` (title/description/severity/reporter, inline validation, no-credentials notice)
|
||||
- `design_system/lib/design_system.dart` — barrel updated
|
||||
- `kell_web/lib/composition/app_services.dart` — `observability` field + `logger`/`bugReportService` accessors + `appVersion`; all 4 factories stamp environment labels (FAKE/WP/SQ/MULTI)
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — mirrored observability wiring across all 4 factories
|
||||
- `kell_web/lib/main.dart` — `KcErrorReporter.install()` + `runGuarded()` + startup record
|
||||
- `kell_mobile/lib/main.dart` — mirrored error handler installation and startup record
|
||||
- `kell_web/lib/shell/app_shell.dart` — `_ReportProblemAction` app bar action wired to `bugReportService`
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — `_reportProblem()` app bar action capturing the active tab
|
||||
- tests added:
|
||||
- `core/test/observability_test.dart` — 56 tests across `KcLogLevel`, `KcRedactor`, `KcLogRecord`, `KcInMemoryLogSink`, `KcMultiLogSink`, `KcLogger`, `KcErrorReporter`, `KcBugSeverity`, `KcBugReport`, `KcBugReportService`
|
||||
- `design_system/test/kc_bug_report_dialog_test.dart` — 10 tests (rendering, screen context, validation, trimming, severity selection, cancel)
|
||||
- `kell_web/test/shell/bug_report_action_test.dart` — 10 tests (service wiring, shared buffer, environment labels, shell submission flow)
|
||||
- `kell_mobile/test/shell/bug_report_action_test.dart` — 9 tests (service wiring, shared buffer, shell submission flow, active-tab capture)
|
||||
- tests: passed (1475/1475 total; core 90/90 +56; design_system 51/51 +10; kell_web 62/62 +10; kell_mobile 81/81 +9)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages and apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-mobile-pos
|
||||
|
||||
- status: in progress (ready to merge)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ Rules:
|
|||
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
|
||||
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
|
||||
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
|
||||
- ✅ Square Mobile POS / Checkout landed (Stage 10C-POS complete — merged `feat/square-mobile-pos` → `main`, commit `7bf8f55`, 2026-07-25).
|
||||
- ✅ Platform observability activated (Stage 16A complete — branch `feat/observability-error-logging`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells.
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -141,24 +143,25 @@ Rules:
|
|||
|
||||
### Latest known validation state on `main`
|
||||
|
||||
> **Last verified:** 2026-07-25 — Comprehensive State Audit (`chore/audit-state-sync`)
|
||||
> **Last verified:** 2026-08-29 — Release Readiness Review (`feat/observability-error-logging`)
|
||||
|
||||
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||
- All packages passing
|
||||
- latest reported count for `core`: `24/24 passed`
|
||||
- latest reported count for `design_system`: `41/41 passed`
|
||||
- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C)
|
||||
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS)
|
||||
- latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability)
|
||||
- latest reported count for `design_system`: `51/51 passed` ← +10 new (Stage 16A bug report dialog)
|
||||
- latest reported count for `feature_inventory`: `167/167 passed`
|
||||
- latest reported count for `feature_orders`: `234/234 passed`
|
||||
- latest reported count for `feature_wordpress`: `545/545 passed`
|
||||
- latest reported count for `kell_web`: `52/52 passed`
|
||||
- latest reported count for `kell_web`: `62/62 passed` ← +10 new (Stage 16A shell wiring)
|
||||
- latest reported count for `integrations`: `123/123 passed`
|
||||
- latest reported count for `data`: `63/63 passed`
|
||||
- latest reported count for `auth`: `42/42 passed`
|
||||
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS)
|
||||
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`)
|
||||
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge
|
||||
- latest reported count for `kell_mobile`: `81/81 passed` ← +9 new (Stage 16A shell wiring)
|
||||
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
|
||||
- total: `1,475/1,475 passed` (+85 new from Stage 16A branch `feat/observability-error-logging`)
|
||||
- active branch: `feat/observability-error-logging` (Stage 16A) — ready for merge
|
||||
|
||||
> **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged.
|
||||
> **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker.
|
||||
|
||||
#### Baseline test coverage (established 2026-05-22)
|
||||
|
||||
|
|
@ -233,6 +236,41 @@ Delivered:
|
|||
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
|
||||
- Analyze clean
|
||||
|
||||
### Stage 16A — complete (2026-08-29)
|
||||
|
||||
**`feat/observability-error-logging`** — Error logging, crash capture, and bug reporting
|
||||
|
||||
Closes audit findings P3.4 (structured logging), P3.5 (crash reporting), and the
|
||||
"No logging infrastructure" / "No crash reporting" architectural risks.
|
||||
|
||||
Delivered in `core`:
|
||||
|
||||
- `KcLogLevel` — five ordered severities (`debug`, `info`, `warning`, `error`, `fatal`) with threshold comparison
|
||||
- `KcLogRecord` — immutable structured record with `format()`, `toJson()`, and `isFailure`
|
||||
- `KcRedactor` — credential redaction applied to every logged context map, traversing nested maps and lists
|
||||
- `KcLogSink` contract with `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer), and `KcMultiLogSink` (fault-tolerant fan-out)
|
||||
- `KcLogger` — the logging facade; drops sub-threshold records, redacts context, and never throws
|
||||
- `KcErrorReporter` — installs `FlutterError.onError`, `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`
|
||||
- `KcBugReport` / `KcBugSeverity` — the bug report model with redacted diagnostics, `toJson()`, and `toMarkdown()`
|
||||
- `KcBugReportSink` contract + `KcInMemoryBugReportSink`
|
||||
- `KcBugReportService` — validates input, attaches the recent log tail as evidence, and degrades gracefully when the tracker is unreachable
|
||||
- `KcObservability` — assembles logger, evidence buffer, and bug report service so they always share one buffer
|
||||
|
||||
Delivered in `design_system`:
|
||||
|
||||
- `KcBugReportDialog` + `KcBugReportDraft` — the shared operator reporting form with inline validation and a stated no-credentials guarantee
|
||||
|
||||
Wired into both app targets:
|
||||
|
||||
- `KcObservability` held by `AppServices` and `MobileAppServices`; every factory stamps its environment label (`FAKE`, `WP`, `SQ`, `MULTI`)
|
||||
- `KcErrorReporter.install(...)` + `runGuarded(...)` in both `main.dart` entry points, with a startup record capturing the effective environment and any fake-mode fallback
|
||||
- "Report a problem" action in the `AppShell` app bar and the `MobileShell` app bar, capturing the active screen
|
||||
|
||||
Validation:
|
||||
|
||||
- 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total
|
||||
- `dart analyze --fatal-infos` clean across all packages and apps
|
||||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
||||
|
|
@ -312,11 +350,11 @@ These items harden the platform for production deployment.
|
|||
|
||||
| ID | Item | Target Stage |
|
||||
| ----- | --------------------------------------------------------------------------------------- | ----------------- |
|
||||
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A |
|
||||
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16C |
|
||||
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
|
||||
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
|
||||
| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A |
|
||||
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A |
|
||||
| P3.4 | ~~Structured logging infrastructure~~ - DONE: `KcLogger` (Stage 16A) | Complete |
|
||||
| P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete |
|
||||
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
|
||||
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
|
||||
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone |
|
||||
|
|
@ -327,14 +365,18 @@ These items harden the platform for production deployment.
|
|||
|
||||
### Key technical debt findings
|
||||
|
||||
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL)
|
||||
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH)
|
||||
- ~~**`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory**~~ — ✅ RESOLVED in Stage 10E; both factories exist and `KcBootstrap` validates Square credentials
|
||||
- ~~**`SquareCatalogSyncService` ID conflation**~~ — ✅ RESOLVED in Stage 10E; `syncCatalog()` now reads `draft.squareCatalogObjectId`
|
||||
- ~~**No logging infrastructure**~~ — ✅ RESOLVED in Stage 16A; `KcLogger` with structured records and credential redaction
|
||||
- ~~**No crash reporting**~~ — ✅ RESOLVED in Stage 16A; `KcErrorReporter` captures framework, platform, and zone errors
|
||||
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
|
||||
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
|
||||
- **No remote log or crash sink** — diagnostics are in-process only and lost on restart; unavailable off-device (HIGH)
|
||||
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
|
||||
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
|
||||
- **`feature_policy` has only a smoke test** — no controller or widget tests (MEDIUM)
|
||||
- **`feature_policy` has only a smoke test** — 17 tests in one file; no controller or widget tests (MEDIUM)
|
||||
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
|
||||
- **No dependency injection / state management framework** — `ChangeNotifier` + direct construction will strain as the sync engine and offline queue land (MEDIUM)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1202,7 +1244,10 @@ Harden the platform for production deployment beyond a single operator.
|
|||
- `feat/error-tracking`
|
||||
- `feat/secure-deployment`
|
||||
|
||||
#### Stage 16A — Error tracking and observability
|
||||
#### ~~Stage 16A — Error tracking and observability~~ ✅ COMPLETE
|
||||
|
||||
> Delivered on `feat/observability-error-logging` (2026-08-29).
|
||||
> See "Stage 16A — complete (2026-08-29)" above for the full manifest.
|
||||
|
||||
##### Goal
|
||||
|
||||
|
|
@ -1210,10 +1255,25 @@ Add crash reporting, error tracking, and basic usage analytics.
|
|||
|
||||
##### Requirements
|
||||
|
||||
- Integrate lightweight crash reporting (e.g., Sentry)
|
||||
- Add structured logging for API calls, sync operations, and errors
|
||||
- Add basic usage analytics (feature usage frequency, sync success rates)
|
||||
- Add health check dashboard showing integration status per channel
|
||||
- ✅ Crash/error capture — `KcErrorReporter` installs `FlutterError.onError`,
|
||||
`PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`.
|
||||
Implemented as a first-party abstraction rather than binding directly to
|
||||
Sentry so no vendor dependency is introduced before the deployment model is
|
||||
settled; `KcLogSink` is the extension point for a hosted backend.
|
||||
- ✅ Structured logging for errors and state transitions — `KcLogger` with
|
||||
five severities, threshold filtering, and mandatory credential redaction.
|
||||
- ⏳ Usage analytics (feature usage frequency, sync success rates) — **deferred**.
|
||||
`KcLogRecord` carries the structured `context` needed to derive these, but no
|
||||
aggregation or reporting surface exists yet.
|
||||
- ⏳ Health check dashboard per channel — **deferred**. The `IntegrationHealthCheck`
|
||||
contract exists in `integrations` but has no implementation or UI.
|
||||
|
||||
##### Remaining scope (tracked as Stage 16C)
|
||||
|
||||
- Remote log/crash sink (`KcLogSink` implementation posting to a backend)
|
||||
- Integration health dashboard driven by `IntegrationHealthCheck`
|
||||
- Usage analytics aggregation
|
||||
- In-app log viewer for operator self-service diagnostics
|
||||
|
||||
#### Stage 16B — Secure deployment configuration
|
||||
|
||||
|
|
@ -1228,6 +1288,25 @@ Move beyond `--dart-define` for credential management in production.
|
|||
- Connection testing UI (verify WooCommerce and Square connectivity)
|
||||
- Environment-specific configuration (dev/staging/production)
|
||||
|
||||
#### Stage 16C — Resilience and remote diagnostics
|
||||
|
||||
##### Goal
|
||||
|
||||
Make integrations survive transient failure, and make diagnostics visible off-device.
|
||||
|
||||
##### Requirements
|
||||
|
||||
- Wire `ExponentialBackoffRetryPolicy` into `SquareApiClient` and
|
||||
`WooCommerceApiClient` for 5xx and network errors (P3.1)
|
||||
- Wire `TokenBucketRateLimiter` into Square calls — Square enforces rate limits
|
||||
and currently a burst sync can be throttled with no recovery
|
||||
- Implement `IntegrationHealthCheck` per channel and surface it on the
|
||||
Integrations page
|
||||
- Remote `KcLogSink` implementation so `ERROR`/`FATAL` records and submitted bug
|
||||
reports leave the device
|
||||
- In-app log viewer for operator self-service diagnostics
|
||||
- Usage analytics aggregation (feature usage frequency, sync success rates)
|
||||
|
||||
---
|
||||
|
||||
## Suggested improvements (identified from codebase analysis)
|
||||
|
|
@ -1289,15 +1368,17 @@ Architecture documentation (`traceability-index.md` coverage gaps) identifies "P
|
|||
|
||||
**Recommendation:** Addressed in **Stage 9C — WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API.
|
||||
|
||||
### 8. Error tracking and observability
|
||||
### 8. ~~Error tracking and observability~~ ✅ PARTIALLY RESOLVED
|
||||
|
||||
No monitoring, error tracking, or analytics strategy exists for the Flutter applications. In production use:
|
||||
|
||||
- Runtime errors are invisible unless the operator sees them
|
||||
- No crash reporting
|
||||
- No usage analytics to guide prioritization
|
||||
|
||||
**Recommendation:** Addressed in **Stage 16A — Error tracking and observability**.
|
||||
> Resolved for error capture and diagnostics by **Stage 16A** (2026-08-29).
|
||||
> Runtime errors are now captured by `KcErrorReporter`, recorded through
|
||||
> `KcLogger`, and attachable to operator bug reports via `KcBugReportService`.
|
||||
> Credential redaction (`KcRedactor`) is enforced on every logged context map.
|
||||
>
|
||||
> **Still open:** logs and reports are held in-process only — there is no remote
|
||||
> sink, so diagnostics are lost when the app restarts and are unavailable to
|
||||
> anyone but the operator on the device. Usage analytics remain unimplemented.
|
||||
> Both are tracked as **Stage 16C**.
|
||||
|
||||
### 9. ~~Empty tools directory~~ ✅ RESOLVED
|
||||
|
||||
|
|
@ -1507,12 +1588,14 @@ Working rules:
|
|||
|
||||
### `KC_ENV` values
|
||||
|
||||
| Value | Description | Status |
|
||||
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
|
||||
| `fake` | All fake repositories | ✅ Active |
|
||||
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
||||
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) |
|
||||
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) |
|
||||
| Value | Description | Status |
|
||||
| ----------- | -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `fake` | All fake repositories | ✅ Active |
|
||||
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
||||
| `square` | Square real + others fake | ✅ Active — `square()` factory + `KcBootstrap` validation (Stage 10E) |
|
||||
| `multi` | Both WooCommerce and Square real | ✅ Active — `multi()` factory + `KcBootstrap` validation (Stage 10E) |
|
||||
|
||||
> **Credential handling:** All four modes fall back to `fake` with a logged warning when required credentials are absent. Credential values are never written to logs or bug reports — `KcRedactor` masks any context key containing `token`, `secret`, `key`, `auth`, or `credential`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This section contains operational runbooks, CI/CD documentation, and business pr
|
|||
| ------------------------------------------------- | ---------------------------------------------------- | ---------------- |
|
||||
| [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | ✅ Comprehensive |
|
||||
| [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | ✅ Complete |
|
||||
| [Release Checklist](release-checklist.md) | Per-environment release gates and sign-off record | ✅ Complete |
|
||||
|
||||
## Analysis Findings
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
# Release Checklist
|
||||
|
||||
**Owner:** Release Committee
|
||||
**Applies to:** `kell_web` (Flutter Web / Windows Desktop) and `kell_mobile` (Flutter Android)
|
||||
**Last reviewed:** 2026-08-29
|
||||
**Review cycle:** Every release; document reviewed at least quarterly
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the **mandatory gates** a build must pass before it is
|
||||
promoted into each target environment. Each environment inherits every gate from
|
||||
the environment before it, then adds its own.
|
||||
|
||||
Promotion is **sequential**: Development → Test/QA → Staging → Production. A
|
||||
build may not skip an environment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment Matrix
|
||||
|
||||
| Environment | Target | `KC_ENV` | Square mode | Data | Audience | Approver |
|
||||
| --------------- | --------------------------------- | --------------------- | ------------ | ----------------------- | ------------- | ----------------- |
|
||||
| **Development** | Local workstation | `fake` | n/a | In-memory fakes | Engineer | Self |
|
||||
| **Test / QA** | CI runner + internal test devices | `wordpress` | n/a | WooCommerce staging | Engineer + QA | Engineering lead |
|
||||
| **Staging** | Pre-production host + test device | `multi` | `sandbox` | Staging WC + Square SBX | QA + business | Release committee |
|
||||
| **Production** | Live web host + Play Store track | `wordpress` / `multi` | `production` | Live WC + Square live | Operators | Release committee |
|
||||
|
||||
> **Rule:** `KC_SQUARE_ENVIRONMENT=production` is permitted **only** in the
|
||||
> Production environment. Any lower environment using a live Square token is an
|
||||
> immediate stop-ship defect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Universal Gates (all environments)
|
||||
|
||||
These must pass before any promotion.
|
||||
|
||||
- [ ] `dart analyze --fatal-infos` is clean for all 11 packages and both apps
|
||||
- [ ] Full test suite passes — current baseline **1,475 / 1,475**
|
||||
- [ ] No test is skipped, `@Skip`-ed, or commented out without a linked issue
|
||||
- [ ] `docs/development/build_execution_tracker.md` has a slice entry for the change
|
||||
- [ ] `docs/development/master_development_brief.md` reflects the new baseline
|
||||
- [ ] No secrets committed — credentials supplied only via `--dart-define`
|
||||
- [ ] No `TODO`/`FIXME` introduced without a linked tracker item
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
cd kell_creations_apps
|
||||
./tools/run_all_tests.sh --analyze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Development Environment
|
||||
|
||||
**Purpose:** Fast inner-loop work against in-memory fakes. No external calls.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
flutter run -d chrome --dart-define=KC_ENV=fake # kell_web
|
||||
flutter run -d android --dart-define=KC_ENV=fake # kell_mobile
|
||||
```
|
||||
|
||||
### Gates
|
||||
|
||||
- [ ] Universal gates pass
|
||||
- [ ] Feature branch is cut from current `main`
|
||||
- [ ] Slice is vertical and reviewable (domain → application → data → presentation)
|
||||
- [ ] New public APIs carry doc comments
|
||||
- [ ] Environment badge renders `FAKE`
|
||||
- [ ] "Report a problem" action opens the dialog and submits successfully
|
||||
- [ ] No unexpected `ERROR`/`FATAL` records in the console during a smoke pass
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Analyze clean, tests green, tracker updated. Open PR into `main`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test / QA Environment
|
||||
|
||||
**Purpose:** Validate real WooCommerce integration and cross-platform parity.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
flutter build web --dart-define=KC_ENV=wordpress \
|
||||
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
|
||||
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||
|
||||
flutter build apk --debug --dart-define=KC_ENV=wordpress \
|
||||
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
|
||||
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||
```
|
||||
|
||||
### Gates
|
||||
|
||||
- [ ] All Development gates pass
|
||||
- [ ] Forgejo `Flutter Test` workflow green
|
||||
- [ ] Forgejo `Flutter Analyze` workflow green
|
||||
- [ ] Coverage reported in the CI summary; no unexplained decrease
|
||||
- [ ] Credentials point at **staging** WooCommerce — never production
|
||||
- [ ] Environment badge renders `WP`
|
||||
- [ ] Missing-credential path verified: app falls back to `FAKE` **and logs a warning**
|
||||
|
||||
### Functional verification (WooCommerce)
|
||||
|
||||
- [ ] Product list loads, filters, searches, and sorts
|
||||
- [ ] Single-field edits persist (name, price, description, category)
|
||||
- [ ] Status transitions behave (`unpublished` → `draft`, `pendingReview` → `published`)
|
||||
- [ ] Product creation completes on web (`CreateProductDialog`) and mobile (`MobileCreateProductPage`)
|
||||
- [ ] Category CRUD works
|
||||
- [ ] Bulk actions (move-to-draft, publish, submit-for-review) work
|
||||
- [ ] API failures surface a user-visible error — never a silent failure
|
||||
|
||||
### Observability verification
|
||||
|
||||
- [ ] Startup record written with the effective environment
|
||||
- [ ] A forced API failure produces an `ERROR` record with source and context
|
||||
- [ ] Bug report submission attaches the recent log tail
|
||||
- [ ] **Credential redaction confirmed** — no token, key, or secret appears in any log or report
|
||||
|
||||
### Mobile-specific
|
||||
|
||||
- [ ] Touch targets meet the 48×48dp minimum
|
||||
- [ ] Haptics fire (`mediumImpact` state changes, `lightImpact` field edits)
|
||||
- [ ] Layout is correct on a notch/gesture-bar device
|
||||
- [ ] Back navigation behaves throughout
|
||||
|
||||
### Exit criteria
|
||||
|
||||
CI green, functional pass recorded, no open Critical or High defects.
|
||||
|
||||
---
|
||||
|
||||
## 5. Staging Environment
|
||||
|
||||
**Purpose:** Production rehearsal with both channels live in sandbox mode.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
flutter build web --release --dart-define=KC_ENV=multi \
|
||||
--dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \
|
||||
--dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||
--dart-define=KC_WC_CONSUMER_SECRET=cs_xxx \
|
||||
--dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl_sandbox_xxx \
|
||||
--dart-define=KC_SQUARE_LOCATION_ID=L_xxx \
|
||||
--dart-define=KC_SQUARE_ENVIRONMENT=sandbox
|
||||
```
|
||||
|
||||
The Android build uses the same defines with `flutter build apk --release`.
|
||||
|
||||
### Gates
|
||||
|
||||
- [ ] All Test/QA gates pass
|
||||
- [ ] Built in **release** mode (not debug/profile)
|
||||
- [ ] `KC_SQUARE_ENVIRONMENT=sandbox` — confirmed, not `production`
|
||||
- [ ] Square token is a **sandbox** token
|
||||
- [ ] Environment badge renders `MULTI`
|
||||
- [ ] Incomplete-credential path falls back to `FAKE` with a logged warning
|
||||
|
||||
### Multi-channel verification
|
||||
|
||||
- [ ] Square catalog sync completes; `SquareSyncStatusPanel` reports the result
|
||||
- [ ] `ProductIdMapping` records are created correctly — **no duplicate listings**
|
||||
- [ ] Already-synced products reuse their existing mapping (ID conflation regression check)
|
||||
- [ ] Square inventory counts load and reconcile
|
||||
- [ ] Partial sync failure surfaces per-item `SyncError` detail
|
||||
- [ ] Integrations page reflects live sync state on web and mobile
|
||||
|
||||
### Mobile POS verification (sandbox)
|
||||
|
||||
- [ ] Product selection → cart → payment flow completes
|
||||
- [ ] Cash payment records correctly
|
||||
- [ ] Card payment completes against Square sandbox
|
||||
- [ ] Receipt renders with the correct total
|
||||
- [ ] Payment failure is handled without data loss
|
||||
|
||||
### Non-functional
|
||||
|
||||
- [ ] Release build starts within an acceptable time on a representative device
|
||||
- [ ] No memory growth across an extended session
|
||||
- [ ] Web build loads over the target network profile
|
||||
- [ ] Offline/interrupted-network behaviour is understood and documented
|
||||
|
||||
> **Known limitation:** Offline caching and the sales queue (Stage 14) are **not
|
||||
> implemented**. Network loss during a market sale is currently unrecoverable.
|
||||
> This must be stated in release notes for any field use.
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Business sign-off recorded, no open Critical/High defects, rollback plan agreed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Production Environment
|
||||
|
||||
**Purpose:** Live operator use against real customers and real money.
|
||||
|
||||
### Pre-flight
|
||||
|
||||
- [ ] All Staging gates pass
|
||||
- [ ] Release committee approval recorded
|
||||
- [ ] Release notes drafted, including known limitations
|
||||
- [ ] Rollback plan documented and the previous artifact retained
|
||||
- [ ] Version bumped in both `pubspec.yaml` files
|
||||
- [ ] `AppServices.appVersion` and `MobileAppServices.appVersion` match `pubspec.yaml`
|
||||
- [ ] Release tagged in Git
|
||||
|
||||
### Credential and safety gates
|
||||
|
||||
- [ ] Production WooCommerce credentials sourced from the secret store — **not** the repo
|
||||
- [ ] `KC_SQUARE_ENVIRONMENT=production` set deliberately
|
||||
- [ ] Square production token scoped to the minimum required permissions
|
||||
- [ ] Square location ID matches the intended physical location
|
||||
- [ ] A staging credential is confirmed absent from the production build
|
||||
- [ ] Credential redaction verified once more in the production artifact
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
flutter build web --release --dart-define=KC_ENV=multi \
|
||||
--dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \
|
||||
--dart-define=KC_WC_CONSUMER_KEY=$WC_KEY \
|
||||
--dart-define=KC_WC_CONSUMER_SECRET=$WC_SECRET \
|
||||
--dart-define=KC_SQUARE_ACCESS_TOKEN=$SQUARE_TOKEN \
|
||||
--dart-define=KC_SQUARE_LOCATION_ID=$SQUARE_LOCATION \
|
||||
--dart-define=KC_SQUARE_ENVIRONMENT=production
|
||||
```
|
||||
|
||||
The Android release build uses the same defines with `flutter build appbundle --release`.
|
||||
|
||||
### Post-deployment verification
|
||||
|
||||
- [ ] App loads and the environment badge is correct
|
||||
- [ ] A read-only operation succeeds (product list loads)
|
||||
- [ ] A single low-risk write succeeds and is confirmed in WooCommerce admin
|
||||
- [ ] Square sync runs once and mappings are correct
|
||||
- [ ] **One low-value real transaction** processed end-to-end and reconciled in Square
|
||||
- [ ] Bug reporting works from the production build
|
||||
- [ ] No `ERROR`/`FATAL` records during the verification pass
|
||||
|
||||
### Monitoring window
|
||||
|
||||
- [ ] Observed for the first operating session
|
||||
- [ ] Bug report queue reviewed before sign-off
|
||||
- [ ] Operators briefed on "Report a problem" and known limitations
|
||||
|
||||
### Rollback triggers
|
||||
|
||||
Roll back immediately on any of:
|
||||
|
||||
- Incorrect pricing, inventory, or payment amounts
|
||||
- Duplicate product listings from a sync defect
|
||||
- Failed or double-charged payments
|
||||
- Credential leakage in any log or report
|
||||
- Any Critical defect without a workaround
|
||||
|
||||
---
|
||||
|
||||
## 7. Release Sign-Off Record
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------- |
|
||||
| Release version | |
|
||||
| Git tag / commit | |
|
||||
| Target environment | |
|
||||
| Test result | \_\_ / 1475 passing |
|
||||
| Analyze result | |
|
||||
| Known limitations | |
|
||||
| Approver | |
|
||||
| Date | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Release Blockers
|
||||
|
||||
Carried from `PROJECT_STATE_AUDIT.md`. Each must be closed or explicitly accepted
|
||||
as a documented limitation before the affected environment is used in earnest.
|
||||
|
||||
| ID | Item | Blocks |
|
||||
| ----- | ------------------------------------------------------------- | -------------------------- |
|
||||
| P2.5 | `WooCommerceOrdersRepository` not wired into `AppServices` | Test/QA orders workflow |
|
||||
| P2.6 | `WooCommerceInventoryRepository` not wired into `AppServices` | Test/QA inventory workflow |
|
||||
| P2.8 | Offline cache and sales queue not implemented (Stage 14) | Production field use |
|
||||
| P3.2 | `SecureCredentialStore` not implemented | Production |
|
||||
| P3.3 | Auth guard not wired into either shell | Production |
|
||||
| P3.1 | `RetryPolicy` / `RateLimiter` not wired into API clients | Staging, Production |
|
||||
| P3.6 | `feature_policy` has only a smoke test | Test/QA |
|
||||
| P2.10 | Policy register CSVs contain headers only | Governance compliance |
|
||||
|
||||
> **Committee position:** With Stage 16A complete, the platform now has error
|
||||
> logging and bug reporting. It is **approved for Development and Test/QA**.
|
||||
> **Staging is conditionally approved** pending P3.1. **Production is not
|
||||
> approved** until P3.2, P3.3, and P2.8 are closed — the platform currently has
|
||||
> no authentication guard, no secure credential storage, and no offline
|
||||
> resilience for in-person sales.
|
||||
|
|
@ -43,7 +43,11 @@ class MobileAppServices extends KcAppServices {
|
|||
/// In square/multi mode this could be populated from the Square catalog.
|
||||
final List<OrderItem> checkoutCatalogue;
|
||||
|
||||
const MobileAppServices({
|
||||
/// The observability stack: structured logger, in-memory evidence buffer,
|
||||
/// and the bug report service backing the shell's "Report a problem" action.
|
||||
final KcObservability observability;
|
||||
|
||||
MobileAppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
|
|
@ -51,7 +55,27 @@ class MobileAppServices extends KcAppServices {
|
|||
required this.paymentService,
|
||||
this.squareSyncController,
|
||||
this.checkoutCatalogue = _fakeCatalogue,
|
||||
});
|
||||
KcObservability? observability,
|
||||
}) : observability = observability ?? _defaultObservability('FAKE');
|
||||
|
||||
/// The application version reported in bug reports.
|
||||
///
|
||||
/// Kept in sync with `version:` in `pubspec.yaml`.
|
||||
static const String appVersion = '1.0.0+1';
|
||||
|
||||
/// Convenience accessor for the shared structured logger.
|
||||
KcLogger get logger => observability.logger;
|
||||
|
||||
/// Convenience accessor for the bug report service.
|
||||
KcBugReportService get bugReportService => observability.bugReportService;
|
||||
|
||||
static KcObservability _defaultObservability(String environmentLabel) =>
|
||||
KcObservability.standard(
|
||||
appVersion: appVersion,
|
||||
environment: environmentLabel,
|
||||
platform: 'android',
|
||||
source: 'kell_mobile',
|
||||
);
|
||||
|
||||
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||
factory MobileAppServices.fake() {
|
||||
|
|
@ -61,6 +85,7 @@ class MobileAppServices extends KcAppServices {
|
|||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
paymentService: FakePaymentService(),
|
||||
observability: _defaultObservability('FAKE'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +109,7 @@ class MobileAppServices extends KcAppServices {
|
|||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||
paymentService: FakePaymentService(),
|
||||
observability: _defaultObservability('WP'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +142,7 @@ class MobileAppServices extends KcAppServices {
|
|||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
observability: _defaultObservability('SQ'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +187,7 @@ class MobileAppServices extends KcAppServices {
|
|||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
observability: _defaultObservability('MULTI'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,28 @@ void main() {
|
|||
MobileAppServices.serviceFactory,
|
||||
);
|
||||
|
||||
runApp(
|
||||
KcAppScope<MobileAppServices>(
|
||||
services: services,
|
||||
config: effectiveConfig,
|
||||
child: const KellMobileApp(),
|
||||
),
|
||||
// Route uncaught framework and platform errors into the structured logger
|
||||
// before the first frame, so startup failures are captured too.
|
||||
KcErrorReporter.install(services.logger);
|
||||
|
||||
services.logger.info(
|
||||
'Application starting',
|
||||
source: 'main',
|
||||
context: {
|
||||
'environment': effectiveConfig.environment.key,
|
||||
'requestedEnvironment': config.environment.key,
|
||||
'fellBackToFake': config.environment != effectiveConfig.environment,
|
||||
'appVersion': MobileAppServices.appVersion,
|
||||
},
|
||||
);
|
||||
|
||||
KcErrorReporter.runGuarded(services.logger, () {
|
||||
runApp(
|
||||
KcAppScope<MobileAppServices>(
|
||||
services: services,
|
||||
config: effectiveConfig,
|
||||
child: const KellMobileApp(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -67,6 +68,37 @@ class _MobileShellState extends State<MobileShell> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// Opens the shared bug report dialog and submits the resulting draft with
|
||||
/// the current screen and recent log tail attached as evidence.
|
||||
Future<void> _reportProblem() async {
|
||||
final services = KcAppScope.of<MobileAppServices>(context);
|
||||
final screen = _titles[_selectedIndex];
|
||||
|
||||
final draft = await KcBugReportDialog.show(context, screen: screen);
|
||||
if (draft == null || !mounted) return;
|
||||
|
||||
final result = await services.bugReportService.submit(
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
severity: KcBugSeverity.fromString(draft.severityName),
|
||||
reporter: draft.reporter,
|
||||
screen: screen,
|
||||
diagnostics: {'tabIndex': _selectedIndex},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.succeeded
|
||||
? 'Report ${result.report!.id} submitted. Thank you.'
|
||||
: result.errorMessage ?? 'Could not submit the report.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = KcAppScope.configOf<MobileAppServices>(context);
|
||||
|
|
@ -75,6 +107,11 @@ class _MobileShellState extends State<MobileShell> {
|
|||
appBar: AppBar(
|
||||
title: Text(_titles[_selectedIndex]),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
tooltip: 'Report a problem',
|
||||
onPressed: _reportProblem,
|
||||
),
|
||||
_EnvironmentBadge(environment: config.environment),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/app.dart';
|
||||
import 'package:kell_mobile/composition/mobile_app_services.dart';
|
||||
|
||||
/// Pumps the mobile app with fake services so the shell's bug report action
|
||||
/// can be exercised end-to-end.
|
||||
Future<MobileAppServices> _pumpApp(WidgetTester tester) async {
|
||||
final services = MobileAppServices.fake();
|
||||
|
||||
await tester.pumpWidget(
|
||||
KcAppScope<MobileAppServices>(
|
||||
services: services,
|
||||
config: const KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
),
|
||||
child: const KellMobileApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// Matches the Nth [TextField] inside the open bug report dialog.
|
||||
///
|
||||
/// Scoped to the dialog because feature pages behind it (for example the
|
||||
/// inventory search bar) contribute their own text fields.
|
||||
Finder _dialogField(int index) => find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(TextField),
|
||||
).at(index);
|
||||
|
||||
void main() {
|
||||
group('MobileAppServices observability wiring', () {
|
||||
test('fake factory builds a usable observability stack', () {
|
||||
final services = MobileAppServices.fake();
|
||||
|
||||
expect(services.observability, isNotNull);
|
||||
expect(services.logger, same(services.observability.logger));
|
||||
expect(services.bugReportService, same(services.observability.bugReportService));
|
||||
});
|
||||
|
||||
test('logger and bug report service share the same evidence buffer', () async {
|
||||
final services = MobileAppServices.fake();
|
||||
services.logger.error('Checkout failed', context: {'sku': 'MWH-001'});
|
||||
|
||||
final result = await services.bugReportService.submit(
|
||||
title: 'Checkout broken',
|
||||
description: 'Card payment does nothing.',
|
||||
);
|
||||
|
||||
expect(result.succeeded, isTrue);
|
||||
expect(result.report!.hasFailureEvidence, isTrue);
|
||||
expect(
|
||||
result.report!.logTail.any((r) => r.message == 'Checkout failed'),
|
||||
isTrue,
|
||||
reason: 'the log tail must come from the same buffer the logger writes to',
|
||||
);
|
||||
});
|
||||
|
||||
test('stamps the android platform and app version', () async {
|
||||
final services = MobileAppServices.fake();
|
||||
final result = await services.bugReportService.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.report!.platform, 'android');
|
||||
expect(result.report!.appVersion, MobileAppServices.appVersion);
|
||||
});
|
||||
|
||||
test('square factory labels reports with the SQ environment', () async {
|
||||
final services = MobileAppServices.square(accessToken: 'tok', locationId: 'loc');
|
||||
final result = await services.bugReportService.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.report!.environment, 'SQ');
|
||||
});
|
||||
});
|
||||
|
||||
group('MobileShell report a problem action', () {
|
||||
testWidgets('exposes a bug report action in the app bar', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
|
||||
expect(find.byTooltip('Report a problem'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opens the shared bug report dialog', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Report a problem'), findsOneWidget);
|
||||
expect(find.text('Screen: Dashboard'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submits the report and confirms via snack bar', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(_dialogField(0), 'Dashboard blank');
|
||||
await tester.enterText(_dialogField(1), 'No KPI cards render.');
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('submitted. Thank you.'), findsOneWidget);
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports, hasLength(1));
|
||||
expect(sink.reports.single.title, 'Dashboard blank');
|
||||
expect(sink.reports.single.screen, 'Dashboard');
|
||||
expect(sink.reports.single.diagnostics['tabIndex'], 0);
|
||||
});
|
||||
|
||||
testWidgets('cancelling submits nothing', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports, isEmpty);
|
||||
expect(find.textContaining('submitted'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('records the active tab as the reporting screen', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.inventory_2_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Screen: Inventory'), findsOneWidget);
|
||||
|
||||
await tester.enterText(_dialogField(0), 'Stock wrong');
|
||||
await tester.enterText(_dialogField(1), 'Counts do not match.');
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports.single.screen, 'Inventory');
|
||||
expect(sink.reports.single.diagnostics['tabIndex'], 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -28,13 +28,37 @@ class AppServices extends KcAppServices {
|
|||
/// Present only in `square` and `multi` environments.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const AppServices({
|
||||
/// The observability stack: structured logger, in-memory evidence buffer,
|
||||
/// and the bug report service backing the shell's "Report a problem" action.
|
||||
final KcObservability observability;
|
||||
|
||||
AppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
required this.productPublishingRepository,
|
||||
this.squareSyncController,
|
||||
});
|
||||
KcObservability? observability,
|
||||
}) : observability = observability ?? _defaultObservability('FAKE');
|
||||
|
||||
/// The application version reported in bug reports.
|
||||
///
|
||||
/// Kept in sync with `version:` in `pubspec.yaml`.
|
||||
static const String appVersion = '1.0.0+1';
|
||||
|
||||
/// Convenience accessor for the shared structured logger.
|
||||
KcLogger get logger => observability.logger;
|
||||
|
||||
/// Convenience accessor for the bug report service.
|
||||
KcBugReportService get bugReportService => observability.bugReportService;
|
||||
|
||||
static KcObservability _defaultObservability(String environmentLabel) =>
|
||||
KcObservability.standard(
|
||||
appVersion: appVersion,
|
||||
environment: environmentLabel,
|
||||
platform: 'web',
|
||||
source: 'kell_web',
|
||||
);
|
||||
|
||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||
factory AppServices.fake() {
|
||||
|
|
@ -43,6 +67,7 @@ class AppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
observability: _defaultObservability('FAKE'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +94,7 @@ class AppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||
observability: _defaultObservability('WP'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +126,7 @@ class AppServices extends KcAppServices {
|
|||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
observability: _defaultObservability('SQ'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +169,7 @@ class AppServices extends KcAppServices {
|
|||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
observability: _defaultObservability('MULTI'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,32 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'composition/app_config.dart';
|
||||
import 'composition/app_scope.dart';
|
||||
import 'composition/app_services.dart';
|
||||
import 'composition/bootstrap.dart';
|
||||
|
||||
void main() {
|
||||
final config = AppConfig.fromEnvironment();
|
||||
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
||||
|
||||
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
|
||||
// Route uncaught framework and platform errors into the structured logger
|
||||
// before the first frame, so startup failures are captured too.
|
||||
KcErrorReporter.install(services.logger);
|
||||
|
||||
services.logger.info(
|
||||
'Application starting',
|
||||
source: 'main',
|
||||
context: {
|
||||
'environment': effectiveConfig.environment.key,
|
||||
'requestedEnvironment': config.environment.key,
|
||||
'fellBackToFake': config.environment != effectiveConfig.environment,
|
||||
'appVersion': AppServices.appVersion,
|
||||
},
|
||||
);
|
||||
|
||||
KcErrorReporter.runGuarded(services.logger, () {
|
||||
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../composition/app_config.dart';
|
||||
|
|
@ -25,6 +27,7 @@ class AppShell extends StatelessWidget {
|
|||
appBar: AppBar(
|
||||
title: const Text('Kell Creations'),
|
||||
actions: [
|
||||
_ReportProblemAction(screen: title),
|
||||
_EnvironmentBadge(environment: config.environment),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
|
|
@ -146,6 +149,53 @@ class AppShell extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// An [AppBar] action that opens the shared bug report dialog and submits the
|
||||
/// resulting draft through [AppServices.bugReportService].
|
||||
///
|
||||
/// The recent log tail is attached automatically as diagnostic evidence, and
|
||||
/// credentials are redacted by `KcRedactor` before anything is recorded.
|
||||
class _ReportProblemAction extends StatelessWidget {
|
||||
/// The screen the operator is reporting from.
|
||||
final String screen;
|
||||
|
||||
const _ReportProblemAction({required this.screen});
|
||||
|
||||
Future<void> _report(BuildContext context) async {
|
||||
final services = AppScope.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
final draft = await KcBugReportDialog.show(context, screen: screen);
|
||||
if (draft == null) return;
|
||||
|
||||
final result = await services.bugReportService.submit(
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
severity: KcBugSeverity.fromString(draft.severityName),
|
||||
reporter: draft.reporter,
|
||||
screen: screen,
|
||||
);
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.succeeded
|
||||
? 'Report ${result.report!.id} submitted. Thank you.'
|
||||
: result.errorMessage ?? 'Could not submit the report.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
tooltip: 'Report a problem',
|
||||
onPressed: () => _report(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small coloured chip displayed in the [AppBar] that shows the current
|
||||
/// runtime environment (e.g. "FAKE" or "WP").
|
||||
class _EnvironmentBadge extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_web/app.dart';
|
||||
import 'package:kell_web/composition/app_config.dart';
|
||||
import 'package:kell_web/composition/app_scope.dart';
|
||||
import 'package:kell_web/composition/app_services.dart';
|
||||
|
||||
/// Pumps the web app with fake services so the shell's bug report action can
|
||||
/// be exercised end-to-end.
|
||||
Future<AppServices> _pumpApp(WidgetTester tester) async {
|
||||
final services = AppServices.fake();
|
||||
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
services: services,
|
||||
config: const AppConfig(
|
||||
environment: AppEnvironment.fake,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
),
|
||||
child: const KellWebApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// Matches the Nth [TextField] inside the open bug report dialog.
|
||||
///
|
||||
/// Scoped to the dialog because feature pages behind it contribute their own
|
||||
/// text fields.
|
||||
Finder _dialogField(int index) =>
|
||||
find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)).at(index);
|
||||
|
||||
void main() {
|
||||
group('AppServices observability wiring', () {
|
||||
test('fake factory builds a usable observability stack', () {
|
||||
final services = AppServices.fake();
|
||||
|
||||
expect(services.observability, isNotNull);
|
||||
expect(services.logger, same(services.observability.logger));
|
||||
expect(services.bugReportService, same(services.observability.bugReportService));
|
||||
});
|
||||
|
||||
test('logger and bug report service share the same evidence buffer', () async {
|
||||
final services = AppServices.fake();
|
||||
services.logger.error('Publish failed', context: {'productId': '42'});
|
||||
|
||||
final result = await services.bugReportService.submit(
|
||||
title: 'Publishing broken',
|
||||
description: 'Publish button reports success but nothing changes.',
|
||||
);
|
||||
|
||||
expect(result.succeeded, isTrue);
|
||||
expect(result.report!.hasFailureEvidence, isTrue);
|
||||
expect(
|
||||
result.report!.logTail.any((r) => r.message == 'Publish failed'),
|
||||
isTrue,
|
||||
reason: 'the log tail must come from the same buffer the logger writes to',
|
||||
);
|
||||
});
|
||||
|
||||
test('stamps the web platform and app version', () async {
|
||||
final services = AppServices.fake();
|
||||
final result = await services.bugReportService.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.report!.platform, 'web');
|
||||
expect(result.report!.appVersion, AppServices.appVersion);
|
||||
});
|
||||
|
||||
test('wordpress factory labels reports with the WP environment', () async {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://example.com',
|
||||
consumerKey: 'ck',
|
||||
consumerSecret: 'cs',
|
||||
);
|
||||
final result = await services.bugReportService.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.report!.environment, 'WP');
|
||||
});
|
||||
|
||||
test('multi factory labels reports with the MULTI environment', () async {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://example.com',
|
||||
wcConsumerKey: 'ck',
|
||||
wcConsumerSecret: 'cs',
|
||||
squareAccessToken: 'tok',
|
||||
squareLocationId: 'loc',
|
||||
);
|
||||
final result = await services.bugReportService.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.report!.environment, 'MULTI');
|
||||
});
|
||||
});
|
||||
|
||||
group('AppShell report a problem action', () {
|
||||
testWidgets('exposes a bug report action in the app bar', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
|
||||
expect(find.byTooltip('Report a problem'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opens the shared bug report dialog with the screen name', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Report a problem'), findsOneWidget);
|
||||
expect(find.textContaining('Screen: '), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submits the report and confirms via snack bar', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(_dialogField(0), 'Dashboard blank');
|
||||
await tester.enterText(_dialogField(1), 'No KPI cards render.');
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('submitted. Thank you.'), findsOneWidget);
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports, hasLength(1));
|
||||
expect(sink.reports.single.title, 'Dashboard blank');
|
||||
expect(sink.reports.single.screen, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('cancelling submits nothing', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports, isEmpty);
|
||||
expect(find.textContaining('submitted'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('validation blocks submission of an empty report', (tester) async {
|
||||
final services = await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('A title is required.'), findsOneWidget);
|
||||
|
||||
final sink = services.observability.bugReportService.sink as KcInMemoryBugReportSink;
|
||||
expect(sink.reports, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,15 @@
|
|||
/// - [KcServiceFactory] — generic factory for environment-aware service construction
|
||||
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
||||
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
|
||||
///
|
||||
/// It also provides the shared observability stack used by every app target:
|
||||
///
|
||||
/// - [KcLogger] / [KcLogRecord] / [KcLogLevel] — structured logging
|
||||
/// - [KcLogSink] implementations — console, bounded in-memory buffer, fan-out
|
||||
/// - [KcErrorReporter] — global uncaught-error capture
|
||||
/// - [KcBugReportService] / [KcBugReport] — operator bug reporting with
|
||||
/// diagnostic evidence attached
|
||||
/// - [KcRedactor] — credential redaction applied to all logged context
|
||||
library;
|
||||
|
||||
// Composition
|
||||
|
|
@ -15,3 +24,14 @@ export 'src/composition/kc_app_config.dart';
|
|||
export 'src/composition/kc_app_scope.dart';
|
||||
export 'src/composition/kc_app_services.dart';
|
||||
export 'src/composition/kc_bootstrap.dart';
|
||||
|
||||
// Observability
|
||||
export 'src/observability/kc_bug_report.dart';
|
||||
export 'src/observability/kc_bug_report_service.dart';
|
||||
export 'src/observability/kc_error_reporter.dart';
|
||||
export 'src/observability/kc_log_level.dart';
|
||||
export 'src/observability/kc_log_record.dart';
|
||||
export 'src/observability/kc_log_sink.dart';
|
||||
export 'src/observability/kc_logger.dart';
|
||||
export 'src/observability/kc_observability.dart';
|
||||
export 'src/observability/kc_redactor.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
/// The bug report domain model.
|
||||
library;
|
||||
|
||||
import 'kc_log_record.dart';
|
||||
import 'kc_redactor.dart';
|
||||
|
||||
/// How severely a reported defect affects the operator.
|
||||
enum KcBugSeverity {
|
||||
/// Cosmetic or minor annoyance; work can continue.
|
||||
low('Low'),
|
||||
|
||||
/// Degrades a workflow but a workaround exists.
|
||||
medium('Medium'),
|
||||
|
||||
/// Blocks a business workflow with no workaround.
|
||||
high('High'),
|
||||
|
||||
/// Data loss, incorrect financial figures, or total outage.
|
||||
critical('Critical');
|
||||
|
||||
/// Display label for UI.
|
||||
final String label;
|
||||
|
||||
const KcBugSeverity(this.label);
|
||||
|
||||
/// Parses a string into a [KcBugSeverity], defaulting to [medium].
|
||||
static KcBugSeverity fromString(String value) {
|
||||
for (final severity in values) {
|
||||
if (severity.name == value.toLowerCase()) return severity;
|
||||
}
|
||||
return medium;
|
||||
}
|
||||
}
|
||||
|
||||
/// An operator-submitted defect report with attached diagnostic evidence.
|
||||
///
|
||||
/// A report bundles what the operator observed with the machine context needed
|
||||
/// to triage it: the app version, environment mode, platform, and the recent
|
||||
/// log tail leading up to the failure.
|
||||
class KcBugReport {
|
||||
/// Stable identifier used for correlation between the app and the tracker.
|
||||
final String id;
|
||||
|
||||
/// Short summary supplied by the operator.
|
||||
final String title;
|
||||
|
||||
/// Free-text description, including reproduction steps.
|
||||
final String description;
|
||||
|
||||
/// Operator-assessed impact.
|
||||
final KcBugSeverity severity;
|
||||
|
||||
/// When the report was created.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Application version string (e.g. `1.0.0+1`).
|
||||
final String appVersion;
|
||||
|
||||
/// Runtime environment label (e.g. `FAKE`, `WP`, `SQ`, `MULTI`).
|
||||
final String environment;
|
||||
|
||||
/// Host platform description (e.g. `android`, `web`).
|
||||
final String platform;
|
||||
|
||||
/// Optional reporter identity (email or operator name).
|
||||
final String? reporter;
|
||||
|
||||
/// The screen or workflow the operator was on when the defect occurred.
|
||||
final String? screen;
|
||||
|
||||
/// Recent structured log records attached as evidence.
|
||||
final List<KcLogRecord> logTail;
|
||||
|
||||
/// Additional structured diagnostics. Redacted on construction.
|
||||
final Map<String, Object?> diagnostics;
|
||||
|
||||
KcBugReport({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.severity,
|
||||
required this.createdAt,
|
||||
required this.appVersion,
|
||||
required this.environment,
|
||||
required this.platform,
|
||||
this.reporter,
|
||||
this.screen,
|
||||
List<KcLogRecord> logTail = const [],
|
||||
Map<String, Object?> diagnostics = const {},
|
||||
}) : logTail = List.unmodifiable(logTail),
|
||||
diagnostics = Map.unmodifiable(KcRedactor.redactMap(diagnostics));
|
||||
|
||||
/// Whether the attached evidence contains at least one failure record.
|
||||
bool get hasFailureEvidence => logTail.any((r) => r.isFailure);
|
||||
|
||||
/// A JSON-serialisable payload suitable for transmission to a tracker.
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'severity': severity.name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'appVersion': appVersion,
|
||||
'environment': environment,
|
||||
'platform': platform,
|
||||
if (reporter != null) 'reporter': reporter,
|
||||
if (screen != null) 'screen': screen,
|
||||
if (diagnostics.isNotEmpty) 'diagnostics': diagnostics,
|
||||
'logTail': logTail.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
|
||||
/// A human-readable rendering used for clipboard export and issue bodies.
|
||||
String toMarkdown() {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('# Bug Report: $title')
|
||||
..writeln()
|
||||
..writeln('- **ID:** `$id`')
|
||||
..writeln('- **Severity:** ${severity.label}')
|
||||
..writeln('- **Reported:** ${createdAt.toIso8601String()}')
|
||||
..writeln('- **App version:** $appVersion')
|
||||
..writeln('- **Environment:** $environment')
|
||||
..writeln('- **Platform:** $platform');
|
||||
|
||||
if (reporter != null) buffer.writeln('- **Reporter:** $reporter');
|
||||
if (screen != null) buffer.writeln('- **Screen:** $screen');
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Description')
|
||||
..writeln()
|
||||
..writeln(description);
|
||||
|
||||
if (diagnostics.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Diagnostics')
|
||||
..writeln();
|
||||
for (final entry in diagnostics.entries) {
|
||||
buffer.writeln('- `${entry.key}`: ${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
if (logTail.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Recent log records')
|
||||
..writeln()
|
||||
..writeln('```');
|
||||
for (final record in logTail) {
|
||||
buffer.writeln(record.format());
|
||||
}
|
||||
buffer.writeln('```');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'KcBugReport($id, ${severity.label}: $title)';
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/// Bug report submission contracts and the default in-memory implementation.
|
||||
library;
|
||||
|
||||
import 'kc_bug_report.dart';
|
||||
import 'kc_log_level.dart';
|
||||
import 'kc_log_sink.dart';
|
||||
import 'kc_logger.dart';
|
||||
|
||||
/// The outcome of a [KcBugReportService.submit] call.
|
||||
class KcBugReportResult {
|
||||
/// Whether the report was accepted by the sink.
|
||||
final bool succeeded;
|
||||
|
||||
/// The submitted report, present on success.
|
||||
final KcBugReport? report;
|
||||
|
||||
/// Failure detail, present when [succeeded] is `false`.
|
||||
final String? errorMessage;
|
||||
|
||||
const KcBugReportResult.success(this.report) : succeeded = true, errorMessage = null;
|
||||
|
||||
const KcBugReportResult.failure(this.errorMessage) : succeeded = false, report = null;
|
||||
|
||||
@override
|
||||
String toString() => succeeded
|
||||
? 'KcBugReportResult.success(${report?.id})'
|
||||
: 'KcBugReportResult.failure($errorMessage)';
|
||||
}
|
||||
|
||||
/// A destination that persists or transmits submitted [KcBugReport]s.
|
||||
///
|
||||
/// Implementations may write to local storage, POST to a Forgejo issue
|
||||
/// endpoint, or forward to a crash-reporting backend. Keeping this abstract
|
||||
/// means the app never depends on a specific tracker.
|
||||
abstract class KcBugReportSink {
|
||||
/// Persists or transmits [report]. Throws on failure.
|
||||
Future<void> send(KcBugReport report);
|
||||
}
|
||||
|
||||
/// An in-memory [KcBugReportSink] used in fake mode and in tests.
|
||||
///
|
||||
/// Retains every submitted report so the UI can display a submission history
|
||||
/// without a backend.
|
||||
class KcInMemoryBugReportSink implements KcBugReportSink {
|
||||
final List<KcBugReport> _reports = [];
|
||||
|
||||
/// All reports submitted so far, oldest first.
|
||||
List<KcBugReport> get reports => List.unmodifiable(_reports);
|
||||
|
||||
@override
|
||||
Future<void> send(KcBugReport report) async {
|
||||
_reports.add(report);
|
||||
}
|
||||
|
||||
/// Discards all retained reports.
|
||||
void clear() => _reports.clear();
|
||||
}
|
||||
|
||||
/// Creates and submits [KcBugReport]s with diagnostic evidence attached.
|
||||
///
|
||||
/// The service is the single entry point used by both app shells. It stamps
|
||||
/// each report with build/environment metadata, attaches the recent log tail
|
||||
/// from [logSink], and records the submission outcome through [logger] so a
|
||||
/// failed submission is itself observable.
|
||||
class KcBugReportService {
|
||||
/// Where submitted reports are sent.
|
||||
final KcBugReportSink sink;
|
||||
|
||||
/// Structured logger used to record submission attempts.
|
||||
final KcLogger logger;
|
||||
|
||||
/// Optional buffer supplying the recent log tail attached as evidence.
|
||||
final KcInMemoryLogSink? logSink;
|
||||
|
||||
/// Application version stamped onto every report.
|
||||
final String appVersion;
|
||||
|
||||
/// Environment label stamped onto every report.
|
||||
final String environment;
|
||||
|
||||
/// Platform label stamped onto every report.
|
||||
final String platform;
|
||||
|
||||
/// Number of recent log records attached to each report.
|
||||
final int logTailSize;
|
||||
|
||||
final DateTime Function() _clock;
|
||||
final String Function()? _idGenerator;
|
||||
|
||||
int _sequence = 0;
|
||||
|
||||
KcBugReportService({
|
||||
required this.sink,
|
||||
required this.logger,
|
||||
required this.appVersion,
|
||||
required this.environment,
|
||||
required this.platform,
|
||||
this.logSink,
|
||||
this.logTailSize = 50,
|
||||
DateTime Function()? clock,
|
||||
String Function()? idGenerator,
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_idGenerator = idGenerator;
|
||||
|
||||
/// Builds a report without submitting it.
|
||||
///
|
||||
/// Useful for previewing the payload (including redacted diagnostics and the
|
||||
/// attached log tail) before the operator confirms submission.
|
||||
KcBugReport buildReport({
|
||||
required String title,
|
||||
required String description,
|
||||
KcBugSeverity severity = KcBugSeverity.medium,
|
||||
String? reporter,
|
||||
String? screen,
|
||||
Map<String, Object?> diagnostics = const {},
|
||||
}) {
|
||||
return KcBugReport(
|
||||
id: _nextId(),
|
||||
title: title,
|
||||
description: description,
|
||||
severity: severity,
|
||||
createdAt: _clock(),
|
||||
appVersion: appVersion,
|
||||
environment: environment,
|
||||
platform: platform,
|
||||
reporter: reporter,
|
||||
screen: screen,
|
||||
logTail: logSink?.recent(logTailSize) ?? const [],
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds and submits a report.
|
||||
///
|
||||
/// [title] and [description] must not be blank. Validation failures return a
|
||||
/// [KcBugReportResult.failure] rather than throwing so the UI can surface the
|
||||
/// message directly.
|
||||
///
|
||||
/// Sink failures are caught, logged, and returned as a failure result — a
|
||||
/// broken tracker must never crash the app while an operator is reporting a
|
||||
/// different problem.
|
||||
Future<KcBugReportResult> submit({
|
||||
required String title,
|
||||
required String description,
|
||||
KcBugSeverity severity = KcBugSeverity.medium,
|
||||
String? reporter,
|
||||
String? screen,
|
||||
Map<String, Object?> diagnostics = const {},
|
||||
}) async {
|
||||
if (title.trim().isEmpty) {
|
||||
return const KcBugReportResult.failure('A title is required.');
|
||||
}
|
||||
if (description.trim().isEmpty) {
|
||||
return const KcBugReportResult.failure('A description is required.');
|
||||
}
|
||||
|
||||
final report = buildReport(
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
severity: severity,
|
||||
reporter: reporter,
|
||||
screen: screen,
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
|
||||
try {
|
||||
await sink.send(report);
|
||||
logger.log(
|
||||
severity == KcBugSeverity.critical ? KcLogLevel.error : KcLogLevel.info,
|
||||
'Bug report submitted',
|
||||
source: 'KcBugReportService',
|
||||
context: {
|
||||
'reportId': report.id,
|
||||
'severity': severity.name,
|
||||
'logRecordsAttached': report.logTail.length,
|
||||
},
|
||||
);
|
||||
return KcBugReportResult.success(report);
|
||||
} catch (e, s) {
|
||||
logger.error(
|
||||
'Bug report submission failed',
|
||||
source: 'KcBugReportService',
|
||||
context: {'reportId': report.id},
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return KcBugReportResult.failure('Could not submit the report: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _nextId() {
|
||||
final generator = _idGenerator;
|
||||
if (generator != null) return generator();
|
||||
_sequence++;
|
||||
return 'KC-BUG-${_clock().millisecondsSinceEpoch}-$_sequence';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/// Global uncaught-error capture for all Kell Creations app targets.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'kc_logger.dart';
|
||||
|
||||
/// Installs global error handlers so no runtime failure goes unrecorded.
|
||||
///
|
||||
/// Without this, uncaught widget-build errors are printed to a console nobody
|
||||
/// reads and asynchronous errors vanish silently. [KcErrorReporter] routes all
|
||||
/// three failure channels into the structured [KcLogger]:
|
||||
///
|
||||
/// 1. `FlutterError.onError` — errors raised inside the Flutter framework
|
||||
/// (build, layout, paint).
|
||||
/// 2. `PlatformDispatcher.instance.onError` — uncaught asynchronous errors
|
||||
/// that reach the engine.
|
||||
/// 3. [runGuarded] — a guarded zone wrapping `runApp` for anything else.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// void main() {
|
||||
/// final logger = KcLogger(sink: KcConsoleLogSink());
|
||||
/// KcErrorReporter.install(logger);
|
||||
/// runApp(const MyApp());
|
||||
/// }
|
||||
/// ```
|
||||
class KcErrorReporter {
|
||||
const KcErrorReporter._();
|
||||
|
||||
/// The `source` tag applied to every globally captured record.
|
||||
static const String source = 'KcErrorReporter';
|
||||
|
||||
/// The previously installed [FlutterError.onError], retained so [restore]
|
||||
/// can put the framework back to its original state (used by tests).
|
||||
static FlutterExceptionHandler? _previousFlutterOnError;
|
||||
|
||||
/// Whether [install] has been called and not yet undone by [restore].
|
||||
static bool _installed = false;
|
||||
|
||||
/// Whether global handlers are currently installed.
|
||||
static bool get isInstalled => _installed;
|
||||
|
||||
/// Routes framework and platform errors into [logger].
|
||||
///
|
||||
/// When [forwardToOriginalHandler] is `true` (the default) the previous
|
||||
/// `FlutterError.onError` is still invoked, preserving the standard red
|
||||
/// error screen and console output in debug builds.
|
||||
///
|
||||
/// Calling [install] twice without [restore] is a no-op beyond swapping the
|
||||
/// active logger, so it is safe to call from every app target's `main`.
|
||||
static void install(KcLogger logger, {bool forwardToOriginalHandler = true}) {
|
||||
if (!_installed) {
|
||||
_previousFlutterOnError = FlutterError.onError;
|
||||
_installed = true;
|
||||
}
|
||||
|
||||
final previous = _previousFlutterOnError;
|
||||
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
logger.error(
|
||||
'Uncaught Flutter framework error',
|
||||
source: source,
|
||||
context: {
|
||||
'library': details.library ?? 'unknown',
|
||||
'context': details.context?.toDescription() ?? '',
|
||||
'silent': details.silent,
|
||||
},
|
||||
error: details.exception,
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
|
||||
if (forwardToOriginalHandler && previous != null) {
|
||||
previous(details);
|
||||
}
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
|
||||
logger.fatal(
|
||||
'Uncaught asynchronous error',
|
||||
source: source,
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
// Returning true marks the error as handled so the app is not killed.
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Runs [body] inside a guarded [Zone] that reports uncaught errors to
|
||||
/// [logger], then rethrows nothing — the zone absorbs the failure after it
|
||||
/// has been recorded.
|
||||
///
|
||||
/// Wrap `runApp` with this to capture errors that escape both
|
||||
/// `FlutterError.onError` and the platform dispatcher.
|
||||
static R? runGuarded<R>(KcLogger logger, R Function() body) {
|
||||
return runZonedGuarded<R>(body, (Object error, StackTrace stack) {
|
||||
logger.fatal('Uncaught zone error', source: source, error: error, stackTrace: stack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Restores the error handlers captured before [install].
|
||||
///
|
||||
/// Primarily used by tests so global state does not leak between cases.
|
||||
static void restore() {
|
||||
if (!_installed) return;
|
||||
FlutterError.onError = _previousFlutterOnError;
|
||||
PlatformDispatcher.instance.onError = null;
|
||||
_previousFlutterOnError = null;
|
||||
_installed = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/// Severity levels for structured log records.
|
||||
///
|
||||
/// Ordered from least to most severe. [KcLogLevel.severity] is used to
|
||||
/// filter records against a configured minimum level.
|
||||
library;
|
||||
|
||||
/// The severity of a [KcLogRecord].
|
||||
enum KcLogLevel {
|
||||
/// Fine-grained diagnostic detail, normally disabled outside development.
|
||||
debug('DEBUG', 10),
|
||||
|
||||
/// Routine operational events (navigation, sync started, order created).
|
||||
info('INFO', 20),
|
||||
|
||||
/// Recoverable conditions that warrant attention but did not fail the
|
||||
/// operation (credential fallback, partial sync failure, retry).
|
||||
warning('WARNING', 30),
|
||||
|
||||
/// An operation failed. The user-facing action did not complete.
|
||||
error('ERROR', 40),
|
||||
|
||||
/// An unrecoverable failure. The app or a critical subsystem is unusable.
|
||||
fatal('FATAL', 50);
|
||||
|
||||
/// Uppercase label used in formatted output.
|
||||
final String label;
|
||||
|
||||
/// Numeric severity used for threshold comparisons. Higher is more severe.
|
||||
final int severity;
|
||||
|
||||
const KcLogLevel(this.label, this.severity);
|
||||
|
||||
/// Whether this level is at least as severe as [minimum].
|
||||
bool isAtLeast(KcLogLevel minimum) => severity >= minimum.severity;
|
||||
|
||||
/// Parses a string into a [KcLogLevel], defaulting to [info].
|
||||
static KcLogLevel fromString(String value) {
|
||||
for (final level in values) {
|
||||
if (level.name == value.toLowerCase()) return level;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/// The immutable structured log record emitted by [KcLogger].
|
||||
library;
|
||||
|
||||
import 'kc_log_level.dart';
|
||||
|
||||
/// A single structured log entry.
|
||||
///
|
||||
/// Records are immutable value objects so they can be safely buffered,
|
||||
/// forwarded to multiple sinks, and attached to bug reports.
|
||||
class KcLogRecord {
|
||||
/// Severity of the event.
|
||||
final KcLogLevel level;
|
||||
|
||||
/// Logical source of the event, e.g. `SquareSyncController`.
|
||||
///
|
||||
/// Used to group and filter records by subsystem.
|
||||
final String source;
|
||||
|
||||
/// Human-readable description of what happened.
|
||||
final String message;
|
||||
|
||||
/// When the event occurred.
|
||||
final DateTime timestamp;
|
||||
|
||||
/// Structured key/value context (ids, counts, durations).
|
||||
///
|
||||
/// Must not contain secrets — see [KcRedactor] for sanitising values
|
||||
/// before they are attached.
|
||||
final Map<String, Object?> context;
|
||||
|
||||
/// The associated error object, when the record describes a failure.
|
||||
final Object? error;
|
||||
|
||||
/// The stack trace captured at the point of failure.
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
KcLogRecord({
|
||||
required this.level,
|
||||
required this.source,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
Map<String, Object?> context = const {},
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
}) : context = Map.unmodifiable(context);
|
||||
|
||||
/// Whether this record describes a failure (has an [error] attached or is
|
||||
/// at [KcLogLevel.error] severity or above).
|
||||
bool get isFailure => error != null || level.isAtLeast(KcLogLevel.error);
|
||||
|
||||
/// A single-line, human-readable rendering used by console sinks.
|
||||
String format() {
|
||||
final buffer = StringBuffer()
|
||||
..write('[${timestamp.toIso8601String()}] ')
|
||||
..write('${level.label.padRight(7)} ')
|
||||
..write('$source — $message');
|
||||
|
||||
if (context.isNotEmpty) {
|
||||
final pairs = context.entries.map((e) => '${e.key}=${e.value}').join(', ');
|
||||
buffer.write(' {$pairs}');
|
||||
}
|
||||
if (error != null) {
|
||||
buffer.write(' | error: $error');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// A JSON-serialisable representation for export and bug-report payloads.
|
||||
///
|
||||
/// [stackTrace] is rendered as a string so the map stays serialisable.
|
||||
Map<String, Object?> toJson() => {
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'level': level.label,
|
||||
'source': source,
|
||||
'message': message,
|
||||
if (context.isNotEmpty) 'context': context,
|
||||
if (error != null) 'error': error.toString(),
|
||||
if (stackTrace != null) 'stackTrace': stackTrace.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => format();
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/// Destinations for structured log records.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'kc_log_level.dart';
|
||||
import 'kc_log_record.dart';
|
||||
|
||||
/// A destination that receives [KcLogRecord]s from [KcLogger].
|
||||
///
|
||||
/// Implementations must never throw — [KcLogger] treats sinks as
|
||||
/// fire-and-forget so a broken sink can never break a business operation.
|
||||
abstract class KcLogSink {
|
||||
/// Handles a single [record].
|
||||
void write(KcLogRecord record);
|
||||
}
|
||||
|
||||
/// A sink that writes formatted records to the debug console.
|
||||
///
|
||||
/// Uses [debugPrint] so output is rate-limited by the Flutter framework and
|
||||
/// stripped from release builds by default ([enabledInRelease] overrides this
|
||||
/// for diagnosing production issues on a device).
|
||||
class KcConsoleLogSink implements KcLogSink {
|
||||
/// When `false` (the default) the sink is silent in release builds.
|
||||
final bool enabledInRelease;
|
||||
|
||||
const KcConsoleLogSink({this.enabledInRelease = false});
|
||||
|
||||
@override
|
||||
void write(KcLogRecord record) {
|
||||
if (kReleaseMode && !enabledInRelease) return;
|
||||
debugPrint(record.format());
|
||||
final trace = record.stackTrace;
|
||||
if (trace != null && record.level.isAtLeast(KcLogLevel.error)) {
|
||||
debugPrint(trace.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, in-memory ring buffer of recent log records.
|
||||
///
|
||||
/// This is the evidence source for bug reports: when an operator files a
|
||||
/// report, the most recent [capacity] records are attached so the failure has
|
||||
/// context without requiring a remote logging backend.
|
||||
class KcInMemoryLogSink implements KcLogSink {
|
||||
/// Maximum number of records retained. Oldest records are evicted first.
|
||||
final int capacity;
|
||||
|
||||
final List<KcLogRecord> _records = [];
|
||||
|
||||
KcInMemoryLogSink({this.capacity = 200}) : assert(capacity > 0, 'capacity must be positive');
|
||||
|
||||
@override
|
||||
void write(KcLogRecord record) {
|
||||
_records.add(record);
|
||||
while (_records.length > capacity) {
|
||||
_records.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// All retained records, oldest first.
|
||||
List<KcLogRecord> get records => List.unmodifiable(_records);
|
||||
|
||||
/// Retained records at [minimumLevel] or above, oldest first.
|
||||
List<KcLogRecord> recordsAtLeast(KcLogLevel minimumLevel) =>
|
||||
List.unmodifiable(_records.where((r) => r.level.isAtLeast(minimumLevel)));
|
||||
|
||||
/// The most recent [count] records, oldest first.
|
||||
List<KcLogRecord> recent(int count) {
|
||||
if (count >= _records.length) return records;
|
||||
return List.unmodifiable(_records.sublist(_records.length - count));
|
||||
}
|
||||
|
||||
/// Discards all retained records.
|
||||
void clear() => _records.clear();
|
||||
}
|
||||
|
||||
/// A sink that forwards each record to several [sinks].
|
||||
///
|
||||
/// A failure in one sink never prevents the others from receiving the record.
|
||||
class KcMultiLogSink implements KcLogSink {
|
||||
final List<KcLogSink> sinks;
|
||||
|
||||
const KcMultiLogSink(this.sinks);
|
||||
|
||||
@override
|
||||
void write(KcLogRecord record) {
|
||||
for (final sink in sinks) {
|
||||
try {
|
||||
sink.write(record);
|
||||
} catch (_) {
|
||||
// A broken sink must never break the calling operation.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/// The structured logging facade used across the Kell Creations platform.
|
||||
library;
|
||||
|
||||
import 'kc_log_level.dart';
|
||||
import 'kc_log_record.dart';
|
||||
import 'kc_log_sink.dart';
|
||||
import 'kc_redactor.dart';
|
||||
|
||||
/// Emits structured [KcLogRecord]s to a configured [KcLogSink].
|
||||
///
|
||||
/// ## Design contract
|
||||
///
|
||||
/// - Logging is **never** allowed to throw. A failing sink is swallowed so an
|
||||
/// observability problem can never become a business-logic failure.
|
||||
/// - All context is passed through [KcRedactor] so credentials cannot leak
|
||||
/// into logs or bug reports.
|
||||
/// - Records below [minimumLevel] are dropped before the sink is invoked.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// final logger = KcLogger(sink: KcConsoleLogSink(), source: 'SquareSync');
|
||||
/// logger.info('Sync started', context: {'itemCount': 42});
|
||||
///
|
||||
/// try {
|
||||
/// await service.syncAll();
|
||||
/// } catch (e, s) {
|
||||
/// logger.error('Sync failed', error: e, stackTrace: s);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Use [forSource] to derive a child logger that tags every record with a
|
||||
/// subsystem name while sharing the same sink and threshold.
|
||||
class KcLogger {
|
||||
/// The destination for emitted records.
|
||||
final KcLogSink sink;
|
||||
|
||||
/// Records below this severity are dropped.
|
||||
final KcLogLevel minimumLevel;
|
||||
|
||||
/// Default `source` applied to records that do not override it.
|
||||
final String source;
|
||||
|
||||
/// Injectable clock, primarily for deterministic tests.
|
||||
final DateTime Function() _clock;
|
||||
|
||||
KcLogger({
|
||||
required this.sink,
|
||||
this.minimumLevel = KcLogLevel.info,
|
||||
this.source = 'app',
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
/// A logger that discards everything. Useful as a default in tests and in
|
||||
/// constructors where observability is optional.
|
||||
factory KcLogger.silent() =>
|
||||
KcLogger(sink: const KcMultiLogSink([]), minimumLevel: KcLogLevel.fatal);
|
||||
|
||||
/// Returns a logger that shares this logger's sink, threshold, and clock but
|
||||
/// tags every record with [source].
|
||||
KcLogger forSource(String source) =>
|
||||
KcLogger(sink: sink, minimumLevel: minimumLevel, source: source, clock: _clock);
|
||||
|
||||
/// Logs at [KcLogLevel.debug].
|
||||
void debug(String message, {String? source, Map<String, Object?> context = const {}}) =>
|
||||
log(KcLogLevel.debug, message, source: source, context: context);
|
||||
|
||||
/// Logs at [KcLogLevel.info].
|
||||
void info(String message, {String? source, Map<String, Object?> context = const {}}) =>
|
||||
log(KcLogLevel.info, message, source: source, context: context);
|
||||
|
||||
/// Logs at [KcLogLevel.warning].
|
||||
void warning(
|
||||
String message, {
|
||||
String? source,
|
||||
Map<String, Object?> context = const {},
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => log(
|
||||
KcLogLevel.warning,
|
||||
message,
|
||||
source: source,
|
||||
context: context,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [KcLogLevel.error].
|
||||
void error(
|
||||
String message, {
|
||||
String? source,
|
||||
Map<String, Object?> context = const {},
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => log(
|
||||
KcLogLevel.error,
|
||||
message,
|
||||
source: source,
|
||||
context: context,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [KcLogLevel.fatal].
|
||||
void fatal(
|
||||
String message, {
|
||||
String? source,
|
||||
Map<String, Object?> context = const {},
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => log(
|
||||
KcLogLevel.fatal,
|
||||
message,
|
||||
source: source,
|
||||
context: context,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Emits a record at [level], unless it is below [minimumLevel].
|
||||
///
|
||||
/// [context] is redacted before it reaches the sink. Any exception raised by
|
||||
/// the sink is swallowed.
|
||||
void log(
|
||||
KcLogLevel level,
|
||||
String message, {
|
||||
String? source,
|
||||
Map<String, Object?> context = const {},
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (!level.isAtLeast(minimumLevel)) return;
|
||||
|
||||
try {
|
||||
sink.write(
|
||||
KcLogRecord(
|
||||
level: level,
|
||||
source: source ?? this.source,
|
||||
message: message,
|
||||
timestamp: _clock(),
|
||||
context: KcRedactor.redactMap(context),
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Observability must never break the caller.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/// The assembled observability stack shared by every app target.
|
||||
library;
|
||||
|
||||
import 'kc_bug_report_service.dart';
|
||||
import 'kc_log_level.dart';
|
||||
import 'kc_log_sink.dart';
|
||||
import 'kc_logger.dart';
|
||||
|
||||
/// Bundles the logger, its in-memory evidence buffer, and the bug report
|
||||
/// service into a single value that app service containers can hold.
|
||||
///
|
||||
/// Assembling these together guarantees the three components stay consistent:
|
||||
/// the buffer wired into the logger is the same buffer the bug report service
|
||||
/// reads its evidence from. Building them independently is the most likely way
|
||||
/// to end up with empty bug reports.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// final observability = KcObservability.standard(
|
||||
/// appVersion: '1.0.0+1',
|
||||
/// environment: config.environmentLabel,
|
||||
/// platform: 'android',
|
||||
/// );
|
||||
///
|
||||
/// KcErrorReporter.install(observability.logger);
|
||||
/// observability.logger.info('Bootstrap complete');
|
||||
/// ```
|
||||
class KcObservability {
|
||||
/// Bounded buffer of recent records, attached to bug reports as evidence.
|
||||
final KcInMemoryLogSink logBuffer;
|
||||
|
||||
/// The structured logger used across the app target.
|
||||
final KcLogger logger;
|
||||
|
||||
/// The service backing the shell's "Report a problem" action.
|
||||
final KcBugReportService bugReportService;
|
||||
|
||||
const KcObservability({
|
||||
required this.logBuffer,
|
||||
required this.logger,
|
||||
required this.bugReportService,
|
||||
});
|
||||
|
||||
/// Builds the standard stack: console output plus a bounded in-memory buffer,
|
||||
/// with an in-memory bug report sink.
|
||||
///
|
||||
/// Pass [bugReportSink] to route reports to a real tracker while keeping the
|
||||
/// rest of the wiring intact.
|
||||
factory KcObservability.standard({
|
||||
required String appVersion,
|
||||
required String environment,
|
||||
required String platform,
|
||||
String source = 'app',
|
||||
KcLogLevel minimumLevel = KcLogLevel.info,
|
||||
int bufferCapacity = 200,
|
||||
KcBugReportSink? bugReportSink,
|
||||
}) {
|
||||
final logBuffer = KcInMemoryLogSink(capacity: bufferCapacity);
|
||||
final logger = KcLogger(
|
||||
sink: KcMultiLogSink([const KcConsoleLogSink(), logBuffer]),
|
||||
minimumLevel: minimumLevel,
|
||||
source: source,
|
||||
);
|
||||
|
||||
return KcObservability(
|
||||
logBuffer: logBuffer,
|
||||
logger: logger,
|
||||
bugReportService: KcBugReportService(
|
||||
sink: bugReportSink ?? KcInMemoryBugReportSink(),
|
||||
logger: logger,
|
||||
logSink: logBuffer,
|
||||
appVersion: appVersion,
|
||||
environment: environment,
|
||||
platform: platform,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A stack that records nothing, for tests that do not assert on logging.
|
||||
factory KcObservability.silent() {
|
||||
final logBuffer = KcInMemoryLogSink();
|
||||
final logger = KcLogger(sink: logBuffer, minimumLevel: KcLogLevel.fatal);
|
||||
|
||||
return KcObservability(
|
||||
logBuffer: logBuffer,
|
||||
logger: logger,
|
||||
bugReportService: KcBugReportService(
|
||||
sink: KcInMemoryBugReportSink(),
|
||||
logger: logger,
|
||||
logSink: logBuffer,
|
||||
appVersion: 'test',
|
||||
environment: 'FAKE',
|
||||
platform: 'test',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/// Secret redaction for log context and bug-report payloads.
|
||||
///
|
||||
/// The platform passes WooCommerce and Square credentials through
|
||||
/// `--dart-define`. Those values must never reach a log sink, a diagnostics
|
||||
/// export, or a bug report. [KcRedactor] is the single chokepoint that
|
||||
/// enforces this.
|
||||
library;
|
||||
|
||||
/// Removes sensitive values from structured context maps.
|
||||
class KcRedactor {
|
||||
/// The replacement written in place of a sensitive value.
|
||||
static const String mask = '***REDACTED***';
|
||||
|
||||
/// Substrings that mark a key as sensitive (case-insensitive).
|
||||
static const List<String> sensitiveKeyFragments = [
|
||||
'token',
|
||||
'secret',
|
||||
'password',
|
||||
'passwd',
|
||||
'consumerkey',
|
||||
'consumer_key',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth',
|
||||
'credential',
|
||||
'bearer',
|
||||
'signature',
|
||||
'cookie',
|
||||
];
|
||||
|
||||
const KcRedactor._();
|
||||
|
||||
/// Returns `true` when [key] names a sensitive value.
|
||||
static bool isSensitiveKey(String key) {
|
||||
final normalised = key.toLowerCase().replaceAll('-', '');
|
||||
return sensitiveKeyFragments.any(normalised.contains);
|
||||
}
|
||||
|
||||
/// Returns a copy of [context] with every sensitive value replaced by [mask].
|
||||
///
|
||||
/// Nested maps and lists are traversed so secrets cannot hide inside
|
||||
/// structured payloads.
|
||||
static Map<String, Object?> redactMap(Map<String, Object?> context) {
|
||||
return {
|
||||
for (final entry in context.entries)
|
||||
entry.key: isSensitiveKey(entry.key) ? mask : _redactValue(entry.value),
|
||||
};
|
||||
}
|
||||
|
||||
/// Masks all but the last [visible] characters of [value].
|
||||
///
|
||||
/// Used when an identifier is useful for correlation but must not be
|
||||
/// disclosed in full. Values shorter than or equal to [visible] are fully
|
||||
/// masked.
|
||||
static String maskValue(String value, {int visible = 4}) {
|
||||
if (value.isEmpty) return '';
|
||||
if (value.length <= visible) return mask;
|
||||
return '$mask${value.substring(value.length - visible)}';
|
||||
}
|
||||
|
||||
static Object? _redactValue(Object? value) {
|
||||
if (value is Map) {
|
||||
return redactMap(value.map((k, v) => MapEntry(k.toString(), v)));
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_redactValue).toList();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A sink that always throws, used to prove logging never breaks callers.
|
||||
class _ThrowingLogSink implements KcLogSink {
|
||||
@override
|
||||
void write(KcLogRecord record) => throw StateError('sink is broken');
|
||||
}
|
||||
|
||||
/// A bug report sink that always fails, used to prove submission degrades
|
||||
/// gracefully.
|
||||
class _FailingBugReportSink implements KcBugReportSink {
|
||||
@override
|
||||
Future<void> send(KcBugReport report) async => throw StateError('tracker offline');
|
||||
}
|
||||
|
||||
/// A stand-in error type with a stable name for assertions.
|
||||
class _ApiFailureStub implements Exception {
|
||||
const _ApiFailureStub();
|
||||
@override
|
||||
String toString() => 'ApiFailureStub';
|
||||
}
|
||||
|
||||
final _fixedClock = DateTime.utc(2026, 8, 29, 12, 30);
|
||||
|
||||
void main() {
|
||||
// ── KcLogLevel ─────────────────────────────────────────────────────────────
|
||||
group('KcLogLevel', () {
|
||||
test('severity increases from debug to fatal', () {
|
||||
expect(KcLogLevel.debug.severity, lessThan(KcLogLevel.info.severity));
|
||||
expect(KcLogLevel.info.severity, lessThan(KcLogLevel.warning.severity));
|
||||
expect(KcLogLevel.warning.severity, lessThan(KcLogLevel.error.severity));
|
||||
expect(KcLogLevel.error.severity, lessThan(KcLogLevel.fatal.severity));
|
||||
});
|
||||
|
||||
test('isAtLeast compares severity inclusively', () {
|
||||
expect(KcLogLevel.error.isAtLeast(KcLogLevel.warning), isTrue);
|
||||
expect(KcLogLevel.error.isAtLeast(KcLogLevel.error), isTrue);
|
||||
expect(KcLogLevel.debug.isAtLeast(KcLogLevel.info), isFalse);
|
||||
});
|
||||
|
||||
test('fromString parses known values and defaults to info', () {
|
||||
expect(KcLogLevel.fromString('warning'), KcLogLevel.warning);
|
||||
expect(KcLogLevel.fromString('fatal'), KcLogLevel.fatal);
|
||||
expect(KcLogLevel.fromString('nonsense'), KcLogLevel.info);
|
||||
});
|
||||
|
||||
test('labels are uppercase', () {
|
||||
expect(KcLogLevel.debug.label, 'DEBUG');
|
||||
expect(KcLogLevel.fatal.label, 'FATAL');
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcRedactor ─────────────────────────────────────────────────────────────
|
||||
group('KcRedactor', () {
|
||||
test('identifies sensitive keys regardless of case or dashes', () {
|
||||
expect(KcRedactor.isSensitiveKey('accessToken'), isTrue);
|
||||
expect(KcRedactor.isSensitiveKey('CONSUMER_SECRET'), isTrue);
|
||||
expect(KcRedactor.isSensitiveKey('Api-Key'), isTrue);
|
||||
expect(KcRedactor.isSensitiveKey('Authorization'), isTrue);
|
||||
expect(KcRedactor.isSensitiveKey('productId'), isFalse);
|
||||
});
|
||||
|
||||
test('redactMap masks sensitive values and preserves safe ones', () {
|
||||
final redacted = KcRedactor.redactMap({
|
||||
'squareAccessToken': 'EAAAlreal',
|
||||
'productId': 42,
|
||||
'siteUrl': 'https://store.example.com',
|
||||
});
|
||||
|
||||
expect(redacted['squareAccessToken'], KcRedactor.mask);
|
||||
expect(redacted['productId'], 42);
|
||||
expect(redacted['siteUrl'], 'https://store.example.com');
|
||||
});
|
||||
|
||||
test('redactMap traverses nested maps and lists', () {
|
||||
final redacted = KcRedactor.redactMap({
|
||||
'request': {
|
||||
'headers': {'authorization': 'Bearer abc'},
|
||||
'items': [
|
||||
{'sku': 'A1', 'apiKey': 'shh'},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
final request = redacted['request']! as Map<String, Object?>;
|
||||
final headers = request['headers']! as Map<String, Object?>;
|
||||
expect(headers['authorization'], KcRedactor.mask);
|
||||
|
||||
final items = request['items']! as List<Object?>;
|
||||
final first = items.first! as Map<String, Object?>;
|
||||
expect(first['sku'], 'A1');
|
||||
expect(first['apiKey'], KcRedactor.mask);
|
||||
});
|
||||
|
||||
test('maskValue keeps only the trailing characters', () {
|
||||
expect(KcRedactor.maskValue('abcdefgh'), '${KcRedactor.mask}efgh');
|
||||
expect(KcRedactor.maskValue('abc'), KcRedactor.mask);
|
||||
expect(KcRedactor.maskValue(''), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcLogRecord ────────────────────────────────────────────────────────────
|
||||
group('KcLogRecord', () {
|
||||
test('format includes timestamp, level, source, message and context', () {
|
||||
final record = KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 'SquareSync',
|
||||
message: 'Sync started',
|
||||
timestamp: _fixedClock,
|
||||
context: {'itemCount': 3},
|
||||
);
|
||||
|
||||
final formatted = record.format();
|
||||
expect(formatted, contains('2026-08-29T12:30:00.000Z'));
|
||||
expect(formatted, contains('INFO'));
|
||||
expect(formatted, contains('SquareSync'));
|
||||
expect(formatted, contains('Sync started'));
|
||||
expect(formatted, contains('itemCount=3'));
|
||||
});
|
||||
|
||||
test('isFailure is true for error level or attached error', () {
|
||||
final errorRecord = KcLogRecord(
|
||||
level: KcLogLevel.error,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
);
|
||||
final infoWithError = KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
error: StateError('boom'),
|
||||
);
|
||||
final plainInfo = KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
);
|
||||
|
||||
expect(errorRecord.isFailure, isTrue);
|
||||
expect(infoWithError.isFailure, isTrue);
|
||||
expect(plainInfo.isFailure, isFalse);
|
||||
});
|
||||
|
||||
test('toJson is serialisable and includes error and stack trace', () {
|
||||
final record = KcLogRecord(
|
||||
level: KcLogLevel.error,
|
||||
source: 'Api',
|
||||
message: 'Request failed',
|
||||
timestamp: _fixedClock,
|
||||
context: {'statusCode': 500},
|
||||
error: const _ApiFailureStub(),
|
||||
stackTrace: StackTrace.fromString('#0 main'),
|
||||
);
|
||||
|
||||
final json = record.toJson();
|
||||
expect(json['level'], 'ERROR');
|
||||
expect(json['source'], 'Api');
|
||||
expect(json['timestamp'], '2026-08-29T12:30:00.000Z');
|
||||
expect((json['context']! as Map)['statusCode'], 500);
|
||||
expect(json['error'], contains('ApiFailureStub'));
|
||||
expect(json['stackTrace'], contains('#0 main'));
|
||||
});
|
||||
|
||||
test('context is unmodifiable', () {
|
||||
final record = KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
context: {'a': 1},
|
||||
);
|
||||
|
||||
expect(() => record.context['b'] = 2, throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcInMemoryLogSink ──────────────────────────────────────────────────────
|
||||
group('KcInMemoryLogSink', () {
|
||||
KcLogRecord recordAt(int i, {KcLogLevel level = KcLogLevel.info}) => KcLogRecord(
|
||||
level: level,
|
||||
source: 'test',
|
||||
message: 'message $i',
|
||||
timestamp: _fixedClock,
|
||||
);
|
||||
|
||||
test('retains records in insertion order', () {
|
||||
final sink = KcInMemoryLogSink()
|
||||
..write(recordAt(1))
|
||||
..write(recordAt(2));
|
||||
|
||||
expect(sink.records.map((r) => r.message), ['message 1', 'message 2']);
|
||||
});
|
||||
|
||||
test('evicts oldest records beyond capacity', () {
|
||||
final sink = KcInMemoryLogSink(capacity: 2)
|
||||
..write(recordAt(1))
|
||||
..write(recordAt(2))
|
||||
..write(recordAt(3));
|
||||
|
||||
expect(sink.records.length, 2);
|
||||
expect(sink.records.map((r) => r.message), ['message 2', 'message 3']);
|
||||
});
|
||||
|
||||
test('recordsAtLeast filters by severity', () {
|
||||
final sink = KcInMemoryLogSink()
|
||||
..write(recordAt(1))
|
||||
..write(recordAt(2, level: KcLogLevel.error));
|
||||
|
||||
expect(sink.recordsAtLeast(KcLogLevel.error).length, 1);
|
||||
expect(sink.recordsAtLeast(KcLogLevel.error).single.message, 'message 2');
|
||||
});
|
||||
|
||||
test('recent returns the trailing window', () {
|
||||
final sink = KcInMemoryLogSink()
|
||||
..write(recordAt(1))
|
||||
..write(recordAt(2))
|
||||
..write(recordAt(3));
|
||||
|
||||
expect(sink.recent(2).map((r) => r.message), ['message 2', 'message 3']);
|
||||
expect(sink.recent(10).length, 3);
|
||||
});
|
||||
|
||||
test('clear discards retained records', () {
|
||||
final sink = KcInMemoryLogSink()..write(recordAt(1));
|
||||
sink.clear();
|
||||
expect(sink.records, isEmpty);
|
||||
});
|
||||
|
||||
test('records list is unmodifiable', () {
|
||||
final sink = KcInMemoryLogSink()..write(recordAt(1));
|
||||
expect(() => sink.records.add(recordAt(2)), throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('rejects non-positive capacity', () {
|
||||
expect(() => KcInMemoryLogSink(capacity: 0), throwsA(isA<AssertionError>()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcMultiLogSink ─────────────────────────────────────────────────────────
|
||||
group('KcMultiLogSink', () {
|
||||
test('fans out to every sink', () {
|
||||
final a = KcInMemoryLogSink();
|
||||
final b = KcInMemoryLogSink();
|
||||
KcMultiLogSink([a, b]).write(
|
||||
KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
),
|
||||
);
|
||||
|
||||
expect(a.records.length, 1);
|
||||
expect(b.records.length, 1);
|
||||
});
|
||||
|
||||
test('a throwing sink does not prevent delivery to the others', () {
|
||||
final healthy = KcInMemoryLogSink();
|
||||
KcMultiLogSink([_ThrowingLogSink(), healthy]).write(
|
||||
KcLogRecord(
|
||||
level: KcLogLevel.info,
|
||||
source: 's',
|
||||
message: 'm',
|
||||
timestamp: _fixedClock,
|
||||
),
|
||||
);
|
||||
|
||||
expect(healthy.records.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcLogger ───────────────────────────────────────────────────────────────
|
||||
group('KcLogger', () {
|
||||
late KcInMemoryLogSink sink;
|
||||
late KcLogger logger;
|
||||
|
||||
setUp(() {
|
||||
sink = KcInMemoryLogSink();
|
||||
logger = KcLogger(
|
||||
sink: sink,
|
||||
minimumLevel: KcLogLevel.debug,
|
||||
source: 'default',
|
||||
clock: () => _fixedClock,
|
||||
);
|
||||
});
|
||||
|
||||
test('emits records at each severity helper', () {
|
||||
logger
|
||||
..debug('d')
|
||||
..info('i')
|
||||
..warning('w')
|
||||
..error('e')
|
||||
..fatal('f');
|
||||
|
||||
expect(sink.records.map((r) => r.level), [
|
||||
KcLogLevel.debug,
|
||||
KcLogLevel.info,
|
||||
KcLogLevel.warning,
|
||||
KcLogLevel.error,
|
||||
KcLogLevel.fatal,
|
||||
]);
|
||||
});
|
||||
|
||||
test('drops records below the minimum level', () {
|
||||
KcLogger(sink: sink, minimumLevel: KcLogLevel.warning, clock: () => _fixedClock)
|
||||
..debug('dropped')
|
||||
..info('dropped')
|
||||
..warning('kept');
|
||||
|
||||
expect(sink.records.length, 1);
|
||||
expect(sink.records.single.message, 'kept');
|
||||
});
|
||||
|
||||
test('applies the default source and allows per-call override', () {
|
||||
logger
|
||||
..info('a')
|
||||
..info('b', source: 'Override');
|
||||
|
||||
expect(sink.records[0].source, 'default');
|
||||
expect(sink.records[1].source, 'Override');
|
||||
});
|
||||
|
||||
test('redacts sensitive context before it reaches the sink', () {
|
||||
logger.info('Configured', context: {'consumerSecret': 'cs_live', 'siteUrl': 'https://x'});
|
||||
|
||||
final context = sink.records.single.context;
|
||||
expect(context['consumerSecret'], KcRedactor.mask);
|
||||
expect(context['siteUrl'], 'https://x');
|
||||
});
|
||||
|
||||
test('captures error and stack trace on failure records', () {
|
||||
final trace = StackTrace.fromString('#0 failing');
|
||||
logger.error('Boom', error: const _ApiFailureStub(), stackTrace: trace);
|
||||
|
||||
final record = sink.records.single;
|
||||
expect(record.error, isA<_ApiFailureStub>());
|
||||
expect(record.stackTrace, trace);
|
||||
expect(record.isFailure, isTrue);
|
||||
});
|
||||
|
||||
test('uses the injected clock for timestamps', () {
|
||||
logger.info('m');
|
||||
expect(sink.records.single.timestamp, _fixedClock);
|
||||
});
|
||||
|
||||
test('never throws when the sink throws', () {
|
||||
final brittle = KcLogger(sink: _ThrowingLogSink(), minimumLevel: KcLogLevel.debug);
|
||||
expect(() => brittle.error('still safe'), returnsNormally);
|
||||
});
|
||||
|
||||
test('forSource derives a child sharing sink and threshold', () {
|
||||
final child = logger.forSource('SquareSync');
|
||||
child.info('scoped');
|
||||
|
||||
expect(child.sink, same(sink));
|
||||
expect(child.minimumLevel, KcLogLevel.debug);
|
||||
expect(sink.records.single.source, 'SquareSync');
|
||||
});
|
||||
|
||||
test('silent logger discards everything below fatal', () {
|
||||
final silent = KcLogger.silent();
|
||||
expect(silent.minimumLevel, KcLogLevel.fatal);
|
||||
expect(() => silent.info('ignored'), returnsNormally);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcErrorReporter ────────────────────────────────────────────────────────
|
||||
group('KcErrorReporter', () {
|
||||
late KcInMemoryLogSink sink;
|
||||
late KcLogger logger;
|
||||
|
||||
setUp(() {
|
||||
sink = KcInMemoryLogSink();
|
||||
logger = KcLogger(sink: sink, minimumLevel: KcLogLevel.debug, clock: () => _fixedClock);
|
||||
});
|
||||
|
||||
tearDown(KcErrorReporter.restore);
|
||||
|
||||
test('install reports installation state', () {
|
||||
expect(KcErrorReporter.isInstalled, isFalse);
|
||||
KcErrorReporter.install(logger, forwardToOriginalHandler: false);
|
||||
expect(KcErrorReporter.isInstalled, isTrue);
|
||||
KcErrorReporter.restore();
|
||||
expect(KcErrorReporter.isInstalled, isFalse);
|
||||
});
|
||||
|
||||
test('captures uncaught Flutter framework errors as error records', () {
|
||||
KcErrorReporter.install(logger, forwardToOriginalHandler: false);
|
||||
|
||||
FlutterError.onError!(
|
||||
FlutterErrorDetails(
|
||||
exception: const _ApiFailureStub(),
|
||||
stack: StackTrace.fromString('#0 build'),
|
||||
library: 'widgets library',
|
||||
),
|
||||
);
|
||||
|
||||
final record = sink.records.single;
|
||||
expect(record.level, KcLogLevel.error);
|
||||
expect(record.source, KcErrorReporter.source);
|
||||
expect(record.error, isA<_ApiFailureStub>());
|
||||
expect(record.context['library'], 'widgets library');
|
||||
});
|
||||
|
||||
test('forwards to the original handler when requested', () {
|
||||
var forwarded = false;
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (_) => forwarded = true;
|
||||
|
||||
KcErrorReporter.install(logger);
|
||||
FlutterError.onError!(FlutterErrorDetails(exception: const _ApiFailureStub()));
|
||||
|
||||
expect(forwarded, isTrue);
|
||||
expect(sink.records.single.level, KcLogLevel.error);
|
||||
|
||||
KcErrorReporter.restore();
|
||||
FlutterError.onError = original;
|
||||
});
|
||||
|
||||
test('runGuarded records uncaught zone errors as fatal', () async {
|
||||
KcErrorReporter.runGuarded(logger, () {
|
||||
Future<void>.error(const _ApiFailureStub());
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(sink.records.single.level, KcLogLevel.fatal);
|
||||
expect(sink.records.single.message, 'Uncaught zone error');
|
||||
});
|
||||
|
||||
test('runGuarded returns the body result when nothing throws', () {
|
||||
final result = KcErrorReporter.runGuarded<int>(logger, () => 7);
|
||||
expect(result, 7);
|
||||
expect(sink.records, isEmpty);
|
||||
});
|
||||
|
||||
test('restore is safe to call when not installed', () {
|
||||
expect(KcErrorReporter.restore, returnsNormally);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcBugSeverity ──────────────────────────────────────────────────────────
|
||||
group('KcBugSeverity', () {
|
||||
test('exposes display labels', () {
|
||||
expect(KcBugSeverity.low.label, 'Low');
|
||||
expect(KcBugSeverity.critical.label, 'Critical');
|
||||
});
|
||||
|
||||
test('fromString parses known values and defaults to medium', () {
|
||||
expect(KcBugSeverity.fromString('high'), KcBugSeverity.high);
|
||||
expect(KcBugSeverity.fromString('unknown'), KcBugSeverity.medium);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcBugReport ────────────────────────────────────────────────────────────
|
||||
group('KcBugReport', () {
|
||||
KcBugReport buildReport({
|
||||
List<KcLogRecord> logTail = const [],
|
||||
Map<String, Object?> diagnostics = const {},
|
||||
}) => KcBugReport(
|
||||
id: 'KC-BUG-1',
|
||||
title: 'Checkout fails',
|
||||
description: 'Tapping Card does nothing.',
|
||||
severity: KcBugSeverity.high,
|
||||
createdAt: _fixedClock,
|
||||
appVersion: '1.0.0+1',
|
||||
environment: 'SQ',
|
||||
platform: 'android',
|
||||
reporter: 'operator@example.com',
|
||||
screen: 'MobileCheckoutPage',
|
||||
logTail: logTail,
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
|
||||
test('redacts diagnostics on construction', () {
|
||||
final report = buildReport(diagnostics: {'accessToken': 'EAAAsecret', 'cartSize': 2});
|
||||
|
||||
expect(report.diagnostics['accessToken'], KcRedactor.mask);
|
||||
expect(report.diagnostics['cartSize'], 2);
|
||||
});
|
||||
|
||||
test('hasFailureEvidence reflects attached failure records', () {
|
||||
final withFailure = buildReport(
|
||||
logTail: [
|
||||
KcLogRecord(
|
||||
level: KcLogLevel.error,
|
||||
source: 's',
|
||||
message: 'failed',
|
||||
timestamp: _fixedClock,
|
||||
),
|
||||
],
|
||||
);
|
||||
final withoutFailure = buildReport(
|
||||
logTail: [
|
||||
KcLogRecord(level: KcLogLevel.info, source: 's', message: 'ok', timestamp: _fixedClock),
|
||||
],
|
||||
);
|
||||
|
||||
expect(withFailure.hasFailureEvidence, isTrue);
|
||||
expect(withoutFailure.hasFailureEvidence, isFalse);
|
||||
});
|
||||
|
||||
test('toJson carries metadata and the serialised log tail', () {
|
||||
final report = buildReport(
|
||||
logTail: [
|
||||
KcLogRecord(
|
||||
level: KcLogLevel.error,
|
||||
source: 'Checkout',
|
||||
message: 'payment failed',
|
||||
timestamp: _fixedClock,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final json = report.toJson();
|
||||
expect(json['id'], 'KC-BUG-1');
|
||||
expect(json['severity'], 'high');
|
||||
expect(json['appVersion'], '1.0.0+1');
|
||||
expect(json['environment'], 'SQ');
|
||||
expect(json['platform'], 'android');
|
||||
expect(json['screen'], 'MobileCheckoutPage');
|
||||
expect((json['logTail']! as List).length, 1);
|
||||
});
|
||||
|
||||
test('toMarkdown renders the report with evidence', () {
|
||||
final report = buildReport(
|
||||
diagnostics: {'cartSize': 2},
|
||||
logTail: [
|
||||
KcLogRecord(
|
||||
level: KcLogLevel.error,
|
||||
source: 'Checkout',
|
||||
message: 'payment failed',
|
||||
timestamp: _fixedClock,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final markdown = report.toMarkdown();
|
||||
expect(markdown, contains('# Bug Report: Checkout fails'));
|
||||
expect(markdown, contains('**Severity:** High'));
|
||||
expect(markdown, contains('**Environment:** SQ'));
|
||||
expect(markdown, contains('## Diagnostics'));
|
||||
expect(markdown, contains('cartSize'));
|
||||
expect(markdown, contains('## Recent log records'));
|
||||
expect(markdown, contains('payment failed'));
|
||||
});
|
||||
|
||||
test('logTail is unmodifiable', () {
|
||||
final report = buildReport();
|
||||
expect(
|
||||
() => report.logTail.add(
|
||||
KcLogRecord(level: KcLogLevel.info, source: 's', message: 'm', timestamp: _fixedClock),
|
||||
),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── KcBugReportService ─────────────────────────────────────────────────────
|
||||
group('KcBugReportService', () {
|
||||
late KcInMemoryLogSink logSink;
|
||||
late KcInMemoryBugReportSink reportSink;
|
||||
late KcLogger logger;
|
||||
late KcBugReportService service;
|
||||
|
||||
setUp(() {
|
||||
logSink = KcInMemoryLogSink();
|
||||
reportSink = KcInMemoryBugReportSink();
|
||||
logger = KcLogger(sink: logSink, minimumLevel: KcLogLevel.debug, clock: () => _fixedClock);
|
||||
service = KcBugReportService(
|
||||
sink: reportSink,
|
||||
logger: logger,
|
||||
logSink: logSink,
|
||||
appVersion: '1.0.0+1',
|
||||
environment: 'FAKE',
|
||||
platform: 'test',
|
||||
clock: () => _fixedClock,
|
||||
idGenerator: () => 'KC-BUG-FIXED',
|
||||
);
|
||||
});
|
||||
|
||||
test('submits a valid report and stamps build metadata', () async {
|
||||
final result = await service.submit(
|
||||
title: 'Sync stalls',
|
||||
description: 'Square sync never completes.',
|
||||
severity: KcBugSeverity.high,
|
||||
);
|
||||
|
||||
expect(result.succeeded, isTrue);
|
||||
expect(result.report!.id, 'KC-BUG-FIXED');
|
||||
expect(result.report!.appVersion, '1.0.0+1');
|
||||
expect(result.report!.environment, 'FAKE');
|
||||
expect(result.report!.platform, 'test');
|
||||
expect(reportSink.reports.length, 1);
|
||||
});
|
||||
|
||||
test('attaches the recent log tail as evidence', () async {
|
||||
logger.error('Square sync failed', context: {'stage': 'catalog'});
|
||||
|
||||
final result = await service.submit(title: 'Sync stalls', description: 'Details here.');
|
||||
|
||||
expect(result.report!.logTail, isNotEmpty);
|
||||
expect(result.report!.hasFailureEvidence, isTrue);
|
||||
});
|
||||
|
||||
test('rejects a blank title', () async {
|
||||
final result = await service.submit(title: ' ', description: 'Details here.');
|
||||
|
||||
expect(result.succeeded, isFalse);
|
||||
expect(result.errorMessage, 'A title is required.');
|
||||
expect(reportSink.reports, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects a blank description', () async {
|
||||
final result = await service.submit(title: 'Title', description: ' ');
|
||||
|
||||
expect(result.succeeded, isFalse);
|
||||
expect(result.errorMessage, 'A description is required.');
|
||||
expect(reportSink.reports, isEmpty);
|
||||
});
|
||||
|
||||
test('trims whitespace from title and description', () async {
|
||||
final result = await service.submit(title: ' Sync stalls ', description: ' Details. ');
|
||||
|
||||
expect(result.report!.title, 'Sync stalls');
|
||||
expect(result.report!.description, 'Details.');
|
||||
});
|
||||
|
||||
test('redacts diagnostics supplied by the caller', () async {
|
||||
final result = await service.submit(
|
||||
title: 'Auth issue',
|
||||
description: 'Cannot connect.',
|
||||
diagnostics: {'consumerSecret': 'cs_live_abc', 'endpoint': '/wp-json'},
|
||||
);
|
||||
|
||||
expect(result.report!.diagnostics['consumerSecret'], KcRedactor.mask);
|
||||
expect(result.report!.diagnostics['endpoint'], '/wp-json');
|
||||
});
|
||||
|
||||
test('logs a record when submission succeeds', () async {
|
||||
await service.submit(title: 'T', description: 'D');
|
||||
|
||||
final submitted = logSink.records.where((r) => r.message == 'Bug report submitted');
|
||||
expect(submitted, hasLength(1));
|
||||
expect(submitted.single.context['reportId'], 'KC-BUG-FIXED');
|
||||
});
|
||||
|
||||
test('logs critical reports at error severity', () async {
|
||||
await service.submit(title: 'T', description: 'D', severity: KcBugSeverity.critical);
|
||||
|
||||
final submitted = logSink.records.firstWhere((r) => r.message == 'Bug report submitted');
|
||||
expect(submitted.level, KcLogLevel.error);
|
||||
});
|
||||
|
||||
test('returns a failure result and logs when the sink throws', () async {
|
||||
final failing = KcBugReportService(
|
||||
sink: _FailingBugReportSink(),
|
||||
logger: logger,
|
||||
appVersion: '1.0.0+1',
|
||||
environment: 'FAKE',
|
||||
platform: 'test',
|
||||
clock: () => _fixedClock,
|
||||
idGenerator: () => 'KC-BUG-FIXED',
|
||||
);
|
||||
|
||||
final result = await failing.submit(title: 'T', description: 'D');
|
||||
|
||||
expect(result.succeeded, isFalse);
|
||||
expect(result.errorMessage, contains('Could not submit the report'));
|
||||
expect(logSink.records.any((r) => r.message == 'Bug report submission failed'), isTrue);
|
||||
});
|
||||
|
||||
test('buildReport does not submit', () {
|
||||
final report = service.buildReport(title: 'T', description: 'D');
|
||||
|
||||
expect(report.id, 'KC-BUG-FIXED');
|
||||
expect(reportSink.reports, isEmpty);
|
||||
});
|
||||
|
||||
test('generates unique ids when no id generator is supplied', () async {
|
||||
final auto = KcBugReportService(
|
||||
sink: reportSink,
|
||||
logger: logger,
|
||||
appVersion: '1.0.0+1',
|
||||
environment: 'FAKE',
|
||||
platform: 'test',
|
||||
clock: () => _fixedClock,
|
||||
);
|
||||
|
||||
final first = await auto.submit(title: 'A', description: 'D');
|
||||
final second = await auto.submit(title: 'B', description: 'D');
|
||||
|
||||
expect(first.report!.id, isNot(second.report!.id));
|
||||
});
|
||||
|
||||
test('in-memory sink retains submissions and can be cleared', () async {
|
||||
await service.submit(title: 'T', description: 'D');
|
||||
expect(reportSink.reports, hasLength(1));
|
||||
|
||||
reportSink.clear();
|
||||
expect(reportSink.reports, isEmpty);
|
||||
});
|
||||
|
||||
test('result toString reflects success and failure', () async {
|
||||
final ok = await service.submit(title: 'T', description: 'D');
|
||||
const bad = KcBugReportResult.failure('nope');
|
||||
|
||||
expect(ok.toString(), contains('success'));
|
||||
expect(bad.toString(), contains('failure'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export 'src/theme/kc_typography.dart';
|
|||
export 'src/layout/kc_breakpoints.dart';
|
||||
|
||||
// Widgets
|
||||
export 'src/widgets/kc_bug_report_dialog.dart';
|
||||
export 'src/widgets/kc_card.dart';
|
||||
export 'src/widgets/kc_empty_state.dart';
|
||||
export 'src/widgets/kc_error_state.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/kc_spacing.dart';
|
||||
|
||||
/// The values captured by [KcBugReportDialog] when the operator submits.
|
||||
///
|
||||
/// The dialog is deliberately decoupled from `core` so `design_system` stays
|
||||
/// dependency-free. The caller maps [severityName] onto `KcBugSeverity` and
|
||||
/// forwards the values to `KcBugReportService.submit`.
|
||||
class KcBugReportDraft {
|
||||
/// Short summary of the defect.
|
||||
final String title;
|
||||
|
||||
/// Detailed description and reproduction steps.
|
||||
final String description;
|
||||
|
||||
/// Selected severity, matching the `KcBugSeverity` enum names
|
||||
/// (`low`, `medium`, `high`, `critical`).
|
||||
final String severityName;
|
||||
|
||||
/// Optional reporter identity.
|
||||
final String? reporter;
|
||||
|
||||
const KcBugReportDraft({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.severityName,
|
||||
this.reporter,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'KcBugReportDraft($severityName: $title)';
|
||||
}
|
||||
|
||||
/// A shared bug-reporting dialog used by both the web and mobile shells.
|
||||
///
|
||||
/// Collects a title, description, severity, and optional reporter identity.
|
||||
/// Validation is enforced inline before the dialog can be submitted, so the
|
||||
/// caller only ever receives a well-formed [KcBugReportDraft].
|
||||
///
|
||||
/// Returns the draft on submit, or `null` when cancelled.
|
||||
///
|
||||
/// ```dart
|
||||
/// final draft = await KcBugReportDialog.show(context);
|
||||
/// if (draft != null) {
|
||||
/// await service.submit(
|
||||
/// title: draft.title,
|
||||
/// description: draft.description,
|
||||
/// severity: KcBugSeverity.fromString(draft.severityName),
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class KcBugReportDialog extends StatefulWidget {
|
||||
/// The screen or workflow the operator is reporting from, shown as context.
|
||||
final String? screen;
|
||||
|
||||
/// Severity options offered, as `(name, label)` pairs.
|
||||
final List<({String name, String label})> severities;
|
||||
|
||||
const KcBugReportDialog({
|
||||
super.key,
|
||||
this.screen,
|
||||
this.severities = const [
|
||||
(name: 'low', label: 'Low'),
|
||||
(name: 'medium', label: 'Medium'),
|
||||
(name: 'high', label: 'High'),
|
||||
(name: 'critical', label: 'Critical'),
|
||||
],
|
||||
});
|
||||
|
||||
/// Shows the dialog and resolves to the submitted draft, or `null` if the
|
||||
/// operator cancelled.
|
||||
static Future<KcBugReportDraft?> show(BuildContext context, {String? screen}) {
|
||||
return showDialog<KcBugReportDraft>(
|
||||
context: context,
|
||||
builder: (_) => KcBugReportDialog(screen: screen),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<KcBugReportDialog> createState() => _KcBugReportDialogState();
|
||||
}
|
||||
|
||||
class _KcBugReportDialogState extends State<KcBugReportDialog> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _reporterController = TextEditingController();
|
||||
|
||||
late String _severityName = widget.severities.length > 1
|
||||
? widget.severities[1].name
|
||||
: widget.severities.first.name;
|
||||
|
||||
String? _titleError;
|
||||
String? _descriptionError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_reporterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final title = _titleController.text.trim();
|
||||
final description = _descriptionController.text.trim();
|
||||
|
||||
setState(() {
|
||||
_titleError = title.isEmpty ? 'A title is required.' : null;
|
||||
_descriptionError = description.isEmpty ? 'A description is required.' : null;
|
||||
});
|
||||
|
||||
if (title.isEmpty || description.isEmpty) return;
|
||||
|
||||
final reporter = _reporterController.text.trim();
|
||||
|
||||
Navigator.of(context).pop(
|
||||
KcBugReportDraft(
|
||||
title: title,
|
||||
description: description,
|
||||
severityName: _severityName,
|
||||
reporter: reporter.isEmpty ? null : reporter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Report a problem'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.screen != null) ...[
|
||||
Text('Screen: ${widget.screen}', style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
],
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'What went wrong?',
|
||||
hintText: 'Short summary',
|
||||
errorText: _titleError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: KcSpacing.md),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Details and steps to reproduce',
|
||||
errorText: _descriptionError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: KcSpacing.md),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _severityName,
|
||||
decoration: const InputDecoration(labelText: 'Severity'),
|
||||
items: [
|
||||
for (final severity in widget.severities)
|
||||
DropdownMenuItem(value: severity.name, child: Text(severity.label)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _severityName = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: KcSpacing.md),
|
||||
TextField(
|
||||
controller: _reporterController,
|
||||
decoration: const InputDecoration(labelText: 'Your name or email (optional)'),
|
||||
),
|
||||
const SizedBox(height: KcSpacing.md),
|
||||
Text(
|
||||
'Recent activity logs are attached automatically. '
|
||||
'Credentials are never included.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: _submit, child: const Text('Submit report')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Pumps a host exposing a button that opens [KcBugReportDialog].
|
||||
///
|
||||
/// [onResult] receives the resolved draft (or `null` when cancelled).
|
||||
Future<void> _pumpHost(
|
||||
WidgetTester tester, {
|
||||
String? screen,
|
||||
void Function(KcBugReportDraft?)? onResult,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () async {
|
||||
final draft = await KcBugReportDialog.show(context, screen: screen);
|
||||
onResult?.call(draft);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Fills the required fields with valid values.
|
||||
Future<void> _fillRequired(WidgetTester tester, {String title = 'Title', String body = 'Body'}) async {
|
||||
await tester.enterText(find.byType(TextField).at(0), title);
|
||||
await tester.enterText(find.byType(TextField).at(1), body);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KcBugReportDraft', () {
|
||||
test('holds the captured values', () {
|
||||
const draft = KcBugReportDraft(
|
||||
title: 'Checkout fails',
|
||||
description: 'Card button does nothing',
|
||||
severityName: 'high',
|
||||
reporter: 'op@example.com',
|
||||
);
|
||||
|
||||
expect(draft.title, 'Checkout fails');
|
||||
expect(draft.description, 'Card button does nothing');
|
||||
expect(draft.severityName, 'high');
|
||||
expect(draft.reporter, 'op@example.com');
|
||||
expect(draft.toString(), contains('high'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KcBugReportDialog', () {
|
||||
testWidgets('renders the reporting form', (tester) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
expect(find.text('Report a problem'), findsOneWidget);
|
||||
expect(find.text('What went wrong?'), findsOneWidget);
|
||||
expect(find.text('Details and steps to reproduce'), findsOneWidget);
|
||||
expect(find.text('Severity'), findsOneWidget);
|
||||
expect(find.text('Submit report'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the originating screen when supplied', (tester) async {
|
||||
await _pumpHost(tester, screen: 'MobileCheckoutPage');
|
||||
|
||||
expect(find.text('Screen: MobileCheckoutPage'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('states that logs are attached and credentials excluded', (tester) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
expect(find.textContaining('Recent activity logs are attached'), findsOneWidget);
|
||||
expect(find.textContaining('Credentials are never included'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('blocks submission and shows errors when fields are blank', (tester) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('A title is required.'), findsOneWidget);
|
||||
expect(find.text('A description is required.'), findsOneWidget);
|
||||
expect(find.text('Report a problem'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rejects whitespace-only input', (tester) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
await _fillRequired(tester, title: ' ', body: ' ');
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('A title is required.'), findsOneWidget);
|
||||
expect(find.text('A description is required.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('returns a trimmed draft on submit', (tester) async {
|
||||
KcBugReportDraft? captured;
|
||||
await _pumpHost(tester, onResult: (d) => captured = d);
|
||||
|
||||
await _fillRequired(tester, title: ' Sync stalls ', body: ' Never completes. ');
|
||||
await tester.enterText(find.byType(TextField).at(2), ' op@example.com ');
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.title, 'Sync stalls');
|
||||
expect(captured!.description, 'Never completes.');
|
||||
expect(captured!.reporter, 'op@example.com');
|
||||
expect(captured!.severityName, 'medium');
|
||||
});
|
||||
|
||||
testWidgets('omits an empty reporter', (tester) async {
|
||||
KcBugReportDraft? captured;
|
||||
await _pumpHost(tester, onResult: (d) => captured = d);
|
||||
|
||||
await _fillRequired(tester);
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured!.reporter, isNull);
|
||||
});
|
||||
|
||||
testWidgets('captures the selected severity', (tester) async {
|
||||
KcBugReportDraft? captured;
|
||||
await _pumpHost(tester, onResult: (d) => captured = d);
|
||||
|
||||
await _fillRequired(tester);
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Critical').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Submit report'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured!.severityName, 'critical');
|
||||
});
|
||||
|
||||
testWidgets('cancel resolves to null', (tester) async {
|
||||
var resolved = false;
|
||||
KcBugReportDraft? captured;
|
||||
|
||||
await _pumpHost(
|
||||
tester,
|
||||
onResult: (d) {
|
||||
captured = d;
|
||||
resolved = true;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(resolved, isTrue);
|
||||
expect(captured, isNull);
|
||||
expect(find.text('Report a problem'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ nav:
|
|||
- Operations Overview: operations/index.md
|
||||
- CI/CD Workflow: operations/cicd-workflow.md
|
||||
- Architecture Workflow: operations/architecture-workflow.md
|
||||
- Release Checklist: operations/release-checklist.md
|
||||
- Standards:
|
||||
- Standards Overview: "standards/index.md"
|
||||
- Integrations:
|
||||
|
|
|
|||
Loading…
Reference in New Issue