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
|
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
||||||
|
|
||||||
## 1. Executive Summary & Vision
|
## 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].
|
- **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].
|
- **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 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 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
||||||
| **Stage 10A** | Square API Client & Authentication | ✅ 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 10B** | Square Catalog & Inventory Sync | ✅ Merged |
|
||||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
|
| **Stage 10C** | Square Mobile POS & In-Person Payments | ✅ Merged |
|
||||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
|
| **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 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
|
- 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
|
- 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)
|
- 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
|
- 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
|
## 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
|
### feat/square-mobile-pos
|
||||||
|
|
||||||
- status: in progress (ready to merge)
|
- 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).
|
- ✅ `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).
|
- ✅ `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).
|
- ✅ `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`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -141,24 +143,25 @@ Rules:
|
||||||
|
|
||||||
### Latest known validation state on `main`
|
### 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
|
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||||
- All packages passing
|
- All packages passing
|
||||||
- latest reported count for `core`: `24/24 passed`
|
- latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability)
|
||||||
- latest reported count for `design_system`: `41/41 passed`
|
- 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` ← +35 new (Stage 10C)
|
- latest reported count for `feature_inventory`: `167/167 passed`
|
||||||
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS)
|
- latest reported count for `feature_orders`: `234/234 passed`
|
||||||
- latest reported count for `feature_wordpress`: `545/545 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 `integrations`: `123/123 passed`
|
||||||
- latest reported count for `data`: `63/63 passed`
|
- latest reported count for `data`: `63/63 passed`
|
||||||
- latest reported count for `auth`: `42/42 passed`
|
- latest reported count for `auth`: `42/42 passed`
|
||||||
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS)
|
- latest reported count for `kell_mobile`: `81/81 passed` ← +9 new (Stage 16A shell wiring)
|
||||||
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`)
|
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
|
||||||
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge
|
- 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)
|
#### 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
|
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
|
||||||
- Analyze clean
|
- 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
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
**`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 |
|
| 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.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
|
||||||
| P3.3 | Auth guard wired into app shell (login screen, route guard) | 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.4 | ~~Structured logging infrastructure~~ - DONE: `KcLogger` (Stage 16A) | Complete |
|
||||||
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A |
|
| P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete |
|
||||||
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
|
| 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.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 |
|
| 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
|
### Key technical debt findings
|
||||||
|
|
||||||
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL)
|
- ~~**`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory**~~ — ✅ RESOLVED in Stage 10E; both factories exist and `KcBootstrap` validates Square credentials
|
||||||
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH)
|
- ~~**`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)
|
- **`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)
|
- **`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)
|
- **`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)
|
- **`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)
|
- **`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/error-tracking`
|
||||||
- `feat/secure-deployment`
|
- `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
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -1210,10 +1255,25 @@ Add crash reporting, error tracking, and basic usage analytics.
|
||||||
|
|
||||||
##### Requirements
|
##### Requirements
|
||||||
|
|
||||||
- Integrate lightweight crash reporting (e.g., Sentry)
|
- ✅ Crash/error capture — `KcErrorReporter` installs `FlutterError.onError`,
|
||||||
- Add structured logging for API calls, sync operations, and errors
|
`PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`.
|
||||||
- Add basic usage analytics (feature usage frequency, sync success rates)
|
Implemented as a first-party abstraction rather than binding directly to
|
||||||
- Add health check dashboard showing integration status per channel
|
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
|
#### 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)
|
- Connection testing UI (verify WooCommerce and Square connectivity)
|
||||||
- Environment-specific configuration (dev/staging/production)
|
- 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)
|
## 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.
|
**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:
|
> Resolved for error capture and diagnostics by **Stage 16A** (2026-08-29).
|
||||||
|
> Runtime errors are now captured by `KcErrorReporter`, recorded through
|
||||||
- Runtime errors are invisible unless the operator sees them
|
> `KcLogger`, and attachable to operator bug reports via `KcBugReportService`.
|
||||||
- No crash reporting
|
> Credential redaction (`KcRedactor`) is enforced on every logged context map.
|
||||||
- No usage analytics to guide prioritization
|
>
|
||||||
|
> **Still open:** logs and reports are held in-process only — there is no remote
|
||||||
**Recommendation:** Addressed in **Stage 16A — Error tracking and observability**.
|
> 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
|
### 9. ~~Empty tools directory~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
|
@ -1507,12 +1588,14 @@ Working rules:
|
||||||
|
|
||||||
### `KC_ENV` values
|
### `KC_ENV` values
|
||||||
|
|
||||||
| Value | Description | Status |
|
| Value | Description | Status |
|
||||||
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
|
| ----------- | -------------------------------- | --------------------------------------------------------------------- |
|
||||||
| `fake` | All fake repositories | ✅ Active |
|
| `fake` | All fake repositories | ✅ Active |
|
||||||
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
||||||
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) |
|
| `square` | Square real + others fake | ✅ Active — `square()` factory + `KcBootstrap` validation (Stage 10E) |
|
||||||
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) |
|
| `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 |
|
| [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 |
|
| [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
|
## 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.
|
/// In square/multi mode this could be populated from the Square catalog.
|
||||||
final List<OrderItem> checkoutCatalogue;
|
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.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
|
|
@ -51,7 +55,27 @@ class MobileAppServices extends KcAppServices {
|
||||||
required this.paymentService,
|
required this.paymentService,
|
||||||
this.squareSyncController,
|
this.squareSyncController,
|
||||||
this.checkoutCatalogue = _fakeCatalogue,
|
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.
|
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||||
factory MobileAppServices.fake() {
|
factory MobileAppServices.fake() {
|
||||||
|
|
@ -61,6 +85,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: FakeProductPublishingRepository(),
|
productPublishingRepository: FakeProductPublishingRepository(),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
|
observability: _defaultObservability('FAKE'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +109,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
|
observability: _defaultObservability('WP'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,6 +142,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
productPublishingRepository: catalogRepo,
|
productPublishingRepository: catalogRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
|
observability: _defaultObservability('SQ'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +187,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
productPublishingRepository: wcPublishingRepo,
|
productPublishingRepository: wcPublishingRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
|
observability: _defaultObservability('MULTI'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,28 @@ void main() {
|
||||||
MobileAppServices.serviceFactory,
|
MobileAppServices.serviceFactory,
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(
|
// Route uncaught framework and platform errors into the structured logger
|
||||||
KcAppScope<MobileAppServices>(
|
// before the first frame, so startup failures are captured too.
|
||||||
services: services,
|
KcErrorReporter.install(services.logger);
|
||||||
config: effectiveConfig,
|
|
||||||
child: const KellMobileApp(),
|
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:core/core.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -67,6 +68,37 @@ class _MobileShellState extends State<MobileShell> {
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = KcAppScope.configOf<MobileAppServices>(context);
|
final config = KcAppScope.configOf<MobileAppServices>(context);
|
||||||
|
|
@ -75,6 +107,11 @@ class _MobileShellState extends State<MobileShell> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_titles[_selectedIndex]),
|
title: Text(_titles[_selectedIndex]),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
tooltip: 'Report a problem',
|
||||||
|
onPressed: _reportProblem,
|
||||||
|
),
|
||||||
_EnvironmentBadge(environment: config.environment),
|
_EnvironmentBadge(environment: config.environment),
|
||||||
const SizedBox(width: 12),
|
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.
|
/// Present only in `square` and `multi` environments.
|
||||||
final SquareSyncController? squareSyncController;
|
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.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
required this.productPublishingRepository,
|
required this.productPublishingRepository,
|
||||||
this.squareSyncController,
|
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.
|
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||||
factory AppServices.fake() {
|
factory AppServices.fake() {
|
||||||
|
|
@ -43,6 +67,7 @@ class AppServices extends KcAppServices {
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: FakeProductPublishingRepository(),
|
productPublishingRepository: FakeProductPublishingRepository(),
|
||||||
|
observability: _defaultObservability('FAKE'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +94,7 @@ class AppServices extends KcAppServices {
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
|
observability: _defaultObservability('WP'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +126,7 @@ class AppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: catalogRepo,
|
productPublishingRepository: catalogRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
|
observability: _defaultObservability('SQ'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +169,7 @@ class AppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: wcPublishingRepo,
|
productPublishingRepository: wcPublishingRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
|
observability: _defaultObservability('MULTI'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,32 @@
|
||||||
|
import 'package:core/core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'composition/app_config.dart';
|
import 'composition/app_config.dart';
|
||||||
import 'composition/app_scope.dart';
|
import 'composition/app_scope.dart';
|
||||||
|
import 'composition/app_services.dart';
|
||||||
import 'composition/bootstrap.dart';
|
import 'composition/bootstrap.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final config = AppConfig.fromEnvironment();
|
final config = AppConfig.fromEnvironment();
|
||||||
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../composition/app_config.dart';
|
import '../composition/app_config.dart';
|
||||||
|
|
@ -25,6 +27,7 @@ class AppShell extends StatelessWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Kell Creations'),
|
title: const Text('Kell Creations'),
|
||||||
actions: [
|
actions: [
|
||||||
|
_ReportProblemAction(screen: title),
|
||||||
_EnvironmentBadge(environment: config.environment),
|
_EnvironmentBadge(environment: config.environment),
|
||||||
const SizedBox(width: 12),
|
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
|
/// A small coloured chip displayed in the [AppBar] that shows the current
|
||||||
/// runtime environment (e.g. "FAKE" or "WP").
|
/// runtime environment (e.g. "FAKE" or "WP").
|
||||||
class _EnvironmentBadge extends StatelessWidget {
|
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
|
/// - [KcServiceFactory] — generic factory for environment-aware service construction
|
||||||
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
||||||
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
|
/// - [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;
|
library;
|
||||||
|
|
||||||
// Composition
|
// Composition
|
||||||
|
|
@ -15,3 +24,14 @@ export 'src/composition/kc_app_config.dart';
|
||||||
export 'src/composition/kc_app_scope.dart';
|
export 'src/composition/kc_app_scope.dart';
|
||||||
export 'src/composition/kc_app_services.dart';
|
export 'src/composition/kc_app_services.dart';
|
||||||
export 'src/composition/kc_bootstrap.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';
|
export 'src/layout/kc_breakpoints.dart';
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
|
export 'src/widgets/kc_bug_report_dialog.dart';
|
||||||
export 'src/widgets/kc_card.dart';
|
export 'src/widgets/kc_card.dart';
|
||||||
export 'src/widgets/kc_empty_state.dart';
|
export 'src/widgets/kc_empty_state.dart';
|
||||||
export 'src/widgets/kc_error_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
|
- Operations Overview: operations/index.md
|
||||||
- CI/CD Workflow: operations/cicd-workflow.md
|
- CI/CD Workflow: operations/cicd-workflow.md
|
||||||
- Architecture Workflow: operations/architecture-workflow.md
|
- Architecture Workflow: operations/architecture-workflow.md
|
||||||
|
- Release Checklist: operations/release-checklist.md
|
||||||
- Standards:
|
- Standards:
|
||||||
- Standards Overview: "standards/index.md"
|
- Standards Overview: "standards/index.md"
|
||||||
- Integrations:
|
- Integrations:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue