mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 22:18:33 +00:00
fix(openapi):validate API spec imports (#8322)
* fix(openapi):validate API spec imports and resolve preview to error state instead of hanging * fix(openapi):allows opening of malformed files with correct extension * fix(openapi):reject non-OpenAPI content in spec preview before timeout * test(openapi): add unit + e2e coverage for spec import validation * refactor(openapi):centralise spec error messages and fix dialog restore * fix:removed nested try catch * test:added common locators * refactor:removed repeated lines * refactor:removed redundant file reads --------- Co-authored-by: Adwait Aayush <adwaitaayush@Adwaits-MacBook-Air.local>
This commit is contained in:
@@ -1,11 +1,34 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import { IconAlertCircle, IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
import { SPEC_PREVIEW_ERRORS } from './constants';
|
||||
|
||||
const PREVIEW_TIMEOUT_MS = 15000;
|
||||
|
||||
const getPreviewParseError = (content) => {
|
||||
if (!content || typeof content !== 'string') return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
try {
|
||||
parsed = jsyaml.load(content);
|
||||
} catch {
|
||||
return SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON;
|
||||
}
|
||||
}
|
||||
if (!isOpenApiSpec(parsed)) {
|
||||
return SPEC_PREVIEW_ERRORS.INVALID_OPENAPI;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 450;
|
||||
@@ -51,16 +74,40 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
|
||||
: { flex: '1 1 50%', minWidth: 0 };
|
||||
|
||||
const [swaggerReady, setSwaggerReady] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(null);
|
||||
const previewTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSwaggerReady(false);
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
setPreviewError(SPEC_PREVIEW_ERRORS.EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseErr = getPreviewParseError(content);
|
||||
if (parseErr) {
|
||||
setPreviewError(parseErr);
|
||||
return;
|
||||
}
|
||||
setPreviewError(null);
|
||||
|
||||
previewTimeoutRef.current = setTimeout(() => {
|
||||
setPreviewError(SPEC_PREVIEW_ERRORS.TIMEOUT);
|
||||
}, PREVIEW_TIMEOUT_MS);
|
||||
|
||||
return () => clearTimeout(previewTimeoutRef.current);
|
||||
}, [content]);
|
||||
|
||||
const handleSwaggerComplete = useCallback(() => {
|
||||
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
|
||||
// before hiding the loader — avoids a flash of unrendered content.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setSwaggerReady(true));
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
setSwaggerReady(true);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -101,19 +148,33 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
|
||||
className="api-spec-right-pane relative"
|
||||
style={{ flex: '1 1 50%', minWidth: 0 }}
|
||||
>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
{previewError ? (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
className="absolute inset-0 flex items-center justify-center p-8"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
<div className="flex flex-col items-center gap-3 text-center opacity-70">
|
||||
<IconAlertCircle size={28} strokeWidth={1.5} />
|
||||
<span className="text-sm">{previewError}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SPEC_PREVIEW_ERRORS = {
|
||||
EMPTY: 'Unable to render preview: No API definition provided.',
|
||||
INVALID_YAML_JSON: 'Unable to render preview: content is not a valid YAML or JSON.',
|
||||
INVALID_OPENAPI: 'Unable to render preview: content is not a valid OpenAPI specification.',
|
||||
TIMEOUT: 'Preview timed out. The spec may be too large or contain unsupported content.'
|
||||
} as const;
|
||||
@@ -11,6 +11,10 @@ export const convertOpenapiToBruno = (data, options = {}) => {
|
||||
};
|
||||
|
||||
export const isOpenApiSpec = (data) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof data.info !== 'object' || data.info === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { dialog, ipcMain } = require('electron');
|
||||
const { normalizeAndResolvePath } = require('../utils/filesystem');
|
||||
const { generateUidBasedOnHash } = require('../utils/common');
|
||||
const { parseApiSpecContent } = require('../utils/apiSpecs');
|
||||
const {
|
||||
addApiSpecToWorkspace,
|
||||
readWorkspaceConfig,
|
||||
@@ -11,6 +12,18 @@ const {
|
||||
|
||||
const DEFAULT_WORKSPACE_NAME = 'My Workspace';
|
||||
|
||||
const INVALID_EXTENSION_MESSAGE
|
||||
= 'Invalid file format. Please select a valid OpenAPI spec in YAML or JSON format.';
|
||||
|
||||
const VALID_API_SPEC_EXTENSIONS = ['.yaml', '.yml', '.json'];
|
||||
|
||||
const validateApiSpec = (filePath) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!VALID_API_SPEC_EXTENSIONS.includes(ext)) {
|
||||
throw new Error(INVALID_EXTENSION_MESSAGE);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => {
|
||||
if (isDefault) {
|
||||
return {
|
||||
@@ -24,7 +37,8 @@ const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => {
|
||||
|
||||
const openApiSpecDialog = async (win, watcher, options = {}) => {
|
||||
const { filePaths } = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile', 'createFile']
|
||||
properties: ['openFile', 'createFile'],
|
||||
filters: [{ name: 'OpenAPI Spec', extensions: ['yaml', 'yml', 'json'] }]
|
||||
});
|
||||
|
||||
if (filePaths && filePaths[0]) {
|
||||
@@ -39,6 +53,8 @@ const openApiSpecDialog = async (win, watcher, options = {}) => {
|
||||
|
||||
const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
try {
|
||||
validateApiSpec(apiSpecPath);
|
||||
|
||||
const uid = generateUidBasedOnHash(apiSpecPath);
|
||||
|
||||
if (options.workspacePath) {
|
||||
@@ -76,28 +92,22 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
if (!watcher.hasWatcher(apiSpecPath)) {
|
||||
ipcMain.emit('main:apispec-opened', win, apiSpecPath, uid, options.workspacePath);
|
||||
} else {
|
||||
const rawContent = fs.readFileSync(apiSpecPath, 'utf8');
|
||||
const extension = path.extname(apiSpecPath);
|
||||
|
||||
win.webContents.send('main:apispec-tree-updated', 'addFile', {
|
||||
pathname: apiSpecPath,
|
||||
uid: uid,
|
||||
raw: require('fs').readFileSync(apiSpecPath, 'utf8'),
|
||||
name: require('path').basename(apiSpecPath, require('path').extname(apiSpecPath)),
|
||||
filename: require('path').basename(apiSpecPath),
|
||||
json: (() => {
|
||||
const ext = require('path').extname(apiSpecPath).toLowerCase();
|
||||
const content = require('fs').readFileSync(apiSpecPath, 'utf8');
|
||||
if (ext === '.yaml' || ext === '.yml') {
|
||||
return require('js-yaml').load(content);
|
||||
} else if (ext === '.json') {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
raw: rawContent,
|
||||
name: path.basename(apiSpecPath, path.extname(apiSpecPath)),
|
||||
filename: path.basename(apiSpecPath),
|
||||
json: parseApiSpecContent(rawContent, extension)
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!options.dontSendDisplayErrors) {
|
||||
win.webContents.send('main:display-error', {
|
||||
error: err.message || 'An error occurred while opening the apiSpec'
|
||||
message: err.message || 'An error occurred while opening the apiSpec'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,5 +115,6 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
|
||||
module.exports = {
|
||||
openApiSpec,
|
||||
openApiSpecDialog
|
||||
openApiSpecDialog,
|
||||
INVALID_EXTENSION_MESSAGE
|
||||
};
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
const _ = require('lodash');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const chokidar = require('chokidar');
|
||||
const { getApiSpecUid } = require('../cache/apiSpecUids');
|
||||
const yaml = require('js-yaml');
|
||||
const { isDirectory } = require('../utils/filesystem');
|
||||
const { safeParseJSON } = require('../utils/common');
|
||||
const { parseApiSpecContent } = require('../utils/apiSpecs');
|
||||
|
||||
const hasApiSpecExtension = (filename) => {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
return ['yaml', 'yml', 'json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
|
||||
};
|
||||
|
||||
const parseApiSpecContent = (pathname) => {
|
||||
const extension = path.extname(pathname).toLowerCase();
|
||||
let content = fs.readFileSync(pathname, 'utf8');
|
||||
|
||||
if (extension === '.yaml' || extension === '.yml') {
|
||||
return yaml.load(content);
|
||||
} else if (extension === '.json') {
|
||||
return safeParseJSON(content);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hydrateApiSpecWithUuid = (apiSpec, pathname) => {
|
||||
apiSpec.uid = getApiSpecUid(pathname);
|
||||
return apiSpec;
|
||||
@@ -34,9 +21,11 @@ const add = async (win, pathname) => {
|
||||
try {
|
||||
const basename = path.basename(pathname);
|
||||
const file = {};
|
||||
const apiSpecContent = parseApiSpecContent(pathname);
|
||||
const raw = fs.readFileSync(pathname, 'utf8');
|
||||
const extension = path.extname(pathname);
|
||||
const apiSpecContent = parseApiSpecContent(raw, extension);
|
||||
|
||||
file.raw = fs.readFileSync(pathname, 'utf8');
|
||||
file.raw = raw;
|
||||
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
|
||||
file.filename = basename;
|
||||
file.pathname = pathname;
|
||||
@@ -53,9 +42,11 @@ const change = async (win, pathname) => {
|
||||
try {
|
||||
const basename = path.basename(pathname);
|
||||
const file = {};
|
||||
const apiSpecContent = parseApiSpecContent(pathname);
|
||||
const raw = fs.readFileSync(pathname, 'utf8');
|
||||
const extension = path.extname(pathname);
|
||||
const apiSpecContent = parseApiSpecContent(raw, extension);
|
||||
|
||||
file.raw = fs.readFileSync(pathname, 'utf8');
|
||||
file.raw = raw;
|
||||
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
|
||||
file.filename = basename;
|
||||
file.pathname = pathname;
|
||||
|
||||
19
packages/bruno-electron/src/utils/apiSpecs.js
Normal file
19
packages/bruno-electron/src/utils/apiSpecs.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const parseApiSpecContent = (content, extension) => {
|
||||
const ext = (extension || '').toLowerCase();
|
||||
|
||||
try {
|
||||
if (ext === '.yaml' || ext === '.yml') {
|
||||
return yaml.load(content);
|
||||
} else if (ext === '.json') {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = { parseApiSpecContent };
|
||||
107
packages/bruno-electron/tests/app/apiSpecs.spec.js
Normal file
107
packages/bruno-electron/tests/app/apiSpecs.spec.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { INVALID_EXTENSION_MESSAGE } = require('../../src/app/apiSpecs');
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: jest.fn() },
|
||||
ipcMain: { emit: jest.fn() }
|
||||
}));
|
||||
|
||||
const { ipcMain } = require('electron');
|
||||
const { openApiSpec } = require('../../src/app/apiSpecs');
|
||||
|
||||
describe('openApiSpec', () => {
|
||||
let tmpDir;
|
||||
let win;
|
||||
let watcher;
|
||||
|
||||
const writeSpecFile = (filename, content) => {
|
||||
const filePath = path.join(tmpDir, filename);
|
||||
fs.writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-apispec-'));
|
||||
win = { webContents: { send: jest.fn() } };
|
||||
watcher = { hasWatcher: jest.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(['spec.txt', 'spec.xml', 'spec', 'spec.yamlx'])(
|
||||
'rejects a file with an unsupported extension (%s)',
|
||||
async (filename) => {
|
||||
const specPath = writeSpecFile(filename, 'This is a plain text file, not an OpenAPI spec.');
|
||||
|
||||
await openApiSpec(win, watcher, specPath);
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith('main:display-error', {
|
||||
message: INVALID_EXTENSION_MESSAGE
|
||||
});
|
||||
expect(ipcMain.emit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['openapi.yaml', 'openapi.yml', 'openapi.json', 'openapi.YAML', 'openapi.Json'])(
|
||||
'accepts a file with a supported extension (%s)',
|
||||
async (filename) => {
|
||||
const specPath = writeSpecFile(filename, '{}');
|
||||
watcher.hasWatcher.mockReturnValue(false);
|
||||
|
||||
await openApiSpec(win, watcher, specPath);
|
||||
|
||||
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
|
||||
expect(ipcMain.emit).toHaveBeenCalledWith('main:apispec-opened', win, specPath, expect.any(String), undefined);
|
||||
}
|
||||
);
|
||||
|
||||
test('does not send a display error when dontSendDisplayErrors is set', async () => {
|
||||
const specPath = writeSpecFile('spec.txt', 'not a spec');
|
||||
|
||||
await openApiSpec(win, watcher, specPath, { dontSendDisplayErrors: true });
|
||||
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opens a valid spec by emitting main:apispec-opened', async () => {
|
||||
const specPath = writeSpecFile('openapi.yaml', 'openapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}\n');
|
||||
watcher.hasWatcher.mockReturnValue(false);
|
||||
|
||||
await openApiSpec(win, watcher, specPath);
|
||||
|
||||
expect(ipcMain.emit).toHaveBeenCalledWith('main:apispec-opened', win, specPath, expect.any(String), undefined);
|
||||
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
|
||||
});
|
||||
|
||||
test('opens a malformed file with a valid extension without throwing, resolving json to null', async () => {
|
||||
const specPath = writeSpecFile('malformed.yaml', 'openapi: 3.0.0\ninfo:\n title: Test\n version: : :\npaths: [');
|
||||
watcher.hasWatcher.mockReturnValue(true);
|
||||
|
||||
await openApiSpec(win, watcher, specPath);
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith(
|
||||
'main:apispec-tree-updated',
|
||||
'addFile',
|
||||
expect.objectContaining({ pathname: specPath, json: null })
|
||||
);
|
||||
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
|
||||
});
|
||||
|
||||
test('opens a broken JSON file with a valid extension without throwing, resolving json to null', async () => {
|
||||
const specPath = writeSpecFile('broken.json', '{\n "openapi": "3.0.0",\n "info": {\n "title": "Test"\n "version": "1.0.0"\n },\n "paths": {\n');
|
||||
watcher.hasWatcher.mockReturnValue(true);
|
||||
|
||||
await openApiSpec(win, watcher, specPath);
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith(
|
||||
'main:apispec-tree-updated',
|
||||
'addFile',
|
||||
expect.objectContaining({ pathname: specPath, json: null })
|
||||
);
|
||||
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
|
||||
});
|
||||
});
|
||||
74
tests/import/openapi/api-spec-panel-validation.spec.ts
Normal file
74
tests/import/openapi/api-spec-panel-validation.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
import { openApiSpecFromDialog, openApiSpecSidebarItem } from '../../utils/page/openapi/render-spec';
|
||||
import { SPEC_PREVIEW_ERRORS } from '../../../packages/bruno-app/src/components/ApiSpecPanel/constants';
|
||||
import { INVALID_EXTENSION_MESSAGE } from '../../../packages/bruno-electron/src/app/apiSpecs';
|
||||
|
||||
test.describe('API Spec Panel - open & preview validation', () => {
|
||||
test.beforeAll(async ({ electronApp }) => {
|
||||
await electronApp.evaluate(({ dialog }) => {
|
||||
(dialog as any).__savedShowOpenDialog = dialog.showOpenDialog;
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async ({ electronApp }) => {
|
||||
await electronApp.evaluate(({ dialog }) => {
|
||||
dialog.showOpenDialog = (dialog as any).__savedShowOpenDialog;
|
||||
delete (dialog as any).__savedShowOpenDialog;
|
||||
});
|
||||
});
|
||||
|
||||
test('Reject a file with an unsupported extension', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-invalid-extension.txt');
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await expect(
|
||||
page.getByText(INVALID_EXTENSION_MESSAGE)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Show the empty-spec message when opening an empty file', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-empty.yaml');
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await openApiSpecSidebarItem(page, 'openapi-empty');
|
||||
await expect(page.getByText(SPEC_PREVIEW_ERRORS.EMPTY)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Show a parse error when opening malformed YAML', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-malformed.yaml');
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await openApiSpecSidebarItem(page, 'openapi-malformed');
|
||||
await expect(
|
||||
page.getByText(SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Show a parse error when opening malformed JSON', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-broken.json');
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await openApiSpecSidebarItem(page, 'openapi-broken');
|
||||
await expect(
|
||||
page.getByText(SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Show a spec error when the file parses but is not a valid OpenAPI document', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-missing-info.yaml');
|
||||
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await openApiSpecSidebarItem(page, 'openapi-missing-info');
|
||||
// Parses as an object but lacks the openapi/swagger + info contract, so it must fail
|
||||
// fast with a precise message rather than falling through to the preview timeout.
|
||||
await expect(
|
||||
page.getByText(SPEC_PREVIEW_ERRORS.INVALID_OPENAPI)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Render a valid spec without any preview error', async ({ page, electronApp }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-simple.json');
|
||||
await openApiSpecFromDialog(page, electronApp, openApiFile);
|
||||
await openApiSpecSidebarItem(page, 'Simple Test API');
|
||||
// Panel opens for the valid spec (filename shown in the header) and no preview error appears
|
||||
await expect(page.getByText('openapi-simple.json')).toBeVisible();
|
||||
await expect(page.getByText(/Unable to render preview/i)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
19
tests/import/openapi/fixtures/openapi-broken.json
Normal file
19
tests/import/openapi/fixtures/openapi-broken.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Malformed OpenAPI"
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"summary": "Test endpoint",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
tests/import/openapi/fixtures/openapi-empty.yaml
Normal file
0
tests/import/openapi/fixtures/openapi-empty.yaml
Normal file
@@ -0,0 +1 @@
|
||||
This is a plain text file, not an OpenAPI spec.
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Page, Locator } from '../../../playwright';
|
||||
import { buildApiSpecPanelLocators } from './openapi/render-spec';
|
||||
|
||||
export const buildCommonLocators = (page: Page) => ({
|
||||
runner: () => page.getByTestId('run-button'),
|
||||
openApi: {
|
||||
render: buildApiSpecPanelLocators(page)
|
||||
},
|
||||
saveButton: () => page
|
||||
.locator('.infotip')
|
||||
.filter({ hasText: /^Save/ }),
|
||||
|
||||
30
tests/utils/page/openapi/render-spec.ts
Normal file
30
tests/utils/page/openapi/render-spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, Page, ElectronApplication } from '../../../../playwright';
|
||||
|
||||
export const buildApiSpecPanelLocators = (page: Page) => ({
|
||||
addMenuButton: () => page.getByTestId('api-specs-header-add-menu'),
|
||||
openApiSpecMenuItem: () => page.getByTestId('api-specs-header-add-menu-open-api-spec'),
|
||||
sidebarItem: (name: string) => page.locator('.api-spec-item').filter({ hasText: name })
|
||||
});
|
||||
|
||||
export const openApiSpecFromDialog = async (
|
||||
page: Page,
|
||||
electronApp: ElectronApplication,
|
||||
filePath: string
|
||||
): Promise<void> => {
|
||||
await test.step(`Open API spec from path: ${filePath}`, async () => {
|
||||
await electronApp.evaluate(({ dialog }, filePath) => {
|
||||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [filePath] });
|
||||
}, filePath);
|
||||
|
||||
const { addMenuButton, openApiSpecMenuItem } = buildApiSpecPanelLocators(page);
|
||||
await addMenuButton().click();
|
||||
await openApiSpecMenuItem().click();
|
||||
});
|
||||
};
|
||||
|
||||
export const openApiSpecSidebarItem = async (page: Page, name: string): Promise<void> => {
|
||||
await test.step(`Open API spec sidebar item "${name}"`, async () => {
|
||||
const { sidebarItem } = buildApiSpecPanelLocators(page);
|
||||
await sidebarItem(name).click();
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user