@matt82198/aesop 0.1.0-beta.5 → 0.1.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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -1,180 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import { TrackerCard } from './TrackerCard';
4
- import { fixtureTrackerItems } from '../test/fixtures';
5
- import * as api from '../lib/api';
6
-
7
- vi.mock('../lib/api');
8
-
9
- afterEach(() => {
10
- vi.clearAllMocks();
11
- });
12
-
13
- describe('TrackerCard', () => {
14
- it('renders title, priority chip, and tags', () => {
15
- const item = fixtureTrackerItems[0]; // P0 in-progress item
16
- render(<TrackerCard item={item} />);
17
-
18
- expect(screen.getByText(item.title)).toBeInTheDocument();
19
- expect(screen.getByText(item.priority)).toBeInTheDocument();
20
- expect(screen.getByText('ui')).toBeInTheDocument();
21
- expect(screen.getByText('wave-14')).toBeInTheDocument();
22
- });
23
-
24
- it('shows expand/collapse button and toggles details on click', async () => {
25
- const item = fixtureTrackerItems[0];
26
- render(<TrackerCard item={item} />);
27
-
28
- const expandButton = screen.getByRole('button', { name: /expand/i });
29
- expect(expandButton).toHaveAttribute('aria-expanded', 'false');
30
-
31
- // Initially details should not be visible
32
- expect(screen.queryByText(/Created/)).not.toBeInTheDocument();
33
-
34
- fireEvent.click(expandButton);
35
- await waitFor(() => {
36
- expect(expandButton).toHaveAttribute('aria-expanded', 'true');
37
- });
38
-
39
- // Now details should be visible
40
- expect(screen.getByText(/Created/)).toBeInTheDocument();
41
- });
42
-
43
- it('displays notes in expanded state', () => {
44
- const item = fixtureTrackerItems[0];
45
- render(<TrackerCard item={item} />);
46
-
47
- // Expand
48
- fireEvent.click(screen.getByRole('button', { name: /expand/i }));
49
-
50
- expect(screen.getByText(item.notes!)).toBeInTheDocument();
51
- });
52
-
53
- it('displays created and completed timestamps when expanded', () => {
54
- const item = fixtureTrackerItems[1]; // This one has completed_at
55
- render(<TrackerCard item={item} />);
56
-
57
- fireEvent.click(screen.getByRole('button', { name: /expand/i }));
58
-
59
- const times = screen.getAllByRole('time');
60
- expect(times.length).toBeGreaterThanOrEqual(2); // created + completed
61
- });
62
-
63
- it('renders pr_link as inert text when URL is malicious (javascript:)', async () => {
64
- const item = fixtureTrackerItems[4]; // The hostile javascript: pr_link
65
- expect(item.pr_link).toBe('javascript:alert(1)');
66
-
67
- render(<TrackerCard item={item} />);
68
- fireEvent.click(screen.getByRole('button', { name: /expand/i }));
69
-
70
- await waitFor(() => {
71
- // Should render as <code class="pr-link-inert"> not as <a href>
72
- const code = screen.getByText('javascript:alert(1)');
73
- expect(code.tagName).toBe('CODE');
74
- expect(code).toHaveClass('pr-link-inert');
75
- });
76
-
77
- // Assert no <a> element exists
78
- expect(screen.queryByRole('link', { name: 'javascript:alert(1)' })).not.toBeInTheDocument();
79
- });
80
-
81
- it('renders pr_link as clickable anchor when URL is https://', async () => {
82
- const item = {
83
- ...fixtureTrackerItems[0],
84
- pr_link: 'https://github.com/matt82198/aesop/pull/113',
85
- };
86
-
87
- render(<TrackerCard item={item} />);
88
- fireEvent.click(screen.getByRole('button', { name: /expand/i }));
89
-
90
- await waitFor(() => {
91
- const link = screen.getByRole('link', { name: /github.com/ });
92
- expect(link).toHaveAttribute('href', item.pr_link);
93
- expect(link).toHaveAttribute('target', '_blank');
94
- });
95
- });
96
-
97
- it('renders action buttons for the tracker item', () => {
98
- const item = fixtureTrackerItems[0];
99
-
100
- render(<TrackerCard item={item} />);
101
-
102
- // Find all buttons
103
- const buttons = screen.getAllByRole('button');
104
- const actionButtons = buttons.filter((b) => {
105
- const text = b.textContent?.trim() || '';
106
- return text === 'Claim' || text === 'Done' || text === 'Archive';
107
- });
108
-
109
- // Should have at least 3 action buttons
110
- expect(actionButtons.length).toBeGreaterThanOrEqual(3);
111
- });
112
-
113
- it('Done button updates status to done', async () => {
114
- const item = fixtureTrackerItems[0];
115
- const mockUpdate = vi.fn();
116
- const updated = { ...item, status: 'done' as const, lane: 'done' as const };
117
-
118
- vi.mocked(api.updateTrackerItem).mockResolvedValue(updated);
119
-
120
- render(<TrackerCard item={item} onUpdate={mockUpdate} />);
121
-
122
- // Find the Done button within the actions section
123
- const buttons = screen.getAllByRole('button');
124
- const doneButton = buttons.find((b) => b.textContent === 'Done');
125
- expect(doneButton).toBeDefined();
126
-
127
- fireEvent.click(doneButton!);
128
-
129
- await waitFor(() => {
130
- expect(api.updateTrackerItem).toHaveBeenCalledWith(item.id, {
131
- lane: 'done',
132
- status: 'done',
133
- });
134
- });
135
- });
136
-
137
- it('Archive button updates status to archived', async () => {
138
- const item = fixtureTrackerItems[0];
139
- const mockUpdate = vi.fn();
140
- const updated = { ...item, status: 'archived' as const, lane: 'done' as const };
141
-
142
- vi.mocked(api.updateTrackerItem).mockResolvedValue(updated);
143
-
144
- render(<TrackerCard item={item} onUpdate={mockUpdate} />);
145
-
146
- const archiveButton = screen.getByRole('button', { name: /Archive/ });
147
- fireEvent.click(archiveButton);
148
-
149
- await waitFor(() => {
150
- expect(api.updateTrackerItem).toHaveBeenCalledWith(item.id, {
151
- status: 'archived',
152
- lane: 'done',
153
- });
154
- });
155
- });
156
-
157
- it('renders all action buttons', () => {
158
- const item = fixtureTrackerItems[0];
159
-
160
- render(<TrackerCard item={item} />);
161
-
162
- // Verify all action buttons are present
163
- const buttons = screen.getAllByRole('button');
164
- const claimButton = buttons.find((b) => b.textContent?.trim() === 'Claim');
165
- const doneButton = buttons.find((b) => b.textContent?.trim() === 'Done');
166
- const archiveButton = buttons.find((b) => b.textContent?.trim() === 'Archive');
167
-
168
- expect(claimButton).toBeDefined();
169
- expect(doneButton).toBeDefined();
170
- expect(archiveButton).toBeDefined();
171
- });
172
-
173
- it('buttons are disabled when status already matches action', () => {
174
- const item = { ...fixtureTrackerItems[0], status: 'in-progress' as const };
175
- render(<TrackerCard item={item} />);
176
-
177
- const claimButton = screen.getByRole('button', { name: /Claim/ });
178
- expect(claimButton).toBeDisabled();
179
- });
180
- });
@@ -1,160 +0,0 @@
1
- /**
2
- * TrackerCard — individual tracker item card.
3
- * Renders priority chip, tags, expandable details (notes, timestamps, sanitized pr_link).
4
- * Actions: Claim (move to in-progress), Done (move to done), Archive.
5
- * Errors announced via aria-live.
6
- */
7
-
8
- import { useState } from 'react';
9
- import { TESTIDS } from '../test/fixtures';
10
- import type { TrackerItem } from '../lib/types';
11
- import { updateTrackerItem } from '../lib/api';
12
- import { sanitizeUrl } from '../lib/sanitizeUrl';
13
-
14
- interface TrackerCardProps {
15
- item: TrackerItem;
16
- onUpdate?: (updated: TrackerItem) => void;
17
- }
18
-
19
- const PRIORITY_STYLES: Record<string, string> = {
20
- P0: '--color-status-error',
21
- P1: '--color-status-warn',
22
- P2: '--color-status-info',
23
- };
24
-
25
- export function TrackerCard({ item, onUpdate }: TrackerCardProps) {
26
- const [expanded, setExpanded] = useState(false);
27
- const [loading, setLoading] = useState(false);
28
- const [error, setError] = useState<string | null>(null);
29
-
30
- async function handleAction(action: 'claim' | 'done' | 'archive') {
31
- setLoading(true);
32
- setError(null);
33
- try {
34
- const updates: Record<string, string> = {};
35
- if (action === 'claim') {
36
- updates.lane = 'in-progress';
37
- updates.status = 'in-progress';
38
- } else if (action === 'done') {
39
- updates.lane = 'done';
40
- updates.status = 'done';
41
- } else if (action === 'archive') {
42
- updates.status = 'archived';
43
- updates.lane = 'done';
44
- }
45
- const updated = await updateTrackerItem(item.id, updates);
46
- onUpdate?.(updated);
47
- } catch (err) {
48
- setError(err instanceof Error ? err.message : 'Action failed');
49
- } finally {
50
- setLoading(false);
51
- }
52
- }
53
-
54
- const priorityColor = PRIORITY_STYLES[item.priority] || '--color-status-neutral';
55
- const sanitized = sanitizeUrl(item.pr_link);
56
-
57
- return (
58
- <div className="tracker-card" data-testid={TESTIDS.trackerCard}>
59
- <div className="tracker-card-header">
60
- <div className="tracker-card-title-row">
61
- <span
62
- className="priority-chip"
63
- style={{ backgroundColor: `var(${priorityColor})` }}
64
- >
65
- {item.priority}
66
- </span>
67
- <h3 className="tracker-card-title">{item.title}</h3>
68
- </div>
69
- <button
70
- type="button"
71
- className="expand-button"
72
- onClick={() => setExpanded(!expanded)}
73
- aria-expanded={expanded}
74
- aria-label={`${expanded ? 'Collapse' : 'Expand'} details for ${item.title}`}
75
- >
76
- {expanded ? '▼' : '▶'}
77
- </button>
78
- </div>
79
-
80
- {item.tags.length > 0 && (
81
- <div className="tracker-card-tags">
82
- {item.tags.map((tag) => (
83
- <span key={tag} className="tag">
84
- {tag}
85
- </span>
86
- ))}
87
- </div>
88
- )}
89
-
90
- {expanded && (
91
- <div className="tracker-card-details">
92
- {item.notes && (
93
- <div className="detail-section">
94
- <label className="detail-label">Notes</label>
95
- <p>{item.notes}</p>
96
- </div>
97
- )}
98
-
99
- <div className="detail-section">
100
- <label className="detail-label">Created</label>
101
- <time>{new Date(item.created_at).toLocaleString()}</time>
102
- </div>
103
-
104
- {item.completed_at && (
105
- <div className="detail-section">
106
- <label className="detail-label">Completed</label>
107
- <time>{new Date(item.completed_at).toLocaleString()}</time>
108
- </div>
109
- )}
110
-
111
- {item.pr_link && (
112
- <div className="detail-section">
113
- <label className="detail-label">PR Link</label>
114
- {sanitized ? (
115
- <a href={sanitized} target="_blank" rel="noopener noreferrer">
116
- {item.pr_link}
117
- </a>
118
- ) : (
119
- <code className="pr-link-inert">{item.pr_link}</code>
120
- )}
121
- </div>
122
- )}
123
- </div>
124
- )}
125
-
126
- <div className="tracker-card-actions">
127
- <button
128
- type="button"
129
- disabled={loading || item.status === 'in-progress'}
130
- onClick={() => handleAction('claim')}
131
- aria-label={`Claim: ${item.title}`}
132
- >
133
- Claim
134
- </button>
135
- <button
136
- type="button"
137
- disabled={loading || item.status === 'done'}
138
- onClick={() => handleAction('done')}
139
- aria-label={`Mark done: ${item.title}`}
140
- >
141
- Done
142
- </button>
143
- <button
144
- type="button"
145
- disabled={loading || item.status === 'archived'}
146
- onClick={() => handleAction('archive')}
147
- aria-label={`Archive: ${item.title}`}
148
- >
149
- Archive
150
- </button>
151
- </div>
152
-
153
- {error && (
154
- <div className="card-error" role="alert" aria-live="assertive">
155
- {error}
156
- </div>
157
- )}
158
- </div>
159
- );
160
- }
@@ -1,189 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import { TrackerForm } from './TrackerForm';
4
- import { fixtureTrackerItems } from '../test/fixtures';
5
- import * as api from '../lib/api';
6
-
7
- vi.mock('../lib/api');
8
-
9
- afterEach(() => {
10
- vi.clearAllMocks();
11
- });
12
-
13
- describe('TrackerForm', () => {
14
- it('renders form with labeled inputs', () => {
15
- render(<TrackerForm />);
16
-
17
- expect(screen.getByLabelText('Title')).toBeInTheDocument();
18
- expect(screen.getByLabelText('Priority')).toBeInTheDocument();
19
- expect(screen.getByLabelText(/Tags/)).toBeInTheDocument();
20
- expect(screen.getByLabelText('Notes')).toBeInTheDocument();
21
- });
22
-
23
- it('title field is required and form does not submit without it', async () => {
24
- const newItem = fixtureTrackerItems[0];
25
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
26
-
27
- render(<TrackerForm />);
28
-
29
- const submitButton = screen.getByRole('button', { name: /Create Item/ });
30
- fireEvent.click(submitButton);
31
-
32
- // createTrackerItem should NOT be called since title is empty
33
- await new Promise((resolve) => setTimeout(resolve, 100));
34
- expect(api.createTrackerItem).not.toHaveBeenCalled();
35
- });
36
-
37
- it('calls createTrackerItem with form data and announces success', async () => {
38
- const mockSuccess = vi.fn();
39
- const newItem = fixtureTrackerItems[0];
40
-
41
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
42
-
43
- render(<TrackerForm onSuccess={mockSuccess} />);
44
-
45
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Test item' } });
46
- fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } });
47
- fireEvent.change(screen.getByLabelText(/Tags/), { target: { value: 'tag1, tag2' } });
48
- fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Test notes' } });
49
-
50
- const submitButton = screen.getByRole('button', { name: /Create Item/ });
51
- fireEvent.click(submitButton);
52
-
53
- await waitFor(() => {
54
- expect(api.createTrackerItem).toHaveBeenCalledWith({
55
- title: 'Test item',
56
- priority: 'P0',
57
- tags: ['tag1', 'tag2'],
58
- notes: 'Test notes',
59
- });
60
- expect(mockSuccess).toHaveBeenCalled();
61
- });
62
-
63
- // Success message should be announced
64
- const successMsg = screen.getByText('Item created successfully!');
65
- expect(successMsg).toHaveAttribute('role', 'status');
66
- expect(successMsg).toHaveAttribute('aria-live', 'polite');
67
- });
68
-
69
- it('parses comma-separated tags correctly', async () => {
70
- const newItem = fixtureTrackerItems[0];
71
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
72
-
73
- render(<TrackerForm />);
74
-
75
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Test' } });
76
- fireEvent.change(screen.getByLabelText(/Tags/), { target: { value: ' ui , wave-14 , critical ' } });
77
-
78
- fireEvent.click(screen.getByRole('button', { name: /Create Item/ }));
79
-
80
- await waitFor(() => {
81
- expect(api.createTrackerItem).toHaveBeenCalledWith(
82
- expect.objectContaining({
83
- tags: ['ui', 'wave-14', 'critical'],
84
- })
85
- );
86
- });
87
- });
88
-
89
- it('trims whitespace from title and notes', async () => {
90
- const newItem = fixtureTrackerItems[0];
91
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
92
-
93
- render(<TrackerForm />);
94
-
95
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: ' Test item ' } });
96
- fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' Some notes ' } });
97
-
98
- fireEvent.click(screen.getByRole('button', { name: /Create Item/ }));
99
-
100
- await waitFor(() => {
101
- expect(api.createTrackerItem).toHaveBeenCalledWith(
102
- expect.objectContaining({
103
- title: 'Test item',
104
- notes: 'Some notes',
105
- })
106
- );
107
- });
108
- });
109
-
110
- it('clears form after successful submission', async () => {
111
- const newItem = fixtureTrackerItems[0];
112
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
113
-
114
- render(<TrackerForm />);
115
-
116
- const titleInput = screen.getByLabelText('Title') as HTMLInputElement;
117
- fireEvent.change(titleInput, { target: { value: 'Test item' } });
118
- fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } });
119
- fireEvent.change(screen.getByLabelText(/Tags/), { target: { value: 'tag1' } });
120
-
121
- fireEvent.click(screen.getByRole('button', { name: /Create Item/ }));
122
-
123
- await waitFor(() => {
124
- expect(titleInput.value).toBe('');
125
- });
126
- });
127
-
128
- it('announces errors via aria-live=assertive', async () => {
129
- vi.mocked(api.createTrackerItem).mockRejectedValue(new Error('Network error'));
130
-
131
- render(<TrackerForm />);
132
-
133
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Test' } });
134
- fireEvent.click(screen.getByRole('button', { name: /Create Item/ }));
135
-
136
- await waitFor(() => {
137
- const errorMsg = screen.getByText('Network error');
138
- expect(errorMsg).toHaveAttribute('role', 'alert');
139
- expect(errorMsg).toHaveAttribute('aria-live', 'assertive');
140
- });
141
- });
142
-
143
- it('disables inputs and button while loading', async () => {
144
- vi.mocked(api.createTrackerItem).mockImplementation(
145
- () => new Promise((resolve) => setTimeout(() => resolve(fixtureTrackerItems[0]), 100))
146
- );
147
-
148
- render(<TrackerForm />);
149
-
150
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Test' } });
151
- const submitButton = screen.getByRole('button', { name: /Create Item/ });
152
-
153
- fireEvent.click(submitButton);
154
-
155
- expect(submitButton).toBeDisabled();
156
- expect(screen.getByLabelText('Title')).toBeDisabled();
157
-
158
- await waitFor(() => {
159
- expect(submitButton).not.toBeDisabled();
160
- });
161
- });
162
-
163
- it('default priority is P1', () => {
164
- render(<TrackerForm />);
165
-
166
- const prioritySelect = screen.getByLabelText('Priority') as HTMLSelectElement;
167
- expect(prioritySelect.value).toBe('P1');
168
- });
169
-
170
- it('omits notes if empty', async () => {
171
- const newItem = fixtureTrackerItems[0];
172
- vi.mocked(api.createTrackerItem).mockResolvedValue(newItem);
173
-
174
- render(<TrackerForm />);
175
-
176
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Test' } });
177
- // Don't set notes
178
-
179
- fireEvent.click(screen.getByRole('button', { name: /Create Item/ }));
180
-
181
- await waitFor(() => {
182
- expect(api.createTrackerItem).toHaveBeenCalledWith(
183
- expect.not.objectContaining({
184
- notes: '',
185
- })
186
- );
187
- });
188
- });
189
- });
@@ -1,144 +0,0 @@
1
- /**
2
- * TrackerForm — form to create new tracker items.
3
- * Labeled inputs for title, priority, tags, notes.
4
- * Submit via api.ts with CSRF.
5
- * Validation, success/error announced via aria-live.
6
- */
7
-
8
- import { useState } from 'react';
9
- import { TESTIDS } from '../test/fixtures';
10
- import { createTrackerItem } from '../lib/api';
11
-
12
- interface TrackerFormProps {
13
- onSuccess?: () => void;
14
- }
15
-
16
- export function TrackerForm({ onSuccess }: TrackerFormProps) {
17
- const [title, setTitle] = useState('');
18
- const [priority, setPriority] = useState('P1');
19
- const [tags, setTags] = useState('');
20
- const [notes, setNotes] = useState('');
21
- const [loading, setLoading] = useState(false);
22
- const [error, setError] = useState<string | null>(null);
23
- const [success, setSuccess] = useState(false);
24
-
25
- function handleSubmit(e: React.FormEvent) {
26
- e.preventDefault();
27
- setError(null);
28
- setSuccess(false);
29
-
30
- // Validation
31
- if (!title.trim()) {
32
- setError('Title is required');
33
- return;
34
- }
35
-
36
- // If validation passes, proceed with async submit
37
- performSubmit();
38
- }
39
-
40
- async function performSubmit() {
41
-
42
- const tagArray = tags
43
- .split(',')
44
- .map((t) => t.trim())
45
- .filter((t) => t.length > 0);
46
-
47
- setLoading(true);
48
- try {
49
- await createTrackerItem({
50
- title: title.trim(),
51
- priority,
52
- tags: tagArray,
53
- notes: notes.trim() || undefined,
54
- });
55
-
56
- setSuccess(true);
57
- setTitle('');
58
- setPriority('P1');
59
- setTags('');
60
- setNotes('');
61
- onSuccess?.();
62
- } catch (err) {
63
- setError(err instanceof Error ? err.message : 'Failed to create item');
64
- } finally {
65
- setLoading(false);
66
- }
67
- }
68
-
69
- return (
70
- <form className="tracker-form" data-testid={TESTIDS.trackerForm} onSubmit={handleSubmit}>
71
- <div className="form-group">
72
- <label htmlFor="tracker-title">Title</label>
73
- <input
74
- id="tracker-title"
75
- type="text"
76
- value={title}
77
- onChange={(e) => setTitle(e.target.value)}
78
- placeholder="Enter item title"
79
- disabled={loading}
80
- required
81
- data-testid={TESTIDS.trackerFormTitle}
82
- />
83
- </div>
84
-
85
- <div className="form-group">
86
- <label htmlFor="tracker-priority">Priority</label>
87
- <select
88
- id="tracker-priority"
89
- value={priority}
90
- onChange={(e) => setPriority(e.target.value)}
91
- disabled={loading}
92
- >
93
- <option value="P0">P0 (Critical)</option>
94
- <option value="P1">P1 (High)</option>
95
- <option value="P2">P2 (Medium)</option>
96
- </select>
97
- </div>
98
-
99
- <div className="form-group">
100
- <label htmlFor="tracker-tags">Tags (comma-separated)</label>
101
- <input
102
- id="tracker-tags"
103
- type="text"
104
- value={tags}
105
- onChange={(e) => setTags(e.target.value)}
106
- placeholder="e.g., ui, wave-14, critical"
107
- disabled={loading}
108
- />
109
- </div>
110
-
111
- <div className="form-group">
112
- <label htmlFor="tracker-notes">Notes</label>
113
- <textarea
114
- id="tracker-notes"
115
- value={notes}
116
- onChange={(e) => setNotes(e.target.value)}
117
- placeholder="Optional notes"
118
- rows={3}
119
- disabled={loading}
120
- />
121
- </div>
122
-
123
- <button
124
- type="submit"
125
- disabled={loading}
126
- data-testid={TESTIDS.trackerFormSubmit}
127
- >
128
- {loading ? 'Creating...' : 'Create Item'}
129
- </button>
130
-
131
- {error && (
132
- <div className="form-error" role="alert" aria-live="assertive">
133
- {error}
134
- </div>
135
- )}
136
-
137
- {success && (
138
- <div className="form-success" role="status" aria-live="polite">
139
- Item created successfully!
140
- </div>
141
- )}
142
- </form>
143
- );
144
- }