Compare commits
No commits in common. "666abd2c2623c031acb6edea045b754612a79b27" and "7bf8f55a3e7e1432ade9362a45da8f7b9088db26" have entirely different histories.
666abd2c26
...
7bf8f55a3e
|
|
@ -1,13 +1,3 @@
|
||||||
<!--
|
|
||||||
MD011 (no-reversed-links) is disabled for this file.
|
|
||||||
|
|
||||||
This PRD uses `(descriptive text)[cite: n]` source-citation markers, which are
|
|
||||||
syntactically indistinguishable from a reversed Markdown link. They are not
|
|
||||||
links and must not be "corrected" — running `markdownlint --fix` on this file
|
|
||||||
will silently rewrite them into malformed links and destroy the citations.
|
|
||||||
-->
|
|
||||||
<!-- markdownlint-disable MD011 -->
|
|
||||||
|
|
||||||
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
||||||
|
|
||||||
## 1. Executive Summary & Vision
|
## 1. Executive Summary & Vision
|
||||||
|
|
@ -62,7 +52,6 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
|
||||||
|
|
||||||
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
|
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
|
||||||
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
|
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
|
||||||
- **Observability & Support:** Structured logging (`KcLogger`) with five severities and a bounded in-memory evidence buffer; global uncaught-error capture (`KcErrorReporter`) across framework, platform, and zone channels; operator bug reporting (`KcBugReportService`) that attaches the recent log tail; mandatory credential redaction (`KcRedactor`) on all logged context so API tokens and secrets can never reach a log or a submitted report.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -87,12 +76,7 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m
|
||||||
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
|
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
|
||||||
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
||||||
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
|
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
|
||||||
| **Stage 10B** | Square Catalog & Inventory Sync | ✅ Merged |
|
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] |
|
||||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ✅ Merged |
|
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
|
||||||
| **Stage 10E** | `SquareSyncController`, `square()`/`multi()` factories, ID fix | ✅ Merged |
|
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
|
||||||
| **Stage 10F** | Square Sync UI (`SquareSyncStatusPanel`, Integrations pages) | ✅ Merged |
|
|
||||||
| **Stage 16A** | **Error Logging, Crash Capture & Bug Reporting** | ✅ Merged |
|
|
||||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | 🔄 **Next Stage** |
|
|
||||||
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
|
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
|
||||||
| **Stage 16B** | Secure Credential Storage & Auth Guard | ⏳ Planned — Production blocker |
|
|
||||||
| **Stage 16C** | Retry/Rate Limiting & Remote Diagnostics | ⏳ Planned — Staging blocker |
|
|
||||||
|
|
|
||||||
|
|
@ -6,53 +6,12 @@
|
||||||
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
||||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||||
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
||||||
- merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55`
|
- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout
|
||||||
- merged: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting; merged to main 2026-08-29
|
|
||||||
- main baseline updated through: `feat/observability-error-logging` (Stage 16A complete, merged 2026-08-29)
|
|
||||||
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
||||||
- total tests on branch: 1475/1475 passing (+85 new: 56 core + 10 design_system + 10 kell_web + 9 kell_mobile); dart analyze --fatal-infos clean across all packages
|
- 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
|
||||||
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
|
|
||||||
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
|
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
### feat/observability-error-logging
|
|
||||||
|
|
||||||
- status: merged to main
|
|
||||||
- branch: `feat/observability-error-logging`
|
|
||||||
- stage: 16A — Error logging, crash reporting, and bug reporting
|
|
||||||
- date: 2026-08-29
|
|
||||||
- inspection: complete
|
|
||||||
- implementation: complete
|
|
||||||
- closes audit findings: P3.4 (structured logging), P3.5 (crash reporting), and the "No logging infrastructure" / "No crash reporting" architectural risks in `PROJECT_STATE_AUDIT.md` §3.4
|
|
||||||
- files changed:
|
|
||||||
- `core/lib/src/observability/kc_log_level.dart` — new `KcLogLevel` enum (debug/info/warning/error/fatal) with `severity`, `label`, `isAtLeast()`, `fromString()`
|
|
||||||
- `core/lib/src/observability/kc_log_record.dart` — new immutable `KcLogRecord` (level, source, message, timestamp, unmodifiable context, error, stackTrace); `format()`, `toJson()`, `isFailure`
|
|
||||||
- `core/lib/src/observability/kc_redactor.dart` — new `KcRedactor`; `isSensitiveKey()`, `redactMap()` (recurses nested maps/lists), `maskValue()`; 14 sensitive key fragments
|
|
||||||
- `core/lib/src/observability/kc_log_sink.dart` — new `KcLogSink` contract; `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer with `recent()`/`recordsAtLeast()`/`clear()`), `KcMultiLogSink` (fault-tolerant fan-out)
|
|
||||||
- `core/lib/src/observability/kc_logger.dart` — new `KcLogger` facade; level helpers, threshold filtering, automatic context redaction, `forSource()`, `KcLogger.silent()`; never throws
|
|
||||||
- `core/lib/src/observability/kc_error_reporter.dart` — new `KcErrorReporter`; `install()` wires `FlutterError.onError` + `PlatformDispatcher.instance.onError`, `runGuarded()` wraps `runApp`, `restore()` for test isolation
|
|
||||||
- `core/lib/src/observability/kc_bug_report.dart` — new `KcBugSeverity` enum and `KcBugReport` model with redacted diagnostics, `hasFailureEvidence`, `toJson()`, `toMarkdown()`
|
|
||||||
- `core/lib/src/observability/kc_bug_report_service.dart` — new `KcBugReportResult`, `KcBugReportSink` contract, `KcInMemoryBugReportSink`, and `KcBugReportService` (validation, log-tail evidence, graceful sink-failure handling)
|
|
||||||
- `core/lib/src/observability/kc_observability.dart` — new `KcObservability` bundle; `standard()` and `silent()` factories guarantee logger and bug report service share one evidence buffer
|
|
||||||
- `core/lib/core.dart` — barrel updated with all 9 observability exports
|
|
||||||
- `design_system/lib/src/widgets/kc_bug_report_dialog.dart` — new `KcBugReportDraft` and `KcBugReportDialog` (title/description/severity/reporter, inline validation, no-credentials notice)
|
|
||||||
- `design_system/lib/design_system.dart` — barrel updated
|
|
||||||
- `kell_web/lib/composition/app_services.dart` — `observability` field + `logger`/`bugReportService` accessors + `appVersion`; all 4 factories stamp environment labels (FAKE/WP/SQ/MULTI)
|
|
||||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — mirrored observability wiring across all 4 factories
|
|
||||||
- `kell_web/lib/main.dart` — `KcErrorReporter.install()` + `runGuarded()` + startup record
|
|
||||||
- `kell_mobile/lib/main.dart` — mirrored error handler installation and startup record
|
|
||||||
- `kell_web/lib/shell/app_shell.dart` — `_ReportProblemAction` app bar action wired to `bugReportService`
|
|
||||||
- `kell_mobile/lib/shell/mobile_shell.dart` — `_reportProblem()` app bar action capturing the active tab
|
|
||||||
- tests added:
|
|
||||||
- `core/test/observability_test.dart` — 56 tests across `KcLogLevel`, `KcRedactor`, `KcLogRecord`, `KcInMemoryLogSink`, `KcMultiLogSink`, `KcLogger`, `KcErrorReporter`, `KcBugSeverity`, `KcBugReport`, `KcBugReportService`
|
|
||||||
- `design_system/test/kc_bug_report_dialog_test.dart` — 10 tests (rendering, screen context, validation, trimming, severity selection, cancel)
|
|
||||||
- `kell_web/test/shell/bug_report_action_test.dart` — 10 tests (service wiring, shared buffer, environment labels, shell submission flow)
|
|
||||||
- `kell_mobile/test/shell/bug_report_action_test.dart` — 9 tests (service wiring, shared buffer, shell submission flow, active-tab capture)
|
|
||||||
- tests: passed (1475/1475 total; core 90/90 +56; design_system 51/51 +10; kell_web 62/62 +10; kell_mobile 81/81 +9)
|
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages and apps)
|
|
||||||
- brief updated: yes
|
|
||||||
|
|
||||||
### feat/square-mobile-pos
|
### feat/square-mobile-pos
|
||||||
|
|
||||||
- status: in progress (ready to merge)
|
- status: in progress (ready to merge)
|
||||||
|
|
@ -764,4 +723,4 @@
|
||||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
|
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
|
||||||
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
|
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
|
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
@ -130,8 +130,6 @@ Rules:
|
||||||
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
|
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
|
||||||
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
|
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
|
||||||
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
|
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
|
||||||
- ✅ Square Mobile POS / Checkout landed (Stage 10C-POS complete — merged `feat/square-mobile-pos` → `main`, commit `7bf8f55`, 2026-07-25).
|
|
||||||
- ✅ Platform observability activated (Stage 16A complete — merged `feat/observability-error-logging` → `main`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells.
|
|
||||||
|
|
||||||
### Current narrow edit capabilities on `main`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -143,26 +141,24 @@ Rules:
|
||||||
|
|
||||||
### Latest known validation state on `main`
|
### Latest known validation state on `main`
|
||||||
|
|
||||||
> **Last verified:** 2026-08-29 — Release Readiness Review (`feat/observability-error-logging`)
|
> **Last verified:** 2026-07-25 — Comprehensive State Audit (`chore/audit-state-sync`)
|
||||||
|
|
||||||
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||||
- All packages passing
|
- All packages passing
|
||||||
- latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability)
|
- latest reported count for `core`: `24/24 passed`
|
||||||
- latest reported count for `design_system`: `51/51 passed` ← +10 new (Stage 16A bug report dialog)
|
- latest reported count for `design_system`: `41/41 passed`
|
||||||
- latest reported count for `feature_inventory`: `167/167 passed`
|
- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C)
|
||||||
- latest reported count for `feature_orders`: `234/234 passed`
|
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS)
|
||||||
- latest reported count for `feature_wordpress`: `545/545 passed`
|
- latest reported count for `feature_wordpress`: `545/545 passed`
|
||||||
- latest reported count for `kell_web`: `62/62 passed` ← +10 new (Stage 16A shell wiring)
|
- latest reported count for `kell_web`: `52/52 passed`
|
||||||
- latest reported count for `integrations`: `123/123 passed`
|
- latest reported count for `integrations`: `123/123 passed`
|
||||||
- latest reported count for `data`: `63/63 passed`
|
- latest reported count for `data`: `63/63 passed`
|
||||||
- latest reported count for `auth`: `42/42 passed`
|
- latest reported count for `auth`: `42/42 passed`
|
||||||
- latest reported count for `kell_mobile`: `81/81 passed` ← +9 new (Stage 16A shell wiring)
|
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS)
|
||||||
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
|
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`)
|
||||||
- total: `1,475/1,475 passed` (+85 new from Stage 16A branch `feat/observability-error-logging`)
|
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge
|
||||||
- main baseline updated through: `feat/observability-error-logging` (Stage 16A complete, merged 2026-08-29)
|
|
||||||
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -237,41 +233,6 @@ Delivered:
|
||||||
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
|
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
|
||||||
- Analyze clean
|
- Analyze clean
|
||||||
|
|
||||||
### Stage 16A — complete (2026-08-29)
|
|
||||||
|
|
||||||
**`feat/observability-error-logging`** — Error logging, crash capture, and bug reporting
|
|
||||||
|
|
||||||
Closes audit findings P3.4 (structured logging), P3.5 (crash reporting), and the
|
|
||||||
"No logging infrastructure" / "No crash reporting" architectural risks.
|
|
||||||
|
|
||||||
Delivered in `core`:
|
|
||||||
|
|
||||||
- `KcLogLevel` — five ordered severities (`debug`, `info`, `warning`, `error`, `fatal`) with threshold comparison
|
|
||||||
- `KcLogRecord` — immutable structured record with `format()`, `toJson()`, and `isFailure`
|
|
||||||
- `KcRedactor` — credential redaction applied to every logged context map, traversing nested maps and lists
|
|
||||||
- `KcLogSink` contract with `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer), and `KcMultiLogSink` (fault-tolerant fan-out)
|
|
||||||
- `KcLogger` — the logging facade; drops sub-threshold records, redacts context, and never throws
|
|
||||||
- `KcErrorReporter` — installs `FlutterError.onError`, `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`
|
|
||||||
- `KcBugReport` / `KcBugSeverity` — the bug report model with redacted diagnostics, `toJson()`, and `toMarkdown()`
|
|
||||||
- `KcBugReportSink` contract + `KcInMemoryBugReportSink`
|
|
||||||
- `KcBugReportService` — validates input, attaches the recent log tail as evidence, and degrades gracefully when the tracker is unreachable
|
|
||||||
- `KcObservability` — assembles logger, evidence buffer, and bug report service so they always share one buffer
|
|
||||||
|
|
||||||
Delivered in `design_system`:
|
|
||||||
|
|
||||||
- `KcBugReportDialog` + `KcBugReportDraft` — the shared operator reporting form with inline validation and a stated no-credentials guarantee
|
|
||||||
|
|
||||||
Wired into both app targets:
|
|
||||||
|
|
||||||
- `KcObservability` held by `AppServices` and `MobileAppServices`; every factory stamps its environment label (`FAKE`, `WP`, `SQ`, `MULTI`)
|
|
||||||
- `KcErrorReporter.install(...)` + `runGuarded(...)` in both `main.dart` entry points, with a startup record capturing the effective environment and any fake-mode fallback
|
|
||||||
- "Report a problem" action in the `AppShell` app bar and the `MobileShell` app bar, capturing the active screen
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
- 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total
|
|
||||||
- `dart analyze --fatal-infos` clean across all packages and apps
|
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
||||||
|
|
@ -351,11 +312,11 @@ These items harden the platform for production deployment.
|
||||||
|
|
||||||
| ID | Item | Target Stage |
|
| ID | Item | Target Stage |
|
||||||
| ----- | --------------------------------------------------------------------------------------- | ----------------- |
|
| ----- | --------------------------------------------------------------------------------------- | ----------------- |
|
||||||
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16C |
|
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A |
|
||||||
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
|
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
|
||||||
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
|
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
|
||||||
| P3.4 | ~~Structured logging infrastructure~~ - DONE: `KcLogger` (Stage 16A) | Complete |
|
| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A |
|
||||||
| P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete |
|
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A |
|
||||||
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
|
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
|
||||||
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
|
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
|
||||||
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone |
|
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone |
|
||||||
|
|
@ -366,18 +327,14 @@ These items harden the platform for production deployment.
|
||||||
|
|
||||||
### Key technical debt findings
|
### Key technical debt findings
|
||||||
|
|
||||||
- ~~**`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory**~~ — ✅ RESOLVED in Stage 10E; both factories exist and `KcBootstrap` validates Square credentials
|
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL)
|
||||||
- ~~**`SquareCatalogSyncService` ID conflation**~~ — ✅ RESOLVED in Stage 10E; `syncCatalog()` now reads `draft.squareCatalogObjectId`
|
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH)
|
||||||
- ~~**No logging infrastructure**~~ — ✅ RESOLVED in Stage 16A; `KcLogger` with structured records and credential redaction
|
|
||||||
- ~~**No crash reporting**~~ — ✅ RESOLVED in Stage 16A; `KcErrorReporter` captures framework, platform, and zone errors
|
|
||||||
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
|
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
|
||||||
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
|
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
|
||||||
- **No remote log or crash sink** — diagnostics are in-process only and lost on restart; unavailable off-device (HIGH)
|
|
||||||
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
|
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
|
||||||
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
|
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
|
||||||
- **`feature_policy` has only a smoke test** — 17 tests in one file; no controller or widget tests (MEDIUM)
|
- **`feature_policy` has only a smoke test** — no controller or widget tests (MEDIUM)
|
||||||
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
|
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
|
||||||
- **No dependency injection / state management framework** — `ChangeNotifier` + direct construction will strain as the sync engine and offline queue land (MEDIUM)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1245,10 +1202,7 @@ Harden the platform for production deployment beyond a single operator.
|
||||||
- `feat/error-tracking`
|
- `feat/error-tracking`
|
||||||
- `feat/secure-deployment`
|
- `feat/secure-deployment`
|
||||||
|
|
||||||
#### ~~Stage 16A — Error tracking and observability~~ ✅ COMPLETE
|
#### Stage 16A — Error tracking and observability
|
||||||
|
|
||||||
> Delivered on `feat/observability-error-logging` (2026-08-29).
|
|
||||||
> See "Stage 16A — complete (2026-08-29)" above for the full manifest.
|
|
||||||
|
|
||||||
##### Goal
|
##### Goal
|
||||||
|
|
||||||
|
|
@ -1256,25 +1210,10 @@ Add crash reporting, error tracking, and basic usage analytics.
|
||||||
|
|
||||||
##### Requirements
|
##### Requirements
|
||||||
|
|
||||||
- ✅ Crash/error capture — `KcErrorReporter` installs `FlutterError.onError`,
|
- Integrate lightweight crash reporting (e.g., Sentry)
|
||||||
`PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`.
|
- Add structured logging for API calls, sync operations, and errors
|
||||||
Implemented as a first-party abstraction rather than binding directly to
|
- Add basic usage analytics (feature usage frequency, sync success rates)
|
||||||
Sentry so no vendor dependency is introduced before the deployment model is
|
- Add health check dashboard showing integration status per channel
|
||||||
settled; `KcLogSink` is the extension point for a hosted backend.
|
|
||||||
- ✅ Structured logging for errors and state transitions — `KcLogger` with
|
|
||||||
five severities, threshold filtering, and mandatory credential redaction.
|
|
||||||
- ⏳ Usage analytics (feature usage frequency, sync success rates) — **deferred**.
|
|
||||||
`KcLogRecord` carries the structured `context` needed to derive these, but no
|
|
||||||
aggregation or reporting surface exists yet.
|
|
||||||
- ⏳ Health check dashboard per channel — **deferred**. The `IntegrationHealthCheck`
|
|
||||||
contract exists in `integrations` but has no implementation or UI.
|
|
||||||
|
|
||||||
##### Remaining scope (tracked as Stage 16C)
|
|
||||||
|
|
||||||
- Remote log/crash sink (`KcLogSink` implementation posting to a backend)
|
|
||||||
- Integration health dashboard driven by `IntegrationHealthCheck`
|
|
||||||
- Usage analytics aggregation
|
|
||||||
- In-app log viewer for operator self-service diagnostics
|
|
||||||
|
|
||||||
#### Stage 16B — Secure deployment configuration
|
#### Stage 16B — Secure deployment configuration
|
||||||
|
|
||||||
|
|
@ -1289,25 +1228,6 @@ Move beyond `--dart-define` for credential management in production.
|
||||||
- Connection testing UI (verify WooCommerce and Square connectivity)
|
- Connection testing UI (verify WooCommerce and Square connectivity)
|
||||||
- Environment-specific configuration (dev/staging/production)
|
- Environment-specific configuration (dev/staging/production)
|
||||||
|
|
||||||
#### Stage 16C — Resilience and remote diagnostics
|
|
||||||
|
|
||||||
##### Goal
|
|
||||||
|
|
||||||
Make integrations survive transient failure, and make diagnostics visible off-device.
|
|
||||||
|
|
||||||
##### Requirements
|
|
||||||
|
|
||||||
- Wire `ExponentialBackoffRetryPolicy` into `SquareApiClient` and
|
|
||||||
`WooCommerceApiClient` for 5xx and network errors (P3.1)
|
|
||||||
- Wire `TokenBucketRateLimiter` into Square calls — Square enforces rate limits
|
|
||||||
and currently a burst sync can be throttled with no recovery
|
|
||||||
- Implement `IntegrationHealthCheck` per channel and surface it on the
|
|
||||||
Integrations page
|
|
||||||
- Remote `KcLogSink` implementation so `ERROR`/`FATAL` records and submitted bug
|
|
||||||
reports leave the device
|
|
||||||
- In-app log viewer for operator self-service diagnostics
|
|
||||||
- Usage analytics aggregation (feature usage frequency, sync success rates)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Suggested improvements (identified from codebase analysis)
|
## Suggested improvements (identified from codebase analysis)
|
||||||
|
|
@ -1369,17 +1289,15 @@ Architecture documentation (`traceability-index.md` coverage gaps) identifies "P
|
||||||
|
|
||||||
**Recommendation:** Addressed in **Stage 9C — WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API.
|
**Recommendation:** Addressed in **Stage 9C — WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API.
|
||||||
|
|
||||||
### 8. ~~Error tracking and observability~~ ✅ PARTIALLY RESOLVED
|
### 8. Error tracking and observability
|
||||||
|
|
||||||
> Resolved for error capture and diagnostics by **Stage 16A** (2026-08-29).
|
No monitoring, error tracking, or analytics strategy exists for the Flutter applications. In production use:
|
||||||
> Runtime errors are now captured by `KcErrorReporter`, recorded through
|
|
||||||
> `KcLogger`, and attachable to operator bug reports via `KcBugReportService`.
|
- Runtime errors are invisible unless the operator sees them
|
||||||
> Credential redaction (`KcRedactor`) is enforced on every logged context map.
|
- No crash reporting
|
||||||
>
|
- No usage analytics to guide prioritization
|
||||||
> **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
|
**Recommendation:** Addressed in **Stage 16A — Error tracking and observability**.
|
||||||
> anyone but the operator on the device. Usage analytics remain unimplemented.
|
|
||||||
> Both are tracked as **Stage 16C**.
|
|
||||||
|
|
||||||
### 9. ~~Empty tools directory~~ ✅ RESOLVED
|
### 9. ~~Empty tools directory~~ ✅ RESOLVED
|
||||||
|
|
||||||
|
|
@ -1589,14 +1507,12 @@ Working rules:
|
||||||
|
|
||||||
### `KC_ENV` values
|
### `KC_ENV` values
|
||||||
|
|
||||||
| Value | Description | Status |
|
| Value | Description | Status |
|
||||||
| ----------- | -------------------------------- | --------------------------------------------------------------------- |
|
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
|
||||||
| `fake` | All fake repositories | ✅ Active |
|
| `fake` | All fake repositories | ✅ Active |
|
||||||
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
||||||
| `square` | Square real + others fake | ✅ Active — `square()` factory + `KcBootstrap` validation (Stage 10E) |
|
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) |
|
||||||
| `multi` | Both WooCommerce and Square real | ✅ Active — `multi()` factory + `KcBootstrap` validation (Stage 10E) |
|
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) |
|
||||||
|
|
||||||
> **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,7 +8,6 @@ This section contains operational runbooks, CI/CD documentation, and business pr
|
||||||
| ------------------------------------------------- | ---------------------------------------------------- | ---------------- |
|
| ------------------------------------------------- | ---------------------------------------------------- | ---------------- |
|
||||||
| [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | ✅ Comprehensive |
|
| [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | ✅ Comprehensive |
|
||||||
| [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | ✅ Complete |
|
| [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | ✅ Complete |
|
||||||
| [Release Checklist](release-checklist.md) | Per-environment release gates and sign-off record | ✅ Complete |
|
|
||||||
|
|
||||||
## Analysis Findings
|
## Analysis Findings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
# 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,11 +43,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
/// In square/multi mode this could be populated from the Square catalog.
|
/// In square/multi mode this could be populated from the Square catalog.
|
||||||
final List<OrderItem> checkoutCatalogue;
|
final List<OrderItem> checkoutCatalogue;
|
||||||
|
|
||||||
/// The observability stack: structured logger, in-memory evidence buffer,
|
const MobileAppServices({
|
||||||
/// and the bug report service backing the shell's "Report a problem" action.
|
|
||||||
final KcObservability observability;
|
|
||||||
|
|
||||||
MobileAppServices({
|
|
||||||
required this.inventoryRepository,
|
required this.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
|
|
@ -55,27 +51,7 @@ class MobileAppServices extends KcAppServices {
|
||||||
required this.paymentService,
|
required this.paymentService,
|
||||||
this.squareSyncController,
|
this.squareSyncController,
|
||||||
this.checkoutCatalogue = _fakeCatalogue,
|
this.checkoutCatalogue = _fakeCatalogue,
|
||||||
KcObservability? observability,
|
});
|
||||||
}) : observability = observability ?? _defaultObservability('FAKE');
|
|
||||||
|
|
||||||
/// The application version reported in bug reports.
|
|
||||||
///
|
|
||||||
/// Kept in sync with `version:` in `pubspec.yaml`.
|
|
||||||
static const String appVersion = '1.0.0+1';
|
|
||||||
|
|
||||||
/// Convenience accessor for the shared structured logger.
|
|
||||||
KcLogger get logger => observability.logger;
|
|
||||||
|
|
||||||
/// Convenience accessor for the bug report service.
|
|
||||||
KcBugReportService get bugReportService => observability.bugReportService;
|
|
||||||
|
|
||||||
static KcObservability _defaultObservability(String environmentLabel) =>
|
|
||||||
KcObservability.standard(
|
|
||||||
appVersion: appVersion,
|
|
||||||
environment: environmentLabel,
|
|
||||||
platform: 'android',
|
|
||||||
source: 'kell_mobile',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||||
factory MobileAppServices.fake() {
|
factory MobileAppServices.fake() {
|
||||||
|
|
@ -85,7 +61,6 @@ class MobileAppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: FakeProductPublishingRepository(),
|
productPublishingRepository: FakeProductPublishingRepository(),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
observability: _defaultObservability('FAKE'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +84,6 @@ class MobileAppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
observability: _defaultObservability('WP'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +116,6 @@ class MobileAppServices extends KcAppServices {
|
||||||
productPublishingRepository: catalogRepo,
|
productPublishingRepository: catalogRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
observability: _defaultObservability('SQ'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +160,6 @@ class MobileAppServices extends KcAppServices {
|
||||||
productPublishingRepository: wcPublishingRepo,
|
productPublishingRepository: wcPublishingRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
paymentService: FakePaymentService(),
|
paymentService: FakePaymentService(),
|
||||||
observability: _defaultObservability('MULTI'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,11 @@ void main() {
|
||||||
MobileAppServices.serviceFactory,
|
MobileAppServices.serviceFactory,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route uncaught framework and platform errors into the structured logger
|
runApp(
|
||||||
// before the first frame, so startup failures are captured too.
|
KcAppScope<MobileAppServices>(
|
||||||
KcErrorReporter.install(services.logger);
|
services: services,
|
||||||
|
config: effectiveConfig,
|
||||||
services.logger.info(
|
child: const KellMobileApp(),
|
||||||
'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,5 +1,4 @@
|
||||||
import 'package:core/core.dart';
|
import 'package:core/core.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:feature_orders/feature_orders.dart';
|
import 'package:feature_orders/feature_orders.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -68,37 +67,6 @@ class _MobileShellState extends State<MobileShell> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the shared bug report dialog and submits the resulting draft with
|
|
||||||
/// the current screen and recent log tail attached as evidence.
|
|
||||||
Future<void> _reportProblem() async {
|
|
||||||
final services = KcAppScope.of<MobileAppServices>(context);
|
|
||||||
final screen = _titles[_selectedIndex];
|
|
||||||
|
|
||||||
final draft = await KcBugReportDialog.show(context, screen: screen);
|
|
||||||
if (draft == null || !mounted) return;
|
|
||||||
|
|
||||||
final result = await services.bugReportService.submit(
|
|
||||||
title: draft.title,
|
|
||||||
description: draft.description,
|
|
||||||
severity: KcBugSeverity.fromString(draft.severityName),
|
|
||||||
reporter: draft.reporter,
|
|
||||||
screen: screen,
|
|
||||||
diagnostics: {'tabIndex': _selectedIndex},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
result.succeeded
|
|
||||||
? 'Report ${result.report!.id} submitted. Thank you.'
|
|
||||||
: result.errorMessage ?? 'Could not submit the report.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = KcAppScope.configOf<MobileAppServices>(context);
|
final config = KcAppScope.configOf<MobileAppServices>(context);
|
||||||
|
|
@ -107,11 +75,6 @@ class _MobileShellState extends State<MobileShell> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_titles[_selectedIndex]),
|
title: Text(_titles[_selectedIndex]),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
|
||||||
tooltip: 'Report a problem',
|
|
||||||
onPressed: _reportProblem,
|
|
||||||
),
|
|
||||||
_EnvironmentBadge(environment: config.environment),
|
_EnvironmentBadge(environment: config.environment),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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,37 +28,13 @@ class AppServices extends KcAppServices {
|
||||||
/// Present only in `square` and `multi` environments.
|
/// Present only in `square` and `multi` environments.
|
||||||
final SquareSyncController? squareSyncController;
|
final SquareSyncController? squareSyncController;
|
||||||
|
|
||||||
/// The observability stack: structured logger, in-memory evidence buffer,
|
const AppServices({
|
||||||
/// and the bug report service backing the shell's "Report a problem" action.
|
|
||||||
final KcObservability observability;
|
|
||||||
|
|
||||||
AppServices({
|
|
||||||
required this.inventoryRepository,
|
required this.inventoryRepository,
|
||||||
required this.ordersRepository,
|
required this.ordersRepository,
|
||||||
required this.policyRepository,
|
required this.policyRepository,
|
||||||
required this.productPublishingRepository,
|
required this.productPublishingRepository,
|
||||||
this.squareSyncController,
|
this.squareSyncController,
|
||||||
KcObservability? observability,
|
});
|
||||||
}) : observability = observability ?? _defaultObservability('FAKE');
|
|
||||||
|
|
||||||
/// The application version reported in bug reports.
|
|
||||||
///
|
|
||||||
/// Kept in sync with `version:` in `pubspec.yaml`.
|
|
||||||
static const String appVersion = '1.0.0+1';
|
|
||||||
|
|
||||||
/// Convenience accessor for the shared structured logger.
|
|
||||||
KcLogger get logger => observability.logger;
|
|
||||||
|
|
||||||
/// Convenience accessor for the bug report service.
|
|
||||||
KcBugReportService get bugReportService => observability.bugReportService;
|
|
||||||
|
|
||||||
static KcObservability _defaultObservability(String environmentLabel) =>
|
|
||||||
KcObservability.standard(
|
|
||||||
appVersion: appVersion,
|
|
||||||
environment: environmentLabel,
|
|
||||||
platform: 'web',
|
|
||||||
source: 'kell_web',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||||
factory AppServices.fake() {
|
factory AppServices.fake() {
|
||||||
|
|
@ -67,7 +43,6 @@ class AppServices extends KcAppServices {
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: FakeProductPublishingRepository(),
|
productPublishingRepository: FakeProductPublishingRepository(),
|
||||||
observability: _defaultObservability('FAKE'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,7 +69,6 @@ class AppServices extends KcAppServices {
|
||||||
ordersRepository: FakeOrdersRepository(),
|
ordersRepository: FakeOrdersRepository(),
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||||
observability: _defaultObservability('WP'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +100,6 @@ class AppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: catalogRepo,
|
productPublishingRepository: catalogRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
observability: _defaultObservability('SQ'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +142,6 @@ class AppServices extends KcAppServices {
|
||||||
policyRepository: FakePolicyRepository(),
|
policyRepository: FakePolicyRepository(),
|
||||||
productPublishingRepository: wcPublishingRepo,
|
productPublishingRepository: wcPublishingRepo,
|
||||||
squareSyncController: SquareSyncController(syncService: syncService),
|
squareSyncController: SquareSyncController(syncService: syncService),
|
||||||
observability: _defaultObservability('MULTI'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,13 @@
|
||||||
import 'package:core/core.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'composition/app_config.dart';
|
import 'composition/app_config.dart';
|
||||||
import 'composition/app_scope.dart';
|
import 'composition/app_scope.dart';
|
||||||
import 'composition/app_services.dart';
|
|
||||||
import 'composition/bootstrap.dart';
|
import 'composition/bootstrap.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final config = AppConfig.fromEnvironment();
|
final config = AppConfig.fromEnvironment();
|
||||||
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
final (:services, config: effectiveConfig) = Bootstrap.run(config);
|
||||||
|
|
||||||
// Route uncaught framework and platform errors into the structured logger
|
runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp()));
|
||||||
// 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,5 +1,3 @@
|
||||||
import 'package:core/core.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../composition/app_config.dart';
|
import '../composition/app_config.dart';
|
||||||
|
|
@ -27,7 +25,6 @@ class AppShell extends StatelessWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Kell Creations'),
|
title: const Text('Kell Creations'),
|
||||||
actions: [
|
actions: [
|
||||||
_ReportProblemAction(screen: title),
|
|
||||||
_EnvironmentBadge(environment: config.environment),
|
_EnvironmentBadge(environment: config.environment),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
|
|
@ -149,53 +146,6 @@ class AppShell extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An [AppBar] action that opens the shared bug report dialog and submits the
|
|
||||||
/// resulting draft through [AppServices.bugReportService].
|
|
||||||
///
|
|
||||||
/// The recent log tail is attached automatically as diagnostic evidence, and
|
|
||||||
/// credentials are redacted by `KcRedactor` before anything is recorded.
|
|
||||||
class _ReportProblemAction extends StatelessWidget {
|
|
||||||
/// The screen the operator is reporting from.
|
|
||||||
final String screen;
|
|
||||||
|
|
||||||
const _ReportProblemAction({required this.screen});
|
|
||||||
|
|
||||||
Future<void> _report(BuildContext context) async {
|
|
||||||
final services = AppScope.of(context);
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
|
|
||||||
final draft = await KcBugReportDialog.show(context, screen: screen);
|
|
||||||
if (draft == null) return;
|
|
||||||
|
|
||||||
final result = await services.bugReportService.submit(
|
|
||||||
title: draft.title,
|
|
||||||
description: draft.description,
|
|
||||||
severity: KcBugSeverity.fromString(draft.severityName),
|
|
||||||
reporter: draft.reporter,
|
|
||||||
screen: screen,
|
|
||||||
);
|
|
||||||
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
result.succeeded
|
|
||||||
? 'Report ${result.report!.id} submitted. Thank you.'
|
|
||||||
: result.errorMessage ?? 'Could not submit the report.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return IconButton(
|
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
|
||||||
tooltip: 'Report a problem',
|
|
||||||
onPressed: () => _report(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small coloured chip displayed in the [AppBar] that shows the current
|
/// A small coloured chip displayed in the [AppBar] that shows the current
|
||||||
/// runtime environment (e.g. "FAKE" or "WP").
|
/// runtime environment (e.g. "FAKE" or "WP").
|
||||||
class _EnvironmentBadge extends StatelessWidget {
|
class _EnvironmentBadge extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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,15 +8,6 @@
|
||||||
/// - [KcServiceFactory] — generic factory for environment-aware service construction
|
/// - [KcServiceFactory] — generic factory for environment-aware service construction
|
||||||
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
/// - [KcBootstrap] — shared bootstrap logic with WordPress credential validation
|
||||||
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
|
/// - [KcAppScope] — [InheritedWidget] exposing services and config to the widget tree
|
||||||
///
|
|
||||||
/// It also provides the shared observability stack used by every app target:
|
|
||||||
///
|
|
||||||
/// - [KcLogger] / [KcLogRecord] / [KcLogLevel] — structured logging
|
|
||||||
/// - [KcLogSink] implementations — console, bounded in-memory buffer, fan-out
|
|
||||||
/// - [KcErrorReporter] — global uncaught-error capture
|
|
||||||
/// - [KcBugReportService] / [KcBugReport] — operator bug reporting with
|
|
||||||
/// diagnostic evidence attached
|
|
||||||
/// - [KcRedactor] — credential redaction applied to all logged context
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
// Composition
|
// Composition
|
||||||
|
|
@ -24,14 +15,3 @@ export 'src/composition/kc_app_config.dart';
|
||||||
export 'src/composition/kc_app_scope.dart';
|
export 'src/composition/kc_app_scope.dart';
|
||||||
export 'src/composition/kc_app_services.dart';
|
export 'src/composition/kc_app_services.dart';
|
||||||
export 'src/composition/kc_bootstrap.dart';
|
export 'src/composition/kc_bootstrap.dart';
|
||||||
|
|
||||||
// Observability
|
|
||||||
export 'src/observability/kc_bug_report.dart';
|
|
||||||
export 'src/observability/kc_bug_report_service.dart';
|
|
||||||
export 'src/observability/kc_error_reporter.dart';
|
|
||||||
export 'src/observability/kc_log_level.dart';
|
|
||||||
export 'src/observability/kc_log_record.dart';
|
|
||||||
export 'src/observability/kc_log_sink.dart';
|
|
||||||
export 'src/observability/kc_logger.dart';
|
|
||||||
export 'src/observability/kc_observability.dart';
|
|
||||||
export 'src/observability/kc_redactor.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
/// 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)';
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
/// 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
/// 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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
/// 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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/// 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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
/// 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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,716 +0,0 @@
|
||||||
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,7 +10,6 @@ export 'src/theme/kc_typography.dart';
|
||||||
export 'src/layout/kc_breakpoints.dart';
|
export 'src/layout/kc_breakpoints.dart';
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
export 'src/widgets/kc_bug_report_dialog.dart';
|
|
||||||
export 'src/widgets/kc_card.dart';
|
export 'src/widgets/kc_card.dart';
|
||||||
export 'src/widgets/kc_empty_state.dart';
|
export 'src/widgets/kc_empty_state.dart';
|
||||||
export 'src/widgets/kc_error_state.dart';
|
export 'src/widgets/kc_error_state.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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')),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
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,7 +68,6 @@ nav:
|
||||||
- Operations Overview: operations/index.md
|
- Operations Overview: operations/index.md
|
||||||
- CI/CD Workflow: operations/cicd-workflow.md
|
- CI/CD Workflow: operations/cicd-workflow.md
|
||||||
- Architecture Workflow: operations/architecture-workflow.md
|
- Architecture Workflow: operations/architecture-workflow.md
|
||||||
- Release Checklist: operations/release-checklist.md
|
|
||||||
- Standards:
|
- Standards:
|
||||||
- Standards Overview: "standards/index.md"
|
- Standards Overview: "standards/index.md"
|
||||||
- Integrations:
|
- Integrations:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue