@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.
- package/CHANGELOG.md +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- 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
|
-
}
|