const { test, expect } = require('@playwright/test'); const { getCurrentWorkspace, login, uniqueName } = require('./helpers'); test('Operation lifecycle keeps published versions immutable and exposes safe correlation', async ({ page }) => { await login(page); const workspace = await getCurrentWorkspace(page); const name = uniqueName('immutable_lifecycle'); const evidence = await page.evaluate(async ({ workspaceId, operationName }) => { const schema = (field, description) => ({ type: 'object', description, required: true, nullable: false, fields: { [field]: { type: 'string', description: `${field} value used by the lifecycle acceptance test`, required: true, nullable: false, }, }, }); const payload = { name: operationName, display_name: 'Immutable lifecycle acceptance Operation', category: 'quality', protocol: 'rest', security_level: 'standard', target: { kind: 'rest', base_url: 'https://httpbin.org', method: 'GET', path_template: '/anything', static_headers: {}, }, input_schema: schema('input', 'Lifecycle input contract'), output_schema: schema('output', 'Lifecycle output contract'), input_mapping: { rules: [{ source: '$.mcp.input', target: '$.request.query.input', required: true, }], }, output_mapping: { rules: [{ source: '$.response.body.output', target: '$.output.output', required: true, }], }, execution_config: { timeout_ms: 1000, headers: {} }, tool_description: { title: 'Immutable lifecycle acceptance Operation', description: 'Exercises immutable Draft, Published Version, YAML, archive and correlation behavior.', tags: ['quality', 'lifecycle'], examples: [], }, wizard_state: null, }; const created = await window.CrankApi.createOperation(workspaceId, payload); const operationId = created.operation_id; await window.CrankApi.getOperation(workspaceId, operationId); let testResult; try { testResult = await window.CrankApi.runOperationTest(workspaceId, operationId, { version: 1, input: { input: 'acceptance' }, }); } catch (error) { testResult = { tested_version: 1, request_id: error.requestId, trace_id: error.traceId, rejected_safely: true, }; } const published = await window.CrankApi.publishOperation(workspaceId, operationId, 1); const publishedV1 = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1); await window.CrankApi.getOperation(workspaceId, operationId); const changedPayload = JSON.parse(JSON.stringify(payload)); changedPayload.display_name = 'Changed Draft after publication'; changedPayload.tool_description.title = 'Changed Draft after publication'; const changed = await window.CrankApi.updateOperation(workspaceId, operationId, changedPayload); const publishedV1AfterEdit = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1); const yamlV1 = await window.CrankApi.exportOperation(workspaceId, operationId, { version: 1 }); await window.CrankApi.getOperation(workspaceId, operationId); const imported = await window.CrankApi.importOperation( workspaceId, yamlV1, 'upsert', operationId, ); await window.CrankApi.getOperation(workspaceId, operationId); const archived = await window.CrankApi.archiveOperation(workspaceId, operationId); const publishedV1AfterArchive = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1); return { operationId, testResult, published, changed, imported, archived, originalSnapshot: publishedV1.snapshot, afterEditSnapshot: publishedV1AfterEdit.snapshot, afterArchiveSnapshot: publishedV1AfterArchive.snapshot, yamlV1, }; }, { workspaceId: workspace.id, operationName: name }); expect(evidence.testResult.tested_version).toBe(1); expect(evidence.testResult.request_id).toMatch(/^[!-~]{1,128}$/); expect(evidence.testResult.trace_id).toMatch(/^[0-9a-f]{32}$/); expect(evidence.published.published_version).toBe(1); expect(evidence.changed.version).toBe(2); expect(evidence.imported.version).toBe(3); expect(evidence.archived.status).toBe('archived'); expect(evidence.afterEditSnapshot).toEqual(evidence.originalSnapshot); expect(evidence.afterArchiveSnapshot).toEqual(evidence.originalSnapshot); expect(evidence.yamlV1).toContain("format_version: '2'"); for (const forbidden of ['wizard_state:', 'created_at:', 'published_at:', 'operation_id:']) { expect(evidence.yamlV1).not.toContain(forbidden); } }); for (const locale of ['ru', 'en']) { test(`catalog lifecycle actions are state-aware in ${locale}`, async ({ page }) => { await login(page); await page.evaluate((language) => localStorage.setItem('crank_lang', language), locale); const workspace = await getCurrentWorkspace(page); const name = uniqueName(`catalog_lifecycle_${locale}`); const operation = await page.evaluate(async ({ workspaceId, operationName, language }) => { const schema = { type: 'object', description: 'Catalog lifecycle UI contract', required: true, nullable: false, fields: {}, }; const created = await window.CrankApi.createOperation(workspaceId, { name: operationName, display_name: `Catalog lifecycle ${language}`, category: 'quality', protocol: 'rest', security_level: 'standard', target: { kind: 'rest', base_url: 'https://example.test', method: 'GET', path_template: '/', static_headers: {} }, input_schema: schema, output_schema: schema, input_mapping: { rules: [] }, output_mapping: { rules: [] }, execution_config: { timeout_ms: 1000, headers: {} }, tool_description: { title: `Catalog lifecycle ${language}`, description: 'A sufficiently detailed description for lifecycle UI acceptance.', tags: ['quality'], examples: [], }, wizard_state: null, }); await window.CrankApi.getOperation(workspaceId, created.operation_id); await window.CrankApi.publishOperation(workspaceId, created.operation_id, 1); return created; }, { workspaceId: workspace.id, operationName: name, language: locale }); await page.goto('/'); await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name); const row = page.locator('tbody tr').filter({ hasText: name }); await expect(row).toHaveCount(1); await expect(row.locator('.row-btn-delete')).toBeHidden(); await expect(row.locator('.row-btn-edit')).toBeVisible(); await row.locator('.row-btn-edit').click(); await expect(page).toHaveURL(new RegExp(`/wizard/\\?mode=edit&operationId=${operation.operation_id}`)); await expect(page.locator('.wizard-shell')).toBeVisible(); await expect(page.locator('#back-to-catalog')).toBeVisible(); await page.locator('#back-to-catalog').click(); await expect(page).toHaveURL(/\/$/); await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name); const refreshedRow = page.locator('tbody tr').filter({ hasText: name }); page.once('dialog', (dialog) => dialog.accept()); await refreshedRow.locator('[data-testid="operation-archive"]').click(); await expect(refreshedRow).toContainText(locale === 'ru' ? 'Неактивные' : 'Inactive'); await expect(refreshedRow.locator('.row-btn-delete')).toBeHidden(); }); }