fix(openapi): harden story 2.1 production lifecycle
This commit is contained in:
+4
-1
@@ -428,7 +428,10 @@
|
||||
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' }),
|
||||
headers: headers({
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': localStorage.getItem('crank_lang') === 'ru' ? 'ru' : 'en',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
signal: options && options.signal,
|
||||
});
|
||||
|
||||
+6
-2
@@ -2082,7 +2082,7 @@ Object.assign(TRANSLATIONS.en, {
|
||||
'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.server.required': 'Enter a Base URL',
|
||||
'openapi.conflict.label': 'If an operation already exists',
|
||||
'openapi.conflict.rename': 'Create a copy with a new name',
|
||||
'openapi.conflict.skip': 'Skip it',
|
||||
@@ -2110,6 +2110,8 @@ Object.assign(TRANSLATIONS.en, {
|
||||
'openapi.status.creating': 'Creating drafts…',
|
||||
'openapi.status.created': 'Created: {created}; skipped: {skipped}.',
|
||||
'openapi.status.cancelled': 'Request cancelled.',
|
||||
'openapi.status.apply_outcome_unknown': 'The create request may have completed. Retry uses the same import job and is safe.',
|
||||
'openapi.status.refresh_failed': 'Drafts were created, but the operation list could not be refreshed.',
|
||||
'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.',
|
||||
@@ -2196,7 +2198,7 @@ Object.assign(TRANSLATIONS.ru, {
|
||||
'openapi.server.label': 'Base URL',
|
||||
'openapi.server.custom_aria': 'Свой Base URL',
|
||||
'openapi.server.placeholder': 'Или укажите свой base URL, например https://api.example.com',
|
||||
'openapi.server.later': 'Указать позже',
|
||||
'openapi.server.required': 'Укажите Base URL',
|
||||
'openapi.conflict.label': 'Если операция уже существует',
|
||||
'openapi.conflict.rename': 'Создать копию с новым именем',
|
||||
'openapi.conflict.skip': 'Пропустить',
|
||||
@@ -2224,6 +2226,8 @@ Object.assign(TRANSLATIONS.ru, {
|
||||
'openapi.status.creating': 'Создаю черновики…',
|
||||
'openapi.status.created': 'Создано: {created}; пропущено: {skipped}.',
|
||||
'openapi.status.cancelled': 'Запрос отменён.',
|
||||
'openapi.status.apply_outcome_unknown': 'Запрос на создание мог завершиться. Повтор использует ту же задачу импорта и безопасен.',
|
||||
'openapi.status.refresh_failed': 'Черновики созданы, но список операций не удалось обновить.',
|
||||
'openapi.status.context_changed': 'Запрос отменён из-за изменения контекста.',
|
||||
'openapi.error.file_required': 'Сначала выберите файл OpenAPI/Swagger.',
|
||||
'openapi.error.file_type': 'Выберите один файл .yaml, .yml или .json с подходящим типом.',
|
||||
|
||||
+309
-50
@@ -1,5 +1,7 @@
|
||||
(function() {
|
||||
var MAX_FILE_BYTES = 256 * 1024;
|
||||
var APPLY_REPLAY_STORAGE_KEY = 'crank_openapi_apply_replay_v1';
|
||||
var restoredApplyRequest = loadApplyReplay();
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
onImported: null,
|
||||
@@ -12,9 +14,58 @@
|
||||
previewController: null,
|
||||
applyController: null,
|
||||
applyInFlight: false,
|
||||
applyRequest: restoredApplyRequest,
|
||||
applyMetadata: restoredApplyRequest && restoredApplyRequest.operationMetadata || [],
|
||||
applyResult: null,
|
||||
applyOutcomeUnknown: !!restoredApplyRequest,
|
||||
applyRefreshFailed: false,
|
||||
restoreFocus: null,
|
||||
};
|
||||
|
||||
function validApplyReplay(request) {
|
||||
return request
|
||||
&& typeof request.workspaceId === 'string' && request.workspaceId.length > 0 && request.workspaceId.length <= 128
|
||||
&& typeof request.jobId === 'string' && request.jobId.length > 0 && request.jobId.length <= 128
|
||||
&& request.payload && Array.isArray(request.payload.selected_operation_keys)
|
||||
&& request.payload.selected_operation_keys.length <= 1024
|
||||
&& request.payload.selected_operation_keys.every(function(key) {
|
||||
return typeof key === 'string' && key.length > 0 && key.length <= 512;
|
||||
})
|
||||
&& (request.payload.server_url === null
|
||||
|| (typeof request.payload.server_url === 'string' && request.payload.server_url.length <= 2048))
|
||||
&& (request.payload.conflict_mode === 'skip' || request.payload.conflict_mode === 'rename')
|
||||
&& Array.isArray(request.operationMetadata)
|
||||
&& request.operationMetadata.length <= 1024
|
||||
&& request.operationMetadata.every(function(operation) {
|
||||
return operation
|
||||
&& typeof operation.key === 'string' && operation.key.length > 0 && operation.key.length <= 512
|
||||
&& typeof operation.method === 'string' && operation.method.length > 0 && operation.method.length <= 16
|
||||
&& typeof operation.path === 'string' && operation.path.length > 0 && operation.path.length <= 2048;
|
||||
});
|
||||
}
|
||||
|
||||
function loadApplyReplay() {
|
||||
try {
|
||||
var request = JSON.parse(sessionStorage.getItem(APPLY_REPLAY_STORAGE_KEY) || 'null');
|
||||
return validApplyReplay(request) ? request : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistApplyReplay(request) {
|
||||
if (!validApplyReplay(request)) return;
|
||||
try {
|
||||
sessionStorage.setItem(APPLY_REPLAY_STORAGE_KEY, JSON.stringify(request));
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function clearApplyReplay() {
|
||||
try {
|
||||
sessionStorage.removeItem(APPLY_REPLAY_STORAGE_KEY);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -31,10 +82,14 @@
|
||||
return localStorage.getItem('crank_lang') === 'ru' ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
function isCurrent(revision, workspaceId, language) {
|
||||
function isSameContext(revision, workspaceId, language) {
|
||||
return revision === state.revision
|
||||
&& workspaceId === state.workspaceId
|
||||
&& language === locale()
|
||||
&& language === locale();
|
||||
}
|
||||
|
||||
function isCurrent(revision, workspaceId, language) {
|
||||
return isSameContext(revision, workspaceId, language)
|
||||
&& !qs('openapi-import-modal').hidden;
|
||||
}
|
||||
|
||||
@@ -279,7 +334,7 @@
|
||||
servers.forEach(function(server) {
|
||||
var option = document.createElement('option');
|
||||
option.value = server;
|
||||
option.textContent = server || tKey('openapi.server.later');
|
||||
option.textContent = server || tKey('openapi.server.required');
|
||||
serverSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
@@ -425,6 +480,11 @@
|
||||
function clearPreview() {
|
||||
state.jobId = null;
|
||||
state.preview = null;
|
||||
state.applyRequest = null;
|
||||
state.applyMetadata = [];
|
||||
state.applyResult = null;
|
||||
state.applyOutcomeUnknown = false;
|
||||
state.applyRefreshFailed = false;
|
||||
state.filterQuery = '';
|
||||
state.filterMethod = '';
|
||||
qs('openapi-import-preview-panel').hidden = true;
|
||||
@@ -448,6 +508,8 @@
|
||||
state.applyInFlight = false;
|
||||
qs('openapi-import-preview').disabled = false;
|
||||
qs('openapi-import-create').disabled = false;
|
||||
qs('openapi-import-file-select').disabled = false;
|
||||
qs('openapi-import-reset').disabled = false;
|
||||
qs('openapi-import-cancel').hidden = true;
|
||||
clearPreview();
|
||||
|
||||
@@ -462,8 +524,40 @@
|
||||
qs('openapi-import-retry').hidden = !show;
|
||||
}
|
||||
|
||||
function lockApplyInputs(locked) {
|
||||
qs('openapi-import-create').disabled = locked;
|
||||
qs('openapi-import-preview').disabled = locked;
|
||||
qs('openapi-import-file-select').disabled = locked;
|
||||
qs('openapi-import-reset').disabled = locked;
|
||||
}
|
||||
|
||||
function markApplyOutcomeUnknown(options) {
|
||||
if (!state.applyRequest) return false;
|
||||
|
||||
abort(state.applyController);
|
||||
state.applyController = null;
|
||||
state.applyInFlight = false;
|
||||
state.applyOutcomeUnknown = true;
|
||||
persistApplyReplay(state.applyRequest);
|
||||
// Until the same job is replayed, starting another import could hide an
|
||||
// already committed server outcome and lead to duplicate drafts.
|
||||
lockApplyInputs(true);
|
||||
qs('openapi-import-cancel').hidden = true;
|
||||
|
||||
if (!options || options.render !== false) {
|
||||
showRetry(true);
|
||||
setStatus(tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelCurrent() {
|
||||
if (!state.previewController && !state.applyController) return;
|
||||
if (state.applyInFlight || state.applyController) {
|
||||
markApplyOutcomeUnknown();
|
||||
return;
|
||||
}
|
||||
if (!state.previewController) return;
|
||||
|
||||
invalidate();
|
||||
showRetry(!!state.file);
|
||||
@@ -547,13 +641,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function previewOperationByName(name) {
|
||||
function previewOperationForCreated(created) {
|
||||
var groups = state.preview && state.preview.groups || [];
|
||||
|
||||
for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
||||
var operations = groups[groupIndex].operations || [];
|
||||
for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
|
||||
if (operations[operationIndex].suggested_name === name) return operations[operationIndex];
|
||||
if (created.operation_key && operations[operationIndex].key === created.operation_key) {
|
||||
return operations[operationIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Older servers did not return operation_key. Keep result rendering useful
|
||||
// during a rolling upgrade, but use the unambiguous key whenever it exists.
|
||||
for (var fallbackGroupIndex = 0; fallbackGroupIndex < groups.length; fallbackGroupIndex += 1) {
|
||||
var fallbackOperations = groups[fallbackGroupIndex].operations || [];
|
||||
for (var fallbackOperationIndex = 0; fallbackOperationIndex < fallbackOperations.length; fallbackOperationIndex += 1) {
|
||||
if (fallbackOperations[fallbackOperationIndex].suggested_name === created.name) {
|
||||
return fallbackOperations[fallbackOperationIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var metadataIndex = 0; metadataIndex < state.applyMetadata.length; metadataIndex += 1) {
|
||||
if (created.operation_key && state.applyMetadata[metadataIndex].key === created.operation_key) {
|
||||
return state.applyMetadata[metadataIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +728,7 @@
|
||||
+ '</div>';
|
||||
|
||||
response.created.forEach(function(operation) {
|
||||
var previewOperation = previewOperationByName(operation.name);
|
||||
var previewOperation = previewOperationForCreated(operation);
|
||||
var row = document.createElement('div');
|
||||
var nameCell = document.createElement('div');
|
||||
var name = document.createElement('div');
|
||||
@@ -671,6 +784,118 @@
|
||||
node.hidden = false;
|
||||
}
|
||||
|
||||
function createdStatus(response) {
|
||||
return tfKey('openapi.status.created', {
|
||||
created: (response.created || []).length,
|
||||
skipped: (response.skipped || []).length,
|
||||
});
|
||||
}
|
||||
|
||||
async function notifyImported(revision, workspaceId, language) {
|
||||
if (typeof state.onImported !== 'function') return;
|
||||
|
||||
try {
|
||||
await state.onImported();
|
||||
} catch (_error) {
|
||||
state.applyRefreshFailed = true;
|
||||
if (isCurrent(revision, workspaceId, language) && state.applyResult) {
|
||||
setStatus(createdStatus(state.applyResult) + ' ' + tKey('openapi.status.refresh_failed'), false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApply(request) {
|
||||
if (state.applyInFlight) return;
|
||||
|
||||
var revision = state.revision;
|
||||
var workspaceId = request.workspaceId;
|
||||
var language = locale();
|
||||
var controller = new AbortController();
|
||||
|
||||
state.applyController = controller;
|
||||
state.applyInFlight = true;
|
||||
state.applyOutcomeUnknown = false;
|
||||
state.applyRefreshFailed = false;
|
||||
lockApplyInputs(true);
|
||||
qs('openapi-import-cancel').hidden = false;
|
||||
showRetry(false);
|
||||
setStatus(tKey('openapi.status.creating'));
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.createOpenApiImport(
|
||||
workspaceId,
|
||||
request.jobId,
|
||||
request.payload,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
// Abort is advisory: a superseded request can still deliver a response.
|
||||
// Only the controller installed by this attempt may mutate UI authority.
|
||||
if (state.applyController !== controller) return;
|
||||
state.applyOutcomeUnknown = false;
|
||||
clearApplyReplay();
|
||||
state.applyController = null;
|
||||
state.applyInFlight = false;
|
||||
lockApplyInputs(false);
|
||||
qs('openapi-import-cancel').hidden = true;
|
||||
showRetry(false);
|
||||
if (!isSameContext(revision, workspaceId, language)) {
|
||||
// The original workspace job is now resolved. Do not render its result
|
||||
// into another workspace/language context.
|
||||
state.applyResult = null;
|
||||
state.applyRequest = null;
|
||||
return;
|
||||
}
|
||||
state.applyResult = response;
|
||||
state.applyRequest = null;
|
||||
if (isCurrent(revision, workspaceId, language)) {
|
||||
setStatus(createdStatus(response));
|
||||
renderResult(response);
|
||||
}
|
||||
await notifyImported(revision, workspaceId, language);
|
||||
} catch (error) {
|
||||
// A cancelled attempt may settle after an idempotent retry has already
|
||||
// installed a new controller. It must not overwrite the newer state.
|
||||
if (state.applyController !== controller) return;
|
||||
if (error && error.name === 'AbortError') {
|
||||
state.applyOutcomeUnknown = true;
|
||||
persistApplyReplay(state.applyRequest);
|
||||
showRetry(true);
|
||||
if (!qs('openapi-import-modal').hidden) {
|
||||
setStatus(tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isInteger(error && error.status)
|
||||
&& error.status >= 400 && error.status < 500 && error.status !== 408) {
|
||||
state.applyOutcomeUnknown = false;
|
||||
state.applyRequest = null;
|
||||
clearApplyReplay();
|
||||
showRetry(true);
|
||||
if (!qs('openapi-import-modal').hidden) {
|
||||
setStatus(errorStatus(error, 'openapi.error.create'), true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A browser/network failure is not proof that the server rolled the
|
||||
// transaction back. Replaying this exact job is idempotent server-side.
|
||||
state.applyOutcomeUnknown = true;
|
||||
persistApplyReplay(state.applyRequest);
|
||||
showRetry(true);
|
||||
if (!qs('openapi-import-modal').hidden) {
|
||||
setStatus(errorStatus(error, 'openapi.error.create') + ' ' + tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
}
|
||||
} finally {
|
||||
if (state.applyController === controller) {
|
||||
state.applyController = null;
|
||||
state.applyInFlight = false;
|
||||
lockApplyInputs(state.applyOutcomeUnknown);
|
||||
qs('openapi-import-cancel').hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createDrafts() {
|
||||
if (state.applyInFlight) return;
|
||||
|
||||
@@ -690,50 +915,32 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var revision = state.revision;
|
||||
var workspaceId = state.workspaceId;
|
||||
var language = locale();
|
||||
var jobId = state.jobId;
|
||||
var controller = new AbortController();
|
||||
|
||||
state.applyController = controller;
|
||||
state.applyInFlight = true;
|
||||
qs('openapi-import-create').disabled = true;
|
||||
qs('openapi-import-cancel').hidden = false;
|
||||
showRetry(false);
|
||||
setStatus(tKey('openapi.status.creating'));
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.createOpenApiImport(workspaceId, jobId, {
|
||||
state.applyRequest = {
|
||||
workspaceId: state.workspaceId,
|
||||
jobId: state.jobId,
|
||||
payload: {
|
||||
selected_operation_keys: keys,
|
||||
server_url: serverUrl,
|
||||
conflict_mode: qs('openapi-import-conflict-mode').value || 'rename',
|
||||
}, { signal: controller.signal });
|
||||
if (!isCurrent(revision, workspaceId, language)) return;
|
||||
},
|
||||
operationMetadata: (state.preview.groups || []).flatMap(function(group) {
|
||||
return (group.operations || []).filter(function(operation) {
|
||||
return keys.indexOf(operation.key) >= 0;
|
||||
}).map(function(operation) {
|
||||
return { key: operation.key, method: operation.method, path: operation.path };
|
||||
});
|
||||
}),
|
||||
};
|
||||
state.applyMetadata = state.applyRequest.operationMetadata;
|
||||
persistApplyReplay(state.applyRequest);
|
||||
await submitApply(state.applyRequest);
|
||||
}
|
||||
|
||||
setStatus(tfKey('openapi.status.created', {
|
||||
created: (response.created || []).length,
|
||||
skipped: (response.skipped || []).length,
|
||||
}));
|
||||
renderResult(response);
|
||||
if (typeof state.onImported === 'function') await state.onImported();
|
||||
} catch (error) {
|
||||
if (!isCurrent(revision, workspaceId, language)) return;
|
||||
if (error && error.name === 'AbortError') {
|
||||
setStatus(tKey('openapi.status.cancelled'));
|
||||
return;
|
||||
}
|
||||
|
||||
showRetry(false);
|
||||
setStatus(errorStatus(error, 'openapi.error.create'), true, true);
|
||||
} finally {
|
||||
if (isCurrent(revision, workspaceId, language)) {
|
||||
state.applyController = null;
|
||||
state.applyInFlight = false;
|
||||
qs('openapi-import-create').disabled = false;
|
||||
qs('openapi-import-cancel').hidden = true;
|
||||
}
|
||||
function retryCurrent() {
|
||||
if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
return submitApply(state.applyRequest);
|
||||
}
|
||||
return preview();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -745,7 +952,8 @@
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
if (state.workspaceId && state.workspaceId !== options.workspaceId) {
|
||||
if (state.workspaceId && state.workspaceId !== options.workspaceId
|
||||
&& !(state.applyOutcomeUnknown && state.applyRequest)) {
|
||||
invalidate({ clearFile: true });
|
||||
}
|
||||
|
||||
@@ -754,10 +962,36 @@
|
||||
state.restoreFocus = document.activeElement;
|
||||
qs('openapi-import-modal').hidden = false;
|
||||
renderFileName();
|
||||
if (state.applyResult) {
|
||||
renderResult(state.applyResult);
|
||||
setStatus(createdStatus(state.applyResult) + (state.applyRefreshFailed ? ' ' + tKey('openapi.status.refresh_failed') : ''));
|
||||
} else if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
lockApplyInputs(true);
|
||||
showRetry(true);
|
||||
setStatus(tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
qs('openapi-import-retry').focus();
|
||||
return;
|
||||
}
|
||||
qs('openapi-import-file-select').focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.applyInFlight || state.applyController) {
|
||||
markApplyOutcomeUnknown({ render: false });
|
||||
qs('openapi-import-modal').hidden = true;
|
||||
if (state.restoreFocus && typeof state.restoreFocus.focus === 'function') {
|
||||
state.restoreFocus.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
persistApplyReplay(state.applyRequest);
|
||||
qs('openapi-import-modal').hidden = true;
|
||||
if (state.restoreFocus && typeof state.restoreFocus.focus === 'function') {
|
||||
state.restoreFocus.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
invalidate({ clearFile: true });
|
||||
showRetry(false);
|
||||
qs('openapi-import-modal').hidden = true;
|
||||
@@ -796,7 +1030,7 @@
|
||||
selectFile(event.target.files && event.target.files[0]);
|
||||
});
|
||||
qs('openapi-import-preview').addEventListener('click', preview);
|
||||
qs('openapi-import-retry').addEventListener('click', preview);
|
||||
qs('openapi-import-retry').addEventListener('click', retryCurrent);
|
||||
qs('openapi-import-create').addEventListener('click', createDrafts);
|
||||
qs('openapi-import-cancel').addEventListener('click', cancelCurrent);
|
||||
qs('openapi-import-reset').addEventListener('click', reset);
|
||||
@@ -842,6 +1076,15 @@
|
||||
});
|
||||
function invalidateForContext(options) {
|
||||
if (!qs('openapi-import-modal').hidden) {
|
||||
if (state.applyInFlight || state.applyController) {
|
||||
markApplyOutcomeUnknown();
|
||||
return;
|
||||
}
|
||||
if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
showRetry(true);
|
||||
setStatus(tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
return;
|
||||
}
|
||||
invalidate(options);
|
||||
renderFileName();
|
||||
showRetry(!options || !options.clearFile ? !!state.file : false);
|
||||
@@ -861,14 +1104,30 @@
|
||||
if (event.key === 'crank_lang') invalidateForContext();
|
||||
});
|
||||
window.addEventListener('pagehide', function() {
|
||||
invalidate({ clearFile: true });
|
||||
if (state.applyInFlight || state.applyController) {
|
||||
markApplyOutcomeUnknown({ render: false });
|
||||
state.file = null;
|
||||
qs('openapi-import-file').value = '';
|
||||
renderFileName();
|
||||
} else if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
persistApplyReplay(state.applyRequest);
|
||||
} else {
|
||||
invalidate({ clearFile: true });
|
||||
}
|
||||
clearServerSelection();
|
||||
showRetry(false);
|
||||
setStatus('');
|
||||
});
|
||||
window.addEventListener('pageshow', function(event) {
|
||||
if (!event.persisted || qs('openapi-import-modal').hidden) return;
|
||||
|
||||
invalidateForContext({ clearFile: true });
|
||||
if (state.applyOutcomeUnknown && state.applyRequest) {
|
||||
lockApplyInputs(true);
|
||||
showRetry(true);
|
||||
setStatus(tKey('openapi.status.apply_outcome_unknown'), true, true);
|
||||
} else {
|
||||
invalidateForContext({ clearFile: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -242,7 +242,106 @@ test('OpenAPI upload recovers from pagehide and a preview server error', async (
|
||||
releasePreview();
|
||||
});
|
||||
|
||||
test('OpenAPI upload invalidates active draft creation after language or workspace changes', async ({ page }) => {
|
||||
test('OpenAPI apply cancellation preserves the import job for an idempotent retry', 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_retry_same_authority',
|
||||
preview: {
|
||||
source: { servers: ['https://api.example.test'] },
|
||||
findings: [],
|
||||
groups: [{
|
||||
title: 'retry',
|
||||
operations: [{
|
||||
key: 'get:/retry', method: 'GET', path: '/retry', suggested_name: 'preview_name',
|
||||
suggested_display_name: 'Retry', input_fields: 0, output_fields: 0,
|
||||
draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, findings: [],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
let releaseFirst;
|
||||
const firstRequest = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
await page.route('**/imports/openapi/*/create', async (route) => {
|
||||
requests.push({ url: route.request().url(), body: route.request().postData() });
|
||||
if (requests.length === 1) await firstRequest;
|
||||
if (requests.length === 3) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'import_job.application_mismatch', message: 'conflict' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
created: [{
|
||||
operation_key: 'get:/retry', name: 'renamed_after_conflict', operation_id: 'op_retry', version: 1,
|
||||
}],
|
||||
skipped: [], findings: [],
|
||||
}),
|
||||
});
|
||||
} catch (_error) {
|
||||
// The first request is deliberately aborted by the browser.
|
||||
}
|
||||
});
|
||||
|
||||
await page.locator('#openapi-import-file').setInputFiles({
|
||||
name: 'retry.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-create').click();
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await page.locator('#openapi-import-cancel').click();
|
||||
await expect(page.locator('#openapi-import-status')).toContainText(localized('may have completed', 'мог завершиться'));
|
||||
await expect(page.locator('#openapi-import-retry')).toBeVisible();
|
||||
await page.locator('[data-openapi-close]').last().click();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
|
||||
await expect(page.locator('#openapi-import-retry')).toBeVisible();
|
||||
await page.locator('#openapi-import-retry').click();
|
||||
await expect(page.locator('#openapi-import-result')).toContainText('GET /retry');
|
||||
await expect.poll(() => requests.length).toBe(2);
|
||||
// Let the cancelled first attempt answer after the retry. Its stale response
|
||||
// must not overwrite or unlock the newer attempt's state.
|
||||
releaseFirst();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(page.locator('#openapi-import-result')).toContainText('GET /retry');
|
||||
expect(requests[0].url).toContain('/imports/openapi/job_retry_same_authority/create');
|
||||
expect(requests[1].url).toBe(requests[0].url);
|
||||
expect(requests[1].body).toBe(requests[0].body);
|
||||
|
||||
// A received 4xx is a definitive non-commit outcome, unlike a transport
|
||||
// failure. It must release the wizard instead of trapping it in replay mode.
|
||||
await page.locator('#openapi-import-reset').click();
|
||||
await page.locator('#openapi-import-file').setInputFiles({
|
||||
name: 'definitive-4xx.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-create').click();
|
||||
await expect.poll(() => requests.length).toBe(3);
|
||||
await expect(page.locator('#openapi-import-file-select')).toBeEnabled();
|
||||
await expect(page.locator('#openapi-import-reset')).toBeEnabled();
|
||||
await expect(page.locator('#openapi-import-retry')).toBeVisible();
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('crank_openapi_apply_replay_v1'))).toBeNull();
|
||||
});
|
||||
|
||||
test('OpenAPI apply preserves job authority after language or workspace changes', async ({ page }) => {
|
||||
await login(page);
|
||||
await dismissOnboardingIfOpen(page);
|
||||
await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click();
|
||||
@@ -268,12 +367,17 @@ test('OpenAPI upload invalidates active draft creation after language or workspa
|
||||
});
|
||||
});
|
||||
const releases = [];
|
||||
const applyRequests = [];
|
||||
await page.route('**/imports/openapi/*/create', async (route) => {
|
||||
applyRequests.push({ url: route.request().url(), body: route.request().postData() });
|
||||
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: [] }),
|
||||
body: JSON.stringify({
|
||||
created: [{ operation_key: 'get:/active', name: 'active', operation_id: 'op_active', version: 1 }],
|
||||
skipped: [], findings: [],
|
||||
}),
|
||||
});
|
||||
} catch (_error) {
|
||||
// The invalidation aborts a request that must not update the modal afterwards.
|
||||
@@ -295,23 +399,37 @@ test('OpenAPI upload invalidates active draft creation after language or workspa
|
||||
}
|
||||
|
||||
await beginApply('language-change.yaml');
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'crank_lang', newValue: 'en' }));
|
||||
});
|
||||
// Exercise the actual UI language switcher path: it updates storage,
|
||||
// re-renders translations and dispatches crank:langchange in one action.
|
||||
await page.evaluate(() => window.setLang('en'));
|
||||
await expect(page.locator('#openapi-import-status')).toContainText('may have completed');
|
||||
await expect(page.locator('#openapi-import-retry')).toBeVisible();
|
||||
await expect(page.locator('#openapi-import-file-select')).toBeDisabled();
|
||||
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 page.locator('#openapi-import-retry').click();
|
||||
await expect.poll(() => releases.length).toBeGreaterThan(0);
|
||||
releases.shift()();
|
||||
await expect(page.locator('#openapi-import-result')).toContainText('GET /active');
|
||||
expect(applyRequests[1].url).toBe(applyRequests[0].url);
|
||||
expect(applyRequests[1].body).toBe(applyRequests[0].body);
|
||||
|
||||
await page.locator('#openapi-import-reset').click();
|
||||
await beginApply('workspace-change.yaml');
|
||||
const workspaceRequest = applyRequests.at(-1);
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: { id: 'workspace-after-apply' } }));
|
||||
});
|
||||
await expect(page.locator('#openapi-import-status')).toContainText('may have completed');
|
||||
await expect(page.locator('#openapi-import-retry')).toBeVisible();
|
||||
await expect(page.locator('#openapi-import-file-select')).toBeDisabled();
|
||||
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();
|
||||
await page.locator('#openapi-import-retry').click();
|
||||
await expect.poll(() => releases.length).toBeGreaterThan(0);
|
||||
releases.shift()();
|
||||
await expect(page.locator('#openapi-import-file-select')).toBeEnabled();
|
||||
const workspaceReplay = applyRequests.at(-1);
|
||||
expect(workspaceReplay.url).toBe(workspaceRequest.url);
|
||||
expect(workspaceReplay.body).toBe(workspaceRequest.body);
|
||||
});
|
||||
|
||||
test('OpenAPI upload only renders the latest selected file and clears reset or close races', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user