jettypod 4.4.120 → 4.4.121

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 (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,11 +1,13 @@
1
- 'use client';
2
1
 
3
2
  import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
4
3
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
5
4
  import { useSessionActions } from '../contexts/ClaudeSessionContext';
6
- import type { TestDashboardData, TestFeature } from '@/lib/tests';
5
+ import type { TestDashboardData, TestFeature } from '@/lib/db';
7
6
  import { Input } from '@/components/ui/Input';
8
7
  import { Button } from '@/components/ui/Button';
8
+ import { dataBridge } from '@/lib/data-bridge';
9
+ import { invoke, listen } from '@/lib/tauri';
10
+ import { getWebSocketUrl } from '@/lib/utils';
9
11
 
10
12
  interface RealTimeTestsWrapperProps {
11
13
  initialData: TestDashboardData;
@@ -43,7 +45,6 @@ type StepStatus = 'pending' | 'running' | 'passed' | 'failed';
43
45
  export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps) {
44
46
  const [data, setData] = useState<TestDashboardData>(initialData);
45
47
  const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(() => {
46
- if (typeof window === 'undefined') return null;
47
48
  return localStorage.getItem(SELECTED_FEATURE_KEY);
48
49
  });
49
50
  const [searchText, setSearchText] = useState('');
@@ -55,27 +56,35 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
55
56
  const [elapsedDisplay, setElapsedDisplay] = useState<Record<string, number>>({});
56
57
 
57
58
  const elapsedStartRef = useRef<Record<string, number>>({});
59
+ const activeProcessesRef = useRef<Set<number>>(new Set());
60
+ const unlistenersRef = useRef<(() => void)[]>([]);
58
61
  const allFeaturesRef = useRef<TestFeature[]>([]);
62
+ const projectRootRef = useRef<string | null>(null);
59
63
  const { createFixScenarioSession } = useSessionActions();
60
64
 
61
- const refreshUndefinedSteps = useCallback(() => {
62
- fetch('/api/tests/undefined')
63
- .then(res => res.json())
64
- .then(setUndefinedStepsMap)
65
- .catch(() => {});
65
+ // Get project root on mount (needed for spawn_process cwd)
66
+ useEffect(() => {
67
+ dataBridge.getProjectRoot().then(root => {
68
+ projectRootRef.current = root;
69
+ });
66
70
  }, []);
67
71
 
68
- // Fetch undefined steps asynchronously (slow operation, don't block page load)
72
+ // Kill active test processes and remove event listeners on unmount
69
73
  useEffect(() => {
70
- refreshUndefinedSteps();
71
- }, [refreshUndefinedSteps]);
74
+ return () => {
75
+ for (const pid of activeProcessesRef.current) {
76
+ invoke('kill_process', { pid }).catch(() => {});
77
+ }
78
+ activeProcessesRef.current.clear();
79
+ for (const unlisten of unlistenersRef.current) unlisten();
80
+ unlistenersRef.current = [];
81
+ };
82
+ }, []);
72
83
 
73
84
  const refreshData = useCallback(async () => {
74
- const response = await fetch('/api/tests');
75
- const newData = await response.json();
85
+ const newData = await dataBridge.getTestDashboardData();
76
86
  setData(newData);
77
- refreshUndefinedSteps();
78
- }, [refreshUndefinedSteps]);
87
+ }, []);
79
88
 
80
89
  const handleMessage = useCallback(async (message: WebSocketMessage) => {
81
90
  if (message.type === 'test_change') {
@@ -83,8 +92,10 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
83
92
  }
84
93
  }, [refreshData]);
85
94
 
86
- // Elapsed timer tick
95
+ // Elapsed timer tick — only runs when scenarios are actively running
96
+ const hasRunning = runningScenarios.size > 0;
87
97
  useEffect(() => {
98
+ if (!hasRunning) return;
88
99
  const interval = setInterval(() => {
89
100
  const starts = elapsedStartRef.current;
90
101
  if (Object.keys(starts).length === 0) return;
@@ -96,7 +107,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
96
107
  );
97
108
  }, 1000);
98
109
  return () => clearInterval(interval);
99
- }, []);
110
+ }, [hasRunning]);
100
111
 
101
112
  const runScenario = useCallback(async (featureFile: string, scenarioTitle: string) => {
102
113
  const key = `${featureFile}::${scenarioTitle}`;
@@ -116,155 +127,250 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
116
127
  elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
117
128
  setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
118
129
 
119
- return new Promise<void>((resolve) => {
120
- const params = new URLSearchParams({ featureFile, scenarioTitle });
121
- const eventSource = new EventSource(`/api/tests/run/stream?${params}`);
122
-
123
- const steps = scenario?.steps || [];
124
-
125
- eventSource.addEventListener('step_start', (e: MessageEvent) => {
126
- const eventData = JSON.parse(e.data);
127
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
128
- if (stepIndex >= 0) {
129
- setStepProgress(prev => {
130
- const arr = [...(prev[key] || [])];
131
- arr[stepIndex] = 'running';
132
- return { ...prev, [key]: arr };
133
- });
130
+ const steps = scenario?.steps || [];
131
+
132
+ // Cucumber NDJSON message parsing state
133
+ const pickleSteps = new Map<string, { text: string }>();
134
+ const testCaseSteps = new Map<string, { id: string; pickleStepId?: string }[]>();
135
+ let activeTestCaseId: string | null = null;
136
+ let outputBuffer = '';
137
+
138
+ function resolveStepText(testStepId: string): string | null {
139
+ if (!activeTestCaseId) return null;
140
+ const tcSteps = testCaseSteps.get(activeTestCaseId);
141
+ if (!tcSteps) return null;
142
+ const testStep = tcSteps.find(s => s.id === testStepId);
143
+ if (!testStep?.pickleStepId) return null;
144
+ const ps = pickleSteps.get(testStep.pickleStepId);
145
+ return ps ? ps.text : null;
146
+ }
147
+
148
+ function processLine(line: string) {
149
+ const trimmed = line.trim();
150
+ if (!trimmed || !trimmed.startsWith('{')) return;
151
+ let msg: Record<string, any>;
152
+ try { msg = JSON.parse(trimmed); } catch { return; }
153
+
154
+ if (msg.pickle) {
155
+ for (const s of msg.pickle.steps) pickleSteps.set(s.id, s);
156
+ }
157
+ if (msg.testCase) {
158
+ testCaseSteps.set(msg.testCase.id, msg.testCase.testSteps);
159
+ }
160
+ if (msg.testCaseStarted) {
161
+ activeTestCaseId = msg.testCaseStarted.testCaseId;
162
+ }
163
+ if (msg.testStepStarted) {
164
+ const stepText = resolveStepText(msg.testStepStarted.testStepId);
165
+ if (stepText) {
166
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
167
+ if (stepIndex >= 0) {
168
+ setStepProgress(prev => {
169
+ const arr = [...(prev[key] || [])];
170
+ arr[stepIndex] = 'running';
171
+ return { ...prev, [key]: arr };
172
+ });
173
+ }
134
174
  }
135
- });
136
-
137
- eventSource.addEventListener('step_end', (e: MessageEvent) => {
138
- const eventData = JSON.parse(e.data);
139
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
140
- if (stepIndex >= 0) {
141
- setStepProgress(prev => {
142
- const arr = [...(prev[key] || [])];
143
- arr[stepIndex] = eventData.result === 'passed' ? 'passed' : 'failed';
144
- return { ...prev, [key]: arr };
145
- });
175
+ }
176
+ if (msg.testStepFinished) {
177
+ const stepText = resolveStepText(msg.testStepFinished.testStepId);
178
+ if (stepText) {
179
+ const status = msg.testStepFinished.testStepResult.status.toLowerCase();
180
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
181
+ if (stepIndex >= 0) {
182
+ setStepProgress(prev => {
183
+ const arr = [...(prev[key] || [])];
184
+ arr[stepIndex] = status === 'passed' ? 'passed' : 'failed';
185
+ return { ...prev, [key]: arr };
186
+ });
187
+ }
146
188
  }
147
- });
189
+ }
190
+ }
148
191
 
192
+ return new Promise<void>(async (resolve) => {
149
193
  function cleanup() {
150
- eventSource.close();
151
- // Stop timer
152
194
  const { [key]: _, ...restStarts } = elapsedStartRef.current;
153
195
  elapsedStartRef.current = restStarts;
154
- setElapsedDisplay(prev => {
155
- const { [key]: _, ...rest } = prev;
156
- return rest;
157
- });
158
- // Clear step progress
159
- setStepProgress(prev => {
160
- const { [key]: _, ...rest } = prev;
161
- return rest;
162
- });
163
- setRunningScenarios(prev => {
164
- const next = new Set(prev);
165
- next.delete(key);
166
- return next;
167
- });
196
+ setElapsedDisplay(prev => { const { [key]: _, ...rest } = prev; return rest; });
197
+ setStepProgress(prev => { const { [key]: _, ...rest } = prev; return rest; });
198
+ setRunningScenarios(prev => { const next = new Set(prev); next.delete(key); return next; });
168
199
  }
169
200
 
170
- eventSource.addEventListener('test_complete', async () => {
171
- cleanup();
172
- await refreshData();
173
- resolve();
174
- });
175
-
176
- eventSource.onerror = async () => {
201
+ try {
202
+ const escapedTitle = scenarioTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
203
+ const resultsPath = projectRootRef.current
204
+ ? `${projectRootRef.current}/cucumber-results.json`
205
+ : 'cucumber-results.json';
206
+
207
+ // Spawn cucumber-js first, then set up listeners
208
+ const pid = await invoke<number>('spawn_process', {
209
+ command: 'npx',
210
+ args: [
211
+ 'cucumber-js', featureFile,
212
+ '--name', `^${escapedTitle}$`,
213
+ '--format', 'message',
214
+ '--format', `json:${resultsPath}`,
215
+ ],
216
+ label: 'test-runner',
217
+ kind: null,
218
+ cwd: projectRootRef.current,
219
+ env: null,
220
+ });
221
+ activeProcessesRef.current.add(pid);
222
+
223
+ // Listen for stdout lines to parse NDJSON
224
+ const outputUnlisten = await listen<{ pid: number; lines: string[] }>(
225
+ 'process-output-batch',
226
+ (event) => {
227
+ if (event.payload.pid !== pid) return;
228
+ for (const line of event.payload.lines) {
229
+ outputBuffer += line + '\n';
230
+ const bufLines = outputBuffer.split('\n');
231
+ outputBuffer = bufLines.pop() || '';
232
+ for (const l of bufLines) processLine(l);
233
+ }
234
+ },
235
+ );
236
+ unlistenersRef.current.push(outputUnlisten);
237
+
238
+ // Listen for process exit
239
+ const exitUnlisten = await listen<{ pid: number; code: number | null }>(
240
+ 'process-exit',
241
+ async (event) => {
242
+ if (event.payload.pid !== pid) return;
243
+ activeProcessesRef.current.delete(pid);
244
+ // Process remaining buffer
245
+ if (outputBuffer.trim()) processLine(outputBuffer);
246
+ // Ingest results into SQLite and refresh
247
+ try { await dataBridge.ingestTestResults(); } catch { /* best effort */ }
248
+ cleanup();
249
+ await refreshData();
250
+ resolve();
251
+ },
252
+ );
253
+ unlistenersRef.current.push(exitUnlisten);
254
+ } catch {
177
255
  cleanup();
178
256
  await refreshData();
179
257
  resolve();
180
- };
258
+ }
181
259
  });
182
260
  }, [refreshData]);
183
261
 
184
262
  const runAllScenarios = useCallback(async (feature: TestFeature) => {
185
263
  setRunningAll(true);
186
264
 
187
- return new Promise<void>((resolve) => {
188
- const params = new URLSearchParams({ featureFile: feature.featureFile });
189
- const eventSource = new EventSource(`/api/tests/run/stream?${params}`);
190
-
191
- // Track which scenario is currently active
192
- let activeScenarioKey: string | null = null;
193
-
194
- eventSource.addEventListener('scenario_start', (e: MessageEvent) => {
195
- const { scenario } = JSON.parse(e.data);
196
- const key = `${feature.featureFile}::${scenario}`;
197
- activeScenarioKey = key;
198
-
199
- // Find matching scenario for step count
200
- const matchedScenario = feature.scenarios.find(s => s.title === scenario);
201
- setRunningScenarios(prev => new Set(prev).add(key));
202
- if (matchedScenario?.steps?.length) {
203
- setStepProgress(prev => ({
204
- ...prev,
205
- [key]: matchedScenario.steps.map(() => 'pending' as StepStatus),
206
- }));
265
+ // Cucumber NDJSON message parsing state
266
+ const pickleSteps = new Map<string, { text: string }>();
267
+ const pickleNames = new Map<string, string>();
268
+ const testCaseSteps = new Map<string, { id: string; pickleStepId?: string }[]>();
269
+ const testCasePickleIds = new Map<string, string>();
270
+ const testCaseStartedIds = new Map<string, string>();
271
+ let activeTestCaseId: string | null = null;
272
+ let outputBuffer = '';
273
+
274
+ function getScenarioName(testCaseId: string): string | null {
275
+ const pickleId = testCasePickleIds.get(testCaseId);
276
+ return pickleId ? pickleNames.get(pickleId) ?? null : null;
277
+ }
278
+
279
+ function resolveStepText(testStepId: string): string | null {
280
+ if (!activeTestCaseId) return null;
281
+ const tcSteps = testCaseSteps.get(activeTestCaseId);
282
+ if (!tcSteps) return null;
283
+ const testStep = tcSteps.find(s => s.id === testStepId);
284
+ if (!testStep?.pickleStepId) return null;
285
+ const ps = pickleSteps.get(testStep.pickleStepId);
286
+ return ps ? ps.text : null;
287
+ }
288
+
289
+ function processLine(line: string) {
290
+ const trimmed = line.trim();
291
+ if (!trimmed || !trimmed.startsWith('{')) return;
292
+ let msg: Record<string, any>;
293
+ try { msg = JSON.parse(trimmed); } catch { return; }
294
+
295
+ if (msg.pickle) {
296
+ pickleNames.set(msg.pickle.id, msg.pickle.name);
297
+ for (const s of msg.pickle.steps) pickleSteps.set(s.id, s);
298
+ }
299
+ if (msg.testCase) {
300
+ testCaseSteps.set(msg.testCase.id, msg.testCase.testSteps);
301
+ testCasePickleIds.set(msg.testCase.id, msg.testCase.pickleId);
302
+ }
303
+ if (msg.testCaseStarted) {
304
+ const tcs = msg.testCaseStarted;
305
+ activeTestCaseId = tcs.testCaseId;
306
+ testCaseStartedIds.set(tcs.id, tcs.testCaseId);
307
+ const scenario = getScenarioName(tcs.testCaseId);
308
+ if (scenario) {
309
+ const key = `${feature.featureFile}::${scenario}`;
310
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
311
+ setRunningScenarios(prev => new Set(prev).add(key));
312
+ if (matchedScenario?.steps?.length) {
313
+ setStepProgress(prev => ({
314
+ ...prev,
315
+ [key]: matchedScenario.steps.map(() => 'pending' as StepStatus),
316
+ }));
317
+ }
318
+ elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
319
+ setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
207
320
  }
208
- elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
209
- setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
210
- });
211
-
212
- eventSource.addEventListener('step_start', (e: MessageEvent) => {
213
- const eventData = JSON.parse(e.data);
214
- const key = eventData.scenario ? `${feature.featureFile}::${eventData.scenario}` : activeScenarioKey;
215
- if (!key) return;
216
- const matchedScenario = feature.scenarios.find(s => s.title === (eventData.scenario || activeScenarioKey?.split('::')[1]));
217
- const steps = matchedScenario?.steps || [];
218
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
219
- if (stepIndex >= 0) {
220
- setStepProgress(prev => {
221
- const arr = [...(prev[key] || [])];
222
- arr[stepIndex] = 'running';
223
- return { ...prev, [key]: arr };
224
- });
321
+ }
322
+ if (msg.testStepStarted) {
323
+ const stepText = resolveStepText(msg.testStepStarted.testStepId);
324
+ const scenario = activeTestCaseId ? getScenarioName(activeTestCaseId) : null;
325
+ if (stepText && scenario) {
326
+ const key = `${feature.featureFile}::${scenario}`;
327
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
328
+ const steps = matchedScenario?.steps || [];
329
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
330
+ if (stepIndex >= 0) {
331
+ setStepProgress(prev => {
332
+ const arr = [...(prev[key] || [])];
333
+ arr[stepIndex] = 'running';
334
+ return { ...prev, [key]: arr };
335
+ });
336
+ }
225
337
  }
226
- });
227
-
228
- eventSource.addEventListener('step_end', (e: MessageEvent) => {
229
- const eventData = JSON.parse(e.data);
230
- const key = eventData.scenario ? `${feature.featureFile}::${eventData.scenario}` : activeScenarioKey;
231
- if (!key) return;
232
- const matchedScenario = feature.scenarios.find(s => s.title === (eventData.scenario || activeScenarioKey?.split('::')[1]));
233
- const steps = matchedScenario?.steps || [];
234
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
235
- if (stepIndex >= 0) {
236
- setStepProgress(prev => {
237
- const arr = [...(prev[key] || [])];
238
- arr[stepIndex] = eventData.result === 'passed' ? 'passed' : 'failed';
239
- return { ...prev, [key]: arr };
240
- });
338
+ }
339
+ if (msg.testStepFinished) {
340
+ const stepText = resolveStepText(msg.testStepFinished.testStepId);
341
+ const scenario = activeTestCaseId ? getScenarioName(activeTestCaseId) : null;
342
+ if (stepText && scenario) {
343
+ const key = `${feature.featureFile}::${scenario}`;
344
+ const status = msg.testStepFinished.testStepResult.status.toLowerCase();
345
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
346
+ const steps = matchedScenario?.steps || [];
347
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
348
+ if (stepIndex >= 0) {
349
+ setStepProgress(prev => {
350
+ const arr = [...(prev[key] || [])];
351
+ arr[stepIndex] = status === 'passed' ? 'passed' : 'failed';
352
+ return { ...prev, [key]: arr };
353
+ });
354
+ }
241
355
  }
242
- });
243
-
244
- eventSource.addEventListener('scenario_end', (e: MessageEvent) => {
245
- const { scenario } = JSON.parse(e.data);
246
- const key = `${feature.featureFile}::${scenario}`;
247
- // Stop timer and clean up for this scenario
248
- const { [key]: _, ...restStarts } = elapsedStartRef.current;
249
- elapsedStartRef.current = restStarts;
250
- setElapsedDisplay(prev => {
251
- const { [key]: _, ...rest } = prev;
252
- return rest;
253
- });
254
- setStepProgress(prev => {
255
- const { [key]: _, ...rest } = prev;
256
- return rest;
257
- });
258
- setRunningScenarios(prev => {
259
- const next = new Set(prev);
260
- next.delete(key);
261
- return next;
262
- });
263
- });
356
+ }
357
+ if (msg.testCaseFinished) {
358
+ const tcf = msg.testCaseFinished;
359
+ const testCaseId = testCaseStartedIds.get(tcf.testCaseStartedId);
360
+ const scenario = testCaseId ? getScenarioName(testCaseId) : null;
361
+ if (scenario) {
362
+ const key = `${feature.featureFile}::${scenario}`;
363
+ const { [key]: _, ...restStarts } = elapsedStartRef.current;
364
+ elapsedStartRef.current = restStarts;
365
+ setElapsedDisplay(prev => { const { [key]: _, ...rest } = prev; return rest; });
366
+ setStepProgress(prev => { const { [key]: _, ...rest } = prev; return rest; });
367
+ setRunningScenarios(prev => { const next = new Set(prev); next.delete(key); return next; });
368
+ }
369
+ }
370
+ }
264
371
 
372
+ return new Promise<void>(async (resolve) => {
265
373
  function cleanup() {
266
- eventSource.close();
267
- // Clean up any remaining scenario state
268
374
  for (const scenario of feature.scenarios) {
269
375
  const key = `${feature.featureFile}::${scenario.title}`;
270
376
  const { [key]: _, ...restStarts } = elapsedStartRef.current;
@@ -276,26 +382,63 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
276
382
  setRunningAll(false);
277
383
  }
278
384
 
279
- eventSource.addEventListener('test_complete', async () => {
280
- cleanup();
281
- await refreshData();
282
- resolve();
283
- });
284
-
285
- eventSource.onerror = async () => {
385
+ try {
386
+ const resultsPath = projectRootRef.current
387
+ ? `${projectRootRef.current}/cucumber-results.json`
388
+ : 'cucumber-results.json';
389
+
390
+ // Spawn cucumber-js first, then set up listeners
391
+ const pid = await invoke<number>('spawn_process', {
392
+ command: 'npx',
393
+ args: [
394
+ 'cucumber-js', feature.featureFile,
395
+ '--format', 'message',
396
+ '--format', `json:${resultsPath}`,
397
+ ],
398
+ label: 'test-runner',
399
+ kind: null,
400
+ cwd: projectRootRef.current,
401
+ env: null,
402
+ });
403
+ activeProcessesRef.current.add(pid);
404
+
405
+ const outputUnlisten = await listen<{ pid: number; lines: string[] }>(
406
+ 'process-output-batch',
407
+ (event) => {
408
+ if (event.payload.pid !== pid) return;
409
+ for (const line of event.payload.lines) {
410
+ outputBuffer += line + '\n';
411
+ const bufLines = outputBuffer.split('\n');
412
+ outputBuffer = bufLines.pop() || '';
413
+ for (const l of bufLines) processLine(l);
414
+ }
415
+ },
416
+ );
417
+ unlistenersRef.current.push(outputUnlisten);
418
+
419
+ const exitUnlisten = await listen<{ pid: number; code: number | null }>(
420
+ 'process-exit',
421
+ async (event) => {
422
+ if (event.payload.pid !== pid) return;
423
+ activeProcessesRef.current.delete(pid);
424
+ if (outputBuffer.trim()) processLine(outputBuffer);
425
+ try { await dataBridge.ingestTestResults(); } catch { /* best effort */ }
426
+ cleanup();
427
+ await refreshData();
428
+ resolve();
429
+ },
430
+ );
431
+ unlistenersRef.current.push(exitUnlisten);
432
+ } catch {
286
433
  cleanup();
287
434
  await refreshData();
288
435
  resolve();
289
- };
436
+ }
290
437
  });
291
438
  }, [refreshData]);
292
439
 
293
- const wsUrl = typeof window !== 'undefined'
294
- ? `ws://${window.location.hostname}:8080`
295
- : 'ws://localhost:8080';
296
-
297
440
  useWebSocket({
298
- url: wsUrl,
441
+ url: getWebSocketUrl(),
299
442
  onMessage: handleMessage,
300
443
  });
301
444
 
@@ -357,7 +500,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
357
500
  </div>
358
501
  <button
359
502
  onClick={() => setStatusFilter(statusFilter === 'pass' ? null : 'pass')}
360
- className={`flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg cursor-pointer transition-[color,background-color,box-shadow] duration-200 ease-out ${
503
+ className={`flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
361
504
  statusFilter === 'pass' ? 'ring-2 ring-green-500' : 'hover:bg-green-100'
362
505
  }`}
363
506
  data-testid="status-badge-passing"
@@ -369,7 +512,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
369
512
  </button>
370
513
  <button
371
514
  onClick={() => setStatusFilter(statusFilter === 'fail' ? null : 'fail')}
372
- className={`flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg cursor-pointer transition-[color,background-color,box-shadow] duration-200 ease-out ${
515
+ className={`flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
373
516
  statusFilter === 'fail' ? 'ring-2 ring-red-500' : 'hover:bg-red-100'
374
517
  }`}
375
518
  data-testid="status-badge-failing"
@@ -381,7 +524,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
381
524
  </button>
382
525
  <button
383
526
  onClick={() => setStatusFilter(statusFilter === 'pending' ? null : 'pending')}
384
- className={`flex items-center gap-2 px-4 py-2 bg-amber-50 rounded-lg cursor-pointer transition-[color,background-color,box-shadow] duration-200 ease-out ${
527
+ className={`flex items-center gap-2 px-4 py-2 bg-amber-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
385
528
  statusFilter === 'pending' ? 'ring-2 ring-amber-500' : 'hover:bg-amber-100'
386
529
  }`}
387
530
  data-testid="status-badge-pending"
@@ -1,16 +1,17 @@
1
- 'use client';
2
1
 
3
2
  import { useState } from 'react';
4
3
  import { Button } from '@/components/ui/Button';
5
4
  import { Input } from '@/components/ui/Input';
5
+ import { dataBridge } from '@/lib/data-bridge';
6
6
 
7
7
  interface ReviewFooterProps {
8
8
  workItemId: string;
9
9
  onAccepted: () => void;
10
10
  onRejected: (reason: string) => void;
11
+ onAskQuestion: () => void;
11
12
  }
12
13
 
13
- export function ReviewFooter({ workItemId, onAccepted, onRejected }: ReviewFooterProps) {
14
+ export function ReviewFooter({ workItemId, onAccepted, onRejected, onAskQuestion }: ReviewFooterProps) {
14
15
  const [showRejectInput, setShowRejectInput] = useState(false);
15
16
  const [rejectReason, setRejectReason] = useState('');
16
17
  const [isSubmitting, setIsSubmitting] = useState(false);
@@ -20,12 +21,7 @@ export function ReviewFooter({ workItemId, onAccepted, onRejected }: ReviewFoote
20
21
  setErrorMessage(null);
21
22
  setIsSubmitting(true);
22
23
  try {
23
- const res = await fetch(`/api/work/${workItemId}/status`, {
24
- method: 'PATCH',
25
- headers: { 'Content-Type': 'application/json' },
26
- body: JSON.stringify({ status: 'done' }),
27
- });
28
- if (!res.ok) throw new Error('Failed to update status');
24
+ await dataBridge.updateStatus(parseInt(workItemId, 10), 'done');
29
25
  onAccepted();
30
26
  } catch {
31
27
  setErrorMessage('Failed to accept. Please try again.');
@@ -42,12 +38,7 @@ export function ReviewFooter({ workItemId, onAccepted, onRejected }: ReviewFoote
42
38
  setErrorMessage(null);
43
39
  setIsSubmitting(true);
44
40
  try {
45
- const res = await fetch(`/api/work/${workItemId}/status`, {
46
- method: 'PATCH',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ status: 'in_progress', rejectionReason: rejectReason.trim() }),
49
- });
50
- if (!res.ok) throw new Error('Failed to update status');
41
+ await dataBridge.updateStatus(parseInt(workItemId, 10), 'in_progress', rejectReason.trim());
51
42
  const reason = rejectReason.trim();
52
43
  setIsSubmitting(false);
53
44
  setShowRejectInput(false);
@@ -93,6 +84,13 @@ export function ReviewFooter({ workItemId, onAccepted, onRejected }: ReviewFoote
93
84
  >
94
85
  Reject
95
86
  </Button>
87
+ <Button
88
+ onClick={onAskQuestion}
89
+ variant="ghost"
90
+ data-testid="review-ask-question-button"
91
+ >
92
+ Ask a question
93
+ </Button>
96
94
  </div>
97
95
  ) : (
98
96
  <div data-testid="review-reject-input-area">
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { m } from 'framer-motion';
4
3
  import { Button } from '@/components/ui/Button';