import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; interface TestResult { endpoint: string; method: string; status: 'pending' | 'running' | 'success' | 'error'; duration?: number; response?: any; error?: string; timestamp: number; } @Component({ selector: 'app-tests-panel', standalone: true, imports: [CommonModule, FormsModule], template: `

🧪 API Tests Panel

Test all ObsiViewer APIs and validate functionality. Each test shows execution time and response details.

âš¡ Quick checks

Results and timings appear on the right. Use filters there to focus on failed calls.

🔭 API Explorer

Path: {{ currentEndpoint.path }}
Selected: {{ selectedFileName }}
Preview: {{ buildPreviewUrl() }}

Test Results

No tests yet. Use a quick check or the explorer to start.

Newest first • {{ results().length }} result{{ results().length > 1 ? 's' : '' }}

{{ result.method }} {{ result.endpoint }} {{ result.status.toUpperCase() }} ({{ result.duration }}ms)
{{ result.timestamp | date:'mediumTime' }}
{{ result.response ? (result.response | json) : result.error }}

No tests run yet.

Start with a quick health check or run the full smoke suite.

`, styles: [` .tests-panel { max-width: 1200px; margin: 0 auto; padding: 20px; font-family: var(--font-mono), 'Monaco', 'Menlo', monospace; font-size: 14px; color: var(--text-main); background: var(--bg); } .panel-header { margin-bottom: 30px; text-align: center; } .panel-header .header-actions { position: absolute; right: 20px; top: 20px; } .panel-header h2 { margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: var(--text-main); } .panel-description { margin: 0; color: var(--text-muted); font-size: 16px; max-width: 600px; margin: 0 auto; } .panel-layout { display: flex; flex-direction: column; gap: 20px; } .test-sections { display: grid; gap: 20px; } .test-section { border: 1px solid var(--border); border-radius: 8px; padding: 20px; background: var(--card); } .test-section h3 { margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: var(--text-main); } .quick-actions { display: flex; flex-wrap: wrap; gap: 10px; } .quick-actions__full { flex: 1 1 100%; } .quick-actions__hint { margin-top: 8px; font-size: 12px; color: var(--text-muted); } .test-group { display: flex; flex-wrap: wrap; gap: 10px; } .test-btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; min-width: 120px; } .test-btn:disabled { opacity: 0.5; cursor: not-allowed; } .test-btn--primary { background: var(--primary); color: white; } .test-btn--primary:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 85%, black 15%); } .test-btn--secondary { background: var(--muted); color: white; } .test-btn--secondary:hover:not(:disabled) { background: color-mix(in srgb, var(--muted) 85%, black 15%); } .test-btn--info { background: var(--info); color: white; } .test-btn--info:hover:not(:disabled) { background: color-mix(in srgb, var(--info) 85%, black 15%); } .test-btn--warning { background: var(--warning); color: var(--text-main); } .test-btn--warning:hover:not(:disabled) { background: color-mix(in srgb, var(--warning) 85%, black 15%); } .test-btn--danger { background: var(--danger); color: white; } .test-btn--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 85%, black 15%); } .test-btn--accent { background: var(--accent); color: white; } .test-btn--accent:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 85%, black 15%); } .test-btn--neutral { background: var(--secondary); color: white; } .test-btn--neutral:hover:not(:disabled) { background: color-mix(in srgb, var(--secondary) 85%, black 15%); } .file-test-form { display: grid; gap: 15px; } .form-row { display: grid; gap: 5px; } .form-row label { font-weight: 600; color: var(--text-main); } .form-input, .form-textarea { padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 14px; background: var(--card); color: var(--text-main); } .form-input:focus, .form-textarea:focus { outline: none; border-color: var(--ring); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 25%, transparent); } .results-panel { border: 1px solid var(--border); border-radius: 8px; background: var(--card); display: grid; grid-template-rows: auto 1fr; position: static; z-index: 0; } .results-header { display: flex; justify-content: space-between; align-items: center; padding: 20px; border-bottom: 1px solid var(--border); } .results-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: var(--text-main); } .results-subtitle { margin: 4px 0 0; font-size: 12px; color: var(--text-muted); } .results-tools { display: flex; gap: 10px; align-items: center; } .results-filters { display: flex; flex-wrap: wrap; gap: 6px; } .filter-chip { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface-1); font-size: 12px; cursor: pointer; color: var(--text-muted); } .filter-chip--success { border-color: color-mix(in srgb, var(--success) 70%, var(--border)); color: var(--success); } .filter-chip--error { border-color: color-mix(in srgb, var(--danger) 70%, var(--border)); color: var(--danger); } .filter-chip--running { border-color: color-mix(in srgb, var(--warning) 70%, var(--border)); color: var(--warning); } .filter-chip--active { background: color-mix(in srgb, var(--primary) 25%, transparent); color: var(--text-main); border-color: color-mix(in srgb, var(--primary) 80%, var(--border)); } .clear-btn { padding: 6px 12px; background: var(--danger); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .clear-btn:hover { background: color-mix(in srgb, var(--danger) 85%, black 15%); } .clear-btn:disabled { opacity: 0.5; cursor: not-allowed; } .results-list { overflow-y: auto; max-height: 500px; } .result-item { border-bottom: 1px solid var(--border); padding: 15px 20px; } .result-item:last-child { border-bottom: none; } .result-item--success { background: color-mix(in srgb, var(--success) 15%, transparent); border-left: 4px solid var(--success); } .result-item--error { background: color-mix(in srgb, var(--danger) 15%, transparent); border-left: 4px solid var(--danger); } .result-item--running { background: color-mix(in srgb, var(--warning) 15%, transparent); border-left: 4px solid var(--warning); } .result-item--pending { background: var(--surface-1); border-left: 4px solid var(--muted); } .result-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } .result-method { font-weight: bold; color: var(--primary); background: color-mix(in srgb, var(--primary) 15%, transparent); padding: 2px 6px; border-radius: 3px; font-size: 12px; } .result-endpoint { font-family: monospace; font-size: 14px; color: var(--text-main); flex: 1; } .result-status { font-weight: bold; padding: 2px 8px; border-radius: 12px; font-size: 12px; } .result-item--success .result-status { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); } .result-item--error .result-status { background: color-mix(in srgb, var(--danger) 20%, transparent); color: var(--danger); } .result-item--running .result-status { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--warning); } .result-item--pending .result-status { background: var(--surface-1); color: var(--muted); } .result-duration { font-size: 12px; color: var(--text-muted); } .result-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 4px; } .result-timestamp { font-size: 12px; color: var(--text-muted); } .result-toggle { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; font-size: 12px; cursor: pointer; color: var(--text-muted); } .result-details { margin-top: 10px; } .result-json { background: var(--surface-1); padding: 10px; border-radius: 4px; font-size: 12px; max-height: 200px; overflow-y: auto; margin: 0; white-space: pre-wrap; word-break: break-word; color: var(--text-main); } .no-results { text-align: center; padding: 40px 20px; color: var(--text-muted); } .no-results p { margin: 0; font-size: 16px; } .sse-events { margin-top: 15px; padding: 15px; background: var(--surface-1); border-radius: 6px; } .sse-events h4 { margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: var(--text-main); } .events-list { max-height: 150px; overflow-y: auto; } .event-item { display: flex; gap: 10px; padding: 5px 0; font-size: 12px; border-bottom: 1px solid var(--border); } .event-item:last-child { border-bottom: none; } .event-type { font-weight: bold; color: var(--primary); } .event-data { flex: 1; font-family: monospace; color: var(--text-main); } .event-time { color: var(--text-muted); } @media (max-width: 1024px) { .results-list { max-height: 400px; } } .tests-panel--fullscreen { position: fixed; inset: 0; z-index: 50; background: var(--bg); width: 100vw; height: 100vh; overflow: hidden; padding: 12px 12px 12px 12px; } .tests-panel--fullscreen .panel-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 24px; align-items: start; height: calc(100vh - 100px); } .tests-panel--fullscreen .test-sections { overflow: auto; } .tests-panel--fullscreen .results-panel { align-self: stretch; } `] }) export class TestsPanelComponent { private http = inject(HttpClient); // Test form data testFilePath = signal(''); testFileContent = signal(''); testNoteId = signal(''); // Test state results = signal([]); sseEvents = signal>([]); isRunning = signal(false); // UI state for results panel private statusFilter = signal<'all' | 'success' | 'error' | 'running'>('all'); private expandedResults = signal>(new Set()); // SSE connection private sseConnection: EventSource | null = null; isFullscreen = signal(false); // ---------- API Explorer (dynamic) ---------- builderSearchTerm = ''; selectedEndpointKey = signal('health.get'); paramValues = signal>({}); bodyText = signal('{}'); headersText = signal('{}'); selectedFile: File | null = null; selectedFileName = ''; endpoints: Array<{ key: string; group: string; label: string; method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'SSE'; path: string; // may include {param} pathParams?: string[]; query?: Array<{ name: string; required?: boolean }>; contentType?: 'json'|'text'|'binary'; responseType?: 'json'|'text'; headersSupported?: boolean; sampleBody?: any; }> = [ { key: 'health.get', group: 'System', label: 'Health', method: 'GET', path: '/api/health' }, { key: 'perf.get', group: 'System', label: 'Performance', method: 'GET', path: '/__perf' }, { key: 'settings.get', group: 'System', label: 'Get Settings', method: 'GET', path: '/api/settings' }, { key: 'settings.put', group: 'System', label: 'Update Settings', method: 'PUT', path: '/api/settings', contentType: 'json', sampleBody: { enableBackups: true } }, { key: 'events.sse', group: 'Events', label: 'Vault Events (SSE)', method: 'SSE', path: '/api/vault/events' }, { key: 'vault.notesRaw', group: 'Vault', label: 'Load Vault Notes (raw)', method: 'GET', path: '/api/vault' }, { key: 'vault.metadata', group: 'Vault', label: 'Metadata', method: 'GET', path: '/api/vault/metadata' }, { key: 'vault.metadataPaginated', group: 'Vault', label: 'Metadata (paginated)', method: 'GET', path: '/api/vault/metadata/paginated', query: [ { name: 'limit' }, { name: 'cursor' }, { name: 'search' }, { name: 'folder' } ]}, { key: 'files.list', group: 'Files', label: 'List from Meili', method: 'GET', path: '/api/files/list' }, { key: 'files.metadata', group: 'Files', label: 'Metadata (mixed)', method: 'GET', path: '/api/files/metadata', query: [ { name: 'source' } ] }, { key: 'files.byDate', group: 'Files', label: 'By Date', method: 'GET', path: '/api/files/by-date', query: [ { name: 'date', required: true } ] }, { key: 'files.byDateRange', group: 'Files', label: 'By Date Range', method: 'GET', path: '/api/files/by-date-range', query: [ { name: 'start', required: true }, { name: 'end' } ] }, { key: 'files.get', group: 'Files', label: 'Read File', method: 'GET', path: '/api/files', query: [ { name: 'path', required: true } ], responseType: 'text' }, { key: 'files.put', group: 'Files', label: 'Write File', method: 'PUT', path: '/api/files', query: [ { name: 'path', required: true } ], contentType: 'text', sampleBody: '# Title\n\nContent...' }, { key: 'files.putBlob', group: 'Files', label: 'Write Binary Sidecar', method: 'PUT', path: '/api/files/blob', query: [ { name: 'path', required: true } ], contentType: 'binary' }, { key: 'files.rename', group: 'Files', label: 'Rename File', method: 'PUT', path: '/api/files/rename', contentType: 'json', sampleBody: { oldPath: 'folder/old.md', newName: 'new.md' } }, { key: 'notes.create', group: 'Notes', label: 'Create Note', method: 'POST', path: '/api/vault/notes', contentType: 'json', sampleBody: { fileName: 'Nouvelle note.md', folderPath: '', frontmatter: { title: 'Nouvelle note' }, content: '# Nouvelle note' } }, { key: 'notes.updateFrontmatter', group: 'Notes', label: 'Update Frontmatter', method: 'PATCH', path: '/api/vault/notes/{id}', pathParams: ['id'], contentType: 'json', sampleBody: { frontmatter: { title: 'Updated' } } }, { key: 'notes.delete', group: 'Notes', label: 'Delete (to .trash)', method: 'DELETE', path: '/api/vault/notes/{id}', pathParams: ['id'] }, { key: 'notes.move', group: 'Notes', label: 'Move Note', method: 'POST', path: '/api/vault/notes/move', contentType: 'json', sampleBody: { notePath: 'folder/a.md', newFolderPath: 'folder2' } }, { key: 'notes.tags', group: 'Notes', label: 'Update Tags', method: 'PUT', path: '/api/notes/{idOrPath}/tags', pathParams: ['idOrPath'], contentType: 'json', sampleBody: { tags: ['home','docs'] } }, { key: 'folders.list', group: 'Folders', label: 'List Folders', method: 'GET', path: '/api/folders/list' }, { key: 'folders.create', group: 'Folders', label: 'Create Folder', method: 'POST', path: '/api/folders', contentType: 'json', sampleBody: { path: 'new-folder' } }, { key: 'folders.rename', group: 'Folders', label: 'Rename Folder', method: 'PUT', path: '/api/folders/rename', contentType: 'json', sampleBody: { oldPath: 'old-folder', newName: 'renamed-folder' } }, { key: 'folders.delete', group: 'Folders', label: 'Delete Folder', method: 'DELETE', path: '/api/folders', query: [ { name: 'path', required: true } ] }, { key: 'folders.duplicate', group: 'Folders', label: 'Duplicate Folder', method: 'POST', path: '/api/folders/duplicate', contentType: 'json', sampleBody: { sourcePath: 'src', destinationPath: 'dst' } }, { key: 'folders.deletePages', group: 'Folders', label: 'Delete Pages in Folder', method: 'DELETE', path: '/api/folders/pages', query: [ { name: 'path', required: true } ] }, { key: 'attachments.resolve', group: 'Attachments', label: 'Resolve Attachment', method: 'GET', path: '/api/attachments/resolve', query: [ { name: 'name', required: true }, { name: 'note' }, { name: 'base' } ] }, { key: 'search.query', group: 'Search', label: 'Meili Search', method: 'GET', path: '/api/search', query: [ { name: 'q', required: true }, { name: 'limit' }, { name: 'offset' }, { name: 'sort' }, { name: 'highlight' } ] }, { key: 'meili.reindex', group: 'Search', label: 'Manual Reindex', method: 'POST', path: '/api/reindex' }, { key: 'graph.get', group: 'Config', label: 'Get Graph', method: 'GET', path: '/api/vault/graph' }, { key: 'graph.put', group: 'Config', label: 'Update Graph', method: 'PUT', path: '/api/vault/graph', headersSupported: true, contentType: 'json', sampleBody: { search: '', showTags: false } }, { key: 'bookmarks.get', group: 'Config', label: 'Get Bookmarks', method: 'GET', path: '/api/vault/bookmarks' }, { key: 'bookmarks.put', group: 'Config', label: 'Update Bookmarks', method: 'PUT', path: '/api/vault/bookmarks', headersSupported: true, contentType: 'json', sampleBody: { items: [] } }, { key: 'quicklinks.counts', group: 'Quick Links', label: 'Counts', method: 'GET', path: '/api/quick-links/counts' }, { key: 'logs.single', group: 'Logs', label: 'Post Log', method: 'POST', path: '/api/log', contentType: 'json', sampleBody: { event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } } }, { key: 'logs.batch', group: 'Logs', label: 'Post Log (alt)', method: 'POST', path: '/api/logs', contentType: 'json', sampleBody: { source: 'frontend', level: 'info', message: 'hello', data: { any: true } } }, ]; get currentEndpoint() { const key = this.selectedEndpointKey(); return this.endpoints.find(e => e.key === key) || null; } getFilteredEndpoints() { const q = (this.builderSearchTerm || '').toLowerCase(); const list = this.endpoints.slice(); if (!q) return list; return list.filter(e => e.key.toLowerCase().includes(q) || e.label.toLowerCase().includes(q) || e.group.toLowerCase().includes(q) || e.path.toLowerCase().includes(q) ); } onSelectEndpoint(key: string): void { this.selectedEndpointKey.set(key); const ep = this.currentEndpoint; const initParams: Record = {}; (ep?.pathParams || []).forEach(p => initParams[p] = ''); (ep?.query || []).forEach(q => { if (q.required) initParams[q.name] = ''; }); this.paramValues.set(initParams); this.headersText.set(ep?.headersSupported ? '{\n "If-Match": "rev-or-etag"\n}' : '{}'); if (ep?.contentType === 'json' && ep.sampleBody !== undefined) { this.bodyText.set(JSON.stringify(ep.sampleBody, null, 2)); } else if (ep?.contentType === 'text' && typeof ep.sampleBody === 'string') { this.bodyText.set(String(ep.sampleBody)); } else { this.bodyText.set(ep?.contentType === 'json' ? '{}' : ''); } this.selectedFile = null; this.selectedFileName = ''; } updateParamValue(name: string, value: string): void { this.paramValues.update(map => ({ ...map, [name]: value })); } onFileChosen(evt: Event): void { const input = evt.target as HTMLInputElement; const file = input?.files?.[0] || null; this.selectedFile = file; this.selectedFileName = file ? `${file.name} (${file.type || 'application/octet-stream'}, ${file.size} bytes)` : ''; } showBodyEditor(): boolean { const ep = this.currentEndpoint; if (!ep) return false; if (ep.contentType === 'binary') return false; return ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH'; } buildPreviewUrl(): string { const ep = this.currentEndpoint; if (!ep) return ''; const path = this.replacePathParams(ep.path, this.paramValues()); const qs = this.buildQueryString(ep.query, this.paramValues()); return qs ? `${path}?${qs}` : path; } private replacePathParams(path: string, params: Record): string { return path.replace(/\{(\w+)\}/g, (_m, p1) => encodeURIComponent(params[p1] || '')); } private buildQueryString(query: Array<{ name: string; required?: boolean }> | undefined, params: Record): string { if (!query || query.length === 0) return ''; const sp = new URLSearchParams(); for (const q of query) { const v = params[q.name]; if (v !== undefined && v !== '') sp.append(q.name, v); } return sp.toString(); } // ---------- Results helpers ---------- setStatusFilter(filter: 'all' | 'success' | 'error' | 'running'): void { this.statusFilter.set(filter); } isStatusFilterActive(filter: 'all' | 'success' | 'error' | 'running'): boolean { return this.statusFilter() === filter; } getFilteredResults(): TestResult[] { const filter = this.statusFilter(); const all = this.results(); if (filter === 'all') { return all; } return all.filter(r => r.status === filter); } toggleResultDetails(result: TestResult): void { const current = new Set(this.expandedResults()); if (current.has(result)) { current.delete(result); } else { current.add(result); } this.expandedResults.set(current); } isResultExpanded(result: TestResult): boolean { return this.expandedResults().has(result); } async runDynamicRequest(): Promise { const ep = this.currentEndpoint; if (!ep) return; if (ep.method === 'SSE' || ep.path === '/api/vault/events') { this.runSSETest(); return; } // Validate required params for (const p of (ep.pathParams || [])) { if (!this.paramValues()[p]?.trim()) { this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing path param: ${p}`, timestamp: Date.now() }); return; } } for (const q of (ep.query || [])) { if (q.required && !this.paramValues()[q.name]?.trim()) { this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing query param: ${q.name}`, timestamp: Date.now() }); return; } } const urlPath = this.replacePathParams(ep.path, this.paramValues()); const qs = this.buildQueryString(ep.query, this.paramValues()); const fullUrl = qs ? `${urlPath}?${qs}` : urlPath; let headers: Record | undefined; if (this.headersText().trim() && this.headersText().trim() !== '{}') { try { headers = JSON.parse(this.headersText()); } catch (e: any) { this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid headers JSON: ${e.message || e}`, timestamp: Date.now() }); return; } } if (ep.contentType === 'binary') { if (!this.selectedFile) { this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: 'Please choose a file to upload', timestamp: Date.now() }); return; } await this.runTest(ep.method, fullUrl, async () => { return this.http.put(fullUrl, this.selectedFile as any, { headers: { 'Content-Type': (this.selectedFile as File).type || 'application/octet-stream', ...(headers || {}) } }).toPromise(); }); return; } // Prepare body let body: any = undefined; let contentTypeHeader: Record = {}; if (ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH') { if (ep.contentType === 'json') { const txt = this.bodyText(); try { body = txt ? JSON.parse(txt) : {}; } catch (e: any) { this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid JSON body: ${e.message || e}`, timestamp: Date.now() }); return; } contentTypeHeader = { 'Content-Type': 'application/json; charset=utf-8' }; } else if (ep.contentType === 'text') { body = this.bodyText(); contentTypeHeader = { 'Content-Type': 'text/markdown; charset=utf-8' }; } } const responseType = ep.responseType === 'text' ? 'text' : 'json'; await this.runTest(ep.method, fullUrl, async () => { return this.http.request(ep.method, fullUrl, { body, headers: { ...(contentTypeHeader || {}), ...(headers || {}) }, responseType: responseType as any }).toPromise(); }); } toggleFullscreen(): void { this.isFullscreen.update(v => !v); } async runHealthCheck(): Promise { await this.runTest('GET', '/api/health', async () => { return this.http.get('/api/health').toPromise(); }); } async runVaultMetadata(): Promise { await this.runTest('GET', '/api/vault/metadata', async () => { return this.http.get('/api/vault/metadata').toPromise(); }); } async runVaultPaginated(): Promise { await this.runTest('GET', '/api/vault/metadata/paginated', async () => { return this.http.get('/api/vault/metadata/paginated').toPromise(); }); } async runFileRead(): Promise { const path = this.testFilePath().trim(); if (!path) return; await this.runTest('GET', `/api/files?path=${encodeURIComponent(path)}`, async () => { return this.http.get(`/api/files?path=${encodeURIComponent(path)}`, { responseType: 'text' }).toPromise(); }); } async runFileWrite(): Promise { const path = this.testFilePath().trim(); const content = this.testFileContent().trim(); if (!path || !content) return; await this.runTest('PUT', `/api/files?path=${encodeURIComponent(path)}`, async () => { return this.http.put(`/api/files?path=${encodeURIComponent(path)}`, content, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } }).toPromise(); }); } async runFileDelete(): Promise { const path = this.testFilePath().trim(); if (!path) return; await this.runTest('DELETE', `/api/files?path=${encodeURIComponent(path)}`, async () => { return this.http.delete(`/api/files?path=${encodeURIComponent(path)}`).toPromise(); }); } async runTogglePublish(): Promise { const noteId = this.testNoteId().trim(); if (!noteId) return; await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/publish`, async () => { return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/publish`, { value: true }).toPromise(); }); } async runToggleFavorite(): Promise { const noteId = this.testNoteId().trim(); if (!noteId) return; await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/favoris`, async () => { return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/favoris`, { value: true }).toPromise(); }); } async runToggleDraft(): Promise { const noteId = this.testNoteId().trim(); if (!noteId) return; await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/draft`, async () => { return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/draft`, { value: true }).toPromise(); }); } runSSETest(): void { if (this.sseConnection) { this.sseConnection.close(); this.sseConnection = null; } this.sseConnection = new EventSource('/api/vault/events'); this.sseEvents.set([]); this.sseConnection.onopen = () => { this.addResult({ endpoint: '/api/vault/events', method: 'SSE', status: 'success', response: 'SSE connection established', timestamp: Date.now() }); }; this.sseConnection.onmessage = (event) => { try { const data = JSON.parse(event.data); this.sseEvents.update(events => [{ type: data.event || 'message', data, timestamp: new Date() }, ...events.slice(0, 9)]); // Keep last 10 events } catch (e) { // Ignore parse errors } }; this.sseConnection.onerror = (error) => { this.addResult({ endpoint: '/api/vault/events', method: 'SSE', status: 'error', error: 'SSE connection failed', timestamp: Date.now() }); this.sseConnection?.close(); this.sseConnection = null; }; } async runSimulateEvent(): Promise { await this.runTest('POST', '/api/simulate-event', async () => { return this.http.post('/api/simulate-event', { event: 'test', data: { message: 'Test event' } }).toPromise(); }); } async runLogTest(): Promise { const testLogs = [ { event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } }, { event: 'test_error', level: 'error', context: { route: '/api/test' }, data: { error: 'Test error' } } ]; await this.runTest('POST', '/api/log', async () => { return this.http.post('/api/log', testLogs[0]).toPromise(); }); await this.runTest('POST', '/api/logs', async () => { return this.http.post('/api/logs', testLogs[1]).toPromise(); }); } async runBatchTest(): Promise { this.isRunning.set(true); try { // Health check await this.runHealthCheck(); // Vault APIs await this.runVaultMetadata(); await this.runVaultPaginated(); // File operations (using a test file) this.testFilePath.set('test-file.md'); this.testFileContent.set('# Test File\n\nThis is a test file for API testing.'); await this.runFileWrite(); await this.runFileRead(); // State operations (using home note) this.testNoteId.set('home'); await this.runTogglePublish(); await this.runToggleFavorite(); await this.runToggleDraft(); // SSE this.runSSETest(); // Logging await this.runLogTest(); // Clean up await this.runFileDelete(); } catch (error) { console.error('Batch test error:', error); } finally { this.isRunning.set(false); } } private async runTest(method: string, endpoint: string, operation: () => Promise): Promise { const startTime = Date.now(); const result: TestResult = { endpoint, method, status: 'running', timestamp: startTime }; this.results.update(results => [result, ...results]); try { const response = await operation(); result.status = 'success'; result.response = response; } catch (error: any) { result.status = 'error'; result.error = error.message || error.toString(); } result.duration = Date.now() - startTime; this.results.update(results => results.map(r => r === result ? { ...result } : r)); } private addResult(result: TestResult): void { this.results.update(results => [result, ...results]); } clearResults(): void { this.results.set([]); this.sseEvents.set([]); if (this.sseConnection) { this.sseConnection.close(); this.sseConnection = null; } } getResultClass(result: TestResult): string { return `result-item--${result.status}`; } }