fix(openapi): harden story 2.1 production lifecycle
CI / Rust Checks (push) Failing after 4m6s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

This commit is contained in:
2026-08-29 00:48:22 +03:00
parent 2c94af6791
commit bc03c33387
46 changed files with 2198 additions and 276 deletions
+130 -12
View File
@@ -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 }) => {