From 6da51d3810adbe7204ea16029f8551b577b6487d Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 7 Feb 2026 16:36:14 +0100 Subject: [PATCH 1/9] fix: moving from svelte4 to svelte5 also updated tests --- docs/reference/openapi.json | 180 +--------------- frontend/src/App.svelte | 28 +-- frontend/src/components/Header.svelte | 32 +-- .../src/components/NotificationCenter.svelte | 33 +-- frontend/src/components/ProtectedRoute.svelte | 6 +- .../src/components/__tests__/Header.test.ts | 74 ++++--- .../__tests__/NotificationCenter.test.ts | 130 ++++------- .../__tests__/ProtectedRoute.test.ts | 92 +++----- .../components/editor/CodeMirrorEditor.svelte | 19 +- frontend/src/lib/__tests__/auth-init.test.ts | 139 ++++++------ .../src/lib/__tests__/user-settings.test.ts | 21 +- frontend/src/lib/admin/autoRefresh.svelte.ts | 6 +- frontend/src/lib/api-interceptors.ts | 26 +-- frontend/src/lib/api/index.ts | 4 +- frontend/src/lib/api/sdk.gen.ts | 9 +- frontend/src/lib/api/types.gen.ts | 140 +----------- frontend/src/lib/auth-init.ts | 35 ++- frontend/src/lib/editor/execution.svelte.ts | 3 +- frontend/src/lib/user-settings.ts | 9 +- frontend/src/main.ts | 2 +- frontend/src/routes/Editor.svelte | 202 +++++++----------- frontend/src/routes/Login.svelte | 12 +- frontend/src/routes/Notifications.svelte | 19 +- frontend/src/routes/Settings.svelte | 9 +- frontend/src/routes/admin/AdminLayout.svelte | 15 +- frontend/src/stores/__tests__/auth.test.ts | 111 +++++----- .../src/stores/__tests__/errorStore.test.ts | 95 ++++---- .../__tests__/notificationStore.test.ts | 128 +++++------ frontend/src/stores/__tests__/theme.test.ts | 119 +++++------ .../src/stores/__tests__/userSettings.test.ts | 38 ++-- frontend/src/stores/auth.svelte.ts | 174 +++++++++++++++ frontend/src/stores/auth.ts | 169 --------------- frontend/src/stores/errorStore.svelte.ts | 20 ++ frontend/src/stores/errorStore.ts | 26 --- .../src/stores/notificationStore.svelte.ts | 79 +++++++ frontend/src/stores/notificationStore.ts | 102 --------- frontend/src/stores/theme.svelte.ts | 72 +++++++ frontend/src/stores/theme.ts | 79 ------- frontend/src/stores/userSettings.svelte.ts | 27 +++ frontend/src/stores/userSettings.ts | 26 --- 40 files changed, 978 insertions(+), 1532 deletions(-) create mode 100644 frontend/src/stores/auth.svelte.ts delete mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/errorStore.svelte.ts delete mode 100644 frontend/src/stores/errorStore.ts create mode 100644 frontend/src/stores/notificationStore.svelte.ts delete mode 100644 frontend/src/stores/notificationStore.ts create mode 100644 frontend/src/stores/theme.svelte.ts delete mode 100644 frontend/src/stores/theme.ts create mode 100644 frontend/src/stores/userSettings.svelte.ts delete mode 100644 frontend/src/stores/userSettings.ts diff --git a/docs/reference/openapi.json b/docs/reference/openapi.json index d21477bc..2edcb937 100644 --- a/docs/reference/openapi.json +++ b/docs/reference/openapi.json @@ -1859,28 +1859,6 @@ } } }, - "/api/v1/events/health": { - "get": { - "tags": [ - "sse" - ], - "summary": "Sse Health", - "description": "Get SSE service health status.", - "operationId": "sse_health_api_v1_events_health_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SSEHealthResponse" - } - } - } - } - } - } - }, "/api/v1/events/executions/{execution_id}/events": { "get": { "tags": [ @@ -9068,14 +9046,7 @@ "title": "Exit Code" }, "error_type": { - "anyOf": [ - { - "$ref": "#/components/schemas/ExecutionErrorType" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/ExecutionErrorType" }, "error_message": { "type": "string", @@ -9896,7 +9867,6 @@ "saga_commands", "dead_letter_queue", "dlq_events", - "event_bus_stream", "websocket_events" ], "title": "KafkaTopic", @@ -13104,10 +13074,7 @@ "enum": [ "connected", "subscribed", - "heartbeat", - "shutdown", - "status", - "error" + "status" ], "title": "SSEControlEvent", "description": "Control events for execution SSE streams (not from Kafka)." @@ -13178,31 +13145,7 @@ } ], "title": "Message", - "description": "Human-readable message (heartbeat, shutdown)" - }, - "grace_period": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Grace Period", - "description": "Shutdown grace period in seconds" - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error", - "description": "Error message (error event)" + "description": "Human-readable message (subscribed event)" }, "status": { "anyOf": [ @@ -13294,71 +13237,6 @@ "title": "SSEExecutionEventData", "description": "Typed model for SSE execution stream event payload.\n\nThis represents the JSON data sent inside each SSE message for execution streams.\nAll fields except event_type and execution_id are optional since different\nevent types carry different data." }, - "SSEHealthResponse": { - "properties": { - "status": { - "$ref": "#/components/schemas/SSEHealthStatus", - "description": "Health status: healthy or draining" - }, - "kafka_enabled": { - "type": "boolean", - "title": "Kafka Enabled", - "description": "Whether Kafka features are enabled", - "default": true - }, - "active_connections": { - "type": "integer", - "title": "Active Connections", - "description": "Total number of active SSE connections" - }, - "active_executions": { - "type": "integer", - "title": "Active Executions", - "description": "Number of executions being monitored" - }, - "active_consumers": { - "type": "integer", - "title": "Active Consumers", - "description": "Number of active Kafka consumers" - }, - "max_connections_per_user": { - "type": "integer", - "title": "Max Connections Per User", - "description": "Maximum connections allowed per user" - }, - "shutdown": { - "$ref": "#/components/schemas/ShutdownStatusResponse", - "description": "Shutdown status information" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "title": "Timestamp", - "description": "Health check timestamp" - } - }, - "type": "object", - "required": [ - "status", - "active_connections", - "active_executions", - "active_consumers", - "max_connections_per_user", - "shutdown", - "timestamp" - ], - "title": "SSEHealthResponse", - "description": "Response model for SSE health check." - }, - "SSEHealthStatus": { - "type": "string", - "enum": [ - "healthy", - "draining" - ], - "title": "SSEHealthStatus", - "description": "Health status for SSE service." - }, "SagaCancellationResponse": { "properties": { "success": { @@ -14782,57 +14660,6 @@ "title": "SettingsHistoryResponse", "description": "Response model for settings history (limited snapshot of recent changes)" }, - "ShutdownStatusResponse": { - "properties": { - "phase": { - "type": "string", - "title": "Phase", - "description": "Current shutdown phase" - }, - "initiated": { - "type": "boolean", - "title": "Initiated", - "description": "Whether shutdown has been initiated" - }, - "complete": { - "type": "boolean", - "title": "Complete", - "description": "Whether shutdown is complete" - }, - "active_connections": { - "type": "integer", - "title": "Active Connections", - "description": "Number of active connections" - }, - "draining_connections": { - "type": "integer", - "title": "Draining Connections", - "description": "Number of connections being drained" - }, - "duration": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Duration", - "description": "Duration of shutdown in seconds" - } - }, - "type": "object", - "required": [ - "phase", - "initiated", - "complete", - "active_connections", - "draining_connections" - ], - "title": "ShutdownStatusResponse", - "description": "Response model for shutdown status." - }, "SortOrder": { "type": "string", "enum": [ @@ -15984,6 +15811,7 @@ "title": "Reason" } }, + "additionalProperties": true, "type": "object", "required": [ "event_id", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 70226f99..3727c997 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,16 +1,15 @@ -{#if globalError} - +{#if appError.current} + {:else}
diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte index 995f7227..a3cc9489 100644 --- a/frontend/src/components/Header.svelte +++ b/frontend/src/components/Header.svelte @@ -1,7 +1,7 @@ @@ -312,15 +274,15 @@
- scriptName.set(n)} onexample={loadExampleScript} /> + scriptName = n} onexample={loadExampleScript} />
- {#if $script.trim() === ''} + {#if script.trim() === ''}

Editor is Empty

Start typing, upload a file, or use an example to begin.

@@ -342,9 +304,9 @@
{ selectedLang.set(l); selectedVersion.set(v); }} + lang={selectedLang} + version={selectedVersion} + onselect={(l, v) => { selectedLang = l; selectedVersion = v; }} />
- scriptName = n} onexample={loadExampleScript} /> + { scriptName = n; nameEditedByUser = true; }} onexample={loadExampleScript} />
(null); const tabs = [ { id: 'general', label: 'General' }, @@ -68,6 +68,27 @@ { value: 'github', label: 'GitHub' } ]; + function mapApiToFormData(data: UserSettings) { + return { + theme: data.theme || 'auto', + notifications: { + execution_completed: data.notifications?.execution_completed ?? true, + execution_failed: data.notifications?.execution_failed ?? true, + system_updates: data.notifications?.system_updates ?? true, + security_alerts: data.notifications?.security_alerts ?? true, + channels: [...(data.notifications?.channels || ['in_app'])] + }, + editor: { + theme: data.editor?.theme || 'auto', + font_size: data.editor?.font_size || 14, + tab_size: data.editor?.tab_size || 4, + use_tabs: data.editor?.use_tabs ?? false, + word_wrap: data.editor?.word_wrap ?? true, + show_line_numbers: data.editor?.show_line_numbers ?? true, + } + }; + } + onMount(() => { // First verify if user is authenticated if (!authStore.isAuthenticated) { @@ -101,51 +122,25 @@ } setUserSettings(data ?? null); - - if (data) { - formData = { - theme: data.theme || 'auto', - notifications: { - execution_completed: data.notifications?.execution_completed ?? true, - execution_failed: data.notifications?.execution_failed ?? true, - system_updates: data.notifications?.system_updates ?? true, - security_alerts: data.notifications?.security_alerts ?? true, - channels: [...(data.notifications?.channels || ['in_app'])] - }, - editor: { - theme: data.editor?.theme || 'auto', - font_size: data.editor?.font_size || 14, - tab_size: data.editor?.tab_size || 4, - use_tabs: data.editor?.use_tabs ?? false, - word_wrap: data.editor?.word_wrap ?? true, - show_line_numbers: data.editor?.show_line_numbers ?? true, - } - }; - } - savedSnapshot = JSON.stringify(formData); + if (data) formData = mapApiToFormData(data); + savedSnapshot = $state.snapshot(formData); loading = false; } async function saveSettings() { - const currentState = JSON.stringify(formData); - if (currentState === savedSnapshot) { + const current = $state.snapshot(formData); + if (!savedSnapshot || JSON.stringify(current) === JSON.stringify(savedSnapshot)) { toast.info('No changes to save'); return; } saving = true; - const original = JSON.parse(savedSnapshot); - const updates: Record = {}; - - if (formData.theme !== original.theme) { - updates.theme = formData.theme; - } - if (JSON.stringify(formData.notifications) !== JSON.stringify(original.notifications)) { - updates.notifications = formData.notifications; - } - if (JSON.stringify(formData.editor) !== JSON.stringify(original.editor)) { - updates.editor = formData.editor; - } + const updates: Record = {}; + if (current.theme !== savedSnapshot.theme) updates.theme = current.theme; + if (JSON.stringify(current.notifications) !== JSON.stringify(savedSnapshot.notifications)) + updates.notifications = current.notifications; + if (JSON.stringify(current.editor) !== JSON.stringify(savedSnapshot.editor)) + updates.editor = current.editor; const { data, error } = await updateUserSettingsApiV1UserSettingsPut({ body: updates }); if (error) { @@ -154,27 +149,8 @@ } setUserSettings(data); - - formData = { - theme: data.theme || 'auto', - notifications: { - execution_completed: data.notifications?.execution_completed ?? true, - execution_failed: data.notifications?.execution_failed ?? true, - system_updates: data.notifications?.system_updates ?? true, - security_alerts: data.notifications?.security_alerts ?? true, - channels: [...(data.notifications?.channels || ['in_app'])] - }, - editor: { - theme: data.editor?.theme || 'auto', - font_size: data.editor?.font_size || 14, - tab_size: data.editor?.tab_size || 4, - use_tabs: data.editor?.use_tabs ?? false, - word_wrap: data.editor?.word_wrap ?? true, - show_line_numbers: data.editor?.show_line_numbers ?? true, - } - }; - savedSnapshot = JSON.stringify(formData); - + formData = mapApiToFormData(data); + savedSnapshot = $state.snapshot(formData); toast.success('Settings saved successfully'); saving = false; } diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index 776607ab..b09d4c28 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { setupAnimationMock } from '$lib/../__tests__/test-utils'; @@ -84,24 +84,42 @@ vi.mock('$components/Spinner.svelte', async () => (await import('$lib/../__tests__/test-utils')).createMockSvelteComponent('', 'spinner')); vi.mock('@lucide/svelte', async () => - (await import('$lib/../__tests__/test-utils')).createMockIconModule('CirclePlay', 'Settings', 'Lightbulb')); + (await import('$lib/../__tests__/test-utils')).createMockIconModule( + 'CirclePlay', 'Settings', 'Lightbulb', + 'FilePlus', 'Upload', 'Download', 'Save', + 'List', 'Trash2')); -vi.mock('$components/editor', async () => - (await import('$lib/../__tests__/test-utils')).createMockNamedComponents({ - CodeMirrorEditor: '
', +vi.mock('$components/editor', async () => { + const utils = await import('$lib/../__tests__/test-utils'); + const components = utils.createMockNamedComponents({ OutputPanel: '
Execution Output
', LanguageSelect: '
', ResourceLimits: '
', - EditorToolbar: '
', - ScriptActions: '
', - SavedScripts: '
', - })); + }); + // CodeMirrorEditor needs setContent for bind:this usage in newScript/loadScript + const CodeMirrorEditor = function () { + return { setContent() {} }; + } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; + CodeMirrorEditor.render = () => ({ + html: '
', + css: { code: '', map: null }, head: '', + }); + components.CodeMirrorEditor = CodeMirrorEditor; + const { default: EditorToolbar } = await import('$components/editor/EditorToolbar.svelte'); + components.EditorToolbar = EditorToolbar; + const { default: ScriptActions } = await import('$components/editor/ScriptActions.svelte'); + components.ScriptActions = ScriptActions; + const { default: SavedScripts } = await import('$components/editor/SavedScripts.svelte'); + components.SavedScripts = SavedScripts; + return components; +}); describe('Editor', () => { const user = userEvent.setup(); beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); setupAnimationMock(); vi.stubGlobal('confirm', mocks.mockConfirm); mocks.mockAuthStore.isAuthenticated = true; @@ -125,6 +143,8 @@ describe('Editor', () => { mocks.deleteSavedScriptApiV1ScriptsScriptIdDelete.mockResolvedValue({ data: {}, error: undefined }); }); + afterEach(() => vi.unstubAllGlobals()); + async function renderEditor() { const { default: Editor } = await import('$routes/Editor.svelte'); return render(Editor); @@ -172,23 +192,43 @@ describe('Editor', () => { }); describe('Save script', () => { - it('warns when not authenticated', async () => { + it('hides Save button when not authenticated', async () => { mocks.mockAuthStore.isAuthenticated = false; await renderEditor(); await waitFor(() => { expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); }); + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + expect(screen.queryByTitle('Save current script')).not.toBeInTheDocument(); }); - it('creates new script via POST API', async () => { + it('creates new script via POST API when Save is clicked', async () => { await renderEditor(); await waitFor(() => { expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); }); - expect(mocks.createSavedScriptApiV1ScriptsPost).toBeDefined(); + + await user.type(screen.getByLabelText('Script Name'), 'My New Script'); + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByTitle('Save current script')); + + await waitFor(() => { + expect(mocks.createSavedScriptApiV1ScriptsPost).toHaveBeenCalledWith({ + body: expect.objectContaining({ + name: 'My New Script', + lang: 'python', + lang_version: '3.11', + }), + }); + }); + expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); }); it('falls back to create when update returns 404', async () => { + mocks.listSavedScriptsApiV1ScriptsGet.mockResolvedValue({ + data: [{ script_id: 'script-99', name: 'Existing Script', script: 'print(1)', lang: 'python', lang_version: '3.11' }], + error: undefined, + }); mocks.updateSavedScriptApiV1ScriptsScriptIdPut.mockResolvedValue({ data: undefined, error: { detail: 'Not found' }, @@ -198,40 +238,105 @@ describe('Editor', () => { data: { script_id: 'fallback-1' }, error: undefined, }); - const updateResult = await mocks.updateSavedScriptApiV1ScriptsScriptIdPut({}); - expect(updateResult.response?.status).toBe(404); + + await renderEditor(); + await waitFor(() => { + expect(mocks.listSavedScriptsApiV1ScriptsGet).toHaveBeenCalled(); + }); + + // Load saved script to set currentScriptId + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByRole('button', { name: 'Show Saved Scripts' })); + await waitFor(() => { + expect(screen.getByTitle(/Load Existing Script/)).toBeInTheDocument(); + }); + await user.click(screen.getByTitle(/Load Existing Script/)); + expect(mocks.addToast).toHaveBeenCalledWith('info', 'Loaded script: Existing Script'); + + // Options closed after loadScript, reopen and click Save + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByTitle('Save current script')); + + await waitFor(() => { + expect(mocks.updateSavedScriptApiV1ScriptsScriptIdPut).toHaveBeenCalledWith({ + path: { script_id: 'script-99' }, + body: expect.objectContaining({ + name: 'Existing Script', + lang: 'python', + lang_version: '3.11', + }), + }); + }); + await waitFor(() => { + expect(mocks.createSavedScriptApiV1ScriptsPost).toHaveBeenCalledWith({ + body: expect.objectContaining({ + name: 'Existing Script', + lang: 'python', + lang_version: '3.11', + }), + }); + }); + expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script saved successfully.'); }); }); describe('Delete script', () => { - it('calls confirm with script name and deletes on acceptance', async () => { + it('calls confirm and delete API when delete button is clicked', async () => { mocks.mockConfirm.mockReturnValue(true); + mocks.listSavedScriptsApiV1ScriptsGet.mockResolvedValue({ + data: [{ script_id: 'script-99', name: 'My Script', script: 'print(1)', lang: 'python', lang_version: '3.11' }], + error: undefined, + }); + await renderEditor(); await waitFor(() => { - expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + expect(mocks.listSavedScriptsApiV1ScriptsGet).toHaveBeenCalled(); + }); + + // Open options panel, then expand saved scripts list + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByRole('button', { name: 'Show Saved Scripts' })); + await waitFor(() => { + expect(screen.getByTitle('Delete My Script')).toBeInTheDocument(); }); - expect(mocks.deleteSavedScriptApiV1ScriptsScriptIdDelete).toBeDefined(); + + await user.click(screen.getByTitle('Delete My Script')); + expect(mocks.mockConfirm).toHaveBeenCalledWith('Are you sure you want to delete "My Script"?'); + await waitFor(() => { + expect(mocks.deleteSavedScriptApiV1ScriptsScriptIdDelete).toHaveBeenCalledWith({ + path: { script_id: 'script-99' }, + }); + }); + expect(mocks.addToast).toHaveBeenCalledWith('success', 'Script deleted successfully.'); }); }); describe('Execution', () => { - it('has execution state wired from createExecutionState', async () => { + it('calls execution.execute with script, lang, and version when Run Script is clicked', async () => { await renderEditor(); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Code Editor' })).toBeInTheDocument(); + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); }); - expect(mocks.mockExecutionState.execute).toBeDefined(); - expect(mocks.mockExecutionState.reset).toBeDefined(); + + await user.click(screen.getByRole('button', { name: /Run Script/i })); + expect(mocks.mockExecutionState.execute).toHaveBeenCalledWith( + expect.any(String), 'python', '3.11', + ); }); }); describe('New script', () => { - it('verifies execution.reset is available for new script flow', async () => { + it('calls execution.reset and shows toast when New is clicked', async () => { await renderEditor(); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Code Editor' })).toBeInTheDocument(); + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); }); - expect(mocks.mockExecutionState.reset).toBeDefined(); + + // Open options panel then click New inside ScriptActions + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByRole('button', { name: 'New' })); + expect(mocks.mockExecutionState.reset).toHaveBeenCalled(); + expect(mocks.addToast).toHaveBeenCalledWith('info', 'New script started.'); }); }); }); diff --git a/frontend/src/routes/__tests__/Notifications.test.ts b/frontend/src/routes/__tests__/Notifications.test.ts index c892b384..f7771d66 100644 --- a/frontend/src/routes/__tests__/Notifications.test.ts +++ b/frontend/src/routes/__tests__/Notifications.test.ts @@ -71,10 +71,11 @@ describe('Notifications', () => { const notif = createMockNotification({ subject: 'Execution Complete', body: 'Your script finished running', + channel: 'email', severity: 'high', tags: ['completed', 'exec:abc123'], status: 'unread', - } as Parameters[0] & { channel: string }); + }); mocks.mockNotificationStore.notifications = [notif as never]; mocks.mockNotificationStore.unreadCount = 1; @@ -83,6 +84,9 @@ describe('Notifications', () => { expect(screen.getByText('Execution Complete')).toBeInTheDocument(); }); expect(screen.getByText('Your script finished running')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.getByText('high')).toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); }); it('shows "Read" label for read notifications', async () => { @@ -194,23 +198,28 @@ describe('Notifications', () => { }); describe('Delete', () => { + async function findDeleteButton(): Promise { + const btn = await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const found = buttons.find(b => b.classList.contains('text-red-600') || b.querySelector('[data-testid="trash-icon"]')); + expect(found).toBeDefined(); + return found!; + }); + return btn; + } + it('calls delete with exact id and shows success toast', async () => { mocks.mockNotificationStore.notifications = [ createMockNotification({ notification_id: 'del-1' }), ] as never[]; await renderNotifications(); - const deleteBtn = await waitFor(() => { - const buttons = screen.getAllByRole('button'); - return buttons.find(b => b.classList.contains('text-red-600') || b.querySelector('[data-testid="trash-icon"]')); + const deleteBtn = await findDeleteButton(); + await user.click(deleteBtn); + await waitFor(() => { + expect(mocks.mockNotificationStore.delete).toHaveBeenCalledWith('del-1'); }); - if (deleteBtn) { - await user.click(deleteBtn); - await waitFor(() => { - expect(mocks.mockNotificationStore.delete).toHaveBeenCalledWith('del-1'); - }); - expect(mocks.addToast).toHaveBeenCalledWith('success', 'Notification deleted'); - } + expect(mocks.addToast).toHaveBeenCalledWith('success', 'Notification deleted'); }); it('shows error toast on delete failure', async () => { @@ -220,16 +229,11 @@ describe('Notifications', () => { ] as never[]; await renderNotifications(); - const deleteBtn = await waitFor(() => { - const buttons = screen.getAllByRole('button'); - return buttons.find(b => b.classList.contains('text-red-600') || b.querySelector('[data-testid="trash-icon"]')); + const deleteBtn = await findDeleteButton(); + await user.click(deleteBtn); + await waitFor(() => { + expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to delete notification'); }); - if (deleteBtn) { - await user.click(deleteBtn); - await waitFor(() => { - expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to delete notification'); - }); - } }); it('prevents double-click deletion', async () => { @@ -242,16 +246,11 @@ describe('Notifications', () => { ] as never[]; await renderNotifications(); - const deleteBtn = await waitFor(() => { - const buttons = screen.getAllByRole('button'); - return buttons.find(b => b.classList.contains('text-red-600') || b.querySelector('[data-testid="trash-icon"]')); - }); - if (deleteBtn) { - await user.click(deleteBtn); - await user.click(deleteBtn); - expect(mocks.mockNotificationStore.delete).toHaveBeenCalledTimes(1); - resolveDelete!(true); - } + const deleteBtn = await findDeleteButton(); + await user.click(deleteBtn); + await user.click(deleteBtn); + expect(mocks.mockNotificationStore.delete).toHaveBeenCalledTimes(1); + resolveDelete!(true); }); }); diff --git a/frontend/src/routes/__tests__/Settings.test.ts b/frontend/src/routes/__tests__/Settings.test.ts index f39c4636..cd2db7e2 100644 --- a/frontend/src/routes/__tests__/Settings.test.ts +++ b/frontend/src/routes/__tests__/Settings.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { setupAnimationMock } from '$lib/../__tests__/test-utils'; @@ -82,6 +82,8 @@ describe('Settings', () => { }); }); + afterEach(() => vi.unstubAllGlobals()); + async function renderSettings() { const { default: Settings } = await import('$routes/Settings.svelte'); return render(Settings); diff --git a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts index a1555e42..f60457e6 100644 --- a/frontend/src/routes/admin/__tests__/AdminSettings.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSettings.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { mockElementAnimate } from '$routes/admin/__tests__/test-utils'; @@ -84,6 +84,8 @@ describe('AdminSettings', () => { }); }); + afterEach(() => vi.unstubAllGlobals()); + async function renderAdminSettings() { const { default: AdminSettings } = await import('$routes/admin/AdminSettings.svelte'); return render(AdminSettings); From 91ca857851ae1e88aa4f8e637528ee22b5402f7f Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 7 Feb 2026 20:37:55 +0100 Subject: [PATCH 7/9] fix: frontend lint --- frontend/src/lib/api-interceptors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/api-interceptors.ts b/frontend/src/lib/api-interceptors.ts index 68b2b17b..8625feed 100644 --- a/frontend/src/lib/api-interceptors.ts +++ b/frontend/src/lib/api-interceptors.ts @@ -96,9 +96,9 @@ export function initializeApiInterceptors(): void { credentials: 'include', }); - client.interceptors.error.use((error, response, request, _opts) => { + client.interceptors.error.use((error, response: Response | undefined, request, _opts) => { const status = response?.status; - const url = request?.url || ''; + const url = request.url; const isAuthEndpoint = AUTH_ENDPOINTS.some(ep => url.includes(ep)); console.error('[API Error]', { status, url, error }); From d678d01cfd8197c7691ff9d5c56750c2df0de5f9 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 7 Feb 2026 21:01:12 +0100 Subject: [PATCH 8/9] fix: tests --- frontend/e2e/editor.spec.ts | 8 +++++++- frontend/e2e/fixtures.ts | 9 +++++++-- frontend/src/lib/api-interceptors.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/editor.spec.ts b/frontend/e2e/editor.spec.ts index 2d08fd41..bc15f3a3 100644 --- a/frontend/e2e/editor.spec.ts +++ b/frontend/e2e/editor.spec.ts @@ -1,6 +1,6 @@ import { test, expect, runExampleAndExecute, expectToastVisible, describeAuthRequired, - loadExampleScript, openScriptOptions, saveScriptAs, + loadExampleScript, openScriptOptions, saveScriptAs, expandSavedScripts, } from './fixtures'; const PATH = '/editor'; @@ -133,6 +133,9 @@ test.describe('Editor Script Management', () => { await userPage.getByRole('button', { name: /New/i }).click(); await expect(userPage.locator('#scriptNameInput')).toHaveValue(''); + // Expand the saved scripts list to find the saved script + await expandSavedScripts(userPage); + const savedScript = userPage.locator(`text="${scriptName}"`).first(); await expect(savedScript).toBeVisible({ timeout: 3000 }); await savedScript.click(); @@ -145,6 +148,9 @@ test.describe('Editor Script Management', () => { const scriptName = `Delete Test ${Date.now()}`; await saveScriptAs(userPage, scriptName); + // Expand the saved scripts list to find the saved script + await expandSavedScripts(userPage); + const scriptRow = userPage.locator(`text="${scriptName}"`).first(); await expect(scriptRow).toBeVisible({ timeout: 3000 }); const deleteBtn = userPage.locator(`button[title="Delete ${scriptName}"]`); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 9b5194a8..1db6040a 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -204,7 +204,7 @@ async function waitForExecutionResult(page: Page, executionId: string, timeoutMs } export async function runExampleAndExecute(page: Page): Promise { - await page.getByRole('button', { name: /Example/i }).click(); + await page.getByRole('button', { name: 'Example', exact: true }).click(); await expect(page.locator('.cm-content')).not.toBeEmpty({ timeout: 2000 }); const executeResponsePromise = page.waitForResponse((response) => @@ -230,7 +230,7 @@ export async function runExampleAndExecute(page: Page): Promise } export async function loadExampleScript(page: Page): Promise { - await page.getByRole('button', { name: /Example/i }).click(); + await page.getByRole('button', { name: 'Example', exact: true }).click(); await expect(page.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); } @@ -246,6 +246,11 @@ export async function saveScriptAs(page: Page, name: string): Promise { await expectToastVisible(page); } +export async function expandSavedScripts(page: Page): Promise { + const toggle = page.getByRole('button', { name: /Show Saved Scripts/i }); + await toggle.click(); +} + export async function expectAuthRequired(page: Page, path: string): Promise { await clearSession(page); await page.goto(path); diff --git a/frontend/src/lib/api-interceptors.ts b/frontend/src/lib/api-interceptors.ts index 8625feed..c6666c8a 100644 --- a/frontend/src/lib/api-interceptors.ts +++ b/frontend/src/lib/api-interceptors.ts @@ -77,7 +77,7 @@ function handleErrorStatus(status: number | undefined, error: unknown, isAuthEnd if (status === 422 && typeof error === 'object' && error !== null) { const detail = (error as Record).detail; if (Array.isArray(detail) && detail.length > 0) { - toast.error(`Validation error:\n${getErrorMessage(error)}`); + toast.error(`Validation error: ${getErrorMessage(error)}`); return true; } } From 4bf75b360b6ad44e2c903c3a78f7a5dc18498fb7 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 7 Feb 2026 21:13:52 +0100 Subject: [PATCH 9/9] fix: tests --- frontend/e2e/notifications.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/notifications.spec.ts b/frontend/e2e/notifications.spec.ts index bc42b0e7..1c4b43eb 100644 --- a/frontend/e2e/notifications.spec.ts +++ b/frontend/e2e/notifications.spec.ts @@ -47,10 +47,8 @@ test.describe('Notifications Page', () => { await gotoAndWaitForNotifications(userPage); const emptyState = userPage.getByText('No notifications yet'); // Notification cards have aria-label="Mark notification as read" - const notificationCard = userPage.locator('[aria-label="Mark notification as read"]'); - const hasEmptyState = await emptyState.isVisible({ timeout: 3000 }).catch(() => false); - const hasNotifications = await notificationCard.first().isVisible({ timeout: 3000 }).catch(() => false); - expect(hasEmptyState || hasNotifications).toBe(true); + const notificationCard = userPage.locator('[aria-label="Mark notification as read"]').first(); + await expect(emptyState.or(notificationCard)).toBeVisible({ timeout: 5000 }); }); });