@nahisaho/yata-ui 1.7.0

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/src/index.ts ADDED
@@ -0,0 +1,685 @@
1
+ /**
2
+ * YATA UI Server
3
+ *
4
+ * Web-based visualization and management interface for YATA knowledge graphs.
5
+ *
6
+ * @packageDocumentation
7
+ * @module @nahisaho/yata-ui
8
+ *
9
+ * @see REQ-YI-WEB-001 - Web-based Visualization
10
+ * @see REQ-YI-WEB-002 - Interactive Graph Editing
11
+ * @see REQ-YI-WEB-003 - Real-time Updates
12
+ * @see DES-YATA-IMPROVEMENTS-001 - Design Document
13
+ */
14
+
15
+ import express, { Express, Request, Response, Router } from 'express';
16
+ import * as http from 'http';
17
+
18
+ // ============================================================
19
+ // Types
20
+ // ============================================================
21
+
22
+ /**
23
+ * UI Server configuration
24
+ * @see REQ-YI-WEB-001
25
+ */
26
+ export interface UIServerConfig {
27
+ /** Server port */
28
+ port: number;
29
+ /** Host to bind to */
30
+ host?: string;
31
+ /** Enable CORS */
32
+ cors?: boolean;
33
+ /** Static files directory */
34
+ staticDir?: string;
35
+ /** API base path */
36
+ apiBasePath?: string;
37
+ /** Enable real-time updates via SSE */
38
+ enableRealtime?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Graph data for visualization
43
+ */
44
+ export interface GraphData {
45
+ /** Nodes (entities) */
46
+ nodes: GraphNode[];
47
+ /** Edges (relationships) */
48
+ edges: GraphEdge[];
49
+ /** Graph metadata */
50
+ metadata?: Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * Graph node
55
+ */
56
+ export interface GraphNode {
57
+ /** Node ID */
58
+ id: string;
59
+ /** Node label */
60
+ label: string;
61
+ /** Node type */
62
+ type: string;
63
+ /** Namespace */
64
+ namespace?: string;
65
+ /** Position (optional) */
66
+ position?: { x: number; y: number };
67
+ /** Custom data */
68
+ data?: Record<string, unknown>;
69
+ }
70
+
71
+ /**
72
+ * Graph edge
73
+ */
74
+ export interface GraphEdge {
75
+ /** Edge ID */
76
+ id: string;
77
+ /** Source node ID */
78
+ source: string;
79
+ /** Target node ID */
80
+ target: string;
81
+ /** Relationship type */
82
+ type: string;
83
+ /** Edge label */
84
+ label?: string;
85
+ /** Edge weight */
86
+ weight?: number;
87
+ /** Custom data */
88
+ data?: Record<string, unknown>;
89
+ }
90
+
91
+ /**
92
+ * API response
93
+ */
94
+ export interface ApiResponse<T> {
95
+ /** Success status */
96
+ success: boolean;
97
+ /** Response data */
98
+ data?: T;
99
+ /** Error message */
100
+ error?: string;
101
+ /** Timestamp */
102
+ timestamp: string;
103
+ }
104
+
105
+ /**
106
+ * SSE client
107
+ */
108
+ interface SSEClient {
109
+ id: string;
110
+ response: Response;
111
+ namespace?: string;
112
+ }
113
+
114
+ /**
115
+ * Default configuration
116
+ */
117
+ export const DEFAULT_UI_CONFIG: Partial<UIServerConfig> = {
118
+ port: 3000,
119
+ host: 'localhost',
120
+ cors: true,
121
+ apiBasePath: '/api',
122
+ enableRealtime: true,
123
+ };
124
+
125
+ // ============================================================
126
+ // YataUIServer Class
127
+ // ============================================================
128
+
129
+ /**
130
+ * YATA Knowledge Graph Web UI Server
131
+ *
132
+ * Provides web-based visualization and management for YATA knowledge graphs.
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * const server = new YataUIServer({
137
+ * port: 3000,
138
+ * enableRealtime: true,
139
+ * });
140
+ *
141
+ * // Set data provider
142
+ * server.setDataProvider(async () => {
143
+ * return {
144
+ * nodes: [{ id: '1', label: 'Node 1', type: 'entity' }],
145
+ * edges: [],
146
+ * };
147
+ * });
148
+ *
149
+ * await server.start();
150
+ * console.log('UI available at http://localhost:3000');
151
+ * ```
152
+ *
153
+ * @see REQ-YI-WEB-001
154
+ * @see REQ-YI-WEB-002
155
+ * @see REQ-YI-WEB-003
156
+ */
157
+ export class YataUIServer {
158
+ private app: Express;
159
+ private server: http.Server | null = null;
160
+ private config: UIServerConfig;
161
+ private sseClients: Map<string, SSEClient> = new Map();
162
+ private dataProvider: (() => Promise<GraphData>) | null = null;
163
+
164
+ constructor(config: Partial<UIServerConfig> = {}) {
165
+ this.config = { ...DEFAULT_UI_CONFIG, ...config } as UIServerConfig;
166
+ this.app = express();
167
+ this.setupMiddleware();
168
+ this.setupRoutes();
169
+ }
170
+
171
+ // ============================================================
172
+ // Public API
173
+ // ============================================================
174
+
175
+ /**
176
+ * Set data provider function
177
+ * @param provider - Function that returns graph data
178
+ */
179
+ setDataProvider(provider: () => Promise<GraphData>): void {
180
+ this.dataProvider = provider;
181
+ }
182
+
183
+ /**
184
+ * Start the server
185
+ * @see REQ-YI-WEB-001
186
+ */
187
+ async start(): Promise<void> {
188
+ return new Promise((resolve, reject) => {
189
+ try {
190
+ const host = this.config.host ?? '0.0.0.0';
191
+ this.server = this.app.listen(this.config.port, host, () => {
192
+ resolve();
193
+ });
194
+ } catch (error) {
195
+ reject(error);
196
+ }
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Stop the server
202
+ */
203
+ async stop(): Promise<void> {
204
+ // Close all SSE connections
205
+ for (const client of this.sseClients.values()) {
206
+ client.response.end();
207
+ }
208
+ this.sseClients.clear();
209
+
210
+ return new Promise((resolve, reject) => {
211
+ if (!this.server) {
212
+ resolve();
213
+ return;
214
+ }
215
+
216
+ this.server.close((err) => {
217
+ if (err) {
218
+ reject(err);
219
+ } else {
220
+ this.server = null;
221
+ resolve();
222
+ }
223
+ });
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Get server URL
229
+ */
230
+ getUrl(): string {
231
+ return `http://${this.config.host}:${this.config.port}`;
232
+ }
233
+
234
+ /**
235
+ * Check if server is running
236
+ */
237
+ isRunning(): boolean {
238
+ return this.server !== null;
239
+ }
240
+
241
+ /**
242
+ * Broadcast update to all SSE clients
243
+ * @see REQ-YI-WEB-003
244
+ */
245
+ broadcastUpdate(event: string, data: unknown): void {
246
+ const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
247
+
248
+ for (const client of this.sseClients.values()) {
249
+ client.response.write(message);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Get Express app instance for testing
255
+ */
256
+ getApp(): Express {
257
+ return this.app;
258
+ }
259
+
260
+ // ============================================================
261
+ // Internal: Middleware
262
+ // ============================================================
263
+
264
+ private setupMiddleware(): void {
265
+ // JSON body parser
266
+ this.app.use(express.json());
267
+
268
+ // CORS
269
+ if (this.config.cors) {
270
+ this.app.use((_req, res, next) => {
271
+ res.header('Access-Control-Allow-Origin', '*');
272
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
273
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
274
+ next();
275
+ });
276
+ }
277
+ }
278
+
279
+ // ============================================================
280
+ // Internal: Routes
281
+ // ============================================================
282
+
283
+ private setupRoutes(): void {
284
+ const router = Router();
285
+
286
+ // Health check
287
+ router.get('/health', (_req, res) => {
288
+ this.sendResponse(res, { status: 'ok', timestamp: new Date().toISOString() });
289
+ });
290
+
291
+ // Get graph data
292
+ router.get('/graph', async (_req, res) => {
293
+ try {
294
+ const data = await this.getGraphData();
295
+ this.sendResponse(res, data);
296
+ } catch (error) {
297
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get graph data');
298
+ }
299
+ });
300
+
301
+ // Get nodes
302
+ router.get('/nodes', async (_req, res) => {
303
+ try {
304
+ const data = await this.getGraphData();
305
+ this.sendResponse(res, data.nodes);
306
+ } catch (error) {
307
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get nodes');
308
+ }
309
+ });
310
+
311
+ // Get edges
312
+ router.get('/edges', async (_req, res) => {
313
+ try {
314
+ const data = await this.getGraphData();
315
+ this.sendResponse(res, data.edges);
316
+ } catch (error) {
317
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get edges');
318
+ }
319
+ });
320
+
321
+ // Get single node
322
+ router.get('/nodes/:id', async (req, res) => {
323
+ try {
324
+ const data = await this.getGraphData();
325
+ const node = data.nodes.find(n => n.id === req.params.id);
326
+ if (!node) {
327
+ this.sendError(res, 404, 'Node not found');
328
+ return;
329
+ }
330
+ this.sendResponse(res, node);
331
+ } catch (error) {
332
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get node');
333
+ }
334
+ });
335
+
336
+ // SSE endpoint for real-time updates
337
+ if (this.config.enableRealtime) {
338
+ router.get('/events', (req, res) => {
339
+ this.handleSSEConnection(req, res);
340
+ });
341
+ }
342
+
343
+ // Cytoscape data format endpoint
344
+ router.get('/cytoscape', async (_req, res) => {
345
+ try {
346
+ const data = await this.getGraphData();
347
+ const cytoscapeData = this.toCytoscapeFormat(data);
348
+ this.sendResponse(res, cytoscapeData);
349
+ } catch (error) {
350
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get Cytoscape data');
351
+ }
352
+ });
353
+
354
+ // Statistics endpoint
355
+ router.get('/stats', async (_req, res) => {
356
+ try {
357
+ const data = await this.getGraphData();
358
+ const stats = {
359
+ nodeCount: data.nodes.length,
360
+ edgeCount: data.edges.length,
361
+ nodeTypes: this.countByType(data.nodes),
362
+ edgeTypes: this.countByType(data.edges),
363
+ namespaces: [...new Set(data.nodes.map(n => n.namespace).filter(Boolean))],
364
+ };
365
+ this.sendResponse(res, stats);
366
+ } catch (error) {
367
+ this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get stats');
368
+ }
369
+ });
370
+
371
+ // Mount API routes
372
+ this.app.use(this.config.apiBasePath || '/api', router);
373
+
374
+ // Serve static files (for embedded UI)
375
+ if (this.config.staticDir) {
376
+ this.app.use(express.static(this.config.staticDir));
377
+ }
378
+
379
+ // Serve built-in UI
380
+ this.app.get('/', (_req, res) => {
381
+ res.send(this.getBuiltInUI());
382
+ });
383
+ }
384
+
385
+ // ============================================================
386
+ // Internal: SSE
387
+ // ============================================================
388
+
389
+ /**
390
+ * Handle SSE connection
391
+ * @see REQ-YI-WEB-003
392
+ */
393
+ private handleSSEConnection(req: Request, res: Response): void {
394
+ // Set SSE headers
395
+ res.setHeader('Content-Type', 'text/event-stream');
396
+ res.setHeader('Cache-Control', 'no-cache');
397
+ res.setHeader('Connection', 'keep-alive');
398
+
399
+ // Generate client ID
400
+ const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
401
+
402
+ // Register client
403
+ this.sseClients.set(clientId, {
404
+ id: clientId,
405
+ response: res,
406
+ namespace: req.query.namespace as string | undefined,
407
+ });
408
+
409
+ // Send initial connection event
410
+ res.write(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`);
411
+
412
+ // Handle client disconnect
413
+ req.on('close', () => {
414
+ this.sseClients.delete(clientId);
415
+ });
416
+ }
417
+
418
+ // ============================================================
419
+ // Internal: Helpers
420
+ // ============================================================
421
+
422
+ /**
423
+ * Get graph data from provider
424
+ */
425
+ private async getGraphData(): Promise<GraphData> {
426
+ if (!this.dataProvider) {
427
+ return { nodes: [], edges: [] };
428
+ }
429
+ return this.dataProvider();
430
+ }
431
+
432
+ /**
433
+ * Send API response
434
+ */
435
+ private sendResponse<T>(res: Response, data: T): void {
436
+ const response: ApiResponse<T> = {
437
+ success: true,
438
+ data,
439
+ timestamp: new Date().toISOString(),
440
+ };
441
+ res.json(response);
442
+ }
443
+
444
+ /**
445
+ * Send error response
446
+ */
447
+ private sendError(res: Response, status: number, error: string): void {
448
+ const response: ApiResponse<null> = {
449
+ success: false,
450
+ error,
451
+ timestamp: new Date().toISOString(),
452
+ };
453
+ res.status(status).json(response);
454
+ }
455
+
456
+ /**
457
+ * Convert to Cytoscape.js format
458
+ * @see REQ-YI-WEB-001
459
+ */
460
+ private toCytoscapeFormat(data: GraphData): object {
461
+ const elements: object[] = [];
462
+
463
+ // Add nodes
464
+ for (const node of data.nodes) {
465
+ elements.push({
466
+ data: {
467
+ id: node.id,
468
+ label: node.label,
469
+ type: node.type,
470
+ namespace: node.namespace,
471
+ ...node.data,
472
+ },
473
+ position: node.position,
474
+ });
475
+ }
476
+
477
+ // Add edges
478
+ for (const edge of data.edges) {
479
+ elements.push({
480
+ data: {
481
+ id: edge.id,
482
+ source: edge.source,
483
+ target: edge.target,
484
+ label: edge.label || edge.type,
485
+ type: edge.type,
486
+ weight: edge.weight,
487
+ ...edge.data,
488
+ },
489
+ });
490
+ }
491
+
492
+ return { elements };
493
+ }
494
+
495
+ /**
496
+ * Count items by type
497
+ */
498
+ private countByType(items: Array<{ type: string }>): Record<string, number> {
499
+ const counts: Record<string, number> = {};
500
+ for (const item of items) {
501
+ counts[item.type] = (counts[item.type] || 0) + 1;
502
+ }
503
+ return counts;
504
+ }
505
+
506
+ /**
507
+ * Get built-in UI HTML
508
+ */
509
+ private getBuiltInUI(): string {
510
+ return `<!DOCTYPE html>
511
+ <html lang="en">
512
+ <head>
513
+ <meta charset="UTF-8">
514
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
515
+ <title>YATA Knowledge Graph</title>
516
+ <script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
517
+ <style>
518
+ * { margin: 0; padding: 0; box-sizing: border-box; }
519
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
520
+ .container { display: flex; height: 100vh; }
521
+ .sidebar { width: 300px; background: #f5f5f5; padding: 20px; overflow-y: auto; }
522
+ .graph-container { flex: 1; position: relative; }
523
+ #cy { width: 100%; height: 100%; }
524
+ h1 { font-size: 1.5rem; margin-bottom: 20px; color: #333; }
525
+ .stats { margin-bottom: 20px; padding: 15px; background: white; border-radius: 8px; }
526
+ .stat-item { display: flex; justify-content: space-between; margin: 8px 0; }
527
+ .stat-label { color: #666; }
528
+ .stat-value { font-weight: bold; color: #333; }
529
+ .controls { margin-top: 20px; }
530
+ button { padding: 10px 20px; margin: 5px 0; width: 100%; border: none; border-radius: 6px;
531
+ cursor: pointer; font-size: 14px; transition: background 0.2s; }
532
+ .btn-primary { background: #0066cc; color: white; }
533
+ .btn-primary:hover { background: #0052a3; }
534
+ .btn-secondary { background: #e0e0e0; color: #333; }
535
+ .btn-secondary:hover { background: #d0d0d0; }
536
+ .node-info { margin-top: 20px; padding: 15px; background: white; border-radius: 8px; display: none; }
537
+ .node-info.active { display: block; }
538
+ .node-info h3 { margin-bottom: 10px; color: #333; }
539
+ .node-info p { margin: 5px 0; color: #666; font-size: 14px; }
540
+ .legend { margin-top: 20px; }
541
+ .legend-item { display: flex; align-items: center; margin: 5px 0; }
542
+ .legend-color { width: 16px; height: 16px; border-radius: 4px; margin-right: 8px; }
543
+ .connection-status { position: fixed; top: 10px; right: 10px; padding: 8px 16px;
544
+ border-radius: 20px; font-size: 12px; }
545
+ .status-connected { background: #d4edda; color: #155724; }
546
+ .status-disconnected { background: #f8d7da; color: #721c24; }
547
+ </style>
548
+ </head>
549
+ <body>
550
+ <div id="connection-status" class="connection-status status-disconnected">Disconnected</div>
551
+ <div class="container">
552
+ <div class="sidebar">
553
+ <h1>📊 YATA Graph</h1>
554
+ <div id="stats" class="stats">
555
+ <div class="stat-item"><span class="stat-label">Nodes:</span><span id="node-count" class="stat-value">-</span></div>
556
+ <div class="stat-item"><span class="stat-label">Edges:</span><span id="edge-count" class="stat-value">-</span></div>
557
+ </div>
558
+ <div class="controls">
559
+ <button class="btn-primary" onclick="refreshGraph()">🔄 Refresh</button>
560
+ <button class="btn-secondary" onclick="fitGraph()">📐 Fit to View</button>
561
+ <button class="btn-secondary" onclick="exportPNG()">📸 Export PNG</button>
562
+ </div>
563
+ <div id="node-info" class="node-info">
564
+ <h3 id="selected-name">Selected Node</h3>
565
+ <p><strong>ID:</strong> <span id="selected-id">-</span></p>
566
+ <p><strong>Type:</strong> <span id="selected-type">-</span></p>
567
+ <p><strong>Namespace:</strong> <span id="selected-ns">-</span></p>
568
+ </div>
569
+ <div class="legend">
570
+ <h4>Node Types</h4>
571
+ <div class="legend-item"><div class="legend-color" style="background:#4CAF50"></div>Entity</div>
572
+ <div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>Class</div>
573
+ <div class="legend-item"><div class="legend-color" style="background:#FF9800"></div>Function</div>
574
+ <div class="legend-item"><div class="legend-color" style="background:#9C27B0"></div>Interface</div>
575
+ </div>
576
+ </div>
577
+ <div class="graph-container">
578
+ <div id="cy"></div>
579
+ </div>
580
+ </div>
581
+ <script>
582
+ const API_BASE = '${this.config.apiBasePath || '/api'}';
583
+ let cy;
584
+ let eventSource;
585
+
586
+ const typeColors = {
587
+ entity: '#4CAF50', class: '#2196F3', function: '#FF9800',
588
+ interface: '#9C27B0', module: '#607D8B', default: '#9E9E9E'
589
+ };
590
+
591
+ async function initGraph() {
592
+ cy = cytoscape({
593
+ container: document.getElementById('cy'),
594
+ style: [
595
+ { selector: 'node', style: {
596
+ 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center',
597
+ 'background-color': 'data(color)', 'color': '#fff', 'font-size': '12px',
598
+ 'text-outline-width': 2, 'text-outline-color': 'data(color)',
599
+ 'width': 60, 'height': 60
600
+ }},
601
+ { selector: 'edge', style: {
602
+ 'label': 'data(label)', 'curve-style': 'bezier', 'target-arrow-shape': 'triangle',
603
+ 'line-color': '#999', 'target-arrow-color': '#999', 'font-size': '10px',
604
+ 'text-background-color': '#fff', 'text-background-opacity': 0.8,
605
+ 'text-background-padding': '2px'
606
+ }},
607
+ { selector: ':selected', style: {
608
+ 'border-width': 3, 'border-color': '#ff0066'
609
+ }}
610
+ ],
611
+ layout: { name: 'cose', animate: false }
612
+ });
613
+
614
+ cy.on('tap', 'node', function(e) {
615
+ const node = e.target.data();
616
+ document.getElementById('selected-name').textContent = node.label;
617
+ document.getElementById('selected-id').textContent = node.id;
618
+ document.getElementById('selected-type').textContent = node.type || '-';
619
+ document.getElementById('selected-ns').textContent = node.namespace || '-';
620
+ document.getElementById('node-info').classList.add('active');
621
+ });
622
+
623
+ cy.on('tap', function(e) {
624
+ if (e.target === cy) {
625
+ document.getElementById('node-info').classList.remove('active');
626
+ }
627
+ });
628
+
629
+ await refreshGraph();
630
+ connectSSE();
631
+ }
632
+
633
+ async function refreshGraph() {
634
+ try {
635
+ const res = await fetch(API_BASE + '/cytoscape');
636
+ const json = await res.json();
637
+ if (json.success) {
638
+ const elements = json.data.elements.map(el => {
639
+ if (el.data && !el.data.source) {
640
+ el.data.color = typeColors[el.data.type] || typeColors.default;
641
+ }
642
+ return el;
643
+ });
644
+ cy.json({ elements });
645
+ cy.layout({ name: 'cose', animate: true, animationDuration: 500 }).run();
646
+ updateStats();
647
+ }
648
+ } catch (err) { console.error('Failed to refresh:', err); }
649
+ }
650
+
651
+ function updateStats() {
652
+ document.getElementById('node-count').textContent = cy.nodes().length;
653
+ document.getElementById('edge-count').textContent = cy.edges().length;
654
+ }
655
+
656
+ function fitGraph() { cy.fit(50); }
657
+ function exportPNG() { const png = cy.png({ full: true }); window.open(png, '_blank'); }
658
+
659
+ function connectSSE() {
660
+ if (eventSource) eventSource.close();
661
+ eventSource = new EventSource(API_BASE + '/events');
662
+ eventSource.onopen = () => {
663
+ document.getElementById('connection-status').textContent = 'Connected';
664
+ document.getElementById('connection-status').className = 'connection-status status-connected';
665
+ };
666
+ eventSource.onerror = () => {
667
+ document.getElementById('connection-status').textContent = 'Disconnected';
668
+ document.getElementById('connection-status').className = 'connection-status status-disconnected';
669
+ };
670
+ eventSource.addEventListener('update', () => refreshGraph());
671
+ }
672
+
673
+ initGraph();
674
+ </script>
675
+ </body>
676
+ </html>`;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Factory function to create YataUIServer
682
+ */
683
+ export function createYataUIServer(config?: Partial<UIServerConfig>): YataUIServer {
684
+ return new YataUIServer(config);
685
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
11
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.{test,spec}.ts', '__tests__/**/*.{test,spec}.ts'],
8
+ exclude: ['**/node_modules/**', '**/dist/**'],
9
+ coverage: {
10
+ provider: 'v8',
11
+ reporter: ['text', 'json', 'html'],
12
+ },
13
+ },
14
+ });