diff --git a/docs/development/PRD.md b/docs/development/PRD.md index 632ee55..b14c2be 100644 --- a/docs/development/PRD.md +++ b/docs/development/PRD.md @@ -1,3 +1,13 @@ + + + # Product Requirement Document (PRD): Kell Creations Operations Platform ## 1. Executive Summary & Vision @@ -52,6 +62,7 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m - **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3]. - **Offline Operations:** Local SQLite caching (`LocalCache`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3]. +- **Observability & Support:** Structured logging (`KcLogger`) with five severities and a bounded in-memory evidence buffer; global uncaught-error capture (`KcErrorReporter`) across framework, platform, and zone channels; operator bug reporting (`KcBugReportService`) that attaches the recent log tail; mandatory credential redaction (`KcRedactor`) on all logged context so API tokens and secrets can never reach a log or a submitted report. --- @@ -76,7 +87,12 @@ The **Kell Creations Operations Platform** is a cross-platform (Web & Android) m | **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | โœ… Merged[cite: 2, 3] | | **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | โœ… Merged[cite: 2, 3] | | **Stage 10A** | Square API Client & Authentication | โœ… Merged[cite: 2, 3] | -| **Stage 10B** | **Square Catalog & Inventory Sync** | ๐Ÿ”„ **Current Stage**[cite: 2, 3] | -| **Stage 10C** | Square Mobile POS & In-Person Payments | โณ Planned[cite: 3] | -| **Stage 11** | Custom Orders Workflow & Deposit Tracking | โณ Planned[cite: 3] | +| **Stage 10B** | Square Catalog & Inventory Sync | โœ… Merged | +| **Stage 10C** | Square Mobile POS & In-Person Payments | โœ… Merged | +| **Stage 10E** | `SquareSyncController`, `square()`/`multi()` factories, ID fix | โœ… Merged | +| **Stage 10F** | Square Sync UI (`SquareSyncStatusPanel`, Integrations pages) | โœ… Merged | +| **Stage 16A** | **Error Logging, Crash Capture & Bug Reporting** | โœ… Merged | +| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ๐Ÿ”„ **Next Stage** | | **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | โณ Planned[cite: 3] | +| **Stage 16B** | Secure Credential Storage & Auth Guard | โณ Planned โ€” Production blocker | +| **Stage 16C** | Retry/Rate Limiting & Remote Diagnostics | โณ Planned โ€” Staging blocker | diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 1ba2b7b..3934dda 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -6,12 +6,52 @@ - audit completed: `chore/audit-state-sync` โ€” Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker - merged: `feat/square-catalog-sync-service` (Stage 10D) โ€” merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean - main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25) -- active branch: `feat/square-mobile-pos` (Stage 10C) โ€” Square Mobile POS / Checkout +- merged: `feat/square-mobile-pos` (Stage 10C POS) โ€” merged to main, commit `7bf8f55` +- active branch: `feat/observability-error-logging` (Stage 16A) โ€” error logging, crash capture, and bug reporting - next branch: `feat/custom-order-domain` (Stage 11A) โ€” Custom order domain and data -- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile โˆ’ 9 pre-existing kell_mobile); dart analyze clean across all packages +- total tests on branch: 1475/1475 passing (+85 new: 56 core + 10 design_system + 10 kell_web + 9 kell_mobile); dart analyze --fatal-infos clean across all packages +- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count +- release review: `docs/operations/release-checklist.md` created 2026-08-29 โ€” per-environment release gates for Development, Test/QA, Staging, and Production ## Slice tracker +### feat/observability-error-logging + +- status: ready for merge +- branch: `feat/observability-error-logging` +- stage: 16A โ€” Error logging, crash reporting, and bug reporting +- date: 2026-08-29 +- inspection: complete +- implementation: complete +- closes audit findings: P3.4 (structured logging), P3.5 (crash reporting), and the "No logging infrastructure" / "No crash reporting" architectural risks in `PROJECT_STATE_AUDIT.md` ยง3.4 +- files changed: + - `core/lib/src/observability/kc_log_level.dart` โ€” new `KcLogLevel` enum (debug/info/warning/error/fatal) with `severity`, `label`, `isAtLeast()`, `fromString()` + - `core/lib/src/observability/kc_log_record.dart` โ€” new immutable `KcLogRecord` (level, source, message, timestamp, unmodifiable context, error, stackTrace); `format()`, `toJson()`, `isFailure` + - `core/lib/src/observability/kc_redactor.dart` โ€” new `KcRedactor`; `isSensitiveKey()`, `redactMap()` (recurses nested maps/lists), `maskValue()`; 14 sensitive key fragments + - `core/lib/src/observability/kc_log_sink.dart` โ€” new `KcLogSink` contract; `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer with `recent()`/`recordsAtLeast()`/`clear()`), `KcMultiLogSink` (fault-tolerant fan-out) + - `core/lib/src/observability/kc_logger.dart` โ€” new `KcLogger` facade; level helpers, threshold filtering, automatic context redaction, `forSource()`, `KcLogger.silent()`; never throws + - `core/lib/src/observability/kc_error_reporter.dart` โ€” new `KcErrorReporter`; `install()` wires `FlutterError.onError` + `PlatformDispatcher.instance.onError`, `runGuarded()` wraps `runApp`, `restore()` for test isolation + - `core/lib/src/observability/kc_bug_report.dart` โ€” new `KcBugSeverity` enum and `KcBugReport` model with redacted diagnostics, `hasFailureEvidence`, `toJson()`, `toMarkdown()` + - `core/lib/src/observability/kc_bug_report_service.dart` โ€” new `KcBugReportResult`, `KcBugReportSink` contract, `KcInMemoryBugReportSink`, and `KcBugReportService` (validation, log-tail evidence, graceful sink-failure handling) + - `core/lib/src/observability/kc_observability.dart` โ€” new `KcObservability` bundle; `standard()` and `silent()` factories guarantee logger and bug report service share one evidence buffer + - `core/lib/core.dart` โ€” barrel updated with all 9 observability exports + - `design_system/lib/src/widgets/kc_bug_report_dialog.dart` โ€” new `KcBugReportDraft` and `KcBugReportDialog` (title/description/severity/reporter, inline validation, no-credentials notice) + - `design_system/lib/design_system.dart` โ€” barrel updated + - `kell_web/lib/composition/app_services.dart` โ€” `observability` field + `logger`/`bugReportService` accessors + `appVersion`; all 4 factories stamp environment labels (FAKE/WP/SQ/MULTI) + - `kell_mobile/lib/composition/mobile_app_services.dart` โ€” mirrored observability wiring across all 4 factories + - `kell_web/lib/main.dart` โ€” `KcErrorReporter.install()` + `runGuarded()` + startup record + - `kell_mobile/lib/main.dart` โ€” mirrored error handler installation and startup record + - `kell_web/lib/shell/app_shell.dart` โ€” `_ReportProblemAction` app bar action wired to `bugReportService` + - `kell_mobile/lib/shell/mobile_shell.dart` โ€” `_reportProblem()` app bar action capturing the active tab +- tests added: + - `core/test/observability_test.dart` โ€” 56 tests across `KcLogLevel`, `KcRedactor`, `KcLogRecord`, `KcInMemoryLogSink`, `KcMultiLogSink`, `KcLogger`, `KcErrorReporter`, `KcBugSeverity`, `KcBugReport`, `KcBugReportService` + - `design_system/test/kc_bug_report_dialog_test.dart` โ€” 10 tests (rendering, screen context, validation, trimming, severity selection, cancel) + - `kell_web/test/shell/bug_report_action_test.dart` โ€” 10 tests (service wiring, shared buffer, environment labels, shell submission flow) + - `kell_mobile/test/shell/bug_report_action_test.dart` โ€” 9 tests (service wiring, shared buffer, shell submission flow, active-tab capture) +- tests: passed (1475/1475 total; core 90/90 +56; design_system 51/51 +10; kell_web 62/62 +10; kell_mobile 81/81 +9) +- analyze: passed (dart analyze --fatal-infos โ€” no issues found across all packages and apps) +- brief updated: yes + ### feat/square-mobile-pos - status: in progress (ready to merge) @@ -723,4 +763,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 - 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) -- brief updated: yes \ No newline at end of file +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index ced0888..3c385cd 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -130,6 +130,8 @@ Rules: - โœ… `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete โ€” merged `feat/square-catalog-sync-repositories` โ†’ `main`, 2026-07-25). - โœ… `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete โ€” branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25). - โœ… `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete โ€” branch `feat/square-sync-ui` ready for merge, 2026-07-25). +- โœ… Square Mobile POS / Checkout landed (Stage 10C-POS complete โ€” merged `feat/square-mobile-pos` โ†’ `main`, commit `7bf8f55`, 2026-07-25). +- โœ… Platform observability activated (Stage 16A complete โ€” branch `feat/observability-error-logging`, 2026-08-29). Structured logging (`KcLogger`, `KcLogRecord`, `KcLogLevel`), log sinks (`KcConsoleLogSink`, `KcInMemoryLogSink`, `KcMultiLogSink`), credential redaction (`KcRedactor`), global uncaught-error capture (`KcErrorReporter`), and operator bug reporting (`KcBugReport`, `KcBugReportService`, `KcBugReportDialog`) wired into both app shells. ### Current narrow edit capabilities on `main` @@ -141,24 +143,25 @@ Rules: ### Latest known validation state on `main` -> **Last verified:** 2026-07-25 โ€” Comprehensive State Audit (`chore/audit-state-sync`) +> **Last verified:** 2026-08-29 โ€” Release Readiness Review (`feat/observability-error-logging`) - `dart analyze --fatal-infos` clean across all 11 packages/apps - All packages passing -- latest reported count for `core`: `24/24 passed` -- latest reported count for `design_system`: `41/41 passed` -- latest reported count for `feature_inventory`: `167/167 passed` โ† +35 new (Stage 10C) -- latest reported count for `feature_orders`: `234/234 passed` โ† +36 new (Stage 10C POS) +- latest reported count for `core`: `90/90 passed` โ† +56 new (Stage 16A observability) +- latest reported count for `design_system`: `51/51 passed` โ† +10 new (Stage 16A bug report dialog) +- latest reported count for `feature_inventory`: `167/167 passed` +- latest reported count for `feature_orders`: `234/234 passed` - latest reported count for `feature_wordpress`: `545/545 passed` -- latest reported count for `kell_web`: `52/52 passed` +- latest reported count for `kell_web`: `62/62 passed` โ† +10 new (Stage 16A shell wiring) - latest reported count for `integrations`: `123/123 passed` - latest reported count for `data`: `63/63 passed` - latest reported count for `auth`: `42/42 passed` -- latest reported count for `kell_mobile`: `72/72 passed` โ† +18 new (Stage 10C POS) -- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`) -- active branch: `feat/square-mobile-pos` (Stage 10C) โ€” ready for merge +- latest reported count for `kell_mobile`: `81/81 passed` โ† +9 new (Stage 16A shell wiring) +- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file) +- total: `1,475/1,475 passed` (+85 new from Stage 16A branch `feat/observability-error-logging`) +- active branch: `feat/observability-error-logging` (Stage 16A) โ€” ready for merge -> **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged. +> **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin โ€” all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker. #### Baseline test coverage (established 2026-05-22) @@ -233,6 +236,41 @@ Delivered: - 45 new tests (36 feature_orders + 18 kell_mobile โˆ’ 9 pre-existing kell_mobile); 1373 total across all packages - Analyze clean +### Stage 16A โ€” complete (2026-08-29) + +**`feat/observability-error-logging`** โ€” Error logging, crash capture, and bug reporting + +Closes audit findings P3.4 (structured logging), P3.5 (crash reporting), and the +"No logging infrastructure" / "No crash reporting" architectural risks. + +Delivered in `core`: + +- `KcLogLevel` โ€” five ordered severities (`debug`, `info`, `warning`, `error`, `fatal`) with threshold comparison +- `KcLogRecord` โ€” immutable structured record with `format()`, `toJson()`, and `isFailure` +- `KcRedactor` โ€” credential redaction applied to every logged context map, traversing nested maps and lists +- `KcLogSink` contract with `KcConsoleLogSink` (release-silent by default), `KcInMemoryLogSink` (bounded ring buffer), and `KcMultiLogSink` (fault-tolerant fan-out) +- `KcLogger` โ€” the logging facade; drops sub-threshold records, redacts context, and never throws +- `KcErrorReporter` โ€” installs `FlutterError.onError`, `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp` +- `KcBugReport` / `KcBugSeverity` โ€” the bug report model with redacted diagnostics, `toJson()`, and `toMarkdown()` +- `KcBugReportSink` contract + `KcInMemoryBugReportSink` +- `KcBugReportService` โ€” validates input, attaches the recent log tail as evidence, and degrades gracefully when the tracker is unreachable +- `KcObservability` โ€” assembles logger, evidence buffer, and bug report service so they always share one buffer + +Delivered in `design_system`: + +- `KcBugReportDialog` + `KcBugReportDraft` โ€” the shared operator reporting form with inline validation and a stated no-credentials guarantee + +Wired into both app targets: + +- `KcObservability` held by `AppServices` and `MobileAppServices`; every factory stamps its environment label (`FAKE`, `WP`, `SQ`, `MULTI`) +- `KcErrorReporter.install(...)` + `runGuarded(...)` in both `main.dart` entry points, with a startup record capturing the effective environment and any fake-mode fallback +- "Report a problem" action in the `AppShell` app bar and the `MobileShell` app bar, capturing the active screen + +Validation: + +- 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total +- `dart analyze --fatal-infos` clean across all packages and apps + ### Next recommended branch **`feat/custom-order-domain`** โ€” Stage 11A: Custom order domain and data @@ -312,11 +350,11 @@ These items harden the platform for production deployment. | ID | Item | Target Stage | | ----- | --------------------------------------------------------------------------------------- | ----------------- | -| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A | +| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16C | | P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B | | P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B | -| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A | -| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A | +| P3.4 | ~~Structured logging infrastructure~~ - DONE: `KcLogger` (Stage 16A) | Complete | +| P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete | | P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone | | P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone | | P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone | @@ -327,14 +365,18 @@ These items harden the platform for production deployment. ### Key technical debt findings -- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** โ€” `KC_ENV=square` silently falls to fake mode (CRITICAL) -- **`SquareCatalogSyncService` ID conflation** โ€” `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH) +- ~~**`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory**~~ โ€” โœ… RESOLVED in Stage 10E; both factories exist and `KcBootstrap` validates Square credentials +- ~~**`SquareCatalogSyncService` ID conflation**~~ โ€” โœ… RESOLVED in Stage 10E; `syncCatalog()` now reads `draft.squareCatalogObjectId` +- ~~**No logging infrastructure**~~ โ€” โœ… RESOLVED in Stage 16A; `KcLogger` with structured records and credential redaction +- ~~**No crash reporting**~~ โ€” โœ… RESOLVED in Stage 16A; `KcErrorReporter` captures framework, platform, and zone errors - **`RetryPolicy`/`RateLimiter` defined but not wired** โ€” all API calls are single-attempt with no retry or rate limiting (HIGH) - **`auth` package not imported by any app or feature** โ€” app is completely unguarded (HIGH) +- **No remote log or crash sink** โ€” diagnostics are in-process only and lost on restart; unavailable off-device (HIGH) - **`data` package not imported by any feature** โ€” all data is in-memory only; no persistence path (MEDIUM) - **`InventoryController` not wired to `InventorySyncService`** โ€” sync cannot be triggered from UI (MEDIUM) -- **`feature_policy` has only a smoke test** โ€” no controller or widget tests (MEDIUM) +- **`feature_policy` has only a smoke test** โ€” 17 tests in one file; no controller or widget tests (MEDIUM) - **`kell_web` at 54.1% line coverage** โ€” routing, shell, and navigation largely untested (MEDIUM) +- **No dependency injection / state management framework** โ€” `ChangeNotifier` + direct construction will strain as the sync engine and offline queue land (MEDIUM) --- @@ -1202,7 +1244,10 @@ Harden the platform for production deployment beyond a single operator. - `feat/error-tracking` - `feat/secure-deployment` -#### Stage 16A โ€” Error tracking and observability +#### ~~Stage 16A โ€” Error tracking and observability~~ โœ… COMPLETE + +> Delivered on `feat/observability-error-logging` (2026-08-29). +> See "Stage 16A โ€” complete (2026-08-29)" above for the full manifest. ##### Goal @@ -1210,10 +1255,25 @@ Add crash reporting, error tracking, and basic usage analytics. ##### Requirements -- Integrate lightweight crash reporting (e.g., Sentry) -- Add structured logging for API calls, sync operations, and errors -- Add basic usage analytics (feature usage frequency, sync success rates) -- Add health check dashboard showing integration status per channel +- โœ… Crash/error capture โ€” `KcErrorReporter` installs `FlutterError.onError`, + `PlatformDispatcher.instance.onError`, and a guarded zone around `runApp`. + Implemented as a first-party abstraction rather than binding directly to + Sentry so no vendor dependency is introduced before the deployment model is + settled; `KcLogSink` is the extension point for a hosted backend. +- โœ… Structured logging for errors and state transitions โ€” `KcLogger` with + five severities, threshold filtering, and mandatory credential redaction. +- โณ Usage analytics (feature usage frequency, sync success rates) โ€” **deferred**. + `KcLogRecord` carries the structured `context` needed to derive these, but no + aggregation or reporting surface exists yet. +- โณ Health check dashboard per channel โ€” **deferred**. The `IntegrationHealthCheck` + contract exists in `integrations` but has no implementation or UI. + +##### Remaining scope (tracked as Stage 16C) + +- Remote log/crash sink (`KcLogSink` implementation posting to a backend) +- Integration health dashboard driven by `IntegrationHealthCheck` +- Usage analytics aggregation +- In-app log viewer for operator self-service diagnostics #### Stage 16B โ€” Secure deployment configuration @@ -1228,6 +1288,25 @@ Move beyond `--dart-define` for credential management in production. - Connection testing UI (verify WooCommerce and Square connectivity) - Environment-specific configuration (dev/staging/production) +#### Stage 16C โ€” Resilience and remote diagnostics + +##### Goal + +Make integrations survive transient failure, and make diagnostics visible off-device. + +##### Requirements + +- Wire `ExponentialBackoffRetryPolicy` into `SquareApiClient` and + `WooCommerceApiClient` for 5xx and network errors (P3.1) +- Wire `TokenBucketRateLimiter` into Square calls โ€” Square enforces rate limits + and currently a burst sync can be throttled with no recovery +- Implement `IntegrationHealthCheck` per channel and surface it on the + Integrations page +- Remote `KcLogSink` implementation so `ERROR`/`FATAL` records and submitted bug + reports leave the device +- In-app log viewer for operator self-service diagnostics +- Usage analytics aggregation (feature usage frequency, sync success rates) + --- ## Suggested improvements (identified from codebase analysis) @@ -1289,15 +1368,17 @@ Architecture documentation (`traceability-index.md` coverage gaps) identifies "P **Recommendation:** Addressed in **Stage 9C โ€” WooCommerce catalog expansion** which includes image upload, gallery management, and product image assignment via the WooCommerce Media API. -### 8. Error tracking and observability +### 8. ~~Error tracking and observability~~ โœ… PARTIALLY RESOLVED -No monitoring, error tracking, or analytics strategy exists for the Flutter applications. In production use: - -- Runtime errors are invisible unless the operator sees them -- No crash reporting -- No usage analytics to guide prioritization - -**Recommendation:** Addressed in **Stage 16A โ€” Error tracking and observability**. +> Resolved for error capture and diagnostics by **Stage 16A** (2026-08-29). +> Runtime errors are now captured by `KcErrorReporter`, recorded through +> `KcLogger`, and attachable to operator bug reports via `KcBugReportService`. +> Credential redaction (`KcRedactor`) is enforced on every logged context map. +> +> **Still open:** logs and reports are held in-process only โ€” there is no remote +> sink, so diagnostics are lost when the app restarts and are unavailable to +> anyone but the operator on the device. Usage analytics remain unimplemented. +> Both are tracked as **Stage 16C**. ### 9. ~~Empty tools directory~~ โœ… RESOLVED @@ -1507,12 +1588,14 @@ Working rules: ### `KC_ENV` values -| Value | Description | Status | -| ----------- | -------------------------------- | ---------------------------------------------------------------- | -| `fake` | All fake repositories | โœ… Active | -| `wordpress` | WooCommerce real + others fake | โœ… Active | -| `square` | Square real + others fake | โš ๏ธ Keys defined (Stage 10A); factory constructors missing (P1.1) | -| `multi` | Both WooCommerce and Square real | โš ๏ธ Enum value defined; factory constructors missing (P1.1) | +| Value | Description | Status | +| ----------- | -------------------------------- | --------------------------------------------------------------------- | +| `fake` | All fake repositories | โœ… Active | +| `wordpress` | WooCommerce real + others fake | โœ… Active | +| `square` | Square real + others fake | โœ… Active โ€” `square()` factory + `KcBootstrap` validation (Stage 10E) | +| `multi` | Both WooCommerce and Square real | โœ… Active โ€” `multi()` factory + `KcBootstrap` validation (Stage 10E) | + +> **Credential handling:** All four modes fall back to `fake` with a logged warning when required credentials are absent. Credential values are never written to logs or bug reports โ€” `KcRedactor` masks any context key containing `token`, `secret`, `key`, `auth`, or `credential`. --- diff --git a/docs/operations/index.md b/docs/operations/index.md index c95af4e..8703f84 100644 --- a/docs/operations/index.md +++ b/docs/operations/index.md @@ -8,6 +8,7 @@ This section contains operational runbooks, CI/CD documentation, and business pr | ------------------------------------------------- | ---------------------------------------------------- | ---------------- | | [CI/CD Workflow](cicd-workflow.md) | Defines the documentation publishing pipeline | โœ… Comprehensive | | [Architecture Workflow](architecture-workflow.md) | Defines the diagram authoring and publishing process | โœ… Complete | +| [Release Checklist](release-checklist.md) | Per-environment release gates and sign-off record | โœ… Complete | ## Analysis Findings diff --git a/docs/operations/release-checklist.md b/docs/operations/release-checklist.md new file mode 100644 index 0000000..a7ecc99 --- /dev/null +++ b/docs/operations/release-checklist.md @@ -0,0 +1,304 @@ +# Release Checklist + +**Owner:** Release Committee +**Applies to:** `kell_web` (Flutter Web / Windows Desktop) and `kell_mobile` (Flutter Android) +**Last reviewed:** 2026-08-29 +**Review cycle:** Every release; document reviewed at least quarterly + +--- + +## Purpose + +This document defines the **mandatory gates** a build must pass before it is +promoted into each target environment. Each environment inherits every gate from +the environment before it, then adds its own. + +Promotion is **sequential**: Development โ†’ Test/QA โ†’ Staging โ†’ Production. A +build may not skip an environment. + +--- + +## 1. Environment Matrix + +| Environment | Target | `KC_ENV` | Square mode | Data | Audience | Approver | +| --------------- | --------------------------------- | --------------------- | ------------ | ----------------------- | ------------- | ----------------- | +| **Development** | Local workstation | `fake` | n/a | In-memory fakes | Engineer | Self | +| **Test / QA** | CI runner + internal test devices | `wordpress` | n/a | WooCommerce staging | Engineer + QA | Engineering lead | +| **Staging** | Pre-production host + test device | `multi` | `sandbox` | Staging WC + Square SBX | QA + business | Release committee | +| **Production** | Live web host + Play Store track | `wordpress` / `multi` | `production` | Live WC + Square live | Operators | Release committee | + +> **Rule:** `KC_SQUARE_ENVIRONMENT=production` is permitted **only** in the +> Production environment. Any lower environment using a live Square token is an +> immediate stop-ship defect. + +--- + +## 2. Universal Gates (all environments) + +These must pass before any promotion. + +- [ ] `dart analyze --fatal-infos` is clean for all 11 packages and both apps +- [ ] Full test suite passes โ€” current baseline **1,475 / 1,475** +- [ ] No test is skipped, `@Skip`-ed, or commented out without a linked issue +- [ ] `docs/development/build_execution_tracker.md` has a slice entry for the change +- [ ] `docs/development/master_development_brief.md` reflects the new baseline +- [ ] No secrets committed โ€” credentials supplied only via `--dart-define` +- [ ] No `TODO`/`FIXME` introduced without a linked tracker item + +Commands: + +```bash +cd kell_creations_apps +./tools/run_all_tests.sh --analyze +``` + +--- + +## 3. Development Environment + +**Purpose:** Fast inner-loop work against in-memory fakes. No external calls. + +### Build + +```bash +flutter run -d chrome --dart-define=KC_ENV=fake # kell_web +flutter run -d android --dart-define=KC_ENV=fake # kell_mobile +``` + +### Gates + +- [ ] Universal gates pass +- [ ] Feature branch is cut from current `main` +- [ ] Slice is vertical and reviewable (domain โ†’ application โ†’ data โ†’ presentation) +- [ ] New public APIs carry doc comments +- [ ] Environment badge renders `FAKE` +- [ ] "Report a problem" action opens the dialog and submits successfully +- [ ] No unexpected `ERROR`/`FATAL` records in the console during a smoke pass + +### Exit criteria + +Analyze clean, tests green, tracker updated. Open PR into `main`. + +--- + +## 4. Test / QA Environment + +**Purpose:** Validate real WooCommerce integration and cross-platform parity. + +### Build + +```bash +flutter build web --dart-define=KC_ENV=wordpress \ + --dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \ + --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ + --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx + +flutter build apk --debug --dart-define=KC_ENV=wordpress \ + --dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \ + --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ + --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx +``` + +### Gates + +- [ ] All Development gates pass +- [ ] Forgejo `Flutter Test` workflow green +- [ ] Forgejo `Flutter Analyze` workflow green +- [ ] Coverage reported in the CI summary; no unexplained decrease +- [ ] Credentials point at **staging** WooCommerce โ€” never production +- [ ] Environment badge renders `WP` +- [ ] Missing-credential path verified: app falls back to `FAKE` **and logs a warning** + +### Functional verification (WooCommerce) + +- [ ] Product list loads, filters, searches, and sorts +- [ ] Single-field edits persist (name, price, description, category) +- [ ] Status transitions behave (`unpublished` โ†’ `draft`, `pendingReview` โ†’ `published`) +- [ ] Product creation completes on web (`CreateProductDialog`) and mobile (`MobileCreateProductPage`) +- [ ] Category CRUD works +- [ ] Bulk actions (move-to-draft, publish, submit-for-review) work +- [ ] API failures surface a user-visible error โ€” never a silent failure + +### Observability verification + +- [ ] Startup record written with the effective environment +- [ ] A forced API failure produces an `ERROR` record with source and context +- [ ] Bug report submission attaches the recent log tail +- [ ] **Credential redaction confirmed** โ€” no token, key, or secret appears in any log or report + +### Mobile-specific + +- [ ] Touch targets meet the 48ร—48dp minimum +- [ ] Haptics fire (`mediumImpact` state changes, `lightImpact` field edits) +- [ ] Layout is correct on a notch/gesture-bar device +- [ ] Back navigation behaves throughout + +### Exit criteria + +CI green, functional pass recorded, no open Critical or High defects. + +--- + +## 5. Staging Environment + +**Purpose:** Production rehearsal with both channels live in sandbox mode. + +### Build + +```bash +flutter build web --release --dart-define=KC_ENV=multi \ + --dart-define=KC_WC_SITE_URL=https://staging.kellcreations.com \ + --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ + --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx \ + --dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl_sandbox_xxx \ + --dart-define=KC_SQUARE_LOCATION_ID=L_xxx \ + --dart-define=KC_SQUARE_ENVIRONMENT=sandbox +``` + +The Android build uses the same defines with `flutter build apk --release`. + +### Gates + +- [ ] All Test/QA gates pass +- [ ] Built in **release** mode (not debug/profile) +- [ ] `KC_SQUARE_ENVIRONMENT=sandbox` โ€” confirmed, not `production` +- [ ] Square token is a **sandbox** token +- [ ] Environment badge renders `MULTI` +- [ ] Incomplete-credential path falls back to `FAKE` with a logged warning + +### Multi-channel verification + +- [ ] Square catalog sync completes; `SquareSyncStatusPanel` reports the result +- [ ] `ProductIdMapping` records are created correctly โ€” **no duplicate listings** +- [ ] Already-synced products reuse their existing mapping (ID conflation regression check) +- [ ] Square inventory counts load and reconcile +- [ ] Partial sync failure surfaces per-item `SyncError` detail +- [ ] Integrations page reflects live sync state on web and mobile + +### Mobile POS verification (sandbox) + +- [ ] Product selection โ†’ cart โ†’ payment flow completes +- [ ] Cash payment records correctly +- [ ] Card payment completes against Square sandbox +- [ ] Receipt renders with the correct total +- [ ] Payment failure is handled without data loss + +### Non-functional + +- [ ] Release build starts within an acceptable time on a representative device +- [ ] No memory growth across an extended session +- [ ] Web build loads over the target network profile +- [ ] Offline/interrupted-network behaviour is understood and documented + +> **Known limitation:** Offline caching and the sales queue (Stage 14) are **not +> implemented**. Network loss during a market sale is currently unrecoverable. +> This must be stated in release notes for any field use. + +### Exit criteria + +Business sign-off recorded, no open Critical/High defects, rollback plan agreed. + +--- + +## 6. Production Environment + +**Purpose:** Live operator use against real customers and real money. + +### Pre-flight + +- [ ] All Staging gates pass +- [ ] Release committee approval recorded +- [ ] Release notes drafted, including known limitations +- [ ] Rollback plan documented and the previous artifact retained +- [ ] Version bumped in both `pubspec.yaml` files +- [ ] `AppServices.appVersion` and `MobileAppServices.appVersion` match `pubspec.yaml` +- [ ] Release tagged in Git + +### Credential and safety gates + +- [ ] Production WooCommerce credentials sourced from the secret store โ€” **not** the repo +- [ ] `KC_SQUARE_ENVIRONMENT=production` set deliberately +- [ ] Square production token scoped to the minimum required permissions +- [ ] Square location ID matches the intended physical location +- [ ] A staging credential is confirmed absent from the production build +- [ ] Credential redaction verified once more in the production artifact + +### Build + +```bash +flutter build web --release --dart-define=KC_ENV=multi \ + --dart-define=KC_WC_SITE_URL=https://store.kellcreations.com \ + --dart-define=KC_WC_CONSUMER_KEY=$WC_KEY \ + --dart-define=KC_WC_CONSUMER_SECRET=$WC_SECRET \ + --dart-define=KC_SQUARE_ACCESS_TOKEN=$SQUARE_TOKEN \ + --dart-define=KC_SQUARE_LOCATION_ID=$SQUARE_LOCATION \ + --dart-define=KC_SQUARE_ENVIRONMENT=production +``` + +The Android release build uses the same defines with `flutter build appbundle --release`. + +### Post-deployment verification + +- [ ] App loads and the environment badge is correct +- [ ] A read-only operation succeeds (product list loads) +- [ ] A single low-risk write succeeds and is confirmed in WooCommerce admin +- [ ] Square sync runs once and mappings are correct +- [ ] **One low-value real transaction** processed end-to-end and reconciled in Square +- [ ] Bug reporting works from the production build +- [ ] No `ERROR`/`FATAL` records during the verification pass + +### Monitoring window + +- [ ] Observed for the first operating session +- [ ] Bug report queue reviewed before sign-off +- [ ] Operators briefed on "Report a problem" and known limitations + +### Rollback triggers + +Roll back immediately on any of: + +- Incorrect pricing, inventory, or payment amounts +- Duplicate product listings from a sync defect +- Failed or double-charged payments +- Credential leakage in any log or report +- Any Critical defect without a workaround + +--- + +## 7. Release Sign-Off Record + +| Field | Value | +| ------------------ | ------------------- | +| Release version | | +| Git tag / commit | | +| Target environment | | +| Test result | \_\_ / 1475 passing | +| Analyze result | | +| Known limitations | | +| Approver | | +| Date | | + +--- + +## 8. Open Release Blockers + +Carried from `PROJECT_STATE_AUDIT.md`. Each must be closed or explicitly accepted +as a documented limitation before the affected environment is used in earnest. + +| ID | Item | Blocks | +| ----- | ------------------------------------------------------------- | -------------------------- | +| P2.5 | `WooCommerceOrdersRepository` not wired into `AppServices` | Test/QA orders workflow | +| P2.6 | `WooCommerceInventoryRepository` not wired into `AppServices` | Test/QA inventory workflow | +| P2.8 | Offline cache and sales queue not implemented (Stage 14) | Production field use | +| P3.2 | `SecureCredentialStore` not implemented | Production | +| P3.3 | Auth guard not wired into either shell | Production | +| P3.1 | `RetryPolicy` / `RateLimiter` not wired into API clients | Staging, Production | +| P3.6 | `feature_policy` has only a smoke test | Test/QA | +| P2.10 | Policy register CSVs contain headers only | Governance compliance | + +> **Committee position:** With Stage 16A complete, the platform now has error +> logging and bug reporting. It is **approved for Development and Test/QA**. +> **Staging is conditionally approved** pending P3.1. **Production is not +> approved** until P3.2, P3.3, and P2.8 are closed โ€” the platform currently has +> no authentication guard, no secure credential storage, and no offline +> resilience for in-person sales. diff --git a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart index 32c506a..3685bc2 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart @@ -43,7 +43,11 @@ class MobileAppServices extends KcAppServices { /// In square/multi mode this could be populated from the Square catalog. final List checkoutCatalogue; - const MobileAppServices({ + /// The observability stack: structured logger, in-memory evidence buffer, + /// and the bug report service backing the shell's "Report a problem" action. + final KcObservability observability; + + MobileAppServices({ required this.inventoryRepository, required this.ordersRepository, required this.policyRepository, @@ -51,7 +55,27 @@ class MobileAppServices extends KcAppServices { required this.paymentService, this.squareSyncController, this.checkoutCatalogue = _fakeCatalogue, - }); + KcObservability? observability, + }) : observability = observability ?? _defaultObservability('FAKE'); + + /// The application version reported in bug reports. + /// + /// Kept in sync with `version:` in `pubspec.yaml`. + static const String appVersion = '1.0.0+1'; + + /// Convenience accessor for the shared structured logger. + KcLogger get logger => observability.logger; + + /// Convenience accessor for the bug report service. + KcBugReportService get bugReportService => observability.bugReportService; + + static KcObservability _defaultObservability(String environmentLabel) => + KcObservability.standard( + appVersion: appVersion, + environment: environmentLabel, + platform: 'android', + source: 'kell_mobile', + ); /// Creates a [MobileAppServices] backed by fake, in-memory repositories. factory MobileAppServices.fake() { @@ -61,6 +85,7 @@ class MobileAppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: FakeProductPublishingRepository(), paymentService: FakePaymentService(), + observability: _defaultObservability('FAKE'), ); } @@ -84,6 +109,7 @@ class MobileAppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), paymentService: FakePaymentService(), + observability: _defaultObservability('WP'), ); } @@ -116,6 +142,7 @@ class MobileAppServices extends KcAppServices { productPublishingRepository: catalogRepo, squareSyncController: SquareSyncController(syncService: syncService), paymentService: FakePaymentService(), + observability: _defaultObservability('SQ'), ); } @@ -160,6 +187,7 @@ class MobileAppServices extends KcAppServices { productPublishingRepository: wcPublishingRepo, squareSyncController: SquareSyncController(syncService: syncService), paymentService: FakePaymentService(), + observability: _defaultObservability('MULTI'), ); } diff --git a/kell_creations_apps/apps/kell_mobile/lib/main.dart b/kell_creations_apps/apps/kell_mobile/lib/main.dart index 2082374..71eaee9 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/main.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/main.dart @@ -11,11 +11,28 @@ void main() { MobileAppServices.serviceFactory, ); - runApp( - KcAppScope( - services: services, - config: effectiveConfig, - child: const KellMobileApp(), - ), + // Route uncaught framework and platform errors into the structured logger + // before the first frame, so startup failures are captured too. + KcErrorReporter.install(services.logger); + + services.logger.info( + 'Application starting', + source: 'main', + context: { + 'environment': effectiveConfig.environment.key, + 'requestedEnvironment': config.environment.key, + 'fellBackToFake': config.environment != effectiveConfig.environment, + 'appVersion': MobileAppServices.appVersion, + }, ); + + KcErrorReporter.runGuarded(services.logger, () { + runApp( + KcAppScope( + services: services, + config: effectiveConfig, + child: const KellMobileApp(), + ), + ); + }); } diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index c61b62f..d1f7551 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -1,4 +1,5 @@ import 'package:core/core.dart'; +import 'package:design_system/design_system.dart'; import 'package:feature_orders/feature_orders.dart'; import 'package:feature_policy/feature_policy.dart'; import 'package:flutter/material.dart'; @@ -67,6 +68,37 @@ class _MobileShellState extends State { 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 _reportProblem() async { + final services = KcAppScope.of(context); + final screen = _titles[_selectedIndex]; + + final draft = await KcBugReportDialog.show(context, screen: screen); + if (draft == null || !mounted) return; + + final result = await services.bugReportService.submit( + title: draft.title, + description: draft.description, + severity: KcBugSeverity.fromString(draft.severityName), + reporter: draft.reporter, + screen: screen, + diagnostics: {'tabIndex': _selectedIndex}, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.succeeded + ? 'Report ${result.report!.id} submitted. Thank you.' + : result.errorMessage ?? 'Could not submit the report.', + ), + ), + ); + } + @override Widget build(BuildContext context) { final config = KcAppScope.configOf(context); @@ -75,6 +107,11 @@ class _MobileShellState extends State { appBar: AppBar( title: Text(_titles[_selectedIndex]), actions: [ + IconButton( + icon: const Icon(Icons.bug_report_outlined), + tooltip: 'Report a problem', + onPressed: _reportProblem, + ), _EnvironmentBadge(environment: config.environment), const SizedBox(width: 12), ], diff --git a/kell_creations_apps/apps/kell_mobile/test/shell/bug_report_action_test.dart b/kell_creations_apps/apps/kell_mobile/test/shell/bug_report_action_test.dart new file mode 100644 index 0000000..9c0a0f4 --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/test/shell/bug_report_action_test.dart @@ -0,0 +1,153 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_mobile/app.dart'; +import 'package:kell_mobile/composition/mobile_app_services.dart'; + +/// Pumps the mobile app with fake services so the shell's bug report action +/// can be exercised end-to-end. +Future _pumpApp(WidgetTester tester) async { + final services = MobileAppServices.fake(); + + await tester.pumpWidget( + KcAppScope( + 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); + }); + }); +} diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart index 2632f06..e5318d8 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -28,13 +28,37 @@ class AppServices extends KcAppServices { /// Present only in `square` and `multi` environments. final SquareSyncController? squareSyncController; - const AppServices({ + /// The observability stack: structured logger, in-memory evidence buffer, + /// and the bug report service backing the shell's "Report a problem" action. + final KcObservability observability; + + AppServices({ required this.inventoryRepository, required this.ordersRepository, required this.policyRepository, required this.productPublishingRepository, this.squareSyncController, - }); + KcObservability? observability, + }) : observability = observability ?? _defaultObservability('FAKE'); + + /// The application version reported in bug reports. + /// + /// Kept in sync with `version:` in `pubspec.yaml`. + static const String appVersion = '1.0.0+1'; + + /// Convenience accessor for the shared structured logger. + KcLogger get logger => observability.logger; + + /// Convenience accessor for the bug report service. + KcBugReportService get bugReportService => observability.bugReportService; + + static KcObservability _defaultObservability(String environmentLabel) => + KcObservability.standard( + appVersion: appVersion, + environment: environmentLabel, + platform: 'web', + source: 'kell_web', + ); /// Creates an [AppServices] backed by fake, in-memory repositories. factory AppServices.fake() { @@ -43,6 +67,7 @@ class AppServices extends KcAppServices { ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), productPublishingRepository: FakeProductPublishingRepository(), + observability: _defaultObservability('FAKE'), ); } @@ -69,6 +94,7 @@ class AppServices extends KcAppServices { ordersRepository: FakeOrdersRepository(), policyRepository: FakePolicyRepository(), productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), + observability: _defaultObservability('WP'), ); } @@ -100,6 +126,7 @@ class AppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: catalogRepo, squareSyncController: SquareSyncController(syncService: syncService), + observability: _defaultObservability('SQ'), ); } @@ -142,6 +169,7 @@ class AppServices extends KcAppServices { policyRepository: FakePolicyRepository(), productPublishingRepository: wcPublishingRepo, squareSyncController: SquareSyncController(syncService: syncService), + observability: _defaultObservability('MULTI'), ); } diff --git a/kell_creations_apps/apps/kell_web/lib/main.dart b/kell_creations_apps/apps/kell_web/lib/main.dart index 140f18f..afe5799 100644 --- a/kell_creations_apps/apps/kell_web/lib/main.dart +++ b/kell_creations_apps/apps/kell_web/lib/main.dart @@ -1,13 +1,32 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'app.dart'; import 'composition/app_config.dart'; import 'composition/app_scope.dart'; +import 'composition/app_services.dart'; import 'composition/bootstrap.dart'; void main() { final config = AppConfig.fromEnvironment(); final (:services, config: effectiveConfig) = Bootstrap.run(config); - runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp())); + // Route uncaught framework and platform errors into the structured logger + // before the first frame, so startup failures are captured too. + KcErrorReporter.install(services.logger); + + services.logger.info( + 'Application starting', + source: 'main', + context: { + 'environment': effectiveConfig.environment.key, + 'requestedEnvironment': config.environment.key, + 'fellBackToFake': config.environment != effectiveConfig.environment, + 'appVersion': AppServices.appVersion, + }, + ); + + KcErrorReporter.runGuarded(services.logger, () { + runApp(AppScope(services: services, config: effectiveConfig, child: const KellWebApp())); + }); } diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart index 1da2de1..befb282 100644 --- a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart +++ b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart @@ -1,3 +1,5 @@ +import 'package:core/core.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import '../composition/app_config.dart'; @@ -25,6 +27,7 @@ class AppShell extends StatelessWidget { appBar: AppBar( title: const Text('Kell Creations'), actions: [ + _ReportProblemAction(screen: title), _EnvironmentBadge(environment: config.environment), const SizedBox(width: 12), ], @@ -146,6 +149,53 @@ class AppShell extends StatelessWidget { } } +/// An [AppBar] action that opens the shared bug report dialog and submits the +/// resulting draft through [AppServices.bugReportService]. +/// +/// The recent log tail is attached automatically as diagnostic evidence, and +/// credentials are redacted by `KcRedactor` before anything is recorded. +class _ReportProblemAction extends StatelessWidget { + /// The screen the operator is reporting from. + final String screen; + + const _ReportProblemAction({required this.screen}); + + Future _report(BuildContext context) async { + final services = AppScope.of(context); + final messenger = ScaffoldMessenger.of(context); + + final draft = await KcBugReportDialog.show(context, screen: screen); + if (draft == null) return; + + final result = await services.bugReportService.submit( + title: draft.title, + description: draft.description, + severity: KcBugSeverity.fromString(draft.severityName), + reporter: draft.reporter, + screen: screen, + ); + + messenger.showSnackBar( + SnackBar( + content: Text( + result.succeeded + ? 'Report ${result.report!.id} submitted. Thank you.' + : result.errorMessage ?? 'Could not submit the report.', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.bug_report_outlined), + tooltip: 'Report a problem', + onPressed: () => _report(context), + ); + } +} + /// A small coloured chip displayed in the [AppBar] that shows the current /// runtime environment (e.g. "FAKE" or "WP"). class _EnvironmentBadge extends StatelessWidget { diff --git a/kell_creations_apps/apps/kell_web/test/shell/bug_report_action_test.dart b/kell_creations_apps/apps/kell_web/test/shell/bug_report_action_test.dart new file mode 100644 index 0000000..bfbf36f --- /dev/null +++ b/kell_creations_apps/apps/kell_web/test/shell/bug_report_action_test.dart @@ -0,0 +1,163 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kell_web/app.dart'; +import 'package:kell_web/composition/app_config.dart'; +import 'package:kell_web/composition/app_scope.dart'; +import 'package:kell_web/composition/app_services.dart'; + +/// Pumps the web app with fake services so the shell's bug report action can +/// be exercised end-to-end. +Future _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); + }); + }); +} diff --git a/kell_creations_apps/packages/core/lib/core.dart b/kell_creations_apps/packages/core/lib/core.dart index 4c90e84..ef7957d 100644 --- a/kell_creations_apps/packages/core/lib/core.dart +++ b/kell_creations_apps/packages/core/lib/core.dart @@ -8,6 +8,15 @@ /// - [KcServiceFactory] โ€” generic factory for environment-aware service construction /// - [KcBootstrap] โ€” shared bootstrap logic with WordPress credential validation /// - [KcAppScope] โ€” [InheritedWidget] exposing services and config to the widget tree +/// +/// It also provides the shared observability stack used by every app target: +/// +/// - [KcLogger] / [KcLogRecord] / [KcLogLevel] โ€” structured logging +/// - [KcLogSink] implementations โ€” console, bounded in-memory buffer, fan-out +/// - [KcErrorReporter] โ€” global uncaught-error capture +/// - [KcBugReportService] / [KcBugReport] โ€” operator bug reporting with +/// diagnostic evidence attached +/// - [KcRedactor] โ€” credential redaction applied to all logged context library; // Composition @@ -15,3 +24,14 @@ export 'src/composition/kc_app_config.dart'; export 'src/composition/kc_app_scope.dart'; export 'src/composition/kc_app_services.dart'; export 'src/composition/kc_bootstrap.dart'; + +// Observability +export 'src/observability/kc_bug_report.dart'; +export 'src/observability/kc_bug_report_service.dart'; +export 'src/observability/kc_error_reporter.dart'; +export 'src/observability/kc_log_level.dart'; +export 'src/observability/kc_log_record.dart'; +export 'src/observability/kc_log_sink.dart'; +export 'src/observability/kc_logger.dart'; +export 'src/observability/kc_observability.dart'; +export 'src/observability/kc_redactor.dart'; diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report.dart new file mode 100644 index 0000000..f153a3d --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report.dart @@ -0,0 +1,160 @@ +/// The bug report domain model. +library; + +import 'kc_log_record.dart'; +import 'kc_redactor.dart'; + +/// How severely a reported defect affects the operator. +enum KcBugSeverity { + /// Cosmetic or minor annoyance; work can continue. + low('Low'), + + /// Degrades a workflow but a workaround exists. + medium('Medium'), + + /// Blocks a business workflow with no workaround. + high('High'), + + /// Data loss, incorrect financial figures, or total outage. + critical('Critical'); + + /// Display label for UI. + final String label; + + const KcBugSeverity(this.label); + + /// Parses a string into a [KcBugSeverity], defaulting to [medium]. + static KcBugSeverity fromString(String value) { + for (final severity in values) { + if (severity.name == value.toLowerCase()) return severity; + } + return medium; + } +} + +/// An operator-submitted defect report with attached diagnostic evidence. +/// +/// A report bundles what the operator observed with the machine context needed +/// to triage it: the app version, environment mode, platform, and the recent +/// log tail leading up to the failure. +class KcBugReport { + /// Stable identifier used for correlation between the app and the tracker. + final String id; + + /// Short summary supplied by the operator. + final String title; + + /// Free-text description, including reproduction steps. + final String description; + + /// Operator-assessed impact. + final KcBugSeverity severity; + + /// When the report was created. + final DateTime createdAt; + + /// Application version string (e.g. `1.0.0+1`). + final String appVersion; + + /// Runtime environment label (e.g. `FAKE`, `WP`, `SQ`, `MULTI`). + final String environment; + + /// Host platform description (e.g. `android`, `web`). + final String platform; + + /// Optional reporter identity (email or operator name). + final String? reporter; + + /// The screen or workflow the operator was on when the defect occurred. + final String? screen; + + /// Recent structured log records attached as evidence. + final List logTail; + + /// Additional structured diagnostics. Redacted on construction. + final Map 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 logTail = const [], + Map 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 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)'; +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report_service.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report_service.dart new file mode 100644 index 0000000..7a5a3f5 --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_bug_report_service.dart @@ -0,0 +1,197 @@ +/// Bug report submission contracts and the default in-memory implementation. +library; + +import 'kc_bug_report.dart'; +import 'kc_log_level.dart'; +import 'kc_log_sink.dart'; +import 'kc_logger.dart'; + +/// The outcome of a [KcBugReportService.submit] call. +class KcBugReportResult { + /// Whether the report was accepted by the sink. + final bool succeeded; + + /// The submitted report, present on success. + final KcBugReport? report; + + /// Failure detail, present when [succeeded] is `false`. + final String? errorMessage; + + const KcBugReportResult.success(this.report) : succeeded = true, errorMessage = null; + + const KcBugReportResult.failure(this.errorMessage) : succeeded = false, report = null; + + @override + String toString() => succeeded + ? 'KcBugReportResult.success(${report?.id})' + : 'KcBugReportResult.failure($errorMessage)'; +} + +/// A destination that persists or transmits submitted [KcBugReport]s. +/// +/// Implementations may write to local storage, POST to a Forgejo issue +/// endpoint, or forward to a crash-reporting backend. Keeping this abstract +/// means the app never depends on a specific tracker. +abstract class KcBugReportSink { + /// Persists or transmits [report]. Throws on failure. + Future 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 _reports = []; + + /// All reports submitted so far, oldest first. + List get reports => List.unmodifiable(_reports); + + @override + Future 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 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 submit({ + required String title, + required String description, + KcBugSeverity severity = KcBugSeverity.medium, + String? reporter, + String? screen, + Map 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'; + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_error_reporter.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_error_reporter.dart new file mode 100644 index 0000000..7b4b41c --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_error_reporter.dart @@ -0,0 +1,115 @@ +/// Global uncaught-error capture for all Kell Creations app targets. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'kc_logger.dart'; + +/// Installs global error handlers so no runtime failure goes unrecorded. +/// +/// Without this, uncaught widget-build errors are printed to a console nobody +/// reads and asynchronous errors vanish silently. [KcErrorReporter] routes all +/// three failure channels into the structured [KcLogger]: +/// +/// 1. `FlutterError.onError` โ€” errors raised inside the Flutter framework +/// (build, layout, paint). +/// 2. `PlatformDispatcher.instance.onError` โ€” uncaught asynchronous errors +/// that reach the engine. +/// 3. [runGuarded] โ€” a guarded zone wrapping `runApp` for anything else. +/// +/// ## Usage +/// +/// ```dart +/// void main() { +/// final logger = KcLogger(sink: KcConsoleLogSink()); +/// KcErrorReporter.install(logger); +/// runApp(const MyApp()); +/// } +/// ``` +class KcErrorReporter { + const KcErrorReporter._(); + + /// The `source` tag applied to every globally captured record. + static const String source = 'KcErrorReporter'; + + /// The previously installed [FlutterError.onError], retained so [restore] + /// can put the framework back to its original state (used by tests). + static FlutterExceptionHandler? _previousFlutterOnError; + + /// Whether [install] has been called and not yet undone by [restore]. + static bool _installed = false; + + /// Whether global handlers are currently installed. + static bool get isInstalled => _installed; + + /// Routes framework and platform errors into [logger]. + /// + /// When [forwardToOriginalHandler] is `true` (the default) the previous + /// `FlutterError.onError` is still invoked, preserving the standard red + /// error screen and console output in debug builds. + /// + /// Calling [install] twice without [restore] is a no-op beyond swapping the + /// active logger, so it is safe to call from every app target's `main`. + static void install(KcLogger logger, {bool forwardToOriginalHandler = true}) { + if (!_installed) { + _previousFlutterOnError = FlutterError.onError; + _installed = true; + } + + final previous = _previousFlutterOnError; + + FlutterError.onError = (FlutterErrorDetails details) { + logger.error( + 'Uncaught Flutter framework error', + source: source, + context: { + 'library': details.library ?? 'unknown', + 'context': details.context?.toDescription() ?? '', + 'silent': details.silent, + }, + error: details.exception, + stackTrace: details.stack, + ); + + if (forwardToOriginalHandler && previous != null) { + previous(details); + } + }; + + PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + logger.fatal( + 'Uncaught asynchronous error', + source: source, + error: error, + stackTrace: stack, + ); + // Returning true marks the error as handled so the app is not killed. + return true; + }; + } + + /// Runs [body] inside a guarded [Zone] that reports uncaught errors to + /// [logger], then rethrows nothing โ€” the zone absorbs the failure after it + /// has been recorded. + /// + /// Wrap `runApp` with this to capture errors that escape both + /// `FlutterError.onError` and the platform dispatcher. + static R? runGuarded(KcLogger logger, R Function() body) { + return runZonedGuarded(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; + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_log_level.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_log_level.dart new file mode 100644 index 0000000..12668bb --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_log_level.dart @@ -0,0 +1,43 @@ +/// Severity levels for structured log records. +/// +/// Ordered from least to most severe. [KcLogLevel.severity] is used to +/// filter records against a configured minimum level. +library; + +/// The severity of a [KcLogRecord]. +enum KcLogLevel { + /// Fine-grained diagnostic detail, normally disabled outside development. + debug('DEBUG', 10), + + /// Routine operational events (navigation, sync started, order created). + info('INFO', 20), + + /// Recoverable conditions that warrant attention but did not fail the + /// operation (credential fallback, partial sync failure, retry). + warning('WARNING', 30), + + /// An operation failed. The user-facing action did not complete. + error('ERROR', 40), + + /// An unrecoverable failure. The app or a critical subsystem is unusable. + fatal('FATAL', 50); + + /// Uppercase label used in formatted output. + final String label; + + /// Numeric severity used for threshold comparisons. Higher is more severe. + final int severity; + + const KcLogLevel(this.label, this.severity); + + /// Whether this level is at least as severe as [minimum]. + bool isAtLeast(KcLogLevel minimum) => severity >= minimum.severity; + + /// Parses a string into a [KcLogLevel], defaulting to [info]. + static KcLogLevel fromString(String value) { + for (final level in values) { + if (level.name == value.toLowerCase()) return level; + } + return info; + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_log_record.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_log_record.dart new file mode 100644 index 0000000..306793a --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_log_record.dart @@ -0,0 +1,83 @@ +/// The immutable structured log record emitted by [KcLogger]. +library; + +import 'kc_log_level.dart'; + +/// A single structured log entry. +/// +/// Records are immutable value objects so they can be safely buffered, +/// forwarded to multiple sinks, and attached to bug reports. +class KcLogRecord { + /// Severity of the event. + final KcLogLevel level; + + /// Logical source of the event, e.g. `SquareSyncController`. + /// + /// Used to group and filter records by subsystem. + final String source; + + /// Human-readable description of what happened. + final String message; + + /// When the event occurred. + final DateTime timestamp; + + /// Structured key/value context (ids, counts, durations). + /// + /// Must not contain secrets โ€” see [KcRedactor] for sanitising values + /// before they are attached. + final Map 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 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 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(); +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_log_sink.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_log_sink.dart new file mode 100644 index 0000000..5a7ea8f --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_log_sink.dart @@ -0,0 +1,96 @@ +/// Destinations for structured log records. +library; + +import 'package:flutter/foundation.dart'; + +import 'kc_log_level.dart'; +import 'kc_log_record.dart'; + +/// A destination that receives [KcLogRecord]s from [KcLogger]. +/// +/// Implementations must never throw โ€” [KcLogger] treats sinks as +/// fire-and-forget so a broken sink can never break a business operation. +abstract class KcLogSink { + /// Handles a single [record]. + void write(KcLogRecord record); +} + +/// A sink that writes formatted records to the debug console. +/// +/// Uses [debugPrint] so output is rate-limited by the Flutter framework and +/// stripped from release builds by default ([enabledInRelease] overrides this +/// for diagnosing production issues on a device). +class KcConsoleLogSink implements KcLogSink { + /// When `false` (the default) the sink is silent in release builds. + final bool enabledInRelease; + + const KcConsoleLogSink({this.enabledInRelease = false}); + + @override + void write(KcLogRecord record) { + if (kReleaseMode && !enabledInRelease) return; + debugPrint(record.format()); + final trace = record.stackTrace; + if (trace != null && record.level.isAtLeast(KcLogLevel.error)) { + debugPrint(trace.toString()); + } + } +} + +/// A bounded, in-memory ring buffer of recent log records. +/// +/// This is the evidence source for bug reports: when an operator files a +/// report, the most recent [capacity] records are attached so the failure has +/// context without requiring a remote logging backend. +class KcInMemoryLogSink implements KcLogSink { + /// Maximum number of records retained. Oldest records are evicted first. + final int capacity; + + final List _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 get records => List.unmodifiable(_records); + + /// Retained records at [minimumLevel] or above, oldest first. + List recordsAtLeast(KcLogLevel minimumLevel) => + List.unmodifiable(_records.where((r) => r.level.isAtLeast(minimumLevel))); + + /// The most recent [count] records, oldest first. + List 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 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. + } + } + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_logger.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_logger.dart new file mode 100644 index 0000000..ac901dc --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_logger.dart @@ -0,0 +1,150 @@ +/// The structured logging facade used across the Kell Creations platform. +library; + +import 'kc_log_level.dart'; +import 'kc_log_record.dart'; +import 'kc_log_sink.dart'; +import 'kc_redactor.dart'; + +/// Emits structured [KcLogRecord]s to a configured [KcLogSink]. +/// +/// ## Design contract +/// +/// - Logging is **never** allowed to throw. A failing sink is swallowed so an +/// observability problem can never become a business-logic failure. +/// - All context is passed through [KcRedactor] so credentials cannot leak +/// into logs or bug reports. +/// - Records below [minimumLevel] are dropped before the sink is invoked. +/// +/// ## Usage +/// +/// ```dart +/// final logger = KcLogger(sink: KcConsoleLogSink(), source: 'SquareSync'); +/// logger.info('Sync started', context: {'itemCount': 42}); +/// +/// try { +/// await service.syncAll(); +/// } catch (e, s) { +/// logger.error('Sync failed', error: e, stackTrace: s); +/// } +/// ``` +/// +/// Use [forSource] to derive a child logger that tags every record with a +/// subsystem name while sharing the same sink and threshold. +class KcLogger { + /// The destination for emitted records. + final KcLogSink sink; + + /// Records below this severity are dropped. + final KcLogLevel minimumLevel; + + /// Default `source` applied to records that do not override it. + final String source; + + /// Injectable clock, primarily for deterministic tests. + final DateTime Function() _clock; + + KcLogger({ + required this.sink, + this.minimumLevel = KcLogLevel.info, + this.source = 'app', + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + /// A logger that discards everything. Useful as a default in tests and in + /// constructors where observability is optional. + factory KcLogger.silent() => + KcLogger(sink: const KcMultiLogSink([]), minimumLevel: KcLogLevel.fatal); + + /// Returns a logger that shares this logger's sink, threshold, and clock but + /// tags every record with [source]. + KcLogger forSource(String source) => + KcLogger(sink: sink, minimumLevel: minimumLevel, source: source, clock: _clock); + + /// Logs at [KcLogLevel.debug]. + void debug(String message, {String? source, Map context = const {}}) => + log(KcLogLevel.debug, message, source: source, context: context); + + /// Logs at [KcLogLevel.info]. + void info(String message, {String? source, Map context = const {}}) => + log(KcLogLevel.info, message, source: source, context: context); + + /// Logs at [KcLogLevel.warning]. + void warning( + String message, { + String? source, + Map 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 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 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 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. + } + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_observability.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_observability.dart new file mode 100644 index 0000000..692e541 --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_observability.dart @@ -0,0 +1,98 @@ +/// The assembled observability stack shared by every app target. +library; + +import 'kc_bug_report_service.dart'; +import 'kc_log_level.dart'; +import 'kc_log_sink.dart'; +import 'kc_logger.dart'; + +/// Bundles the logger, its in-memory evidence buffer, and the bug report +/// service into a single value that app service containers can hold. +/// +/// Assembling these together guarantees the three components stay consistent: +/// the buffer wired into the logger is the same buffer the bug report service +/// reads its evidence from. Building them independently is the most likely way +/// to end up with empty bug reports. +/// +/// ## Usage +/// +/// ```dart +/// final observability = KcObservability.standard( +/// appVersion: '1.0.0+1', +/// environment: config.environmentLabel, +/// platform: 'android', +/// ); +/// +/// KcErrorReporter.install(observability.logger); +/// observability.logger.info('Bootstrap complete'); +/// ``` +class KcObservability { + /// Bounded buffer of recent records, attached to bug reports as evidence. + final KcInMemoryLogSink logBuffer; + + /// The structured logger used across the app target. + final KcLogger logger; + + /// The service backing the shell's "Report a problem" action. + final KcBugReportService bugReportService; + + const KcObservability({ + required this.logBuffer, + required this.logger, + required this.bugReportService, + }); + + /// Builds the standard stack: console output plus a bounded in-memory buffer, + /// with an in-memory bug report sink. + /// + /// Pass [bugReportSink] to route reports to a real tracker while keeping the + /// rest of the wiring intact. + factory KcObservability.standard({ + required String appVersion, + required String environment, + required String platform, + String source = 'app', + KcLogLevel minimumLevel = KcLogLevel.info, + int bufferCapacity = 200, + KcBugReportSink? bugReportSink, + }) { + final logBuffer = KcInMemoryLogSink(capacity: bufferCapacity); + final logger = KcLogger( + sink: KcMultiLogSink([const KcConsoleLogSink(), logBuffer]), + minimumLevel: minimumLevel, + source: source, + ); + + return KcObservability( + logBuffer: logBuffer, + logger: logger, + bugReportService: KcBugReportService( + sink: bugReportSink ?? KcInMemoryBugReportSink(), + logger: logger, + logSink: logBuffer, + appVersion: appVersion, + environment: environment, + platform: platform, + ), + ); + } + + /// A stack that records nothing, for tests that do not assert on logging. + factory KcObservability.silent() { + final logBuffer = KcInMemoryLogSink(); + final logger = KcLogger(sink: logBuffer, minimumLevel: KcLogLevel.fatal); + + return KcObservability( + logBuffer: logBuffer, + logger: logger, + bugReportService: KcBugReportService( + sink: KcInMemoryBugReportSink(), + logger: logger, + logSink: logBuffer, + appVersion: 'test', + environment: 'FAKE', + platform: 'test', + ), + ); + } +} diff --git a/kell_creations_apps/packages/core/lib/src/observability/kc_redactor.dart b/kell_creations_apps/packages/core/lib/src/observability/kc_redactor.dart new file mode 100644 index 0000000..4822ba9 --- /dev/null +++ b/kell_creations_apps/packages/core/lib/src/observability/kc_redactor.dart @@ -0,0 +1,71 @@ +/// Secret redaction for log context and bug-report payloads. +/// +/// The platform passes WooCommerce and Square credentials through +/// `--dart-define`. Those values must never reach a log sink, a diagnostics +/// export, or a bug report. [KcRedactor] is the single chokepoint that +/// enforces this. +library; + +/// Removes sensitive values from structured context maps. +class KcRedactor { + /// The replacement written in place of a sensitive value. + static const String mask = '***REDACTED***'; + + /// Substrings that mark a key as sensitive (case-insensitive). + static const List 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 redactMap(Map 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; + } +} diff --git a/kell_creations_apps/packages/core/test/observability_test.dart b/kell_creations_apps/packages/core/test/observability_test.dart new file mode 100644 index 0000000..04dd7ed --- /dev/null +++ b/kell_creations_apps/packages/core/test/observability_test.dart @@ -0,0 +1,716 @@ +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A sink that always throws, used to prove logging never breaks callers. +class _ThrowingLogSink implements KcLogSink { + @override + void write(KcLogRecord record) => throw StateError('sink is broken'); +} + +/// A bug report sink that always fails, used to prove submission degrades +/// gracefully. +class _FailingBugReportSink implements KcBugReportSink { + @override + Future 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; + final headers = request['headers']! as Map; + expect(headers['authorization'], KcRedactor.mask); + + final items = request['items']! as List; + final first = items.first! as Map; + 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())); + }); + }); + + // โ”€โ”€ 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.error(const _ApiFailureStub()); + }); + await Future.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(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 logTail = const [], + Map 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')); + }); + }); +} diff --git a/kell_creations_apps/packages/design_system/lib/design_system.dart b/kell_creations_apps/packages/design_system/lib/design_system.dart index 8ec21c7..b47d9ae 100644 --- a/kell_creations_apps/packages/design_system/lib/design_system.dart +++ b/kell_creations_apps/packages/design_system/lib/design_system.dart @@ -10,6 +10,7 @@ export 'src/theme/kc_typography.dart'; export 'src/layout/kc_breakpoints.dart'; // Widgets +export 'src/widgets/kc_bug_report_dialog.dart'; export 'src/widgets/kc_card.dart'; export 'src/widgets/kc_empty_state.dart'; export 'src/widgets/kc_error_state.dart'; diff --git a/kell_creations_apps/packages/design_system/lib/src/widgets/kc_bug_report_dialog.dart b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_bug_report_dialog.dart new file mode 100644 index 0000000..d103320 --- /dev/null +++ b/kell_creations_apps/packages/design_system/lib/src/widgets/kc_bug_report_dialog.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +import '../theme/kc_spacing.dart'; + +/// The values captured by [KcBugReportDialog] when the operator submits. +/// +/// The dialog is deliberately decoupled from `core` so `design_system` stays +/// dependency-free. The caller maps [severityName] onto `KcBugSeverity` and +/// forwards the values to `KcBugReportService.submit`. +class KcBugReportDraft { + /// Short summary of the defect. + final String title; + + /// Detailed description and reproduction steps. + final String description; + + /// Selected severity, matching the `KcBugSeverity` enum names + /// (`low`, `medium`, `high`, `critical`). + final String severityName; + + /// Optional reporter identity. + final String? reporter; + + const KcBugReportDraft({ + required this.title, + required this.description, + required this.severityName, + this.reporter, + }); + + @override + String toString() => 'KcBugReportDraft($severityName: $title)'; +} + +/// A shared bug-reporting dialog used by both the web and mobile shells. +/// +/// Collects a title, description, severity, and optional reporter identity. +/// Validation is enforced inline before the dialog can be submitted, so the +/// caller only ever receives a well-formed [KcBugReportDraft]. +/// +/// Returns the draft on submit, or `null` when cancelled. +/// +/// ```dart +/// final draft = await KcBugReportDialog.show(context); +/// if (draft != null) { +/// await service.submit( +/// title: draft.title, +/// description: draft.description, +/// severity: KcBugSeverity.fromString(draft.severityName), +/// ); +/// } +/// ``` +class KcBugReportDialog extends StatefulWidget { + /// The screen or workflow the operator is reporting from, shown as context. + final String? screen; + + /// Severity options offered, as `(name, label)` pairs. + final List<({String name, String label})> severities; + + const KcBugReportDialog({ + super.key, + this.screen, + this.severities = const [ + (name: 'low', label: 'Low'), + (name: 'medium', label: 'Medium'), + (name: 'high', label: 'High'), + (name: 'critical', label: 'Critical'), + ], + }); + + /// Shows the dialog and resolves to the submitted draft, or `null` if the + /// operator cancelled. + static Future show(BuildContext context, {String? screen}) { + return showDialog( + context: context, + builder: (_) => KcBugReportDialog(screen: screen), + ); + } + + @override + State createState() => _KcBugReportDialogState(); +} + +class _KcBugReportDialogState extends State { + 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( + 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')), + ], + ); + } +} diff --git a/kell_creations_apps/packages/design_system/test/kc_bug_report_dialog_test.dart b/kell_creations_apps/packages/design_system/test/kc_bug_report_dialog_test.dart new file mode 100644 index 0000000..6714693 --- /dev/null +++ b/kell_creations_apps/packages/design_system/test/kc_bug_report_dialog_test.dart @@ -0,0 +1,168 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps a host exposing a button that opens [KcBugReportDialog]. +/// +/// [onResult] receives the resolved draft (or `null` when cancelled). +Future _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 _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)); + 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); + }); + }); +} diff --git a/mkdocs.yml b/mkdocs.yml index b55cfdc..ead626e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Operations Overview: operations/index.md - CI/CD Workflow: operations/cicd-workflow.md - Architecture Workflow: operations/architecture-workflow.md + - Release Checklist: operations/release-checklist.md - Standards: - Standards Overview: "standards/index.md" - Integrations: