diff --git a/TASKS.md b/TASKS.md index ada9d74..a37df50 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,36 +2,19 @@ ## Current -### `feat/streaming-test-run-results` +### `feat/websocket-test-run-polish` -Status: completed +Status: pending DoD: -- `POST /operations/{operation_id}/test-runs` returns mode-aware streaming metadata -- window test-runs expose bounded result flags for the wizard status block -- session and async-job test-runs create persisted runtime state and return references for follow-up pages +- WebSocket wizard test-run shows reconnect / bounded-result semantics clearly +- WebSocket test-run flow is covered by local fixtures and Playwright +- `TASKS.md` stays aligned with the actual streaming branch state ## Next -- `feat/soap-test-run-polish` +- `feat/live-authenticated-staging-entry` ## Backlog -- `feat/streaming-mcp-architecture` -- `feat/mcp-streamable-http-alignment` -- `feat/streaming-core-model` -- `feat/stream-session-store` -- `feat/runtime-window-mode` -- `feat/rest-sse-adapter` -- `feat/grpc-server-streaming-adapter` -- `feat/session-and-job-tools` -- `feat/websocket-upstream-adapter` -- `feat/soap-architecture-and-core-model` -- `feat/soap-adapter-foundation` -- `feat/streaming-ui-config` -- `feat/streaming-e2e` -- `feat/auth-profile-secret-resolution` -- `feat/runtime-upstream-auth` -- `feat/secrets-ui` -- `feat/wizard-auth-selector` -- `feat/manual-regression-pass` +- `feat/live-authenticated-staging-entry` diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index 30d1f9d..4099581 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -1553,6 +1553,9 @@ function currentUpstream() { var customName = textValue('new-upstream-name'); var customUrl = textValue('new-upstream-url'); + if (!customUrl && wizardProtocol === 'soap') { + customUrl = textValue('soap-endpoint-override'); + } if (!customUrl) return null; var authMode = textValue('new-upstream-auth-mode') || 'none'; diff --git a/apps/ui/scripts/stream-fixture-server.js b/apps/ui/scripts/stream-fixture-server.js index f08d0fd..f37e9a5 100644 --- a/apps/ui/scripts/stream-fixture-server.js +++ b/apps/ui/scripts/stream-fixture-server.js @@ -103,6 +103,59 @@ const server = http.createServer((request, response) => { return; } + if (url.pathname === '/soap/leads' && request.method === 'POST') { + let buffer = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + buffer += chunk; + }); + request.on('end', () => { + const hasEmail = buffer.includes('user@example.com') + || buffer.includes('user@example.com'); + + if (!hasEmail) { + response.writeHead(400, { + 'Content-Type': 'text/xml; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end([ + '', + '', + '', + 'Client', + 'missing email', + '', + '', + '', + ].join('')); + return; + } + + response.writeHead(200, { + 'Content-Type': 'text/xml; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end([ + '', + '', + '', + 'lead_soap_123', + 'created', + '', + '', + '', + ].join('')); + }); + request.on('error', () => { + response.writeHead(500, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end('fixture_error'); + }); + return; + } + writeJson(response, 404, { error: 'not_found' }); }); diff --git a/apps/ui/tests/e2e/helpers.js b/apps/ui/tests/e2e/helpers.js index bc0cf7a..046029d 100644 --- a/apps/ui/tests/e2e/helpers.js +++ b/apps/ui/tests/e2e/helpers.js @@ -213,6 +213,84 @@ function buildRestWindowOperationPayload(name) { }; } +const SOAP_TEST_WSDL = ` + + + + + + + + + + + + +`; + +function buildSoapOperationPayload(name) { + return { + name, + display_name: 'Playwright SOAP Lead', + category: 'sales', + protocol: 'soap', + target: { + kind: 'soap', + wsdl_ref: 'sample_wsdl_pending', + service_name: 'Service', + port_name: 'Port', + operation_name: 'Operation', + endpoint_override: `http://127.0.0.1:${STREAM_FIXTURE_PORT}/soap/leads`, + soap_version: 'soap_11', + soap_action: 'urn:createLead', + binding_style: 'document_literal', + headers: [], + metadata: { + input_part_names: ['CreateLeadRequest'], + output_part_names: ['CreateLeadResponse'], + namespaces: ['urn:crm'], + }, + }, + input_schema: schemaObject({ + email: schemaString(true), + }), + output_schema: schemaObject({ + id: schemaString(true), + }), + input_mapping: { + rules: [ + { + source: '$.mcp.email', + target: '$.request.body.email', + required: true, + }, + ], + }, + output_mapping: { + rules: [ + { + source: '$.response.body.id', + target: '$.output.id', + required: true, + }, + ], + }, + execution_config: { + timeout_ms: 1000, + headers: {}, + }, + tool_description: { + title: 'Playwright SOAP Lead', + description: 'Creates a lead through the local SOAP fixture.', + tags: ['playwright', 'soap'], + examples: [{ input: { email: 'user@example.com' } }], + }, + }; +} + function buildGrpcSessionOperationPayload(name) { return { name, @@ -474,10 +552,12 @@ async function mcpToolCall(session, toolName, argumentsValue) { module.exports = { ADMIN_EMAIL, ADMIN_PASSWORD, + SOAP_TEST_WSDL, browserJson, buildGrpcSessionOperationPayload, buildRestAsyncJobOperationPayload, buildRestWindowOperationPayload, + buildSoapOperationPayload, createAgent, createOperation, createPlatformApiKey, diff --git a/apps/ui/tests/e2e/wizard.spec.js b/apps/ui/tests/e2e/wizard.spec.js index 1df7a4a..5b8eb41 100644 --- a/apps/ui/tests/e2e/wizard.spec.js +++ b/apps/ui/tests/e2e/wizard.spec.js @@ -1,5 +1,13 @@ const { test, expect } = require('@playwright/test'); -const { login, localized } = require('./helpers'); +const { + SOAP_TEST_WSDL, + buildSoapOperationPayload, + createOperation, + getCurrentWorkspace, + login, + localized, + uniqueName, +} = require('./helpers'); test('wizard loads and protocol selection updates flow', async ({ page }) => { await login(page); @@ -11,3 +19,77 @@ test('wizard loads and protocol selection updates flow', async ({ page }) => { await expect(page.locator('[data-step-counter]')).toContainText(/2/); await expect(page.locator('#step-panel-2 .step-panel-title')).toContainText(localized('Select upstream', 'Выберите upstream')); }); + +test('soap wizard uploads wsdl, applies discovered binding and runs test', async ({ page }) => { + await login(page); + const workspace = await getCurrentWorkspace(page); + const operation = await createOperation( + page, + workspace.id, + buildSoapOperationPayload(uniqueName('playwright_soap_wizard')), + ); + + await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(operation.operation_id)}`); + await page.locator('#btn-continue').click(); + await page.locator('#btn-continue').click(); + + await expect(page.locator('#step-panel-3-soap .step-panel-title')).toContainText(/WSDL/i); + await page.evaluate((wsdl) => { + const fileName = document.getElementById('wizard-soap-wsdl-name'); + if (fileName) fileName.textContent = 'lead.wsdl'; + }, SOAP_TEST_WSDL); + await page.evaluate(async ({ workspaceId, operationId, wsdl }) => { + const uploaded = await window.CrankApi.uploadWsdlFile( + workspaceId, + operationId, + new TextEncoder().encode(wsdl), + 'lead.wsdl', + ); + const services = await window.CrankApi.listSoapServices( + workspaceId, + operationId, + null, + ); + window.renderSoapServiceCatalog(services.services || []); + return uploaded; + }, { + workspaceId: workspace.id, + operationId: operation.operation_id, + wsdl: SOAP_TEST_WSDL, + }); + + await expect(page.locator('#wizard-soap-services-list button')).toContainText('CreateLead'); + await page.locator('#wizard-soap-services-list button').getByText('CreateLead').click(); + await expect(page.locator('#soap-service-name')).toHaveValue('LeadService'); + await expect(page.locator('#soap-port-name')).toHaveValue('LeadPort'); + await expect(page.locator('#soap-operation-name')).toHaveValue('CreateLead'); + + await page.locator('#btn-continue').click(); + await expect(page.locator('#step-panel-4 .step-panel-title')).toBeVisible(); + await page.locator('#tool-input-schema').fill(JSON.stringify({ + type: 'object', + required: ['email'], + properties: { + email: { type: 'string' }, + }, + }, null, 2)); + await page.locator('#tool-output-schema').fill(JSON.stringify({ + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, null, 2)); + await page.locator('#btn-continue').click(); + await expect(page.locator('[data-i18n="wizard.step5.test_title"]')).toContainText(localized('Test run', 'Тестовый запуск')); + await page.locator('#tool-input-mapping').fill('email: "$.input.email"'); + await page.locator('#tool-output-mapping').fill('id: "$.response.body.id"'); + await page.locator('#tool-exec-config').fill('timeout_ms: 1000'); + + await page.locator('#wizard-test-input').fill(JSON.stringify({ email: 'user@example.com' }, null, 2)); + await page.locator('#wizard-run-test').click(); + + await expect(page.locator('#wizard-test-request-preview')).toHaveValue(/user@example.com/); + await expect(page.locator('#wizard-test-response-preview')).toHaveValue(/lead_soap_123/); + await expect(page.locator('#wizard-test-errors')).toHaveValue('[]'); +}); diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index cac5446..60fb54b 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -2736,27 +2736,9 @@ impl PostgresRegistry { .bind(Json(serialize_json_value(&snapshot.output_mapping)?)) .bind(Json(serialize_json_value(&snapshot.execution_config)?)) .bind(Json(serialize_json_value(&snapshot.tool_description)?)) - .bind(Json( - snapshot - .samples - .clone() - .map(|value| serialize_json_value(&value)) - .transpose()?, - )) - .bind(Json( - snapshot - .generated_draft - .clone() - .map(|value| serialize_json_value(&value)) - .transpose()?, - )) - .bind(Json( - snapshot - .config_export - .clone() - .map(|value| serialize_json_value(&value)) - .transpose()?, - )) + .bind(serialize_option_json_value(&snapshot.samples)?.map(Json)) + .bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json)) + .bind(serialize_option_json_value(&snapshot.config_export)?.map(Json)) .bind(snapshot.id.as_str()) .bind(to_db_version(snapshot.version)) .execute(&mut *tx) @@ -4539,6 +4521,40 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn update_operation_draft_persists_optional_json_columns_as_sql_null() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let mut operation = test_operation("op_rest_02b", 1, OperationStatus::Draft); + + registry + .create_operation(&test_workspace_id(), &operation, None) + .await + .unwrap(); + + operation.generated_draft = None; + operation.samples = None; + operation.config_export = None; + operation.updated_at = "2026-03-25T12:34:00Z".to_owned(); + + registry + .update_operation_draft(&test_workspace_id(), &operation) + .await + .unwrap(); + + let stored = registry + .get_operation_version(&test_workspace_id(), &operation.id, operation.version) + .await + .unwrap() + .unwrap(); + + assert_eq!(stored.snapshot.generated_draft, None); + assert_eq!(stored.snapshot.samples, None); + assert_eq!(stored.snapshot.config_export, None); + + database.cleanup().await; + } + #[tokio::test] async fn stores_auth_profiles_and_artifact_metadata() { let database = TestDatabase::new().await;