fix(openapi): harden story 2.1 production lifecycle
This commit is contained in:
@@ -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