From 420e9416e50611b9cd7197b8fa486abffdfef6bf Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sat, 2 May 2026 20:30:30 +0000 Subject: [PATCH] ui: polish websocket wizard test runs --- TASKS.md | 13 ++-- apps/ui/js/i18n.js | 26 +++++++ apps/ui/js/wizard-live.js | 62 ++++++++++++++++ apps/ui/tests/e2e/helpers.js | 69 +++++++++++++++++ apps/ui/tests/e2e/wizard.spec.js | 122 +++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 8 deletions(-) diff --git a/TASKS.md b/TASKS.md index c79e094..fe195a0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,17 @@ ## Current -### `feat/websocket-test-run-polish` +### `feat/live-authenticated-staging-entry` Status: ready DoD: -- websocket test-run flow clearly reports bounded results, reconnect/error states, and final status -- operator-facing UI reflects websocket-specific runtime semantics instead of generic streaming copy -- targeted UI checks cover websocket wizard/test-run behavior - -## Next - -- `feat/live-authenticated-staging-entry` +- authenticated staging flow runs against the real deployed environment +- operator docs match the current deploy/runtime model +- staging validation is captured without relying on local fixtures ## Completed +- `feat/websocket-test-run-polish` - `feat/frontend-wizard-modularization` - `feat/distributed-mcp-session-store` diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 9ed6df6..da92dbe 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -840,6 +840,19 @@ var TRANSLATIONS = { 'wizard.test.window_truncated_note': 'Results were truncated.', 'wizard.test.window_more_note': 'More data is available if you continue from the streaming views.', 'wizard.test.window_incomplete_note': 'The upstream stream ended before the configured window was fully collected.', + 'wizard.test.websocket_window_completed': 'WebSocket window test completed', + 'wizard.test.websocket_window_completed_body': 'Collected {items} WebSocket event(s) inside the bounded test window.', + 'wizard.test.websocket_window_truncated_note': 'The bounded window stopped after reaching the configured item or byte limit.', + 'wizard.test.websocket_window_more_note': 'The socket produced more events than this test window retained. Increase limits or switch to session mode if you need a longer stream.', + 'wizard.test.websocket_window_incomplete_note': 'The upstream socket closed before the bounded window finished collecting all configured events.', + 'wizard.test.websocket_session_started': 'WebSocket session test started', + 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} was created. Continue from Stream sessions after a short delay.', + 'wizard.test.websocket_async_job_started': 'WebSocket async job test started', + 'wizard.test.websocket_async_job_started_body': 'WebSocket async job {job_id} was created. Continue from the async jobs view for status and result.', + 'wizard.test.websocket_failed': 'WebSocket test returned errors', + 'wizard.test.websocket_failed_body': 'The socket test failed before Crank could collect a bounded result window.', + 'wizard.test.websocket_failed_reconnect_body': 'The upstream socket closed before enough events were collected, and the configured reconnect policy was exhausted.', + 'wizard.test.websocket_failed_closed_body': 'The upstream socket closed before the bounded test window completed.', 'wizard.test.session_started': 'Session test started', 'wizard.test.session_started_body': 'Session {session_id} was created. Check Stream sessions in a few seconds.', 'wizard.test.async_job_started': 'Async job test started', @@ -1853,6 +1866,19 @@ var TRANSLATIONS = { 'wizard.test.window_truncated_note': 'Результат был усечен.', 'wizard.test.window_more_note': 'Доступно продолжение, если перейти к streaming-представлениям.', 'wizard.test.window_incomplete_note': 'Upstream-поток завершился раньше, чем удалось собрать всё окно.', + 'wizard.test.websocket_window_completed': 'WebSocket оконный тест завершен', + 'wizard.test.websocket_window_completed_body': 'В пределах ограниченного тестового окна собрано {items} WebSocket event(s).', + 'wizard.test.websocket_window_truncated_note': 'Ограниченное окно остановилось после достижения лимита по элементам или байтам.', + 'wizard.test.websocket_window_more_note': 'Сокет выдал больше событий, чем сохранило это тестовое окно. Увеличьте лимиты или переключитесь в session mode, если нужен более длинный поток.', + 'wizard.test.websocket_window_incomplete_note': 'Upstream WebSocket закрылся до того, как bounded окно успело собрать все настроенные события.', + 'wizard.test.websocket_session_started': 'WebSocket session-тест запущен', + 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} создана. Продолжайте в разделе Stream sessions через короткую паузу.', + 'wizard.test.websocket_async_job_started': 'WebSocket async job тест запущен', + 'wizard.test.websocket_async_job_started_body': 'Создан WebSocket async job {job_id}. Дальше отслеживайте статус и результат в представлении async jobs.', + 'wizard.test.websocket_failed': 'WebSocket тест завершился с ошибками', + 'wizard.test.websocket_failed_body': 'Тест сокета завершился до того, как Crank успел собрать bounded result window.', + 'wizard.test.websocket_failed_reconnect_body': 'Upstream WebSocket закрылся раньше, чем удалось собрать достаточно событий, и настроенная reconnect policy была исчерпана.', + 'wizard.test.websocket_failed_closed_body': 'Upstream WebSocket закрылся до завершения bounded test window.', 'wizard.test.session_started': 'Session-тест запущен', 'wizard.test.session_started_body': 'Сессия {session_id} создана. Проверьте статус в разделе Stream sessions через несколько секунд.', 'wizard.test.async_job_started': 'Async job тест запущен', diff --git a/apps/ui/js/wizard-live.js b/apps/ui/js/wizard-live.js index 354e124..3159a3c 100644 --- a/apps/ui/js/wizard-live.js +++ b/apps/ui/js/wizard-live.js @@ -390,6 +390,68 @@ function setTextareaValue(id, value) { } function describeWizardTestResult(result) { + if (wizardProtocol === 'websocket') { + if (!result.ok && result.errors && result.errors.length) { + var websocketError = result.errors[0] || {}; + var websocketMessage = websocketError.message || ''; + var websocketBody = tKey('wizard.test.websocket_failed_body'); + if (/reconnect policy exhausted/i.test(websocketMessage)) { + websocketBody = tKey('wizard.test.websocket_failed_reconnect_body'); + } else if (/close frame before collection completed|closed before collection completed/i.test(websocketMessage)) { + websocketBody = tKey('wizard.test.websocket_failed_closed_body'); + } + return { + title: tKey('wizard.test.websocket_failed'), + body: websocketBody, + isError: true, + }; + } + + if (result.stream_session && result.stream_session.session_id) { + return { + title: tKey('wizard.test.websocket_session_started'), + body: tfKey('wizard.test.websocket_session_started_body', { + session_id: result.stream_session.session_id, + poll_after_ms: result.stream_session.poll_after_ms, + }), + isError: false, + }; + } + + if (result.async_job && result.async_job.job_id) { + return { + title: tKey('wizard.test.websocket_async_job_started'), + body: tfKey('wizard.test.websocket_async_job_started_body', { + job_id: result.async_job.job_id, + }), + isError: false, + }; + } + + if (result.window) { + var itemCount = Array.isArray(result.response_preview && result.response_preview.items) + ? result.response_preview.items.length + : 0; + var websocketBodyParts = [ + tfKey('wizard.test.websocket_window_completed_body', { items: itemCount }) + ]; + if (result.window.truncated) { + websocketBodyParts.push(tKey('wizard.test.websocket_window_truncated_note')); + } + if (result.window.has_more) { + websocketBodyParts.push(tKey('wizard.test.websocket_window_more_note')); + } + if (!result.window.window_complete) { + websocketBodyParts.push(tKey('wizard.test.websocket_window_incomplete_note')); + } + return { + title: result.ok ? tKey('wizard.test.websocket_window_completed') : tKey('wizard.test.websocket_failed'), + body: websocketBodyParts.join(' '), + isError: !result.ok, + }; + } + } + if (result.stream_session && result.stream_session.session_id) { return { title: tKey('wizard.test.session_started'), diff --git a/apps/ui/tests/e2e/helpers.js b/apps/ui/tests/e2e/helpers.js index 046029d..b77d63d 100644 --- a/apps/ui/tests/e2e/helpers.js +++ b/apps/ui/tests/e2e/helpers.js @@ -213,6 +213,74 @@ function buildRestWindowOperationPayload(name) { }; } +function buildWebsocketWindowOperationPayload(name, overrides = {}) { + const streaming = Object.assign({ + mode: 'window', + transport_behavior: 'server_stream', + window_duration_ms: 1000, + upstream_timeout_ms: 1000, + max_items: 3, + max_bytes: 16384, + aggregation_mode: 'summary_plus_samples', + items_path: '$.items', + done_path: '$.done', + redacted_paths: [], + truncate_item_fields: false, + drop_duplicates: false, + tool_family: {}, + }, overrides.streaming || {}); + + const websocketOptions = Object.assign({ + heartbeat_interval_ms: 250, + reconnect_max_attempts: 1, + reconnect_backoff_ms: 10, + }, overrides.websocketOptions || {}); + + return { + name, + display_name: 'Playwright WebSocket Window', + category: 'streaming', + protocol: 'websocket', + target: { + kind: 'websocket', + url: `ws://127.0.0.1:${STREAM_FIXTURE_PORT}/events`, + subprotocols: [], + subscribe_message_template: null, + unsubscribe_message_template: null, + static_headers: {}, + }, + input_schema: schemaObject({ + topic: schemaString(false), + }), + output_schema: schemaObject({}), + input_mapping: { + rules: [ + { + source: '$.mcp.topic', + target: '$.request.body.topic', + required: false, + default_value: 'telemetry', + }, + ], + }, + output_mapping: { rules: [] }, + execution_config: { + timeout_ms: 1000, + headers: {}, + protocol_options: { + websocket: websocketOptions, + }, + streaming, + }, + tool_description: { + title: 'Playwright WebSocket Window', + description: 'Collects a bounded WebSocket event window from the local fixture.', + tags: ['playwright', 'streaming', 'websocket', 'window'], + examples: [{ input: {} }], + }, + }; +} + const SOAP_TEST_WSDL = ` { + await login(page); + const workspace = await getCurrentWorkspace(page); + const successOperation = await createOperation( + page, + workspace.id, + buildWebsocketWindowOperationPayload(uniqueName('playwright_websocket_success')), + ); + const failureOperation = await createOperation( + page, + workspace.id, + buildWebsocketWindowOperationPayload(uniqueName('playwright_websocket_failure'), { + streaming: { + max_items: 5, + }, + websocketOptions: { + reconnect_max_attempts: 0, + reconnect_backoff_ms: 0, + }, + }), + ); + + await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(successOperation.operation_id)}`); + await page.locator('#btn-continue').click(); + await page.locator('#upstream-new-trigger').click(); + await page.locator('#new-upstream-name').fill('fixture-websocket'); + await page.locator('#new-upstream-url').fill('ws://127.0.0.1:3310'); + await page.locator('#btn-continue').click(); + await expect(page.locator('#step-panel-3-websocket .step-panel-title')).toContainText(/WebSocket/i); + await page.locator('#websocket-path').fill('/events'); + await page.locator('#websocket-subprotocols').fill(''); + await page.locator('#websocket-subscribe-template').fill(''); + await page.locator('#websocket-unsubscribe-template').fill(''); + await page.locator('#btn-continue').click(); + await page.locator('#btn-continue').click(); + await page.locator('#wizard-test-input').fill('{}'); + await page.evaluate(() => { + const original = window.CrankApi.runOperationTest; + window.__wizardRunTestOriginal = original; + window.CrankApi.runOperationTest = async () => ({ + ok: true, + mode: 'window', + request_preview: { + body: { topic: 'telemetry' }, + }, + response_preview: { + items: [ + { seq: 1, value: 101 }, + { seq: 2, value: 102 }, + { seq: 3, value: 103 }, + ], + window_complete: true, + truncated: false, + has_more: false, + }, + errors: [], + window: { + window_complete: true, + truncated: false, + has_more: false, + cursor: null, + }, + }); + }); + await page.locator('#wizard-run-test').click(); + await expect(page.locator('#wizard-live-status-title')).toContainText(localized('WebSocket window test completed', 'WebSocket оконный тест завершен')); + await expect(page.locator('#wizard-live-status-text')).toContainText(localized('bounded test window', 'тестового окна')); + await expect(page.locator('#wizard-test-response-preview')).toHaveValue(/"seq": 1/); + await expect(page.locator('#wizard-test-request-preview')).toHaveValue(/"topic": "telemetry"/); + await expect(page.locator('#wizard-test-errors')).toHaveValue('[]'); + await page.evaluate(() => { + if (window.__wizardRunTestOriginal) { + window.CrankApi.runOperationTest = window.__wizardRunTestOriginal; + delete window.__wizardRunTestOriginal; + } + }); + + await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(failureOperation.operation_id)}`); + await page.locator('#btn-continue').click(); + await page.locator('#upstream-new-trigger').click(); + await page.locator('#new-upstream-name').fill('fixture-websocket'); + await page.locator('#new-upstream-url').fill('ws://127.0.0.1:3310'); + await page.locator('#btn-continue').click(); + await page.locator('#websocket-path').fill('/events'); + await page.locator('#websocket-subprotocols').fill(''); + await page.locator('#websocket-subscribe-template').fill(''); + await page.locator('#websocket-unsubscribe-template').fill(''); + await page.locator('#btn-continue').click(); + await page.locator('#btn-continue').click(); + await page.locator('#wizard-test-input').fill('{}'); + await page.evaluate(() => { + const original = window.CrankApi.runOperationTest; + window.__wizardRunTestOriginal = original; + window.CrankApi.runOperationTest = async () => ({ + ok: false, + mode: 'window', + request_preview: { + body: { topic: 'telemetry' }, + }, + response_preview: null, + errors: [ + { + code: 'runtime_websocket_error', + message: 'websocket reconnect policy exhausted', + }, + ], + window: null, + }); + }); + await page.locator('#wizard-run-test').click(); + await expect(page.locator('#wizard-live-status-title')).toContainText(localized('WebSocket test returned errors', 'WebSocket тест завершился с ошибками')); + await expect(page.locator('#wizard-live-status-text')).toContainText(localized('reconnect policy was exhausted', 'reconnect policy была исчерпана')); + await expect(page.locator('#wizard-test-errors')).toHaveValue(/runtime_websocket_error/); + await page.evaluate(() => { + if (window.__wizardRunTestOriginal) { + window.CrankApi.runOperationTest = window.__wizardRunTestOriginal; + delete window.__wizardRunTestOriginal; + } + }); +});