feat(openapi): complete upload preview and UI evidence

This commit is contained in:
2026-08-28 15:38:14 +03:00
parent d2849ea3fe
commit 2c94af6791
35 changed files with 3655 additions and 537 deletions
+14 -14
View File
@@ -87,20 +87,6 @@
font-weight: 600;
}
#openapi-import-document {
min-height: 132px;
max-height: 34vh;
resize: vertical;
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: #0d1117;
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
}
#openapi-import-file {
position: absolute;
width: 1px;
@@ -154,6 +140,20 @@
color: var(--red);
}
.openapi-import-status:focus-visible,
.openapi-file-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.openapi-import-modal,
.openapi-import-dialog {
scroll-behavior: auto;
transition: none;
}
}
.openapi-import-preview {
display: grid;
gap: 16px;
+27 -26
View File
@@ -92,7 +92,7 @@
<div class="page-header-actions">
<button class="btn-secondary openapi-import-trigger" @click="handleOpenApiImport()">
<svg width="13" height="13"><use href="icons/wizard/upload.svg#icon"/></svg>
<span>Импорт OpenAPI</span>
<span data-i18n="openapi.trigger">Импорт OpenAPI</span>
</button>
<button class="btn-new" @click="handleNewOperation()">
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
@@ -382,10 +382,10 @@
<section class="openapi-import-dialog" role="dialog" aria-modal="true" aria-labelledby="openapi-import-title">
<div class="openapi-import-header">
<div>
<h2 id="openapi-import-title">Импорт OpenAPI</h2>
<p>Загрузите OpenAPI/Swagger документ, выберите методы и создайте черновики MCP-инструментов.</p>
<h2 id="openapi-import-title" data-i18n="openapi.title">Импорт OpenAPI</h2>
<p data-i18n="openapi.subtitle">Загрузите один OpenAPI/Swagger файл, выберите методы и создайте черновики MCP-инструментов.</p>
</div>
<button class="modal-close" type="button" data-openapi-close aria-label="Закрыть">
<button class="modal-close" type="button" data-openapi-close data-i18n-aria-label="openapi.close" aria-label="Закрыть">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"></line><line x1="11" y1="1" x2="1" y2="11"></line>
</svg>
@@ -393,56 +393,57 @@
</div>
<div class="openapi-import-body">
<div class="openapi-import-upload">
<label class="openapi-file-label">
<span>Файл OpenAPI/Swagger</span>
<div class="openapi-file-label">
<span id="openapi-import-file-label" data-i18n="openapi.file.label">Файл OpenAPI/Swagger</span>
<span class="openapi-file-control">
<span class="btn-secondary openapi-file-button">Выбрать файл</span>
<button class="btn-secondary openapi-file-button" id="openapi-import-file-select" type="button" data-i18n="openapi.file.choose">Выбрать файл</button>
<span class="openapi-file-name" id="openapi-import-file-name">Файл не выбран</span>
</span>
<input id="openapi-import-file" type="file" accept=".yaml,.yml,.json,application/json,text/yaml">
</label>
<textarea id="openapi-import-document" spellcheck="false" placeholder="Вставьте openapi.yaml или swagger.json"></textarea>
<input id="openapi-import-file" type="file" aria-labelledby="openapi-import-file-label" accept=".yaml,.yml,.json,application/json,application/openapi+json,application/yaml,application/x-yaml,text/yaml,text/x-yaml" tabindex="-1">
</div>
<div class="openapi-import-actions">
<button class="btn-primary" id="openapi-import-preview" type="button">Разобрать документ</button>
<button class="btn-secondary" id="openapi-import-reset" type="button">Сбросить</button>
<button class="btn-primary" id="openapi-import-preview" type="button" data-i18n="openapi.preview">Разобрать документ</button>
<button class="btn-secondary" id="openapi-import-cancel" type="button" hidden data-i18n="openapi.cancel">Отменить</button>
<button class="btn-secondary" id="openapi-import-retry" type="button" hidden data-i18n="openapi.retry">Повторить</button>
<button class="btn-secondary" id="openapi-import-reset" type="button" data-i18n="openapi.reset">Сбросить</button>
</div>
</div>
<div class="openapi-import-status" id="openapi-import-status"></div>
<div class="openapi-import-status" id="openapi-import-status" role="status" aria-live="polite" aria-atomic="true" tabindex="-1"></div>
<div class="openapi-import-preview" id="openapi-import-preview-panel" hidden>
<div class="openapi-import-source" id="openapi-import-source"></div>
<div class="openapi-import-server-row">
<label for="openapi-import-server">Base URL</label>
<label for="openapi-import-server" data-i18n="openapi.server.label">Base URL</label>
<select id="openapi-import-server"></select>
<input id="openapi-import-server-custom" class="openapi-import-server-custom" type="url" placeholder="Или укажите свой base URL, например https://api.example.com">
<input id="openapi-import-server-custom" class="openapi-import-server-custom" type="url" data-i18n-aria-label="openapi.server.custom_aria" aria-label="Свой Base URL" data-i18n-ph="openapi.server.placeholder" placeholder="Или укажите свой base URL, например https://api.example.com">
</div>
<div class="openapi-import-server-row">
<label for="openapi-import-conflict-mode">Если операция уже существует</label>
<label for="openapi-import-conflict-mode" data-i18n="openapi.conflict.label">Если операция уже существует</label>
<select id="openapi-import-conflict-mode">
<option value="rename" selected>Создать копию с новым именем</option>
<option value="skip">Пропустить</option>
<option value="rename" selected data-i18n="openapi.conflict.rename">Создать копию с новым именем</option>
<option value="skip" data-i18n="openapi.conflict.skip">Пропустить</option>
</select>
</div>
<div class="openapi-import-toolbar">
<div class="openapi-import-filter">
<label for="openapi-import-search">Поиск методов</label>
<input id="openapi-import-search" type="search" placeholder="Название, operationId или путь">
<label for="openapi-import-search" data-i18n="openapi.search.label">Поиск методов</label>
<input id="openapi-import-search" type="search" data-i18n-ph="openapi.search.placeholder" placeholder="Название, operationId или путь">
</div>
<div class="openapi-import-filter">
<label for="openapi-import-method-filter">HTTP-метод</label>
<label for="openapi-import-method-filter" data-i18n="openapi.method.label">HTTP-метод</label>
<select id="openapi-import-method-filter">
<option value="">Все методы</option>
<option value="" data-i18n="openapi.method.all">Все методы</option>
</select>
</div>
<div class="openapi-import-bulk-actions">
<button class="btn-secondary" id="openapi-import-select-visible" type="button">Выбрать видимые</button>
<button class="btn-secondary" id="openapi-import-clear-visible" type="button">Снять видимые</button>
<button class="btn-secondary" id="openapi-import-select-visible" type="button" data-i18n="openapi.select_visible">Выбрать видимые</button>
<button class="btn-secondary" id="openapi-import-clear-visible" type="button" data-i18n="openapi.clear_visible">Снять видимые</button>
</div>
</div>
<div class="openapi-import-groups" id="openapi-import-groups"></div>
<div class="openapi-import-footer">
<span id="openapi-import-selection">Выбрано: 0</span>
<button class="btn-primary" id="openapi-import-create" type="button">Создать черновики</button>
<span id="openapi-import-selection" data-i18n="openapi.selection.empty">Выбрано: 0</span>
<button class="btn-primary" id="openapi-import-create" type="button" data-i18n="openapi.create">Создать черновики</button>
</div>
<div class="openapi-import-result" id="openapi-import-result" hidden></div>
</div>
+22 -6
View File
@@ -409,13 +409,29 @@
}
return result;
},
previewOpenApiImport: function(workspaceId, documentText) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
document: documentText,
});
previewOpenApiImport: function(workspaceId, file, options) {
var form = new FormData();
form.append('file', file, file.name);
return request(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview',
{
method: 'POST',
// Do not set Content-Type here: the browser owns the multipart boundary.
headers: headers({
'Accept-Language': localStorage.getItem('crank_lang') === 'ru' ? 'ru' : 'en',
}),
body: form,
signal: options && options.signal,
}
);
},
createOpenApiImport: function(workspaceId, jobId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload);
createOpenApiImport: function(workspaceId, jobId, payload, options) {
return request(API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
signal: options && options.signal,
});
},
listAgents: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
+160 -1
View File
@@ -2068,7 +2068,86 @@ var TRANSLATIONS = {
};
Object.assign(TRANSLATIONS.en, {
'openapi.trigger': 'Import OpenAPI',
'openapi.title': 'Import OpenAPI',
'openapi.subtitle': 'Upload one OpenAPI/Swagger file, select methods, and create MCP tool drafts.',
'openapi.close': 'Close',
'openapi.file.label': 'OpenAPI/Swagger file',
'openapi.file.choose': 'Choose file',
'openapi.file.none': 'No file selected',
'openapi.preview': 'Preview document',
'openapi.cancel': 'Cancel request',
'openapi.retry': 'Retry',
'openapi.reset': 'Reset',
'openapi.server.label': 'Base URL',
'openapi.server.custom_aria': 'Custom Base URL',
'openapi.server.placeholder': 'Or provide a base URL, for example https://api.example.com',
'openapi.server.later': 'Specify later',
'openapi.conflict.label': 'If an operation already exists',
'openapi.conflict.rename': 'Create a copy with a new name',
'openapi.conflict.skip': 'Skip it',
'openapi.search.label': 'Search methods',
'openapi.search.placeholder': 'Name, operationId, or path',
'openapi.method.label': 'HTTP method',
'openapi.method.all': 'All methods',
'openapi.select_visible': 'Select visible',
'openapi.clear_visible': 'Clear visible',
'openapi.create': 'Create drafts',
'openapi.selection.empty': 'Selected: 0',
'openapi.selection': 'Selected: {selected} of {total}, shown: {visible}',
'openapi.group.selection': 'selected {selected} of {total}, shown {visible}',
'openapi.group.select': 'Select group',
'openapi.operation.fields': 'inputs: {input} · outputs: {output}',
'openapi.mapping.path': 'Path',
'openapi.mapping.query': 'Query',
'openapi.mapping.header': 'Header',
'openapi.mapping.body': 'Body',
'openapi.mapping.response': 'Response',
'openapi.source.summary': '{name} · {size} · methods: {count}',
'openapi.status.file_ready': 'File is ready for preview.',
'openapi.status.previewing': 'Uploading and parsing the document…',
'openapi.status.preview_ready': 'Document is ready. Select methods to import.',
'openapi.status.creating': 'Creating drafts…',
'openapi.status.created': 'Created: {created}; skipped: {skipped}.',
'openapi.status.cancelled': 'Request cancelled.',
'openapi.status.context_changed': 'The request was cancelled because the context changed.',
'openapi.error.file_required': 'Choose an OpenAPI/Swagger file first.',
'openapi.error.file_type': 'Choose one .yaml, .yml, or .json file with a matching type.',
'openapi.error.file_empty': 'The selected file is empty.',
'openapi.error.file_large': 'The selected file is larger than 256 KiB.',
'openapi.error.no_methods': 'The document does not contain importable methods.',
'openapi.error.workspace': 'No workspace is selected.',
'openapi.error.preview': 'The document could not be previewed.',
'openapi.error.create': 'Drafts could not be created.',
'openapi.error.selection': 'Select at least one method.',
'openapi.error.preview_required': 'Preview the current file first.',
'openapi.error.server': 'Provide a base URL for the drafts.',
'openapi.error.malformed_multipart': 'The upload request is malformed. Choose the file again and retry.',
'openapi.error.invalid_utf8': 'The document must use UTF-8 encoding.',
'openapi.error.invalid_document': 'The selected file is not a valid OpenAPI document.',
'openapi.error.parser_unavailable': 'Document parsing is temporarily unavailable. Retry shortly.',
'openapi.error.storage_unavailable': 'Temporary import storage is unavailable. Retry shortly.',
'openapi.error.source_integrity': 'The uploaded document could not be verified. Choose the file again.',
'openapi.error.source_unavailable': 'The uploaded document is no longer available. Choose it again.',
'openapi.finding.missing_servers': 'No base URL was found in the document.',
'openapi.finding.multiple_servers': 'Several base URLs are available; choose one before creating drafts.',
'openapi.finding.unsupported_request_body': 'This request body needs review in the wizard.',
'openapi.finding.operation_name_renamed': 'The operation name was adjusted to keep it unique.',
'openapi.finding.operation_name_conflict': 'An operation with this name already exists.',
'openapi.finding.weak_tool_description': 'The tool description is too short.',
'openapi.finding.generic': 'This imported item needs review in the wizard.',
'openapi.correlation.request_id': 'Request ID: {id}',
'openapi.correlation.trace_id': 'Trace ID: {id}',
'openapi.result.title': 'Import result',
'openapi.result.open_first': 'Open first draft in the wizard',
'openapi.result.draft': 'Draft',
'openapi.result.findings': 'Findings',
'openapi.result.action': 'Action',
'openapi.result.no_findings': 'No critical findings',
'openapi.result.more': '+{count} more',
'openapi.result.fix': 'Fix in wizard',
'openapi.result.open': 'Open in wizard',
'openapi.result.skipped': 'Skipped',
});
@@ -2103,6 +2182,86 @@ Object.assign(TRANSLATIONS.ru, {
'execution.error.idempotency_in_progress': 'Операция с этим ключом уже выполняется.',
'execution.error.idempotency_conflict': 'Ключ идемпотентности использован с другими параметрами.',
'execution.error.idempotency_outcome_unknown': 'Результат предыдущего выполнения неизвестен; автоматический повтор запрещён.',
'openapi.trigger': 'Импорт OpenAPI',
'openapi.title': 'Импорт OpenAPI',
'openapi.subtitle': 'Загрузите один OpenAPI/Swagger файл, выберите методы и создайте черновики MCP-инструментов.',
'openapi.close': 'Закрыть',
'openapi.file.label': 'Файл OpenAPI/Swagger',
'openapi.file.choose': 'Выбрать файл',
'openapi.file.none': 'Файл не выбран',
'openapi.preview': 'Разобрать документ',
'openapi.cancel': 'Отменить запрос',
'openapi.retry': 'Повторить',
'openapi.reset': 'Сбросить',
'openapi.server.label': 'Base URL',
'openapi.server.custom_aria': 'Свой Base URL',
'openapi.server.placeholder': 'Или укажите свой base URL, например https://api.example.com',
'openapi.server.later': 'Указать позже',
'openapi.conflict.label': 'Если операция уже существует',
'openapi.conflict.rename': 'Создать копию с новым именем',
'openapi.conflict.skip': 'Пропустить',
'openapi.search.label': 'Поиск методов',
'openapi.search.placeholder': 'Название, operationId или путь',
'openapi.method.label': 'HTTP-метод',
'openapi.method.all': 'Все методы',
'openapi.select_visible': 'Выбрать видимые',
'openapi.clear_visible': 'Снять видимые',
'openapi.create': 'Создать черновики',
'openapi.selection.empty': 'Выбрано: 0',
'openapi.selection': 'Выбрано: {selected} из {total}, показано: {visible}',
'openapi.group.selection': 'выбрано {selected} из {total}, показано {visible}',
'openapi.group.select': 'Выбрать группу',
'openapi.operation.fields': 'входов: {input} · выходов: {output}',
'openapi.mapping.path': 'Path',
'openapi.mapping.query': 'Query',
'openapi.mapping.header': 'Header',
'openapi.mapping.body': 'Body',
'openapi.mapping.response': 'Ответ',
'openapi.source.summary': '{name} · {size} · методов: {count}',
'openapi.status.file_ready': 'Файл готов к разбору.',
'openapi.status.previewing': 'Загружаю и разбираю документ…',
'openapi.status.preview_ready': 'Документ готов. Выберите методы для импорта.',
'openapi.status.creating': 'Создаю черновики…',
'openapi.status.created': 'Создано: {created}; пропущено: {skipped}.',
'openapi.status.cancelled': 'Запрос отменён.',
'openapi.status.context_changed': 'Запрос отменён из-за изменения контекста.',
'openapi.error.file_required': 'Сначала выберите файл OpenAPI/Swagger.',
'openapi.error.file_type': 'Выберите один файл .yaml, .yml или .json с подходящим типом.',
'openapi.error.file_empty': 'Выбранный файл пуст.',
'openapi.error.file_large': 'Размер выбранного файла больше 256 KiB.',
'openapi.error.no_methods': 'В документе нет методов для импорта.',
'openapi.error.workspace': 'Не выбран workspace.',
'openapi.error.preview': 'Не удалось разобрать документ.',
'openapi.error.create': 'Не удалось создать черновики.',
'openapi.error.selection': 'Выберите хотя бы один метод.',
'openapi.error.preview_required': 'Сначала разберите текущий файл.',
'openapi.error.server': 'Укажите base URL для черновиков.',
'openapi.error.malformed_multipart': 'Запрос загрузки повреждён. Выберите файл заново и повторите попытку.',
'openapi.error.invalid_utf8': 'Документ должен быть в кодировке UTF-8.',
'openapi.error.invalid_document': 'Выбранный файл не является корректным документом OpenAPI.',
'openapi.error.parser_unavailable': 'Разбор документа временно недоступен. Повторите попытку позже.',
'openapi.error.storage_unavailable': 'Временное хранилище импорта недоступно. Повторите попытку позже.',
'openapi.error.source_integrity': 'Не удалось проверить загруженный документ. Выберите файл заново.',
'openapi.error.source_unavailable': 'Загруженный документ больше недоступен. Выберите его заново.',
'openapi.finding.missing_servers': 'В документе не найден base URL.',
'openapi.finding.multiple_servers': 'В документе несколько base URL; выберите один перед созданием черновиков.',
'openapi.finding.unsupported_request_body': 'Тело этого запроса нужно проверить в мастере.',
'openapi.finding.operation_name_renamed': 'Имя операции изменено, чтобы сохранить уникальность.',
'openapi.finding.operation_name_conflict': 'Операция с таким именем уже существует.',
'openapi.finding.weak_tool_description': 'Описание инструмента слишком короткое.',
'openapi.finding.generic': 'Этот импортированный элемент нужно проверить в мастере.',
'openapi.correlation.request_id': 'Request ID: {id}',
'openapi.correlation.trace_id': 'Trace ID: {id}',
'openapi.result.title': 'Результат импорта',
'openapi.result.open_first': 'Открыть первый черновик в мастере',
'openapi.result.draft': 'Черновик',
'openapi.result.findings': 'Замечания',
'openapi.result.action': 'Действие',
'openapi.result.no_findings': 'Критичных замечаний нет',
'openapi.result.more': '+{count} еще',
'openapi.result.fix': 'Исправить в мастере',
'openapi.result.open': 'Открыть в мастере',
'openapi.result.skipped': 'Пропущено',
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@
"build": "node scripts/build.js",
"e2e:install": "playwright install chromium",
"e2e": "playwright test",
"e2e:openapi": "playwright test tests/e2e/operations.spec.js --workers=1",
"e2e:headed": "playwright test --headed"
},
"dependencies": {
+9 -3
View File
@@ -2,6 +2,14 @@ const { defineConfig, devices } = require('@playwright/test');
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300';
const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === '1';
const jsonOutput = process.env.PLAYWRIGHT_JSON_OUTPUT;
const reporters = process.env.CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'never' }]];
if (jsonOutput) {
reporters.push(['json', { outputFile: jsonOutput }]);
}
module.exports = defineConfig({
testDir: './tests/e2e',
@@ -12,9 +20,7 @@ module.exports = defineConfig({
timeout: 10_000,
},
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'never' }]],
reporter: reporters,
use: {
baseURL,
trace: 'on-first-retry',
+2 -1
View File
@@ -132,7 +132,8 @@ export CRANK_SESSION_TTL_HOURS="24"
export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL"
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
mkdir -p "$CRANK_STORAGE_ROOT"
mkdir -p -m 700 "$CRANK_STORAGE_ROOT"
chmod 700 "$CRANK_STORAGE_ROOT"
(
cd "$ROOT_DIR/apps/ui"
+374 -9
View File
@@ -1,6 +1,16 @@
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', 'Операции'));
@@ -15,14 +25,20 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
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: /Импорт OpenAPI/i }).click();
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
await expect(page.locator('#openapi-import-modal')).toBeVisible();
await page.locator('#openapi-import-document').fill(`
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}:
@@ -76,8 +92,13 @@ paths:
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();
@@ -92,13 +113,13 @@ paths:
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('Ответ');
await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2');
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('Выбрано: 1');
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);
@@ -106,15 +127,359 @@ paths:
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('Результат импорта');
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(/Описание инструмента слишком короткое/);
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере');
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);
});