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