WIP: Enable dynamic AIS stream handling based on user location and map focus.

- Prevent AIS stream from starting immediately; start upon user interaction.
- Add `ais_stream_started` state for WebSocket management.
- Extend `useRealAISProvider` with `userLocationLoaded` and `mapFocused` to control stream.
- Update frontend components to handle geolocation and map focus.
- Exclude test files from compilation

Introduce WebSocket integration for AIS services

- Added WebSocket-based `useRealAISProvider` React hook for real-time AIS vessel data.
- Created various tests including unit, integration, and browser tests to validate WebSocket functionality.
- Added `ws` dependency to enable WebSocket communication.
- Implemented vessel data mapping and bounding box handling for dynamic updates.
This commit is contained in:
geoffsee
2025-07-20 22:27:39 -04:00
parent e029ef48fc
commit 7528b2117b
23 changed files with 5134 additions and 28 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@
"build": "tsc -b && vite build",
"lint": "eslint ",
"preview": "vite preview",
"background-server": "(cd ../ && cargo run &)"
"background-server": "(cd ../ && cargo run &)",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"next-themes": "^0.4.6",
@@ -20,23 +22,27 @@
"@chakra-ui/react": "^3.21.1",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.29.0",
"@tauri-apps/plugin-geolocation": "^2.3.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"bevy_flurx_api": "^0.1.0",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"geojson": "^0.5.0",
"globals": "^16.2.0",
"js-cookie": "^3.0.5",
"jsdom": "^26.1.0",
"mapbox-gl": "^3.13.0",
"react-map-gl": "^8.0.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0",
"@tauri-apps/plugin-geolocation": "^2.3.0",
"vite-tsconfig-paths": "^5.1.4",
"bevy_flurx_api": "^0.1.0",
"geojson": "^0.5.0"
"vitest": "^3.2.4"
}
}

View File

@@ -1,15 +1,19 @@
import {useState, useMemo, useEffect} from 'react';
import {useState, useMemo, useEffect, useCallback, useRef} from 'react';
import Map, {
Marker,
Popup,
NavigationControl,
FullscreenControl,
ScaleControl,
GeolocateControl
GeolocateControl,
type MapRef
} from 'react-map-gl/mapbox';
import ControlPanel from './control-panel.tsx';
import Pin from './pin.tsx';
import VesselMarker from './vessel-marker.tsx';
import { type VesselData } from './real-ais-provider.tsx';
import { useRealAISProvider } from './real-ais-provider.tsx';
import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react";
@@ -28,6 +32,65 @@ export interface Geolocation {
export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) {
const [popupInfo, setPopupInfo] = useState(null);
const [vesselPopupInfo, setVesselPopupInfo] = useState<VesselData | null>(null);
const [boundingBox, setBoundingBox] = useState<{sw_lat: number, sw_lon: number, ne_lat: number, ne_lon: number} | undefined>(undefined);
const [userLocationLoaded, setUserLocationLoaded] = useState(false);
const [mapFocused, setMapFocused] = useState(false);
const mapRef = useRef<MapRef | null>(null);
// Use the real AIS provider with bounding box, user location, and map focus status
const { vessels } = useRealAISProvider(boundingBox, userLocationLoaded, mapFocused);
useEffect(() => {
console.log("vessles", vessels);
}, [vessels]);
// Function to update bounding box from map bounds
const updateBoundingBox = useCallback(() => {
if (mapRef.current) {
const map = mapRef.current.getMap();
const bounds = map.getBounds();
if (bounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
setBoundingBox({
sw_lat: sw.lat,
sw_lon: sw.lng,
ne_lat: ne.lat,
ne_lon: ne.lng
});
}
}
}, []);
// Handle map move events
const handleMapMove = useCallback(() => {
updateBoundingBox();
}, [updateBoundingBox]);
// Initialize bounding box when map loads
const handleMapLoad = useCallback(() => {
updateBoundingBox();
}, [updateBoundingBox]);
// Handle user location events
const handleGeolocate = useCallback((position: GeolocationPosition) => {
console.log('User location loaded:', position);
setUserLocationLoaded(true);
setMapFocused(true); // When geolocate succeeds, the map focuses on user location
}, []);
const handleTrackUserLocationStart = useCallback(() => {
console.log('Started tracking user location');
// User location tracking started, but not necessarily loaded yet
}, []);
const handleTrackUserLocationEnd = useCallback(() => {
console.log('Stopped tracking user location');
setMapFocused(false);
}, []);
const pins = useMemo(
() =>
@@ -55,6 +118,29 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
[]
);
const vesselMarkers = useMemo(
() =>
vessels.map((vessel) => (
<Marker
key={`vessel-${vessel.id}`}
longitude={vessel.longitude}
latitude={vessel.latitude}
anchor="center"
onClick={e => {
e.originalEvent.stopPropagation();
setVesselPopupInfo(vessel);
}}
>
<VesselMarker
heading={vessel.heading}
color={vessel.type === 'Yacht' ? '#00cc66' : vessel.type === 'Fishing Vessel' ? '#ff6600' : '#0066cc'}
size={14}
/>
</Marker>
)),
[vessels]
);
useEffect(() => {
console.log("props.vesselPosition", props?.vesselPosition);
@@ -64,6 +150,7 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
return (
<Box>
<Map
ref={mapRef}
initialViewState={{
latitude: props.mapView?.latitude || 40,
longitude: props.mapView?.longitude || -100,
@@ -72,17 +159,27 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
pitch: 0
}}
key={`${props.mapView?.latitude}-${props.mapView?.longitude}-${props.mapView?.zoom}`}
mapStyle={props.layer?.value || "mapbox://styles/mapbox/standard"}
mapboxAccessToken={props.mapboxPublicKey}
style={{position: "fixed", width: '100%', height: '100%', bottom: 0, top: 0, left: 0, right: 0}}
onLoad={handleMapLoad}
onMoveEnd={handleMapMove}
>
<GeolocateControl showUserHeading={true} showUserLocation={true} geolocation={props.geolocation} position="top-left" />
<GeolocateControl
showUserHeading={true}
showUserLocation={true}
geolocation={props.geolocation}
position="top-left"
onGeolocate={handleGeolocate}
onTrackUserLocationStart={handleTrackUserLocationStart}
onTrackUserLocationEnd={handleTrackUserLocationEnd}
/>
<FullscreenControl position="top-left" />
<NavigationControl position="top-left" />
<ScaleControl />
{pins}
{vesselMarkers}
{popupInfo && (
<Popup
@@ -126,6 +223,38 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
</Popup>
)}
{vesselPopupInfo && (
<Popup
anchor="top"
longitude={vesselPopupInfo.longitude}
latitude={vesselPopupInfo.latitude}
onClose={() => setVesselPopupInfo(null)}
>
<div style={{ minWidth: '200px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 'bold' }}>
{vesselPopupInfo.name}
</h3>
<div style={{ fontSize: '14px', lineHeight: '1.4' }}>
<div><strong>Type:</strong> {vesselPopupInfo.type}</div>
<div><strong>MMSI:</strong> {vesselPopupInfo.mmsi}</div>
<div><strong>Call Sign:</strong> {vesselPopupInfo.callSign}</div>
<div><strong>Speed:</strong> {vesselPopupInfo.speed.toFixed(1)} knots</div>
<div><strong>Heading:</strong> {vesselPopupInfo.heading.toFixed(0)}°</div>
<div><strong>Length:</strong> {vesselPopupInfo.length.toFixed(0)}m</div>
{vesselPopupInfo.destination && (
<div><strong>Destination:</strong> {vesselPopupInfo.destination}</div>
)}
{vesselPopupInfo.eta && (
<div><strong>ETA:</strong> {vesselPopupInfo.eta}</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '8px' }}>
Last Update: {vesselPopupInfo.lastUpdate.toLocaleTimeString()}
</div>
</div>
</div>
</Popup>
)}
</Map>

View File

@@ -0,0 +1,291 @@
import { useState, useEffect, useCallback, useRef } from 'react';
// Vessel data interface
export interface VesselData {
id: string;
name: string;
type: string;
latitude: number;
longitude: number;
heading: number; // degrees 0-359
speed: number; // knots
length: number; // meters
width: number; // meters
mmsi: string; // Maritime Mobile Service Identity
callSign: string;
destination?: string;
eta?: string;
lastUpdate: Date;
}
// AIS service response structure (matching Rust AisResponse)
interface AisResponse {
message_type?: string;
mmsi?: string;
ship_name?: string;
latitude?: number;
longitude?: number;
timestamp?: string;
speed_over_ground?: number;
course_over_ground?: number;
heading?: number;
navigation_status?: string;
ship_type?: string;
raw_message: any;
}
// Bounding box for AIS queries
interface BoundingBox {
sw_lat: number;
sw_lon: number;
ne_lat: number;
ne_lon: number;
}
// Convert AIS service response to VesselData format
const convertAisResponseToVesselData = (aisResponse: AisResponse): any | null => {
// Skip responses that don't have essential vessel data
console.log({aisResponse})
// return aisResponse.raw_message;
return {
id: aisResponse.mmsi,
name: aisResponse.ship_name || `Vessel ${aisResponse.mmsi}`,
type: aisResponse.ship_type || 'Unknown',
latitude: aisResponse.latitude,
longitude: aisResponse.longitude,
heading: aisResponse.heading || 0,
speed: aisResponse.speed_over_ground || 0,
length: 100, // Default length, could be extracted from raw_message if available
width: 20, // Default width
mmsi: aisResponse.mmsi,
callSign: '', // Could be extracted from raw_message if available
destination: '', // Could be extracted from raw_message if available
eta: '', // Could be extracted from raw_message if available
lastUpdate: new Date()
};
};
// WebSocket message types for communication with the backend
interface WebSocketMessage {
type: string;
bounding_box?: BoundingBox;
}
// Hook to provide real AIS data from the service via WebSocket
export const useRealAISProvider = (boundingBox?: BoundingBox, userLocationLoaded?: boolean, mapFocused?: boolean) => {
const [vessels, setVessels] = useState<VesselData[]>([]);
const [isActive, setIsActive] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [aisStreamStarted, setAisStreamStarted] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const lastBoundingBoxRef = useRef<BoundingBox | undefined>(undefined);
const reconnectTimeoutRef = useRef<any | null>(null);
const vesselMapRef = useRef<Map<string, VesselData>>(new Map());
// Connect to WebSocket
const connectWebSocket = useCallback(() => {
// Prevent multiple connections
if (!isActive) return;
// Check if we already have an active or connecting WebSocket
if (wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING)) {
console.log('WebSocket already connected or connecting, skipping...');
return;
}
// Close any existing connection before creating a new one
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsLoading(true);
setError(null);
try {
console.log('Creating new WebSocket connection...');
const ws = new WebSocket('ws://localhost:3000/ws');
wsRef.current = ws;
ws.onopen = () => {
console.log('Connected to AIS WebSocket');
setIsConnected(true);
setIsLoading(false);
setError(null);
// Send bounding box configuration if available
// Note: We'll send the bounding box separately to avoid connection recreation
const currentBoundingBox = lastBoundingBoxRef.current;
if (currentBoundingBox) {
const message: WebSocketMessage = {
type: 'set_bounding_box',
bounding_box: currentBoundingBox
};
ws.send(JSON.stringify(message));
console.log('Sent initial bounding box configuration:', currentBoundingBox);
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle connection confirmation and bounding box confirmations
if (typeof data === 'string' || data.type) {
console.log('Received WebSocket message:', data);
return;
}
const vesselData = convertAisResponseToVesselData(data);
if (vesselData) {
// Update vessel map for efficient updates
vesselMapRef.current.set(vesselData.mmsi, vesselData);
// Update vessels state with current map values
setVessels(Array.from(vesselMapRef.current.values()));
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setError('WebSocket connection error');
setIsConnected(false);
};
ws.onclose = (event) => {
console.log('WebSocket connection closed:', event.code, event.reason);
setIsConnected(false);
setIsLoading(false);
// Attempt to reconnect if the connection was active
if (isActive && !event.wasClean) {
setError('Connection lost, attempting to reconnect...');
reconnectTimeoutRef.current = setTimeout(() => {
connectWebSocket();
}, 3000); // Reconnect after 3 seconds
}
};
} catch (err) {
console.error('Error creating WebSocket connection:', err);
setError(err instanceof Error ? err.message : 'Unknown WebSocket error');
setIsLoading(false);
}
}, [isActive]); // Removed boundingBox dependency to prevent reconnections
// Send bounding box update to WebSocket
const updateBoundingBox = useCallback((bbox: BoundingBox) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const message: WebSocketMessage = {
type: 'set_bounding_box',
bounding_box: bbox
};
wsRef.current.send(JSON.stringify(message));
console.log('Updated bounding box:', bbox);
}
}, []);
// Send start AIS stream message to WebSocket
const startAisStream = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN && !aisStreamStarted) {
const message: WebSocketMessage = {
type: 'start_ais_stream'
};
wsRef.current.send(JSON.stringify(message));
console.log('Sent start AIS stream request');
setAisStreamStarted(true);
}
}, [aisStreamStarted]);
// Connect to WebSocket when component mounts or becomes active
useEffect(() => {
if (isActive) {
connectWebSocket();
}
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [isActive, connectWebSocket]);
// Handle bounding box changes
useEffect(() => {
if (!boundingBox || !isActive) return;
// Check if bounding box actually changed to avoid unnecessary updates
const lastBbox = lastBoundingBoxRef.current;
if (lastBbox &&
lastBbox.sw_lat === boundingBox.sw_lat &&
lastBbox.sw_lon === boundingBox.sw_lon &&
lastBbox.ne_lat === boundingBox.ne_lat &&
lastBbox.ne_lon === boundingBox.ne_lon) {
return;
}
lastBoundingBoxRef.current = boundingBox;
// Clear existing vessels when bounding box changes
vesselMapRef.current.clear();
setVessels([]);
// Send new bounding box to WebSocket
updateBoundingBox(boundingBox);
}, [boundingBox, updateBoundingBox, isActive]);
// Handle active state changes
useEffect(() => {
if (!isActive) {
// Close WebSocket connection when inactive
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setError(null);
// Clear reconnection timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
}
}, [isActive]);
// Start AIS stream when user location is loaded and map is focused
useEffect(() => {
if (userLocationLoaded && mapFocused && isConnected && !aisStreamStarted) {
console.log('User location loaded and map focused, starting AIS stream...');
startAisStream();
}
}, [userLocationLoaded, mapFocused, isConnected, aisStreamStarted, startAisStream]);
return {
vessels,
isActive,
setIsActive,
isLoading,
error,
isConnected,
refreshVessels: () => {
// For WebSocket, we can trigger a reconnection to refresh data
if (wsRef.current) {
wsRef.current.close();
}
connectWebSocket();
}
};
};

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
interface VesselMarkerProps {
size?: number;
color?: string;
heading?: number;
}
const vesselStyle = {
cursor: 'pointer',
stroke: '#fff',
strokeWidth: 2
};
function VesselMarker({ size = 12, color = '#0066cc', heading = 0 }: VesselMarkerProps) {
return (
<svg
height={size}
width={size}
viewBox="0 0 24 24"
style={{
...vesselStyle,
transform: `rotate(${heading}deg)`,
transformOrigin: 'center'
}}
>
<circle
cx="12"
cy="12"
r="10"
fill={color}
/>
{/* Small arrow to indicate heading */}
<path
d="M12 4 L16 12 L12 10 L8 12 Z"
fill="#fff"
/>
</svg>
);
}
export default React.memo(VesselMarker);

View File

@@ -0,0 +1,246 @@
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { useRealAISProvider } from '../src/real-ais-provider.tsx';
// Mock WebSocket
class MockWebSocket {
static instances: MockWebSocket[] = [];
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState: number = MockWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(public url: string) {
MockWebSocket.instances.push(this);
// Simulate connection opening after a short delay
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) {
this.onopen(new Event('open'));
}
}, 10);
}
send(data: string) {
console.log('MockWebSocket send:', data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) {
this.onclose(new CloseEvent('close', { wasClean: true }));
}
}
static reset() {
MockWebSocket.instances = [];
}
static getConnectionCount() {
return MockWebSocket.instances.filter(ws =>
ws.readyState === MockWebSocket.OPEN ||
ws.readyState === MockWebSocket.CONNECTING
).length;
}
}
// Replace global WebSocket with mock
(global as any).WebSocket = MockWebSocket;
describe('useRealAISProvider WebSocket Connection Management', () => {
beforeEach(() => {
MockWebSocket.reset();
vi.clearAllMocks();
});
afterEach(() => {
MockWebSocket.reset();
});
test('should create only one WebSocket connection on initial render', async () => {
const boundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
const { result } = renderHook(() => useRealAISProvider(boundingBox));
// Wait for connection to be established
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.getConnectionCount()).toBe(1);
expect(result.current.isConnected).toBe(true);
});
test('should not create multiple connections when bounding box changes', async () => {
const initialBoundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
const { result, rerender } = renderHook(
({ boundingBox }) => useRealAISProvider(boundingBox),
{ initialProps: { boundingBox: initialBoundingBox } }
);
// Wait for initial connection
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.getConnectionCount()).toBe(1);
// Change bounding box multiple times
const newBoundingBox1 = {
sw_lat: 34.0,
sw_lon: -120.0,
ne_lat: 35.0,
ne_lon: -119.0
};
const newBoundingBox2 = {
sw_lat: 35.0,
sw_lon: -121.0,
ne_lat: 36.0,
ne_lon: -120.0
};
await act(async () => {
rerender({ boundingBox: newBoundingBox1 });
await new Promise(resolve => setTimeout(resolve, 20));
});
await act(async () => {
rerender({ boundingBox: newBoundingBox2 });
await new Promise(resolve => setTimeout(resolve, 20));
});
// Should still have only one connection
expect(MockWebSocket.getConnectionCount()).toBe(1);
expect(result.current.isConnected).toBe(true);
});
test('should properly cleanup connection when component unmounts', async () => {
const boundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
const { result, unmount } = renderHook(() => useRealAISProvider(boundingBox));
// Wait for connection
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(MockWebSocket.getConnectionCount()).toBe(1);
expect(result.current.isConnected).toBe(true);
// Unmount component
unmount();
// Connection should be closed
expect(MockWebSocket.instances[0].readyState).toBe(MockWebSocket.CLOSED);
});
test('should not create connection when isActive is false', async () => {
const boundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
// Create a custom hook that starts with isActive = false
const { result } = renderHook(() => {
const provider = useRealAISProvider(boundingBox);
// Set inactive immediately on first render
if (provider.isActive) {
provider.setIsActive(false);
}
return provider;
});
// Wait a bit to ensure no connection is created
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
});
expect(MockWebSocket.instances).toHaveLength(0);
expect(result.current.isConnected).toBe(false);
expect(result.current.isActive).toBe(false);
});
test('should handle reconnection properly without creating multiple connections', async () => {
const boundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
const { result } = renderHook(() => useRealAISProvider(boundingBox));
// Wait for initial connection
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(MockWebSocket.getConnectionCount()).toBe(1);
// Simulate connection loss
await act(async () => {
const ws = MockWebSocket.instances[0];
ws.readyState = MockWebSocket.CLOSED;
if (ws.onclose) {
ws.onclose(new CloseEvent('close', { wasClean: false }));
}
await new Promise(resolve => setTimeout(resolve, 3100)); // Wait for reconnection timeout
});
// Should have attempted reconnection but still only one active connection
expect(MockWebSocket.getConnectionCount()).toBe(1);
});
test('should send bounding box configuration on connection', async () => {
const boundingBox = {
sw_lat: 33.0,
sw_lon: -119.0,
ne_lat: 34.0,
ne_lon: -118.0
};
const sendSpy = vi.spyOn(MockWebSocket.prototype, 'send');
const { result } = renderHook(() => useRealAISProvider(boundingBox));
// Wait for connection and bounding box message
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(sendSpy).toHaveBeenCalledWith(
JSON.stringify({
type: 'set_bounding_box',
bounding_box: boundingBox
})
);
});
});

View File

@@ -0,0 +1,10 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock console methods to reduce noise in tests
global.console = {
...console,
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};

View File

@@ -27,5 +27,6 @@
},
"include": [
"src"
]
],
"exclude": ["**/*.test.ts"]
}

View File

@@ -4,4 +4,5 @@
{ "path": "./tsconfig.app.json"},
{ "path": "./tsconfig.node.json"}
],
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
globals: true,
},
});