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/e2e/editor.spec.ts b/frontend/e2e/editor.spec.ts index e1afe113..bc15f3a3 100644 --- a/frontend/e2e/editor.spec.ts +++ b/frontend/e2e/editor.spec.ts @@ -1,4 +1,7 @@ -import { test, expect, runExampleAndExecute, expectToastVisible, describeAuthRequired } from './fixtures'; +import { + test, expect, runExampleAndExecute, expectToastVisible, describeAuthRequired, + loadExampleScript, openScriptOptions, saveScriptAs, expandSavedScripts, +} from './fixtures'; const PATH = '/editor'; @@ -35,10 +38,7 @@ test.describe('Editor Page', () => { test('shows file actions when panel opened', async ({ userPage }) => { await userPage.goto(PATH); - // Use the button's accessible name from sr-only text - const optionsToggle = userPage.getByRole('button', { name: 'Toggle Script Options' }); - await expect(optionsToggle).toBeVisible(); - await optionsToggle.click(); + await openScriptOptions(userPage); await expect(userPage.getByText('File Actions')).toBeVisible(); await expect(userPage.getByRole('button', { name: /New/i })).toBeVisible(); await expect(userPage.getByRole('button', { name: /Upload/i })).toBeVisible(); @@ -48,10 +48,8 @@ test.describe('Editor Page', () => { test('can load example script', async ({ userPage }) => { await userPage.goto(PATH); - await userPage.getByRole('button', { name: /Example/i }).click(); - const editor = userPage.locator('.cm-content'); - await expect(editor).not.toBeEmpty({ timeout: 3000 }); - const content = await editor.textContent(); + await loadExampleScript(userPage); + const content = await userPage.locator('.cm-content').textContent(); expect(content).toBeTruthy(); expect(content!.length).toBeGreaterThan(0); }); @@ -97,8 +95,7 @@ test.describe('Editor Execution', () => { test('run button is disabled during execution', async ({ userPage }) => { await userPage.goto(PATH); - await userPage.getByRole('button', { name: /Example/i }).click(); - await expect(userPage.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); + await loadExampleScript(userPage); const runButton = userPage.getByRole('button', { name: /Run Script/i }); await runButton.click(); const executingButton = userPage.getByRole('button', { name: /Executing/i }); @@ -110,31 +107,71 @@ test.describe('Editor Execution', () => { test.describe('Editor Script Management', () => { test('can save script when authenticated', async ({ userPage }) => { await userPage.goto(PATH); - await userPage.getByRole('button', { name: /Example/i }).click(); - await expect(userPage.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); - await userPage.locator('#scriptNameInput').fill(`Test Script ${Date.now()}`); - const optionsToggle = userPage.getByRole('button', { name: 'Toggle Script Options' }); - await optionsToggle.click(); - await userPage.locator('button[title="Save current script"]').click(); - await expectToastVisible(userPage); + await saveScriptAs(userPage, `Test Script ${Date.now()}`); }); test('can create new script', async ({ userPage }) => { await userPage.goto(PATH); - await userPage.getByRole('button', { name: /Example/i }).click(); - await expect(userPage.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); - const optionsToggle = userPage.getByRole('button', { name: 'Toggle Script Options' }); - await optionsToggle.click(); + await loadExampleScript(userPage); + await openScriptOptions(userPage); await userPage.getByRole('button', { name: /New/i }).click(); await expect(userPage.locator('#scriptNameInput')).toHaveValue(''); }); test('shows saved scripts section when authenticated', async ({ userPage }) => { await userPage.goto(PATH); - const optionsToggle = userPage.getByRole('button', { name: 'Toggle Script Options' }); - await optionsToggle.click(); + await openScriptOptions(userPage); await expect(userPage.getByRole('heading', { name: 'Saved Scripts' })).toBeVisible(); }); + + test('can load a previously saved script', async ({ userPage }) => { + await userPage.goto(PATH); + const scriptName = `Load Test ${Date.now()}`; + await saveScriptAs(userPage, scriptName); + + // Create new script to clear state + 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(); + await expectToastVisible(userPage); + await expect(userPage.locator('#scriptNameInput')).toHaveValue(scriptName); + }); + + test('can delete a saved script', async ({ userPage }) => { + await userPage.goto(PATH); + 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}"]`); + await expect(deleteBtn).toBeVisible({ timeout: 2000 }); + userPage.on('dialog', dialog => dialog.accept()); + await deleteBtn.click(); + await expectToastVisible(userPage); + }); + + test('can export script as file', async ({ userPage }) => { + await userPage.goto(PATH); + await loadExampleScript(userPage); + await userPage.locator('#scriptNameInput').fill('export-test'); + await openScriptOptions(userPage); + + const [download] = await Promise.all([ + userPage.waitForEvent('download', { timeout: 5000 }), + userPage.getByRole('button', { name: /Export/i }).click(), + ]); + expect(download.suggestedFilename()).toContain('export-test'); + }); }); describeAuthRequired(test, PATH); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 41d3e9f6..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) => @@ -229,6 +229,28 @@ export async function runExampleAndExecute(page: Page): Promise return result; } +export async function loadExampleScript(page: Page): Promise { + await page.getByRole('button', { name: 'Example', exact: true }).click(); + await expect(page.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); +} + +export async function openScriptOptions(page: Page): Promise { + await page.getByRole('button', { name: 'Toggle Script Options' }).click(); +} + +export async function saveScriptAs(page: Page, name: string): Promise { + await loadExampleScript(page); + await page.locator('#scriptNameInput').fill(name); + await openScriptOptions(page); + await page.locator('button[title="Save current script"]').click(); + 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/e2e/notifications.spec.ts b/frontend/e2e/notifications.spec.ts index e9c910a5..1c4b43eb 100644 --- a/frontend/e2e/notifications.spec.ts +++ b/frontend/e2e/notifications.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, describeAuthRequired } from './fixtures'; +import { test, expect, describeAuthRequired, expectToastVisible } from './fixtures'; const PATH = '/notifications'; const HEADING = 'Notifications'; @@ -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 }); }); }); @@ -83,6 +81,43 @@ test.describe('Notifications Interaction', () => { }); }); +test.describe('Notification Actions', () => { + test('can mark notification as read by clicking', async ({ userPage }) => { + await gotoAndWaitForNotifications(userPage); + const notificationCard = userPage.locator('[aria-label="Mark notification as read"]').first(); + if (await notificationCard.isVisible({ timeout: 3000 }).catch(() => false)) { + // Check if it's unread (has blue background class) + const hasBlue = await notificationCard.evaluate(el => el.classList.toString().includes('bg-blue')); + await notificationCard.click(); + // After clicking, check if styling changed or "Read" label appeared + if (hasBlue) { + await expect(notificationCard.locator('text=Read')).toBeVisible({ timeout: 3000 }); + } + } + }); + + test('can delete a notification', async ({ userPage }) => { + await gotoAndWaitForNotifications(userPage); + const notificationCard = userPage.locator('[aria-label="Mark notification as read"]').first(); + if (await notificationCard.isVisible({ timeout: 3000 }).catch(() => false)) { + const deleteBtn = notificationCard.locator('button').filter({ has: userPage.locator('svg') }).first(); + if (await deleteBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await deleteBtn.click(); + await expectToastVisible(userPage); + } + } + }); + + test('can mark all as read', async ({ userPage }) => { + await gotoAndWaitForNotifications(userPage); + const markAllBtn = userPage.getByRole('button', { name: /mark all as read/i }); + if (await markAllBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await markAllBtn.click(); + await expectToastVisible(userPage); + } + }); +}); + test.describe('Notification Center Header Component', () => { test('shows notification icon in header when authenticated', async ({ userPage }) => { await userPage.goto(PATH); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index c08b1753..44de0cd8 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -148,6 +148,27 @@ test.describe('Settings Save and History', () => { await userPage.getByRole('button', { name: 'Close', exact: true }).click(); await expect(userPage.getByRole('heading', { name: 'Settings History' })).not.toBeVisible(); }); + + test('can restore settings from history', async ({ userPage }) => { + // First make a change and save to ensure history exists + await userPage.goto(PATH); + await userPage.getByRole('button', { name: 'Editor' }).click(); + const fontSizeInput = userPage.locator('#font-size'); + const currentValue = await fontSizeInput.inputValue(); + await fontSizeInput.fill(currentValue === '14' ? '15' : '14'); + await userPage.getByRole('button', { name: 'Save Settings' }).click(); + await expectToastVisible(userPage); + + // Open history and look for restore button + await userPage.getByRole('button', { name: 'View History' }).click(); + await expect(userPage.getByRole('heading', { name: 'Settings History' })).toBeVisible(); + + const restoreBtn = userPage.getByRole('button', { name: 'Restore' }).first(); + await expect(restoreBtn).toBeVisible({ timeout: 3000 }); + userPage.on('dialog', dialog => dialog.accept()); + await restoreBtn.click(); + await expectToastVisible(userPage); + }); }); test.describe('Settings Access Control', () => { diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 40d06c64..3b452ef8 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -26,6 +26,7 @@ export default [ parserOptions: { ecmaVersion: 2022, sourceType: 'module', + project: './tsconfig.json', }, globals: { ...globals.browser, @@ -37,9 +38,12 @@ export default [ '@typescript-eslint': tseslint, }, rules: { - ...tseslint.configs.recommended.rules, + ...tseslint.configs['strict-type-checked'].rules, '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - '@typescript-eslint/no-explicit-any': 'off', // Too noisy for generated code + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/only-throw-error': 'off', // API client returns unknown errors + '@typescript-eslint/no-non-null-assertion': 'off', // Needed with noUncheckedIndexedAccess + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], 'no-unused-vars': 'off', }, }, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 70226f99..f0526c04 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,16 +1,14 @@ -{#if globalError} - +{#if appError.current} + {:else}
diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index 4187954b..37a5f8ec 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -85,16 +85,8 @@ export function createMockSvelteComponent(html: string, testId?: string): { ? html.replace('>', ` data-testid="${testId}">`) : html; - const MockComponent = function (this: object) { - return { - $$: { - on_mount: [], - on_destroy: [], - before_update: [], - after_update: [], - context: new Map(), - }, - }; + const MockComponent = function () { + return {}; } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; MockComponent.render = () => ({ @@ -184,6 +176,77 @@ function createMockStore(initial: T) { } `; +// ============================================================================ +// Mock Module Factories (for use with async vi.mock() factories) +// ============================================================================ + +/** + * Creates a mock module with named Svelte 5 component exports. + * Each component has proper $$ structure for Svelte 5 compatibility. + * + * @param components - Record mapping export names to HTML strings + */ +export function createMockNamedComponents(components: Record): Record { + const module: Record = {}; + for (const [name, html] of Object.entries(components)) { + const Mock = function () { + return {}; + } as unknown as { new (): object; render: () => { html: string; css: { code: string; map: null }; head: string } }; + Mock.render = () => ({ html, css: { code: '', map: null }, head: '' }); + module[name] = Mock; + } + return module; +} + +/** + * Creates a mock @lucide/svelte module with given icon names. + * All icons render as ``. + */ +export function createMockIconModule(...iconNames: string[]): Record { + return createMockNamedComponents( + Object.fromEntries(iconNames.map(name => [name, ''])) + ); +} + +/** + * Creates a mock svelte-sonner module with toast methods that delegate to addToast. + * Usage: `vi.mock('svelte-sonner', async () => (await import('...')).createToastMock(mocks.addToast))` + */ +export function createToastMock(addToast: (...args: unknown[]) => void) { + return { + toast: { + success: (...args: unknown[]) => addToast('success', ...args), + error: (...args: unknown[]) => addToast('error', ...args), + warning: (...args: unknown[]) => addToast('warning', ...args), + info: (...args: unknown[]) => addToast('info', ...args), + }, + }; +} + +/** + * Creates a mock @mateothegreat/svelte5-router module. + * If gotoFn is provided, goto calls are delegated to it for assertion tracking. + */ +export function createMockRouterModule(gotoFn?: (...args: unknown[]) => void) { + return { + goto: gotoFn ? (...args: unknown[]) => gotoFn(...args) : vi.fn(), + route: () => {}, + }; +} + +/** + * Creates a mock $utils/meta module with updateMetaTags and pageMeta. + */ +export function createMetaMock( + updateMetaTagsFn: (...args: unknown[]) => void, + pageMeta: Record, +) { + return { + updateMetaTags: (...args: unknown[]) => updateMetaTagsFn(...args), + pageMeta, + }; +} + // ============================================================================ // Test Data Factories // ============================================================================ @@ -195,6 +258,7 @@ export function createMockNotification(overrides: Partial<{ notification_id: string; subject: string; body: string; + channel: string; status: 'unread' | 'read'; severity: 'low' | 'medium' | 'high' | 'urgent'; tags: string[]; @@ -204,6 +268,7 @@ export function createMockNotification(overrides: Partial<{ notification_id: string; subject: string; body: string; + channel: string; status: 'unread' | 'read'; severity: 'low' | 'medium' | 'high' | 'urgent'; tags: string[]; @@ -214,6 +279,7 @@ export function createMockNotification(overrides: Partial<{ notification_id: 'notif-1', subject: 'Test Notification', body: 'This is a test notification body', + channel: 'in_app', status: 'unread', severity: 'medium', tags: [], 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; nameEditedByUser = true; }} 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; }} />