Files
crank/apps/ui/tests/e2e/operations.spec.js
T

486 lines
23 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const { login, localized, uniqueName } = require('./helpers');
async function dismissOnboardingIfOpen(page) {
await page.locator('#crank-onboarding-panel').waitFor({ state: 'attached' });
await page.locator('[data-testid="onboarding-dismiss"]').waitFor({ state: 'attached' });
await page.evaluate(() => {
const dismiss = document.querySelector('[data-testid="onboarding-dismiss"]');
if (dismiss) dismiss.click();
});
await expect(page.locator('#crank-onboarding-panel')).toBeHidden();
}
test('operations page shows demo catalog and filter works', async ({ page }) => {
await login(page);
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
await expect(page.getByText(localized('Loading operations', 'Загрузка операций'))).toBeHidden();
expect(await page.locator('tbody tr').count()).toBeGreaterThanOrEqual(1);
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
});
test('operations page imports OpenAPI methods as drafts', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
const operationId = uniqueName('get_rates');
const statusOperationId = `${operationId}_status`;
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
await expect(page.locator('#openapi-import-modal')).toBeVisible();
const sourceCanary = 'openapi-source-canary-not-for-dom';
await page.locator('#openapi-import-file').setInputFiles({
name: 'demo-openapi.yaml',
mimeType: 'application/octet-stream',
buffer: Buffer.from(`
openapi: 3.0.3
info:
title: Demo Import API
description: ${sourceCanary}
servers:
paths:
/v1/rates/{date}:
post:
operationId: ${operationId}
summary: Получить курсы валют
description: Курсы.
tags: [currency]
parameters:
- name: date
in: path
required: true
schema: { type: string }
- name: base
in: query
required: true
schema: { type: string }
- name: X-API-Version
in: header
required: false
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [symbols]
properties:
symbols: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
base: { type: string }
/v1/status:
get:
operationId: ${statusOperationId}
summary: Проверить статус
tags: [service]
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string }
`),
});
const previewRequest = page.waitForRequest((request) => request.method() === 'POST'
&& request.url().includes('/imports/openapi/preview'));
await page.locator('#openapi-import-preview').click();
const request = await previewRequest;
expect(request.headers()['content-type']).toMatch(/^multipart\/form-data; boundary=/i);
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i);
await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible();
const importedOperation = page.locator('.openapi-import-operation').filter({ hasText: 'Получить курсы валют' });
await expect(importedOperation).toBeVisible();
await expect(page.locator('.openapi-import-operation').filter({ hasText: 'Проверить статус' })).toBeVisible();
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Path');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('date');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Query');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('base');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Header');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols');
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText(localized('Response', 'Ответ'));
await expect(page.locator('#openapi-import-selection')).toContainText(localized('Selected: 2', 'Выбрано: 2'));
await page.locator('#openapi-import-search').fill('status');
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible();
await page.locator('#openapi-import-clear-visible').click();
await expect(page.locator('#openapi-import-selection')).toContainText(localized('Selected: 1', 'Выбрано: 1'));
await page.locator('#openapi-import-search').fill('');
await page.locator('#openapi-import-method-filter').selectOption('POST');
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Получить курсы валют' })).toBeVisible();
await page.locator('#openapi-import-method-filter').selectOption('');
await page.locator('#openapi-import-server-custom').fill('https://api.example.test');
await page.locator('#openapi-import-create').click();
await expect(page.locator('#openapi-import-result')).toContainText(localized('Import result', 'Результат импорта'));
await expect(page.locator('.openapi-import-result-table')).toBeVisible();
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}');
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(localized('tool description is too short', 'Описание инструмента слишком короткое'));
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(localized('Fix in wizard', 'Исправить в мастере'));
await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute(
'href',
/\/wizard\/\?mode=edit&operationId=op_/
);
await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i'));
await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i'));
await expect(page.locator('body')).not.toContainText(sourceCanary);
});
test('OpenAPI upload rejects invalid files locally and restores focus after Escape', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
const trigger = page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') });
await trigger.focus();
await trigger.click();
await expect(page.locator('#openapi-import-file-select')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(page.locator('[data-openapi-close]').last()).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(page.locator('#openapi-import-reset')).toBeFocused();
await page.locator('#openapi-import-file-select').focus();
await expect(page.locator('#openapi-import-status')).toHaveAttribute('role', 'status');
await expect(page.locator('#openapi-import-status')).toHaveAttribute('aria-live', 'polite');
let previewRequests = 0;
page.on('request', (request) => {
if (request.url().includes('/imports/openapi/preview')) previewRequests += 1;
});
await page.locator('#openapi-import-preview').click();
expect(previewRequests).toBe(0);
const longFileName = `${'a'.repeat(130)}.yaml`;
await page.locator('#openapi-import-file').setInputFiles({
name: longFileName,
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B');
await page.evaluate(() => window.setLang('en'));
await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B');
await expect(page.locator('#openapi-import-server-custom')).toHaveAttribute('aria-label', 'Custom Base URL');
await page.evaluate(() => window.setLang('ru'));
await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B');
await expect(page.locator('#openapi-import-server-custom')).toHaveAttribute('aria-label', 'Свой Base URL');
const invalidFiles = [
{
file: { name: 'wrong-extension.txt', mimeType: 'application/yaml', buffer: Buffer.from('openapi: 3.0.3') },
message: localized('Choose one .yaml', 'Выберите один файл'),
},
{
file: { name: 'wrong-type.yaml', mimeType: 'text/plain', buffer: Buffer.from('openapi: 3.0.3') },
message: localized('matching type', 'подходящим типом'),
},
{
file: { name: 'empty.yaml', mimeType: 'application/yaml', buffer: Buffer.alloc(0) },
message: localized('file is empty', 'файл пуст'),
},
{
file: { name: 'too-large.yaml', mimeType: 'application/yaml', buffer: Buffer.alloc(256 * 1024 + 1, 'x') },
message: localized('larger than 256 KiB', 'больше 256 KiB'),
},
];
for (const invalid of invalidFiles) {
await page.locator('#openapi-import-file').setInputFiles(invalid.file);
await expect(page.locator('#openapi-import-status')).toContainText(invalid.message);
await expect(page.locator('#openapi-import-status')).toBeFocused();
await page.locator('#openapi-import-preview').click();
expect(previewRequests).toBe(0);
}
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
});
test('OpenAPI upload recovers from pagehide and a preview server error', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
let releasePreview;
const pendingPreview = new Promise((resolve) => {
releasePreview = resolve;
});
await page.route('**/imports/openapi/preview', async (route) => {
await pendingPreview;
try {
await route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
} catch (_error) {
// pagehide aborts the active request before the delayed route settles.
}
});
await page.locator('#openapi-import-file').setInputFiles({
name: 'resume.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await page.locator('#openapi-import-preview').click();
await expect(page.locator('#openapi-import-status')).toContainText(localized('Uploading and parsing', 'Загружаю и разбираю'));
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
await expect(page.locator('#openapi-import-status')).toHaveText('');
await expect(page.locator('#openapi-import-cancel')).toBeHidden();
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await expect(page.locator('#openapi-import-retry')).toBeHidden();
await expect(page.locator('#openapi-import-file-name')).toContainText(localized('No file selected', 'Файл не выбран'));
await expect(page.locator('#openapi-import-status')).toContainText(localized('context changed', 'контекст изменился'));
releasePreview();
});
test('OpenAPI upload invalidates active draft creation after language or workspace changes', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
await page.route('**/imports/openapi/preview', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
job_id: 'job_active_apply',
preview: {
source: { servers: ['https://api.example.test'] },
findings: [],
groups: [{
title: 'active apply',
operations: [{
key: 'get:/active', method: 'GET', path: '/active', suggested_name: 'active',
suggested_display_name: 'Active', input_fields: 0, output_fields: 0,
draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, findings: [],
}],
}],
},
}),
});
});
const releases = [];
await page.route('**/imports/openapi/*/create', async (route) => {
await new Promise((resolve) => releases.push(resolve));
try {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ created: [{ name: 'stale draft', operation_id: 'op_stale', version: 1 }], skipped: [], findings: [] }),
});
} catch (_error) {
// The invalidation aborts a request that must not update the modal afterwards.
}
});
async function beginApply(name) {
await page.locator('#openapi-import-file').setInputFiles({
name,
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await page.locator('#openapi-import-preview').click();
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
await page.locator('#openapi-import-server-custom').fill('https://api.example.test');
await page.locator('#openapi-import-create').click();
await expect(page.locator('#openapi-import-cancel')).toBeVisible();
await expect.poll(() => releases.length).toBeGreaterThan(0);
}
await beginApply('language-change.yaml');
await page.evaluate(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'crank_lang', newValue: 'en' }));
});
releases.shift()();
await expect(page.locator('#openapi-import-preview-panel')).toBeHidden();
await expect(page.locator('#openapi-import-result')).toBeHidden();
await expect(page.locator('#openapi-import-create')).toBeEnabled();
await beginApply('workspace-change.yaml');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: { id: 'workspace-after-apply' } }));
});
releases.shift()();
await expect(page.locator('#openapi-import-preview-panel')).toBeHidden();
await expect(page.locator('#openapi-import-result')).toBeHidden();
await expect(page.locator('#openapi-import-file-name')).toContainText('No file selected');
await expect(page.locator('#openapi-import-create')).toBeEnabled();
});
test('OpenAPI upload only renders the latest selected file and clears reset or close races', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
await page.route('**/imports/openapi/preview', async (route) => {
const isFirst = route.request().postData().includes('first.yaml');
await new Promise((resolve) => setTimeout(resolve, isFirst ? 180 : 10));
try {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
job_id: isFirst ? 'job_first' : 'job_second',
preview: {
source: { servers: ['https://api.example.test'] },
findings: [],
groups: [{
title: isFirst ? 'first-only' : 'second-only',
operations: [{
key: isFirst ? 'get:/first' : 'get:/second',
method: 'GET',
path: isFirst ? '/first' : '/second',
suggested_name: isFirst ? 'first' : 'second',
suggested_display_name: isFirst ? 'First only' : 'Second only',
input_fields: 0,
output_fields: 0,
draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } },
findings: [],
}],
}],
},
}),
});
} catch (_error) {
// The browser intentionally aborts stale requests before this response settles.
}
});
await page.locator('#openapi-import-file').setInputFiles({
name: 'first.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await page.locator('#openapi-import-preview').click();
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
await page.locator('#openapi-import-server-custom').fill('https://stale.example.test');
await page.locator('#openapi-import-file').setInputFiles({
name: 'second.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await expect(page.locator('#openapi-import-server-custom')).toHaveValue('');
await expect(page.locator('#openapi-import-server option')).toHaveCount(0);
await page.locator('#openapi-import-preview').click();
await expect(page.locator('.openapi-import-group')).toContainText('second-only');
await page.waitForTimeout(220);
await expect(page.locator('.openapi-import-group')).not.toContainText('first-only');
await page.locator('#openapi-import-file').setInputFiles({
name: 'third.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await page.locator('#openapi-import-preview').click();
await page.locator('#openapi-import-reset').click();
await page.waitForTimeout(220);
await expect(page.locator('#openapi-import-preview-panel')).toBeHidden();
await expect(page.locator('#openapi-import-file-name')).toContainText(localized('No file selected', 'Файл не выбран'));
await page.locator('#openapi-import-file').setInputFiles({
name: 'fourth.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from('openapi: 3.0.3'),
});
await page.locator('#openapi-import-preview').click();
await page.keyboard.press('Escape');
await page.waitForTimeout(220);
await expect(page.locator('#openapi-import-modal')).toBeHidden();
});
test('OpenAPI upload ignores a stale failure and renders only correlation identifiers', async ({ page }) => {
await login(page);
await dismissOnboardingIfOpen(page);
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
const sourceCanary = 'do-not-render-openapi-source-canary';
await page.locator('#openapi-import-file').setInputFiles({
name: 'safe.yaml',
mimeType: 'application/yaml',
buffer: Buffer.from(`openapi: 3.0.3\ninfo: { title: Safe, description: ${sourceCanary} }\npaths: { /health: { get: { responses: { '200': { description: OK } } } } }\n`),
});
let releaseStaleFailure;
const staleFailure = new Promise((resolve) => {
releaseStaleFailure = resolve;
});
await page.route('**/imports/openapi/preview', async (route) => {
await staleFailure;
await route.fulfill({
status: 400,
contentType: 'application/json',
headers: {
'x-request-id': 'req_ui_evidence_1',
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: 'openapi_upload.invalid_document', message: 'Safe upload failure' } }),
});
});
await page.locator('#openapi-import-preview').click();
await expect(page.locator('#openapi-import-cancel')).toBeVisible();
await page.locator('#openapi-import-cancel').click({ force: true });
releaseStaleFailure();
await page.waitForTimeout(100);
await expect(page.locator('#openapi-import-status')).toContainText(localized('Request cancelled', 'Запрос отменён'));
await expect(page.locator('body')).not.toContainText(sourceCanary);
await page.unroute('**/imports/openapi/preview');
await page.route('**/imports/openapi/preview', async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
headers: {
'x-request-id': 'req_ui_evidence_2',
'x-trace-id': 'abcdef0123456789abcdef0123456789',
},
body: JSON.stringify({ error: { code: 'openapi_upload.invalid_document', message: 'Safe upload failure' } }),
});
});
await page.locator('#openapi-import-retry').click();
await expect(page.locator('#openapi-import-status')).toContainText(localized('not a valid OpenAPI document', 'не является корректным документом OpenAPI'));
await expect(page.locator('#openapi-import-status')).toBeFocused();
await expect(page.locator('#openapi-import-status')).toContainText('Request ID: req_ui_evidence_2');
await expect(page.locator('#openapi-import-status')).toContainText('Trace ID: abcdef0123456789abcdef0123456789');
await page.evaluate(() => window.setLang('en'));
await expect(page.locator('#openapi-import-title')).toHaveText('Import OpenAPI');
await page.unroute('**/imports/openapi/preview');
await page.route('**/imports/openapi/preview', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
job_id: 'job_ui_evidence',
preview: {
source: { servers: ['https://api.example.test'] },
findings: [],
groups: [{
title: 'evidence',
operations: [{
key: 'get:/health', method: 'GET', path: '/health', suggested_name: 'health',
suggested_display_name: 'Health', input_fields: 0, output_fields: 0,
draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, findings: [],
}],
}],
},
}),
});
});
await page.locator('#openapi-import-retry').click();
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
let createRequests = 0;
await page.route('**/imports/openapi/*/create', async (route) => {
createRequests += 1;
await new Promise((resolve) => setTimeout(resolve, 100));
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ created: [], skipped: [], findings: [] }) });
});
await page.locator('#openapi-import-create').click();
await page.locator('#openapi-import-create').evaluate((button) => button.click());
await expect(page.locator('#openapi-import-status')).toContainText('Created: 0; skipped: 0.');
expect(createRequests).toBe(1);
});