jettypod 4.4.98 → 4.4.100

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.
@@ -0,0 +1,205 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef } from 'react';
4
+
5
+ export interface ClaudeMessage {
6
+ type: 'text' | 'tool_use' | 'tool_result' | 'error' | 'done' | 'assistant';
7
+ content?: string;
8
+ tool_name?: string;
9
+ tool_input?: Record<string, unknown>;
10
+ result?: string;
11
+ exitCode?: number;
12
+ timestamp: number;
13
+ }
14
+
15
+ export type ClaudeStreamStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
16
+
17
+ interface UseClaudeStreamOptions {
18
+ workItemId: string;
19
+ title?: string;
20
+ description?: string;
21
+ type?: string;
22
+ }
23
+
24
+ interface UseClaudeStreamReturn {
25
+ messages: ClaudeMessage[];
26
+ status: ClaudeStreamStatus;
27
+ error: string | null;
28
+ exitCode: number | null;
29
+ canRetry: boolean;
30
+ start: () => void;
31
+ stop: () => void;
32
+ clear: () => void;
33
+ retry: () => void;
34
+ }
35
+
36
+ export function useClaudeStream({
37
+ workItemId,
38
+ title,
39
+ description,
40
+ type,
41
+ }: UseClaudeStreamOptions): UseClaudeStreamReturn {
42
+ const [messages, setMessages] = useState<ClaudeMessage[]>([]);
43
+ const [status, setStatus] = useState<ClaudeStreamStatus>('idle');
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [exitCode, setExitCode] = useState<number | null>(null);
46
+ const [canRetry, setCanRetry] = useState(false);
47
+ const abortControllerRef = useRef<AbortController | null>(null);
48
+
49
+ const start = useCallback(async () => {
50
+ // Clean up any existing connection
51
+ if (abortControllerRef.current) {
52
+ abortControllerRef.current.abort();
53
+ }
54
+
55
+ setStatus('connecting');
56
+ setMessages([]);
57
+ setError(null);
58
+ setExitCode(null);
59
+ setCanRetry(false);
60
+
61
+ const controller = new AbortController();
62
+ abortControllerRef.current = controller;
63
+
64
+ try {
65
+ const response = await fetch(`/api/claude/${workItemId}`, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ },
70
+ body: JSON.stringify({ title, description, type }),
71
+ signal: controller.signal,
72
+ });
73
+
74
+ if (!response.ok) {
75
+ // Try to parse JSON error response from API
76
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
77
+ try {
78
+ const errorData = await response.json();
79
+ if (errorData.message) {
80
+ errorMessage = errorData.message;
81
+ }
82
+ } catch {
83
+ // Response wasn't JSON, use default message
84
+ }
85
+
86
+ // Determine if retryable based on status code
87
+ const retryable = response.status >= 500 || response.status === 503;
88
+ setCanRetry(retryable);
89
+ throw new Error(errorMessage);
90
+ }
91
+
92
+ if (!response.body) {
93
+ throw new Error('Response body is null');
94
+ }
95
+
96
+ setStatus('streaming');
97
+
98
+ const reader = response.body.getReader();
99
+ const decoder = new TextDecoder();
100
+ let buffer = '';
101
+
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+
105
+ if (done) {
106
+ setStatus('done');
107
+ break;
108
+ }
109
+
110
+ buffer += decoder.decode(value, { stream: true });
111
+
112
+ // Process SSE data lines
113
+ const lines = buffer.split('\n');
114
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
115
+
116
+ for (const line of lines) {
117
+ if (line.startsWith('data: ')) {
118
+ const data = line.slice(6);
119
+ try {
120
+ const parsed = JSON.parse(data);
121
+ const message: ClaudeMessage = {
122
+ ...parsed,
123
+ timestamp: Date.now(),
124
+ };
125
+
126
+ setMessages((prev) => [...prev, message]);
127
+
128
+ if (parsed.type === 'done') {
129
+ if (parsed.exitCode !== undefined && parsed.exitCode !== 0) {
130
+ // Process crashed with non-zero exit
131
+ setStatus('error');
132
+ setExitCode(parsed.exitCode);
133
+ setError(`Process exited with code ${parsed.exitCode}`);
134
+ setCanRetry(true);
135
+ } else {
136
+ setStatus('done');
137
+ }
138
+ } else if (parsed.type === 'error') {
139
+ setStatus('error');
140
+ setError(parsed.content || 'Unknown error');
141
+ setCanRetry(true);
142
+ }
143
+ } catch {
144
+ // Non-JSON line, skip
145
+ }
146
+ }
147
+ }
148
+ }
149
+ } catch (err) {
150
+ if (err instanceof Error && err.name === 'AbortError') {
151
+ // User cancelled, don't set error status
152
+ setStatus('idle');
153
+ } else {
154
+ setStatus('error');
155
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
156
+
157
+ // Detect connection lost vs other errors
158
+ const isNetworkError = err instanceof TypeError ||
159
+ (err instanceof Error && (
160
+ err.message.includes('network') ||
161
+ err.message.includes('fetch') ||
162
+ err.message.includes('Failed to fetch')
163
+ ));
164
+
165
+ if (isNetworkError) {
166
+ setError('Connection lost');
167
+ } else {
168
+ setError(errorMessage);
169
+ }
170
+ setCanRetry(true);
171
+
172
+ setMessages((prev) => [
173
+ ...prev,
174
+ {
175
+ type: 'error',
176
+ content: errorMessage,
177
+ timestamp: Date.now(),
178
+ },
179
+ ]);
180
+ }
181
+ }
182
+ }, [workItemId, title, description, type]);
183
+
184
+ const stop = useCallback(() => {
185
+ if (abortControllerRef.current) {
186
+ abortControllerRef.current.abort();
187
+ abortControllerRef.current = null;
188
+ }
189
+ setStatus('idle');
190
+ }, []);
191
+
192
+ const clear = useCallback(() => {
193
+ setMessages([]);
194
+ setStatus('idle');
195
+ setError(null);
196
+ setExitCode(null);
197
+ setCanRetry(false);
198
+ }, []);
199
+
200
+ const retry = useCallback(() => {
201
+ start();
202
+ }, [start]);
203
+
204
+ return { messages, status, error, exitCode, canRetry, start, stop, clear, retry };
205
+ }
@@ -0,0 +1,201 @@
1
+ // Test data layer for the test dashboard
2
+ // Reads BDD test results and feature file structure
3
+
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ import { execSync } from 'child_process';
7
+
8
+ // Types for test data structure
9
+ export interface TestScenario {
10
+ id: string;
11
+ title: string;
12
+ status: 'pass' | 'fail' | 'pending';
13
+ duration: string;
14
+ error?: string;
15
+ failedStep?: string;
16
+ }
17
+
18
+ export interface TestFeature {
19
+ id: string;
20
+ title: string;
21
+ featureFile: string;
22
+ scenarios: TestScenario[];
23
+ }
24
+
25
+ export interface TestEpic {
26
+ id: string;
27
+ title: string;
28
+ healthBadge: { passing: number; failing: number };
29
+ features: TestFeature[];
30
+ }
31
+
32
+ export interface TestSummary {
33
+ total: number;
34
+ passing: number;
35
+ failing: number;
36
+ pending: number;
37
+ lastRun: string | null;
38
+ }
39
+
40
+ export interface TestDashboardData {
41
+ summary: TestSummary;
42
+ epics: TestEpic[];
43
+ standaloneFeatures: TestFeature[];
44
+ }
45
+
46
+ function getProjectRoot(): string {
47
+ if (process.env.JETTYPOD_PROJECT_PATH) {
48
+ return process.env.JETTYPOD_PROJECT_PATH;
49
+ }
50
+ try {
51
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
52
+ } catch {
53
+ throw new Error('Not in a git repository and JETTYPOD_PROJECT_PATH not set');
54
+ }
55
+ }
56
+
57
+ // Parse a .feature file to extract scenarios
58
+ function parseFeatureFile(filePath: string): { title: string; scenarios: string[] } {
59
+ const content = fs.readFileSync(filePath, 'utf-8');
60
+ const lines = content.split('\n');
61
+
62
+ let title = '';
63
+ const scenarios: string[] = [];
64
+
65
+ for (const line of lines) {
66
+ const trimmed = line.trim();
67
+ if (trimmed.startsWith('Feature:')) {
68
+ title = trimmed.replace('Feature:', '').trim();
69
+ } else if (trimmed.startsWith('Scenario:')) {
70
+ scenarios.push(trimmed.replace('Scenario:', '').trim());
71
+ }
72
+ }
73
+
74
+ return { title, scenarios };
75
+ }
76
+
77
+ // Try to read Cucumber JSON results if available
78
+ function getCucumberResults(projectRoot: string): Map<string, { status: string; duration: number; error?: string }> | null {
79
+ const resultsPath = path.join(projectRoot, 'cucumber-results.json');
80
+ if (!fs.existsSync(resultsPath)) {
81
+ return null;
82
+ }
83
+
84
+ try {
85
+ const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
86
+ const scenarioResults = new Map<string, { status: string; duration: number; error?: string }>();
87
+
88
+ for (const feature of results) {
89
+ for (const element of feature.elements || []) {
90
+ if (element.type === 'scenario') {
91
+ const steps = element.steps || [];
92
+ const failed = steps.some((s: { result?: { status: string } }) => s.result?.status === 'failed');
93
+ const pending = steps.some((s: { result?: { status: string } }) => s.result?.status === 'pending' || s.result?.status === 'undefined');
94
+ const totalDuration = steps.reduce((sum: number, s: { result?: { duration: number } }) => sum + (s.result?.duration || 0), 0);
95
+
96
+ let error: string | undefined;
97
+ if (failed) {
98
+ const failedStep = steps.find((s: { result?: { status: string; error_message?: string } }) => s.result?.status === 'failed');
99
+ error = failedStep?.result?.error_message;
100
+ }
101
+
102
+ scenarioResults.set(element.name, {
103
+ status: failed ? 'failed' : pending ? 'pending' : 'passed',
104
+ duration: totalDuration / 1000000, // Convert nanoseconds to milliseconds
105
+ error,
106
+ });
107
+ }
108
+ }
109
+ }
110
+
111
+ return scenarioResults;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ // Get all feature files from the project
118
+ function getFeatureFiles(projectRoot: string): string[] {
119
+ const featuresDir = path.join(projectRoot, 'features');
120
+ if (!fs.existsSync(featuresDir)) {
121
+ return [];
122
+ }
123
+
124
+ const files: string[] = [];
125
+ const entries = fs.readdirSync(featuresDir, { withFileTypes: true });
126
+
127
+ for (const entry of entries) {
128
+ if (entry.isFile() && entry.name.endsWith('.feature')) {
129
+ files.push(path.join(featuresDir, entry.name));
130
+ }
131
+ }
132
+
133
+ return files;
134
+ }
135
+
136
+ // Main function to get test dashboard data
137
+ export function getTestDashboardData(): TestDashboardData {
138
+ const projectRoot = getProjectRoot();
139
+ const featureFiles = getFeatureFiles(projectRoot);
140
+ const cucumberResults = getCucumberResults(projectRoot);
141
+
142
+ const features: TestFeature[] = [];
143
+ let totalTests = 0;
144
+ let passingTests = 0;
145
+ let failingTests = 0;
146
+ let pendingTests = 0;
147
+
148
+ for (const filePath of featureFiles) {
149
+ const { title, scenarios: scenarioNames } = parseFeatureFile(filePath);
150
+ const featureId = path.basename(filePath, '.feature');
151
+
152
+ const scenarios: TestScenario[] = scenarioNames.map((name, index) => {
153
+ const result = cucumberResults?.get(name);
154
+ let status: 'pass' | 'fail' | 'pending' = 'pending';
155
+ let duration = '0s';
156
+ let error: string | undefined;
157
+
158
+ if (result) {
159
+ status = result.status === 'passed' ? 'pass' : result.status === 'failed' ? 'fail' : 'pending';
160
+ duration = result.duration < 1000 ? `${Math.round(result.duration)}ms` : `${(result.duration / 1000).toFixed(1)}s`;
161
+ error = result.error;
162
+ }
163
+
164
+ totalTests++;
165
+ if (status === 'pass') passingTests++;
166
+ else if (status === 'fail') failingTests++;
167
+ else pendingTests++;
168
+
169
+ return {
170
+ id: `${featureId}-${index}`,
171
+ title: name,
172
+ status,
173
+ duration,
174
+ error,
175
+ };
176
+ });
177
+
178
+ features.push({
179
+ id: featureId,
180
+ title,
181
+ featureFile: path.relative(projectRoot, filePath),
182
+ scenarios,
183
+ });
184
+ }
185
+
186
+ // For now, all features are standalone (no epic grouping from test results)
187
+ // Epic grouping would require mapping features to work items
188
+ const summary: TestSummary = {
189
+ total: totalTests,
190
+ passing: passingTests,
191
+ failing: failingTests,
192
+ pending: pendingTests,
193
+ lastRun: cucumberResults ? new Date().toISOString() : null,
194
+ };
195
+
196
+ return {
197
+ summary,
198
+ epics: [], // Epic grouping can be added later by mapping to work items
199
+ standaloneFeatures: features,
200
+ };
201
+ }
@@ -1,7 +1,37 @@
1
1
  import type { NextConfig } from "next";
2
+ import path from "path";
3
+
4
+ // Dashboard is at apps/dashboard, jettypod lib is at ../../lib relative to that
5
+ // process.cwd() is the dashboard directory during build
6
+ const jettypodLibPath = path.resolve(process.cwd(), '../../lib');
2
7
 
3
8
  const nextConfig: NextConfig = {
4
- /* config options here */
9
+ // Externalize modules with native bindings or dynamic requires
10
+ serverExternalPackages: ['better-sqlite3'],
11
+
12
+ // Set monorepo root for correct file tracing
13
+ outputFileTracingRoot: path.resolve(process.cwd(), '../..'),
14
+
15
+ // Acknowledge we're using webpack config (required for Next.js 16+)
16
+ turbopack: {},
17
+
18
+ webpack: (config, { isServer }) => {
19
+ if (isServer) {
20
+ // Externalize the jettypod lib using absolute path resolution
21
+ config.externals = config.externals || [];
22
+ config.externals.push(({ request }: { request: string }, callback: (err: Error | null | undefined, result?: string) => void) => {
23
+ // Externalize any require that goes to jettypod lib (dynamic requires)
24
+ if (request && request.includes('lib/worktree-facade')) {
25
+ // Use absolute path to jettypod lib
26
+ const moduleName = request.split('lib/')[1];
27
+ const absolutePath = path.join(jettypodLibPath, moduleName);
28
+ return callback(null, `commonjs ${absolutePath}`);
29
+ }
30
+ callback(undefined);
31
+ });
32
+ }
33
+ return config;
34
+ },
5
35
  };
6
36
 
7
37
  export default nextConfig;
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",
7
- "build": "next build",
7
+ "build": "next build --webpack",
8
8
  "start": "next start",
9
9
  "lint": "eslint"
10
10
  },