Files
crank/apps/ui/tests/e2e/operation-lifecycle.spec.js

191 lines
7.7 KiB
JavaScript

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();
});
}