diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js new file mode 100644 index 000000000..6184fb157 --- /dev/null +++ b/packages/shared/jest.config.js @@ -0,0 +1,31 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest/presets/default', + testEnvironment: 'jsdom', + testMatch: ['**/src/**/*.test.ts', '**/src/**/*.test.tsx'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + types: ['jest'], + jsx: 'react', + module: 'ESNext', + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + esModuleInterop: true, + }, + }, + ], + }, + transformIgnorePatterns: [ + 'node_modules/(?!(react|react-dom|@tanstack/react-query|@testing-library)/)', + ], + setupFilesAfterEnv: ['/jest.setup.cjs'], + moduleDirectories: ['node_modules', '/node_modules'], +}; \ No newline at end of file diff --git a/packages/shared/jest.setup.cjs b/packages/shared/jest.setup.cjs new file mode 100644 index 000000000..62818747c --- /dev/null +++ b/packages/shared/jest.setup.cjs @@ -0,0 +1 @@ +module.exports = require('@testing-library/jest-dom'); diff --git a/packages/shared/package.json b/packages/shared/package.json index b0f927612..7707cf428 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,14 +11,19 @@ }, "dependencies": { "@tanstack/react-query": "^5.0.0", - "@types/event-source": "^3.0.0", - "event-source": "^2.0.0", + "eventsource": "^2.0.0", "zustand": "^4.0.0" }, "devDependencies": { + "@jest/globals": "^30.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", "@types/jest": "^29.0.0", "@types/react": "^18.0.0", "jest": "^29.0.0", + "jest-environment-jsdom": "^30.4.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", "typescript": "^5.0.0" }, "peerDependencies": { diff --git a/packages/shared/src/api/client.ts b/packages/shared/src/api/client.ts new file mode 100644 index 000000000..25c0e1a79 --- /dev/null +++ b/packages/shared/src/api/client.ts @@ -0,0 +1,97 @@ +// packages/shared/src/api/client.ts +import { getPlatformBridge } from '../lib/bridge'; + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: any; + headers?: Record; +} + +export class ApiError extends Error { + constructor( + public status: number, + public statusText: string, + public body?: any + ) { + super(`API Error ${status}: ${statusText}`); + this.name = 'ApiError'; + } +} + +export class ApiClient { + private authToken?: string; + + constructor(authToken?: string) { + this.authToken = authToken; + } + + private getBaseUrl(): string { + return getPlatformBridge().getApiBaseUrl(); + } + + private getDefaultHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + + return headers; + } + + async request(path: string, options: RequestOptions = {}): Promise { + const baseUrl = this.getBaseUrl(); + const url = `${baseUrl}${path}`; + + const response = await fetch(url, { + method: options.method || 'GET', + headers: { + ...this.getDefaultHeaders(), + ...options.headers, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + const body = await response.json().catch(() => undefined); + throw new ApiError(response.status, response.statusText, body); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + async get(path: string, headers?: Record): Promise { + return this.request(path, { method: 'GET', headers }); + } + + async post(path: string, body: any, headers?: Record): Promise { + return this.request(path, { method: 'POST', body, headers }); + } + + async put(path: string, body: any, headers?: Record): Promise { + return this.request(path, { method: 'PUT', body, headers }); + } + + async delete(path: string, headers?: Record): Promise { + return this.request(path, { method: 'DELETE', headers }); + } + + async patch(path: string, body: any, headers?: Record): Promise { + return this.request(path, { method: 'PATCH', body, headers }); + } + + setAuthToken(token: string): void { + this.authToken = token; + } + + clearAuthToken(): void { + this.authToken = undefined; + } +} diff --git a/packages/shared/src/api/paths.ts b/packages/shared/src/api/paths.ts new file mode 100644 index 000000000..a9c279554 --- /dev/null +++ b/packages/shared/src/api/paths.ts @@ -0,0 +1,6 @@ +// packages/shared/src/api/paths.ts +// API path constants +export const API_PATHS = { + workspaces: '/api/v1/workspaces', + sessions: '/api/v1/sessions', +} as const; diff --git a/packages/shared/src/api/types.ts b/packages/shared/src/api/types.ts new file mode 100644 index 000000000..511a58135 --- /dev/null +++ b/packages/shared/src/api/types.ts @@ -0,0 +1,13 @@ +// packages/shared/src/api/types.ts +// Types will be generated from OpenAPI schema +// This file is a placeholder for generated types + +export interface ApiResponse { + data: T; +} + +export interface ApiErrorResponse { + error: string; + message: string; + details?: Record; +} diff --git a/packages/shared/src/hooks/useDaemonStatus.ts b/packages/shared/src/hooks/useDaemonStatus.ts new file mode 100644 index 000000000..fd58a9a27 --- /dev/null +++ b/packages/shared/src/hooks/useDaemonStatus.ts @@ -0,0 +1,36 @@ +// packages/shared/src/hooks/useDaemonStatus.ts +import { useEffect, useState } from 'react'; +import { getPlatformBridge, DaemonStatus } from '../lib/bridge'; + +export function useDaemonStatus() { + const [status, setStatus] = useState({ running: false, port: 0 }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + + const checkStatus = async () => { + try { + const bridge = getPlatformBridge(); + const currentStatus = await bridge.getDaemonStatus(); + if (mounted) { + setStatus(currentStatus); + setLoading(false); + } + } catch (error) { + if (mounted) { + setStatus({ running: false, port: 0 }); + setLoading(false); + } + } + }; + + checkStatus(); + + return () => { + mounted = false; + }; + }, []); + + return { status, loading }; +} diff --git a/packages/shared/src/hooks/useWorkspaceQuery.test.tsx b/packages/shared/src/hooks/useWorkspaceQuery.test.tsx new file mode 100644 index 000000000..59b880dab --- /dev/null +++ b/packages/shared/src/hooks/useWorkspaceQuery.test.tsx @@ -0,0 +1,56 @@ +// packages/shared/src/hooks/useWorkspaceQuery.test.tsx +import React from 'react'; +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useWorkspaceQuery } from './useWorkspaceQuery'; +import { ApiClient } from '../api/client'; +import { setPlatformBridge } from '../lib/bridge'; + +describe('useWorkspaceQuery', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + setPlatformBridge({ + getDaemonStatus: async () => ({ running: true, port: 3001 }), + startDaemon: async () => {}, + stopDaemon: async () => {}, + getApiBaseUrl: () => 'http://test:3001', + subscribeApiBaseUrl: () => () => {}, + }); + }); + + it('should fetch workspace data', async () => { + const mockWorkspaces = [ + { id: '1', name: 'Project A', sessions: [] }, + { id: '2', name: 'Project B', sessions: [] }, + ]; + + globalThis.fetch = globalThis.fetch || jest.fn(); + (globalThis.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ workspaces: mockWorkspaces }), + }); + + const { result } = renderHook(() => useWorkspaceQuery(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Trigger the query and wait for it to complete + const refetchPromise = result.current.refetch(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await refetchPromise; + + expect(result.current.data).toEqual({ workspaces: mockWorkspaces }); + }); +}); diff --git a/packages/shared/src/hooks/useWorkspaceQuery.ts b/packages/shared/src/hooks/useWorkspaceQuery.ts new file mode 100644 index 000000000..26cc3002c --- /dev/null +++ b/packages/shared/src/hooks/useWorkspaceQuery.ts @@ -0,0 +1,45 @@ +// packages/shared/src/hooks/useWorkspaceQuery.ts +import { useQuery } from '@tanstack/react-query'; +import { ApiClient } from '../api/client'; + +export interface Session { + id: string; + projectId: string; + agent: string; + status: string; + createdAt: string; +} + +export interface Project { + id: string; + name: string; + sessions: Session[]; +} + +export interface WorkspaceResponse { + workspaces: Project[]; +} + +const WORKSPACE_QUERY_KEY = ['workspaces'] as const; + +export function useWorkspaceQuery(client?: ApiClient) { + const apiClient = client || new ApiClient(); + + const query = useQuery({ + queryKey: WORKSPACE_QUERY_KEY, + queryFn: async () => { + const response = await apiClient.get('/api/v1/workspaces'); + return response; + }, + enabled: false, // Manually refetch on SSE events + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return { + ...query, + refetch: () => query.refetch(), + }; +} + +export { WORKSPACE_QUERY_KEY }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 000000000..cf2f4b91e --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,7 @@ +// packages/shared/src/index.ts +export * from './api/client'; +export * from './api/paths'; +export * from './api/types'; +export * from './lib/bridge'; +export * from './hooks/useWorkspaceQuery'; +export * from './hooks/useDaemonStatus'; diff --git a/packages/shared/src/lib/bridge.ts b/packages/shared/src/lib/bridge.ts new file mode 100644 index 000000000..a050684c1 --- /dev/null +++ b/packages/shared/src/lib/bridge.ts @@ -0,0 +1,28 @@ +export interface DaemonStatus { + running: boolean; + port: number; +} + +export interface PlatformBridge { + // Daemon lifecycle + getDaemonStatus: () => Promise; + startDaemon: () => Promise; + stopDaemon: () => Promise; + + // API base URL resolution + getApiBaseUrl: () => string; + subscribeApiBaseUrl: (callback: (url: string) => void) => () => void; +} + +let platformBridgeInstance: PlatformBridge | null = null; + +export function setPlatformBridge(bridge: PlatformBridge): void { + platformBridgeInstance = bridge; +} + +export function getPlatformBridge(): PlatformBridge { + if (!platformBridgeInstance) { + throw new Error('PlatformBridge not initialized. Call setPlatformBridge() first.'); + } + return platformBridgeInstance; +}