@littlebearapps/platform-admin-sdk 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +4 -7
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +206 -4
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  9. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  10. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  11. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  12. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  13. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  14. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  15. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  16. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  17. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  18. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  19. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  20. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  21. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  22. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  23. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  24. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  25. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  26. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  27. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  28. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  30. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  31. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  32. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  34. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  35. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  36. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  37. package/templates/full/dashboard/src/pages/map.astro +561 -0
  38. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  39. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  40. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  41. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  42. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  43. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  44. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  45. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  46. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  47. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  48. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  49. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  50. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  51. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  52. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  53. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  54. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  55. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  56. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  57. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  58. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  59. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  60. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  61. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  62. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  63. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  64. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  65. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  66. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  67. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  68. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  69. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  70. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  71. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  72. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  73. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  74. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  75. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  76. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  77. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  78. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  79. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  80. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  81. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  82. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  83. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  84. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  85. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  86. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  87. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  88. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  89. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  90. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  91. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  92. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  93. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  94. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  95. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  96. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  97. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  98. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  99. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  100. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  101. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  102. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  103. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  104. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  105. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  106. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  107. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  108. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  109. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  110. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  111. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  112. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  113. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  114. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  115. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  116. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  117. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  118. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  119. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  120. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  121. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  122. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  123. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  124. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  125. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  126. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  127. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  128. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  129. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  130. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  131. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  132. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  133. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  134. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  135. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  136. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  137. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  138. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  139. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  140. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  141. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  142. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  143. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  144. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  145. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  146. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  147. package/templates/shared/tests/unit/billing.test.ts +331 -0
  148. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  149. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  150. package/templates/shared/tests/unit/control.test.ts +226 -0
  151. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  152. package/templates/shared/tests/unit/economics.test.ts +365 -0
  153. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  154. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  155. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  156. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  157. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  158. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  159. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  160. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  161. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  162. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  163. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  164. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  165. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  166. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  167. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  168. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  169. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  170. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  171. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  172. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  173. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  174. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  175. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  176. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  177. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  178. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  179. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  180. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  181. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
  182. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  183. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  184. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  185. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Usage Dashboard - Mobile Responsive Tests
3
+ *
4
+ * Tests mobile-specific behaviour of the Usage Dashboard:
5
+ * - Card view data transformation
6
+ * - Mobile layout data structure
7
+ * - Touch-friendly element properties
8
+ * - Responsive breakpoint logic
9
+ *
10
+ * Note: These tests verify data and logic for mobile views.
11
+ * Visual/CSS testing would require Playwright with browser context.
12
+ *
13
+ * @module tests/e2e/usage-mobile
14
+ * @created 2026-01-05
15
+ * @task task-17.24 - Mobile responsive tests
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import {
20
+ type UnifiedResource,
21
+ type ResourceType,
22
+ type ResourceStatus,
23
+ RESOURCE_TYPE_ICONS,
24
+ RESOURCE_TYPE_LABELS,
25
+ STATUS_COLOURS,
26
+ formatCurrency,
27
+ formatDeltaPct,
28
+ getDeltaClass,
29
+ } from '../../dashboard/src/components/usage/types';
30
+ import { filterResources, sortResources } from '../../dashboard/src/components/usage/transformers';
31
+
32
+ // Test data for mobile view scenarios
33
+ const createMobileTestResources = (): UnifiedResource[] => [
34
+ {
35
+ id: 'worker-brand-copilot-api',
36
+ name: 'brand-copilot-api',
37
+ type: 'worker',
38
+ project: 'brand-copilot',
39
+ usage: { value: 100000, unit: 'requests', formatted: '100K' },
40
+ costCurrent: 5.0,
41
+ costPrior: 4.0,
42
+ costDelta: 1.0,
43
+ costDeltaPct: 25,
44
+ status: 'healthy',
45
+ },
46
+ {
47
+ id: 'd1-brand-copilot-db',
48
+ name: 'brand-copilot-db',
49
+ type: 'd1',
50
+ project: 'brand-copilot',
51
+ usage: { value: 500000, unit: 'rows read', formatted: '500K' },
52
+ costCurrent: 2.5,
53
+ costPrior: 2.0,
54
+ costDelta: 0.5,
55
+ costDeltaPct: 25,
56
+ status: 'warning',
57
+ },
58
+ {
59
+ id: 'kv-platform-cache',
60
+ name: 'platform-cache',
61
+ type: 'kv',
62
+ project: 'platform',
63
+ usage: { value: 50000, unit: 'operations', formatted: '50K' },
64
+ costCurrent: 1.0,
65
+ costPrior: 0,
66
+ costDelta: 1.0,
67
+ costDeltaPct: 'NEW' as const,
68
+ status: 'healthy',
69
+ },
70
+ {
71
+ id: 'r2-ai-logs',
72
+ name: 'brand-copilot-ai-logs',
73
+ type: 'r2',
74
+ project: 'brand-copilot',
75
+ // Uses decimal GB (1 GB = 1,000,000,000 bytes) to match Cloudflare billing
76
+ usage: { value: 1000000000, unit: 'bytes', formatted: '1.00 GB' },
77
+ costCurrent: 3.0,
78
+ costPrior: 3.5,
79
+ costDelta: -0.5,
80
+ costDeltaPct: -14.3,
81
+ status: 'healthy',
82
+ },
83
+ {
84
+ id: 'worker-critical-service',
85
+ name: 'critical-service',
86
+ type: 'worker',
87
+ project: 'platform',
88
+ usage: { value: 25000, unit: 'requests', formatted: '25K' },
89
+ costCurrent: 8.5,
90
+ costPrior: 5.0,
91
+ costDelta: 3.5,
92
+ costDeltaPct: 70,
93
+ status: 'critical',
94
+ },
95
+ ];
96
+
97
+ describe('Mobile Responsive Tests', () => {
98
+ describe('Resource Type Icons and Labels', () => {
99
+ it('provides icons for all resource types', () => {
100
+ const resourceTypes: ResourceType[] = [
101
+ 'worker',
102
+ 'd1',
103
+ 'kv',
104
+ 'r2',
105
+ 'vectorize',
106
+ 'pages',
107
+ 'queues',
108
+ 'workflows',
109
+ 'do',
110
+ 'ai-gateway',
111
+ ];
112
+
113
+ resourceTypes.forEach((type) => {
114
+ expect(RESOURCE_TYPE_ICONS[type]).toBeDefined();
115
+ expect(typeof RESOURCE_TYPE_ICONS[type]).toBe('string');
116
+ expect(RESOURCE_TYPE_ICONS[type].length).toBeGreaterThan(0);
117
+ });
118
+ });
119
+
120
+ it('provides labels for all resource types', () => {
121
+ const resourceTypes: ResourceType[] = [
122
+ 'worker',
123
+ 'd1',
124
+ 'kv',
125
+ 'r2',
126
+ 'vectorize',
127
+ 'pages',
128
+ 'queues',
129
+ 'workflows',
130
+ 'do',
131
+ 'ai-gateway',
132
+ ];
133
+
134
+ resourceTypes.forEach((type) => {
135
+ expect(RESOURCE_TYPE_LABELS[type]).toBeDefined();
136
+ expect(typeof RESOURCE_TYPE_LABELS[type]).toBe('string');
137
+ expect(RESOURCE_TYPE_LABELS[type].length).toBeGreaterThan(0);
138
+ });
139
+ });
140
+
141
+ it('labels are human-readable (not raw type names)', () => {
142
+ expect(RESOURCE_TYPE_LABELS['d1']).toBe('D1');
143
+ expect(RESOURCE_TYPE_LABELS['kv']).toBe('KV');
144
+ expect(RESOURCE_TYPE_LABELS['r2']).toBe('R2');
145
+ expect(RESOURCE_TYPE_LABELS['do']).toBe('Durable Objects');
146
+ expect(RESOURCE_TYPE_LABELS['ai-gateway']).toBe('AI Gateway');
147
+ });
148
+ });
149
+
150
+ describe('Status Colours', () => {
151
+ it('provides colours for all status levels', () => {
152
+ const statuses: ResourceStatus[] = ['healthy', 'warning', 'high', 'critical'];
153
+
154
+ statuses.forEach((status) => {
155
+ expect(STATUS_COLOURS[status]).toBeDefined();
156
+ expect(STATUS_COLOURS[status]).toMatch(/^#[0-9A-Fa-f]{6}$/);
157
+ });
158
+ });
159
+
160
+ it('uses appropriate colour severity', () => {
161
+ // Healthy should be green-ish
162
+ expect(STATUS_COLOURS.healthy).toBe('#10B981');
163
+
164
+ // Warning should be yellow/amber
165
+ expect(STATUS_COLOURS.warning).toBe('#F59E0B');
166
+
167
+ // High should be orange
168
+ expect(STATUS_COLOURS.high).toBe('#F97316');
169
+
170
+ // Critical should be red
171
+ expect(STATUS_COLOURS.critical).toBe('#EF4444');
172
+ });
173
+ });
174
+
175
+ describe('Currency Formatting for Mobile', () => {
176
+ it('formats zero as $0.00', () => {
177
+ expect(formatCurrency(0)).toBe('$0.00');
178
+ });
179
+
180
+ it('formats small amounts with < symbol', () => {
181
+ expect(formatCurrency(0.005)).toBe('< $0.01');
182
+ expect(formatCurrency(0.001)).toBe('< $0.01');
183
+ });
184
+
185
+ it('formats normal amounts with 2 decimal places', () => {
186
+ expect(formatCurrency(5)).toBe('$5.00');
187
+ expect(formatCurrency(5.5)).toBe('$5.50');
188
+ expect(formatCurrency(5.556)).toBe('$5.56');
189
+ });
190
+
191
+ it('keeps amounts compact for mobile display', () => {
192
+ // All formatted values should be reasonably short
193
+ const testAmounts = [0, 0.001, 1.5, 10.99, 100.5, 999.99];
194
+ testAmounts.forEach((amount) => {
195
+ const formatted = formatCurrency(amount);
196
+ expect(formatted.length).toBeLessThan(15);
197
+ });
198
+ });
199
+ });
200
+
201
+ describe('Delta Percentage Formatting for Mobile', () => {
202
+ it('formats NEW as readable string', () => {
203
+ expect(formatDeltaPct('NEW')).toBe('NEW');
204
+ });
205
+
206
+ it('formats null as em dash', () => {
207
+ expect(formatDeltaPct(null)).toBe('—');
208
+ });
209
+
210
+ it('formats zero without sign', () => {
211
+ expect(formatDeltaPct(0)).toBe('0%');
212
+ });
213
+
214
+ it('formats positive with plus sign', () => {
215
+ expect(formatDeltaPct(25)).toBe('+25.0%');
216
+ expect(formatDeltaPct(100)).toBe('+100.0%');
217
+ });
218
+
219
+ it('formats negative with minus sign', () => {
220
+ expect(formatDeltaPct(-15)).toBe('-15.0%');
221
+ expect(formatDeltaPct(-50)).toBe('-50.0%');
222
+ });
223
+
224
+ it('outputs compact strings for mobile', () => {
225
+ const testValues = [0, 25, -25, 100, -100, 'NEW' as const, null];
226
+ testValues.forEach((value) => {
227
+ const formatted = formatDeltaPct(value);
228
+ expect(formatted.length).toBeLessThan(10);
229
+ });
230
+ });
231
+ });
232
+
233
+ describe('Delta Class for Visual Styling', () => {
234
+ it('returns delta-new for NEW resources', () => {
235
+ expect(getDeltaClass('NEW')).toBe('delta-new');
236
+ });
237
+
238
+ it('returns delta-neutral for zero or null', () => {
239
+ expect(getDeltaClass(0)).toBe('delta-neutral');
240
+ expect(getDeltaClass(null)).toBe('delta-neutral');
241
+ });
242
+
243
+ it('returns delta-up for positive changes', () => {
244
+ expect(getDeltaClass(25)).toBe('delta-up');
245
+ expect(getDeltaClass(100)).toBe('delta-up');
246
+ expect(getDeltaClass(0.1)).toBe('delta-up');
247
+ });
248
+
249
+ it('returns delta-down for negative changes', () => {
250
+ expect(getDeltaClass(-10)).toBe('delta-down');
251
+ expect(getDeltaClass(-50)).toBe('delta-down');
252
+ expect(getDeltaClass(-0.1)).toBe('delta-down');
253
+ });
254
+ });
255
+
256
+ describe('Mobile Card Data Structure', () => {
257
+ it('all resources have required mobile card fields', () => {
258
+ const resources = createMobileTestResources();
259
+
260
+ resources.forEach((resource) => {
261
+ // Header row data
262
+ expect(resource.name).toBeDefined();
263
+ expect(typeof resource.name).toBe('string');
264
+ expect(resource.type).toBeDefined();
265
+ expect(resource.status).toBeDefined();
266
+
267
+ // Body metrics
268
+ expect(resource.project).toBeDefined();
269
+ expect(resource.usage).toBeDefined();
270
+ expect(resource.usage.formatted).toBeDefined();
271
+ expect(resource.usage.unit).toBeDefined();
272
+ expect(resource.costCurrent).toBeDefined();
273
+ expect(resource.costDeltaPct).toBeDefined();
274
+ });
275
+ });
276
+
277
+ it('resource names are not too long for mobile', () => {
278
+ const resources = createMobileTestResources();
279
+
280
+ resources.forEach((resource) => {
281
+ // Names should be reasonable length (can be truncated with ellipsis)
282
+ // but very long names could cause layout issues
283
+ expect(resource.name.length).toBeLessThan(100);
284
+ });
285
+ });
286
+
287
+ it('formatted usage values are compact', () => {
288
+ const resources = createMobileTestResources();
289
+
290
+ resources.forEach((resource) => {
291
+ // Formatted values should be human-readable and compact
292
+ expect(resource.usage.formatted.length).toBeLessThan(20);
293
+ });
294
+ });
295
+ });
296
+
297
+ describe('Mobile Sorting Behaviour', () => {
298
+ it('sorts by cost for easy comparison', () => {
299
+ const resources = createMobileTestResources();
300
+ const sorted = sortResources(resources, 'costCurrent', 'desc');
301
+
302
+ // Highest cost first
303
+ expect(sorted[0].costCurrent).toBe(8.5);
304
+ expect(sorted[sorted.length - 1].costCurrent).toBe(1.0);
305
+ });
306
+
307
+ it('sorts by status severity for quick triage', () => {
308
+ const resources = createMobileTestResources();
309
+ const sorted = sortResources(resources, 'status', 'desc');
310
+
311
+ // Critical first
312
+ expect(sorted[0].status).toBe('critical');
313
+ });
314
+
315
+ it('sorts by name alphabetically', () => {
316
+ const resources = createMobileTestResources();
317
+ const sorted = sortResources(resources, 'name', 'asc');
318
+
319
+ // Alphabetical order
320
+ for (let i = 0; i < sorted.length - 1; i++) {
321
+ expect(sorted[i].name.localeCompare(sorted[i + 1].name)).toBeLessThanOrEqual(0);
322
+ }
323
+ });
324
+
325
+ it('handles NEW in delta percentage sorting', () => {
326
+ const resources = createMobileTestResources();
327
+ const sorted = sortResources(resources, 'costDeltaPct', 'desc');
328
+
329
+ // NEW should appear first in descending order
330
+ expect(sorted[0].costDeltaPct).toBe('NEW');
331
+ });
332
+ });
333
+
334
+ describe('Mobile Filtering Behaviour', () => {
335
+ it('filters by project for focused view', () => {
336
+ const resources = createMobileTestResources();
337
+ const filtered = filterResources(resources, { project: 'brand-copilot' });
338
+
339
+ expect(filtered.length).toBe(3);
340
+ expect(filtered.every((r) => r.project === 'brand-copilot')).toBe(true);
341
+ });
342
+
343
+ it('filters by service type', () => {
344
+ const resources = createMobileTestResources();
345
+ const filtered = filterResources(resources, { serviceTypes: ['worker'] });
346
+
347
+ expect(filtered.length).toBe(2);
348
+ expect(filtered.every((r) => r.type === 'worker')).toBe(true);
349
+ });
350
+
351
+ it('filters by non-zero cost for mobile billing focus', () => {
352
+ const resources = createMobileTestResources();
353
+ const filtered = filterResources(resources, { nonZeroCost: true });
354
+
355
+ expect(filtered.length).toBe(5); // All have costs > 0
356
+ expect(filtered.every((r) => r.costCurrent > 0)).toBe(true);
357
+ });
358
+
359
+ it('filters by only changed resources', () => {
360
+ const resources = createMobileTestResources();
361
+ const filtered = filterResources(resources, { onlyChanged: true });
362
+
363
+ // Resources with > 5% change or NEW
364
+ filtered.forEach((r) => {
365
+ if (typeof r.costDeltaPct === 'number') {
366
+ expect(Math.abs(r.costDeltaPct)).toBeGreaterThan(5);
367
+ } else {
368
+ expect(r.costDeltaPct).toBe('NEW');
369
+ }
370
+ });
371
+ });
372
+
373
+ it('supports search query for quick find', () => {
374
+ const resources = createMobileTestResources();
375
+ const filtered = filterResources(resources, { searchQuery: 'brand' });
376
+
377
+ expect(filtered.length).toBe(3);
378
+ expect(filtered.every((r) => r.name.toLowerCase().includes('brand'))).toBe(true);
379
+ });
380
+ });
381
+
382
+ describe('Mobile Touch Target Requirements', () => {
383
+ // Note: These are data validation tests. CSS would need browser testing.
384
+ // The component uses tabindex=0 on rows, making them focusable/clickable.
385
+
386
+ it('resources have unique IDs for click handling', () => {
387
+ const resources = createMobileTestResources();
388
+ const ids = resources.map((r) => r.id);
389
+ const uniqueIds = new Set(ids);
390
+
391
+ expect(uniqueIds.size).toBe(ids.length);
392
+ });
393
+
394
+ it('resources have type for icon display', () => {
395
+ const resources = createMobileTestResources();
396
+
397
+ resources.forEach((resource) => {
398
+ expect(RESOURCE_TYPE_ICONS[resource.type]).toBeDefined();
399
+ });
400
+ });
401
+
402
+ it('resources have status for colour indicator', () => {
403
+ const resources = createMobileTestResources();
404
+
405
+ resources.forEach((resource) => {
406
+ expect(STATUS_COLOURS[resource.status]).toBeDefined();
407
+ });
408
+ });
409
+ });
410
+
411
+ describe('Mobile Breakpoint Data Compatibility', () => {
412
+ // Verify data works for both table and card views
413
+
414
+ it('all fields used in table are available for cards', () => {
415
+ const resources = createMobileTestResources();
416
+ const tableFields = [
417
+ 'name',
418
+ 'type',
419
+ 'project',
420
+ 'usage',
421
+ 'costCurrent',
422
+ 'costDeltaPct',
423
+ 'status',
424
+ ];
425
+
426
+ resources.forEach((resource) => {
427
+ tableFields.forEach((field) => {
428
+ expect(resource).toHaveProperty(field);
429
+ });
430
+ });
431
+ });
432
+
433
+ it('usage has all subfields for mobile display', () => {
434
+ const resources = createMobileTestResources();
435
+
436
+ resources.forEach((resource) => {
437
+ expect(resource.usage).toHaveProperty('value');
438
+ expect(resource.usage).toHaveProperty('unit');
439
+ expect(resource.usage).toHaveProperty('formatted');
440
+ });
441
+ });
442
+ });
443
+
444
+ describe('Edge Cases for Mobile Display', () => {
445
+ it('handles empty resource list', () => {
446
+ const filtered = filterResources([], {});
447
+ expect(filtered).toHaveLength(0);
448
+
449
+ const sorted = sortResources([], 'costCurrent', 'desc');
450
+ expect(sorted).toHaveLength(0);
451
+ });
452
+
453
+ it('handles single resource', () => {
454
+ const resources = createMobileTestResources().slice(0, 1);
455
+
456
+ const sorted = sortResources(resources, 'costCurrent', 'desc');
457
+ expect(sorted).toHaveLength(1);
458
+ });
459
+
460
+ it('handles resource with null delta percentage', () => {
461
+ const resource: UnifiedResource = {
462
+ id: 'test',
463
+ name: 'test-resource',
464
+ type: 'worker',
465
+ project: 'test',
466
+ usage: { value: 100, unit: 'requests', formatted: '100' },
467
+ costCurrent: 1.0,
468
+ costPrior: 0,
469
+ costDelta: 1.0,
470
+ costDeltaPct: null,
471
+ status: 'healthy',
472
+ };
473
+
474
+ expect(formatDeltaPct(resource.costDeltaPct)).toBe('—');
475
+ expect(getDeltaClass(resource.costDeltaPct)).toBe('delta-neutral');
476
+ });
477
+
478
+ it('handles very long resource names', () => {
479
+ const longName = 'this-is-a-very-long-resource-name-that-might-overflow-on-mobile-devices';
480
+ const formatted = longName; // Component truncates with CSS text-overflow: ellipsis
481
+
482
+ expect(formatted.length).toBeGreaterThan(50); // Just verify long names are allowed
483
+ });
484
+
485
+ it('handles large cost values', () => {
486
+ const largeCost = 9999.99;
487
+ const formatted = formatCurrency(largeCost);
488
+
489
+ expect(formatted).toBe('$9999.99');
490
+ expect(formatted.length).toBeLessThan(15);
491
+ });
492
+
493
+ it('handles extreme percentage changes', () => {
494
+ expect(formatDeltaPct(500)).toBe('+500.0%');
495
+ expect(formatDeltaPct(-99)).toBe('-99.0%');
496
+ expect(formatDeltaPct(1000)).toBe('+1000.0%');
497
+ });
498
+ });
499
+
500
+ describe('Accessibility Data for Mobile', () => {
501
+ it('status values are screenreader-friendly', () => {
502
+ const statuses: ResourceStatus[] = ['healthy', 'warning', 'high', 'critical'];
503
+
504
+ statuses.forEach((status) => {
505
+ // Status values should be words, not codes
506
+ expect(status.length).toBeGreaterThan(2);
507
+ expect(/^[a-z]+$/i.test(status)).toBe(true);
508
+ });
509
+ });
510
+
511
+ it('type labels are screenreader-friendly', () => {
512
+ Object.values(RESOURCE_TYPE_LABELS).forEach((label) => {
513
+ // Labels should be readable words/phrases
514
+ expect(label.length).toBeGreaterThan(0);
515
+ expect(/^[A-Za-z0-9\s]+$/.test(label)).toBe(true);
516
+ });
517
+ });
518
+
519
+ it('formatted values include context', () => {
520
+ const resources = createMobileTestResources();
521
+
522
+ resources.forEach((resource) => {
523
+ // Formatted values should be numeric-looking for numbers
524
+ expect(resource.usage.formatted).toBeDefined();
525
+
526
+ // Units provide context
527
+ expect(resource.usage.unit.length).toBeGreaterThan(0);
528
+ });
529
+ });
530
+ });
531
+ });
@@ -0,0 +1,166 @@
1
+ type StatementRecord = { sql: string; params: unknown[] };
2
+
3
+ type QueuedResult = {
4
+ type: 'first';
5
+ value: Record<string, unknown> | null;
6
+ };
7
+
8
+ type QueuedRunResult = {
9
+ type: 'run';
10
+ value: { success: boolean; results?: unknown[] };
11
+ };
12
+
13
+ type QueuedAllResult = {
14
+ type: 'all';
15
+ value: { results: unknown[] };
16
+ };
17
+
18
+ class MockPreparedStatement {
19
+ private boundParams: unknown[] = [];
20
+
21
+ constructor(
22
+ private readonly db: MockD1Database,
23
+ private readonly sql: string
24
+ ) {}
25
+
26
+ bind(...params: unknown[]): MockPreparedStatement {
27
+ this.boundParams = params;
28
+ return this;
29
+ }
30
+
31
+ async run(): Promise<{ success: boolean; results?: unknown[] }> {
32
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
33
+ const queued = this.db.dequeueResult('run');
34
+ return queued?.value ?? { success: true, results: [] };
35
+ }
36
+
37
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
38
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
39
+ const queued = this.db.dequeueResult('first');
40
+ return (queued?.value as T | null) ?? null;
41
+ }
42
+
43
+ async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
44
+ this.db.statements.push({ sql: this.sql, params: this.boundParams });
45
+ const queued = this.db.dequeueResult('all');
46
+ return (queued?.value as { results: T[] }) ?? { results: [] as T[] };
47
+ }
48
+ }
49
+
50
+ export class MockD1Database {
51
+ statements: StatementRecord[] = [];
52
+ private queuedResults: Array<QueuedResult | QueuedRunResult | QueuedAllResult> = [];
53
+
54
+ prepare(query: string): MockPreparedStatement {
55
+ return new MockPreparedStatement(this, query);
56
+ }
57
+
58
+ // These methods are required for the SDK's isD1Database type guard.
59
+ // Without them, the SDK wraps all methods in async circuit-breaker wrappers,
60
+ // which breaks the mock's synchronous dequeueResult/queueFirstResult methods.
61
+ async batch(_statements: unknown[]): Promise<unknown[]> {
62
+ return [];
63
+ }
64
+
65
+ // D1Database.exec method stub
66
+ async execBatch(_query: string): Promise<unknown> {
67
+ return { count: 0 };
68
+ }
69
+
70
+ // Alias for the SDK type guard check (expects 'exec' property)
71
+ get exec(): (_query: string) => Promise<unknown> {
72
+ return this.execBatch.bind(this);
73
+ }
74
+
75
+ queueFirstResult(value: Record<string, unknown> | null): void {
76
+ this.queuedResults.push({ type: 'first', value });
77
+ }
78
+
79
+ queueRunResult(value: { success: boolean; results?: unknown[] }): void {
80
+ this.queuedResults.push({ type: 'run', value });
81
+ }
82
+
83
+ queueAllResult(value: { results: unknown[] }): void {
84
+ this.queuedResults.push({ type: 'all', value });
85
+ }
86
+
87
+ dequeueResult(type: 'first'): QueuedResult | undefined;
88
+ dequeueResult(type: 'run'): QueuedRunResult | undefined;
89
+ dequeueResult(type: 'all'): QueuedAllResult | undefined;
90
+ dequeueResult(
91
+ type: 'first' | 'run' | 'all'
92
+ ): QueuedResult | QueuedRunResult | QueuedAllResult | undefined {
93
+ const index = this.queuedResults.findIndex((item) => item.type === type);
94
+ if (index === -1) {
95
+ return undefined;
96
+ }
97
+
98
+ return this.queuedResults.splice(index, 1)[0];
99
+ }
100
+ }
101
+
102
+ export class MockKVNamespace {
103
+ store = new Map<string, string>();
104
+
105
+ async get(_key: string): Promise<string | null> {
106
+ return null;
107
+ }
108
+
109
+ async put(key: string, value: string, _options?: unknown): Promise<void> {
110
+ this.store.set(key, value);
111
+ }
112
+
113
+ async delete(key: string): Promise<void> {
114
+ this.store.delete(key);
115
+ }
116
+
117
+ async list(): Promise<{ keys: string[]; list_complete: boolean }> {
118
+ return { keys: [], list_complete: true };
119
+ }
120
+ }
121
+
122
+ export class MockR2Bucket {
123
+ objects = new Map<string, string>();
124
+
125
+ async put(key: string, value: string): Promise<void> {
126
+ this.objects.set(key, value);
127
+ }
128
+
129
+ async delete(key: string | string[]): Promise<void> {
130
+ if (Array.isArray(key)) {
131
+ key.forEach((k) => this.objects.delete(k));
132
+ } else {
133
+ this.objects.delete(key);
134
+ }
135
+ }
136
+ }
137
+
138
+ export class MockQueue {
139
+ messages: unknown[] = [];
140
+
141
+ async send(message: unknown): Promise<void> {
142
+ this.messages.push(message);
143
+ }
144
+
145
+ async sendBatch(messages: unknown[]): Promise<void> {
146
+ this.messages.push(...messages);
147
+ }
148
+ }
149
+
150
+ export type GitHubMonitorEnv = {
151
+ PLATFORM_DB: MockD1Database;
152
+ PLATFORM_CACHE: MockKVNamespace;
153
+ PLATFORM_ARCHIVES: MockR2Bucket;
154
+ PLATFORM_TELEMETRY: MockQueue;
155
+ GITHUB_WEBHOOK_SECRET: string;
156
+ };
157
+
158
+ export function createGitHubMonitorEnv(secret = 'test-secret'): GitHubMonitorEnv {
159
+ return {
160
+ PLATFORM_DB: new MockD1Database(),
161
+ PLATFORM_CACHE: new MockKVNamespace(),
162
+ PLATFORM_ARCHIVES: new MockR2Bucket(),
163
+ PLATFORM_TELEMETRY: new MockQueue(),
164
+ GITHUB_WEBHOOK_SECRET: secret,
165
+ };
166
+ }