feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user