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.
- package/.env +2 -1
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
- package/apps/dashboard/app/demo/gates/page.tsx +3 -5
- package/apps/dashboard/app/design-system/page.tsx +1 -1
- package/apps/dashboard/app/globals.css +74 -2
- package/apps/dashboard/app/install-claude/page.tsx +3 -5
- package/apps/dashboard/app/login/page.tsx +17 -20
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +60 -12
- package/apps/dashboard/app/signup/page.tsx +14 -17
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +12 -15
- package/apps/dashboard/app/work/[id]/page.tsx +90 -75
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +70 -61
- package/apps/dashboard/components/CardMenu.tsx +0 -1
- package/apps/dashboard/components/ClaudePanel.tsx +541 -283
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/CopyableId.tsx +1 -2
- package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
- package/apps/dashboard/components/DragContext.tsx +132 -62
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +5 -6
- package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +0 -1
- package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
- package/apps/dashboard/components/EpicGroup.tsx +100 -70
- package/apps/dashboard/components/GateCard.tsx +0 -1
- package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
- package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/JettyLoader.tsx +0 -1
- package/apps/dashboard/components/KanbanBoard.tsx +319 -173
- package/apps/dashboard/components/KanbanCard.tsx +341 -107
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
- package/apps/dashboard/components/MainNav.tsx +24 -25
- package/apps/dashboard/components/MessageBlock.tsx +93 -16
- package/apps/dashboard/components/ModeStartCard.tsx +0 -1
- package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
- package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
- package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
- package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
- package/apps/dashboard/components/ReviewFooter.tsx +12 -14
- package/apps/dashboard/components/SessionList.tsx +0 -1
- package/apps/dashboard/components/SubscribeContent.tsx +40 -11
- package/apps/dashboard/components/TestTree.tsx +1 -2
- package/apps/dashboard/components/TipCard.tsx +2 -4
- package/apps/dashboard/components/Toast.tsx +0 -1
- package/apps/dashboard/components/TypeIcon.tsx +7 -8
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
- package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
- package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
- package/apps/dashboard/components/WorkItemTree.tsx +2 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
- package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
- package/apps/dashboard/components/ui/Button.tsx +1 -1
- package/apps/dashboard/components/ui/Input.tsx +1 -1
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
- package/apps/dashboard/contexts/UsageContext.tsx +62 -31
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1302
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +226 -26
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +253 -122
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +3 -3
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -33
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +167 -30
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/jettypod.js +96 -4
- package/lib/bdd-preflight.js +96 -0
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/work-commands/index.js +58 -16
- package/lib/work-tracking/index.js +108 -8
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +43 -1
- package/skills-templates/chore-mode/SKILL.md +40 -1
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +14 -0
- package/skills-templates/feature-planning/SKILL.md +90 -1
- package/skills-templates/production-mode/SKILL.md +20 -0
- package/skills-templates/simple-improvement/SKILL.md +39 -2
- package/skills-templates/speed-mode/SKILL.md +10 -15
- package/skills-templates/stable-mode/SKILL.md +47 -0
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -55
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
- package/apps/dashboard/electron/ipc-handlers.js +0 -1026
- package/apps/dashboard/electron/main.js +0 -2306
- package/apps/dashboard/electron/preload.js +0 -125
- package/apps/dashboard/electron/session-manager.js +0 -163
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/backlog-parser.ts +0 -50
- package/apps/dashboard/lib/claude-process-manager.ts +0 -529
- package/apps/dashboard/lib/db-bridge.ts +0 -283
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -66
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/bug-icon.svg +0 -9
- package/apps/dashboard/public/buoy-icon.svg +0 -9
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/in-flight-seagull.svg +0 -9
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/pier-icon.svg +0 -14
- package/apps/dashboard/public/star-icon.svg +0 -9
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/public/wrench-icon.svg +0 -9
- package/apps/dashboard/scripts/download-node.js +0 -104
- 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/
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.
|
|
65
|
-
|
|
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
|
-
//
|
|
72
|
+
// Kill active test processes and remove event listeners on unmount
|
|
69
73
|
useEffect(() => {
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
75
|
-
const newData = await response.json();
|
|
85
|
+
const newData = await dataBridge.getTestDashboardData();
|
|
76
86
|
setData(newData);
|
|
77
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const { [key]: _, ...rest } = prev;
|
|
252
|
-
return rest;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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">
|