mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components. - Updated messaging in CollectionStatusSection and OverviewSection for clarity. - Enhanced the SpecDiffModal to provide better visual feedback on changes. - Refactored sync flow logic to ensure accurate endpoint categorization and improved performance. - Added new utility functions for better handling of spec changes and endpoint comparisons.
This commit is contained in:
@@ -71,6 +71,8 @@ const CollectionStatusSection = ({
|
||||
variant: 'muted',
|
||||
message: 'Collection has changes since last sync',
|
||||
badges: { modifiedCount, missingCount, localOnlyCount },
|
||||
version,
|
||||
lastSyncDate,
|
||||
actions: ['revert-all']
|
||||
};
|
||||
}
|
||||
@@ -244,7 +246,7 @@ const CollectionStatusSection = ({
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No changes in collection</h4>
|
||||
<p>The collection matches the last synced spec. Nothing to review.</p>
|
||||
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Action confirmation modal */}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
|
||||
@@ -40,7 +39,7 @@ const SUMMARY_CARDS = [
|
||||
}
|
||||
];
|
||||
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
@@ -48,6 +47,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = storedSpec?.info?.version ?? specMeta?.version;
|
||||
const newVersion = specDrift?.newVersion;
|
||||
const hasVersionChange = version && newVersion && version !== newVersion;
|
||||
const endpointCount = countEndpoints(storedSpec) ?? specMeta?.endpointCount ?? null;
|
||||
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
|
||||
const groupBy = openApiSyncConfig?.groupBy || 'tags';
|
||||
@@ -89,7 +90,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
};
|
||||
|
||||
const details = [
|
||||
{ label: 'Spec Version', value: version ? `v${version}` : '–' },
|
||||
{ label: 'Spec Version', value: hasVersionChange ? `v${version} → v${newVersion}` : version ? `v${version}` : '–' },
|
||||
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '–' },
|
||||
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '–', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
|
||||
{ label: 'Folder Grouping', value: capitalize(groupBy) },
|
||||
@@ -100,6 +101,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
const hasSpecUpdates = specUpdatesPending > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
|
||||
if (activeError) {
|
||||
return {
|
||||
variant: 'danger',
|
||||
@@ -128,7 +133,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates and the collection has changes',
|
||||
title: `The API spec has new updates${versionInfo} and the collection has changes`,
|
||||
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
|
||||
buttons: ['sync', 'changes']
|
||||
};
|
||||
@@ -136,7 +141,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
if (hasSpecUpdates) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates',
|
||||
title: `The API spec has new updates${versionInfo}`,
|
||||
subtitle: 'New or changed requests are available.',
|
||||
buttons: ['sync']
|
||||
};
|
||||
@@ -156,7 +161,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
// buttons: []
|
||||
// };
|
||||
return null;
|
||||
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
|
||||
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="overview-section">
|
||||
@@ -168,12 +173,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">{bannerState.title}</span>
|
||||
{bannerState.showBadge && (
|
||||
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
|
||||
)}
|
||||
{bannerState.showChangesBadge && (
|
||||
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.subtitle && (
|
||||
<p className="banner-subtitle">{bannerState.subtitle}</p>
|
||||
@@ -201,6 +200,11 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
Restore Spec File
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('spec-details') && (
|
||||
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
View Details
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('open-settings') && (
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
|
||||
@@ -47,8 +47,8 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-badges">
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh
|
||||
IconRefresh,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
@@ -35,28 +36,19 @@ const SpecStatusSection = ({
|
||||
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
|
||||
}
|
||||
if (error || specDrift?.isValid === false) {
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: [] };
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
// TODO: re-enable success banner
|
||||
// if (!lastSyncedAt) return null;
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: storedSpec?.info?.version,
|
||||
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
|
||||
// };
|
||||
}
|
||||
if (specDrift.storedSpecMissing) {
|
||||
if (!lastSyncedAt) {
|
||||
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
const hasEndpointUpdates = (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
|
||||
if (hasEndpointUpdates) {
|
||||
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
@@ -71,7 +63,7 @@ const SpecStatusSection = ({
|
||||
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
|
||||
// };
|
||||
return null;
|
||||
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
|
||||
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
|
||||
return (
|
||||
<>
|
||||
{bannerState && (
|
||||
@@ -93,8 +85,8 @@ const SpecStatusSection = ({
|
||||
</span>
|
||||
{bannerState.changes && (
|
||||
<span className="banner-details">
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
@@ -113,7 +105,13 @@ const SpecStatusSection = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
|
||||
{(error || fileNotFound || specDrift?.isValid === false) ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>Unable to check for updates</h4>
|
||||
<p>Fix the connection issue above and check again.</p>
|
||||
</div>
|
||||
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>Last Synced Spec not found in storage</h4>
|
||||
|
||||
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
|
||||
* - specRemovedEndpoints: removed from spec, still in collection
|
||||
*/
|
||||
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
|
||||
const specAddedEndpoints = remoteDrift.missing || [];
|
||||
const specRemovedEndpoints = remoteDrift.localOnly || [];
|
||||
// Only show endpoints as "New in Spec" if they were actually added to the spec
|
||||
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
|
||||
// still exist in both stored and remote spec should not appear here — they belong
|
||||
// in "Collection Changes" only.
|
||||
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
|
||||
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
|
||||
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
|
||||
// the spec should not appear here — they belong in "Collection Changes" only.
|
||||
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
|
||||
// Build lookup sets to determine who changed each modified endpoint
|
||||
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
|
||||
@@ -154,10 +164,7 @@ const SyncReviewPage = ({
|
||||
|
||||
// Accepted — changes that will be applied
|
||||
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to update', 'update', [
|
||||
...specUpdatedEndpoints.filter(isAccepted),
|
||||
...localUpdatedEndpoints.filter(isAccepted)
|
||||
]);
|
||||
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
|
||||
|
||||
// Skipped — changes that will be preserved as-is
|
||||
@@ -167,7 +174,7 @@ const SyncReviewPage = ({
|
||||
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
|
||||
|
||||
return groups;
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
|
||||
const handleConfirmApply = () => {
|
||||
setShowConfirmation(false);
|
||||
@@ -187,7 +194,6 @@ const SyncReviewPage = ({
|
||||
|
||||
onApplySync({
|
||||
endpointDecisions: decisions,
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
// Pass filtered categorized endpoints for performSync to construct the right backend diff
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
|
||||
@@ -90,6 +90,7 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
@@ -328,8 +329,31 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reload drift — passed to useEndpointActions so it can refresh after actions
|
||||
const reloadDrift = () => loadCollectionDrift({ clear: true });
|
||||
// Keep ref in sync so reloadDrift always reads the latest specDrift
|
||||
specDriftRef.current = specDrift;
|
||||
|
||||
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
|
||||
// Uses specDriftRef to avoid stale closure over specDrift state.
|
||||
const reloadDrift = async () => {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
// Refresh remoteDrift if we have a remote spec cached from the last check
|
||||
const currentSpecDrift = specDriftRef.current;
|
||||
if (currentSpecDrift?.newSpec) {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save connection settings from the modal
|
||||
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
|
||||
|
||||
@@ -14,13 +14,13 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
|
||||
} = selections;
|
||||
|
||||
@@ -49,9 +49,7 @@ const useSyncFlow = ({
|
||||
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
removed: removedIds.length > 0
|
||||
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
|
||||
: []
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
@@ -69,7 +67,7 @@ const useSyncFlow = ({
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
|
||||
removeDeletedRequests: localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
@@ -113,10 +111,21 @@ const useSyncFlow = ({
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
// Only treat endpoints as spec changes if they actually changed in the spec
|
||||
// (not locally-added/deleted endpoints that were never in or removed from the spec)
|
||||
const specAddedIds = useMemo(() => {
|
||||
return new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const specRemovedIds = useMemo(() => {
|
||||
return new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const handleConfirmModalSync = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
@@ -125,17 +134,19 @@ const useSyncFlow = ({
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
if (remoteDrift.missing?.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
|
||||
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
if (actuallyAdded.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
|
||||
}
|
||||
if (remoteDrift.modified?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
|
||||
}
|
||||
if (remoteDrift.localOnly?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
|
||||
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
if (actuallyRemoved.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
|
||||
}
|
||||
return groups;
|
||||
}, [remoteDrift]);
|
||||
}, [remoteDrift, specAddedIds, specRemovedIds]);
|
||||
|
||||
return {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
|
||||
@@ -130,7 +130,6 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
onTabSelect={setActiveTab}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
fileNotFound={fileNotFound}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
|
||||
@@ -509,6 +509,139 @@ const buildSpecItemsMap = (collectionItems) => {
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively extracts all key paths from a parsed JSON value (dot-notation).
|
||||
* Used to compare JSON body structure/schema without comparing values.
|
||||
*/
|
||||
const extractJsonKeys = (obj, prefix = '') => {
|
||||
const keys = [];
|
||||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(fullKey);
|
||||
keys.push(...extractJsonKeys(obj[key], fullKey));
|
||||
}
|
||||
} else if (Array.isArray(obj) && obj.length > 0) {
|
||||
// Only inspect first element (spec arrays always have one template item)
|
||||
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two Bruno-format requests field-by-field.
|
||||
* Returns { hasDiff, changes } where changes is an array of human-readable strings.
|
||||
*/
|
||||
const compareRequestFields = (specRequest, actualRequest) => {
|
||||
// Compare parameters by name:type pairs (catches query<->path type changes)
|
||||
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
|
||||
// Compare headers (by name)
|
||||
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
|
||||
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
|
||||
|
||||
// Check for differences
|
||||
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
|
||||
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
|
||||
|
||||
// Check body mode difference
|
||||
const specBodyMode = specRequest.body?.mode || 'none';
|
||||
const actualBodyMode = actualRequest.body?.mode || 'none';
|
||||
const bodyDiff = specBodyMode !== actualBodyMode;
|
||||
|
||||
// Check auth mode difference
|
||||
const specAuthMode = specRequest.auth?.mode || 'none';
|
||||
const actualAuthMode = actualRequest.auth?.mode || 'none';
|
||||
const authDiff = specAuthMode !== actualAuthMode;
|
||||
|
||||
// Check auth config differences when auth modes match
|
||||
let authConfigDiff = false;
|
||||
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
|
||||
if (specAuthMode === 'apikey') {
|
||||
const specApikey = specRequest.auth?.apikey || {};
|
||||
const actualApikey = actualRequest.auth?.apikey || {};
|
||||
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
|
||||
} else if (specAuthMode === 'oauth2') {
|
||||
const specOauth2 = specRequest.auth?.oauth2 || {};
|
||||
const actualOauth2 = actualRequest.auth?.oauth2 || {};
|
||||
const grantType = specOauth2.grantType || actualOauth2.grantType;
|
||||
const commonFields = ['grantType', 'scope'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
|
||||
implicit: [...commonFields, 'authorizationUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check form field names when body modes match and mode is form-based
|
||||
let formFieldsDiff = false;
|
||||
let specFormFieldNames = [];
|
||||
let actualFormFieldNames = [];
|
||||
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
|
||||
if (specBodyMode === 'multipartForm') {
|
||||
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
} else {
|
||||
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
}
|
||||
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
|
||||
}
|
||||
|
||||
// Check JSON body structure when both sides use json mode
|
||||
let jsonBodyDiff = false;
|
||||
if (!bodyDiff && specBodyMode === 'json') {
|
||||
try {
|
||||
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
|
||||
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
|
||||
if (specJson !== null && actualJson !== null) {
|
||||
const specKeys = extractJsonKeys(specJson).sort();
|
||||
const actualKeys = extractJsonKeys(actualJson).sort();
|
||||
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
|
||||
} else if ((specJson === null) !== (actualJson === null)) {
|
||||
jsonBodyDiff = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Malformed JSON — skip structural comparison
|
||||
}
|
||||
}
|
||||
|
||||
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
|
||||
|
||||
const changes = [];
|
||||
if (hasDiff) {
|
||||
if (paramsDiff) {
|
||||
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
|
||||
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
|
||||
if (addedParams.length) changes.push(`+${addedParams.length} params`);
|
||||
if (removedParams.length) changes.push(`-${removedParams.length} params`);
|
||||
}
|
||||
if (headersDiff) {
|
||||
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
|
||||
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
|
||||
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
|
||||
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
|
||||
}
|
||||
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
|
||||
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
|
||||
if (authConfigDiff) changes.push('auth config');
|
||||
if (formFieldsDiff) {
|
||||
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
|
||||
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
|
||||
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
|
||||
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
|
||||
}
|
||||
if (jsonBodyDiff) changes.push('body schema');
|
||||
}
|
||||
|
||||
return { hasDiff, changes };
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the stored spec for a collection and convert it to Bruno collection format.
|
||||
* Throws if no stored spec file exists.
|
||||
@@ -549,127 +682,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
collectionUid, collectionPath, sourceUrl, environmentContext
|
||||
}) => {
|
||||
try {
|
||||
// Get the title/name from the spec
|
||||
const getSpecTitle = (spec) => {
|
||||
return spec?.info?.title || null;
|
||||
};
|
||||
// Compare two OpenAPI specs by converting both to Bruno format and using field-level comparison.
|
||||
// This ensures specDrift uses the same comparison sensitivity as collectionDrift/remoteDrift.
|
||||
const compareSpecs = (oldSpec, newSpec, groupBy) => {
|
||||
// Convert both specs to Bruno collection format
|
||||
const oldBruno = oldSpec ? openApiToBruno(oldSpec, { groupBy }) : { items: [] };
|
||||
const newBruno = newSpec ? openApiToBruno(newSpec, { groupBy }) : { items: [] };
|
||||
|
||||
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
||||
|
||||
const normalizePath = (pathStr) => {
|
||||
return pathStr
|
||||
.replace(/{([^}]+)}/g, ':$1')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
};
|
||||
|
||||
const extractEndpoints = (spec) => {
|
||||
const endpoints = [];
|
||||
if (!spec || !spec.paths) return endpoints;
|
||||
|
||||
// Get base URL from servers
|
||||
const baseUrl = spec.servers?.[0]?.url || '';
|
||||
|
||||
Object.entries(spec.paths).forEach(([pathStr, methods]) => {
|
||||
if (!methods || typeof methods !== 'object') return;
|
||||
|
||||
Object.entries(methods).forEach(([method, operation]) => {
|
||||
if (!HTTP_METHODS.includes(method.toLowerCase())) return;
|
||||
|
||||
// Extract parameters
|
||||
const parameters = operation?.parameters || [];
|
||||
const pathParams = parameters.filter((p) => p.in === 'path');
|
||||
const queryParams = parameters.filter((p) => p.in === 'query');
|
||||
const headerParams = parameters.filter((p) => p.in === 'header');
|
||||
|
||||
// Extract request body
|
||||
const requestBody = operation?.requestBody;
|
||||
const bodyContent = requestBody?.content;
|
||||
const bodySchema = bodyContent?.['application/json']?.schema
|
||||
|| bodyContent?.['application/x-www-form-urlencoded']?.schema
|
||||
|| bodyContent?.['multipart/form-data']?.schema;
|
||||
const bodyExample = bodyContent?.['application/json']?.example
|
||||
|| bodyContent?.['application/json']?.examples;
|
||||
|
||||
// Extract responses
|
||||
const responses = operation?.responses || {};
|
||||
|
||||
endpoints.push({
|
||||
id: `${method.toUpperCase()}:${normalizePath(pathStr)}`,
|
||||
method: method.toUpperCase(),
|
||||
path: pathStr,
|
||||
normalizedPath: normalizePath(pathStr),
|
||||
operationId: operation?.operationId || null,
|
||||
summary: operation?.summary || null,
|
||||
description: operation?.description || null,
|
||||
tags: operation?.tags || [],
|
||||
deprecated: operation?.deprecated || false,
|
||||
// Detailed info for UI
|
||||
details: {
|
||||
parameters: {
|
||||
path: pathParams,
|
||||
query: queryParams,
|
||||
header: headerParams
|
||||
},
|
||||
requestBody: requestBody ? {
|
||||
required: requestBody.required || false,
|
||||
contentType: Object.keys(bodyContent || {})[0] || null,
|
||||
schema: bodySchema,
|
||||
example: bodyExample
|
||||
} : null,
|
||||
responses: Object.entries(responses).map(([code, resp]) => ({
|
||||
code,
|
||||
description: resp.description,
|
||||
schema: resp.content?.['application/json']?.schema
|
||||
}))
|
||||
},
|
||||
// Hash for comparison (MD5 for quick change detection)
|
||||
_hash: crypto.createHash('md5').update(JSON.stringify({
|
||||
parameters,
|
||||
requestBody: operation?.requestBody,
|
||||
responses: operation?.responses
|
||||
})).digest('hex')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
};
|
||||
|
||||
const compareSpecs = (oldSpec, newSpec) => {
|
||||
const oldEndpoints = extractEndpoints(oldSpec);
|
||||
const newEndpoints = extractEndpoints(newSpec);
|
||||
|
||||
const oldEndpointMap = new Map(oldEndpoints.map((ep) => [ep.id, ep]));
|
||||
const newEndpointMap = new Map(newEndpoints.map((ep) => [ep.id, ep]));
|
||||
// Build endpoint maps keyed by METHOD:normalizedPath
|
||||
const oldItems = buildSpecItemsMap(oldBruno.items || []);
|
||||
const newItems = buildSpecItemsMap(newBruno.items || []);
|
||||
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const modified = [];
|
||||
const unchanged = [];
|
||||
|
||||
newEndpoints.forEach((endpoint) => {
|
||||
if (!oldEndpointMap.has(endpoint.id)) {
|
||||
added.push(endpoint);
|
||||
for (const [id, newItem] of newItems) {
|
||||
const colonIndex = id.indexOf(':');
|
||||
const method = id.substring(0, colonIndex);
|
||||
const urlPath = id.substring(colonIndex + 1);
|
||||
|
||||
if (!oldItems.has(id)) {
|
||||
added.push({ id, method, path: urlPath, name: newItem.name });
|
||||
} else {
|
||||
const oldEndpoint = oldEndpointMap.get(endpoint.id);
|
||||
// Check if endpoint was modified by comparing hashes
|
||||
if (oldEndpoint._hash !== endpoint._hash) {
|
||||
modified.push({
|
||||
...endpoint,
|
||||
oldEndpoint: oldEndpoint
|
||||
});
|
||||
const oldItem = oldItems.get(id);
|
||||
const { hasDiff, changes } = compareRequestFields(oldItem.request, newItem.request);
|
||||
if (hasDiff) {
|
||||
modified.push({ id, method, path: urlPath, name: newItem.name, changes: changes.join(', ') });
|
||||
} else {
|
||||
unchanged.push(endpoint);
|
||||
unchanged.push({ id, method, path: urlPath, name: newItem.name });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
oldEndpoints.forEach((endpoint) => {
|
||||
if (!newEndpointMap.has(endpoint.id)) {
|
||||
removed.push(endpoint);
|
||||
for (const [id] of oldItems) {
|
||||
if (!newItems.has(id)) {
|
||||
const colonIndex = id.indexOf(':');
|
||||
const method = id.substring(0, colonIndex);
|
||||
const urlPath = id.substring(colonIndex + 1);
|
||||
const oldItem = oldItems.get(id);
|
||||
removed.push({ id, method, path: urlPath, name: oldItem.name });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Compare metadata (title, version, description)
|
||||
const oldTitle = oldSpec?.info?.title || null;
|
||||
@@ -746,8 +801,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// Check for title/name changes
|
||||
const storedTitle = getSpecTitle(storedSpec);
|
||||
const newTitle = getSpecTitle(newSpec);
|
||||
const storedTitle = storedSpec?.info?.title || null;
|
||||
const newTitle = newSpec?.info?.title || null;
|
||||
const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle;
|
||||
|
||||
// Generate hashes for quick change detection
|
||||
@@ -755,7 +810,16 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
const remoteSpecHash = generateSpecHash(newSpec);
|
||||
const hasRemoteChanges = storedSpecHash !== remoteSpecHash;
|
||||
|
||||
const diff = compareSpecs(storedSpec, newSpec);
|
||||
// Read groupBy from brunoConfig for consistent spec conversion
|
||||
let groupBy = 'tags';
|
||||
try {
|
||||
const { brunoConfig } = loadBrunoConfig(collectionPath);
|
||||
groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
|
||||
} catch (e) {
|
||||
// Default to 'tags' if brunoConfig is not available
|
||||
}
|
||||
|
||||
const diff = compareSpecs(storedSpec, newSpec, groupBy);
|
||||
|
||||
// Detect remote spec format and determine correct filename
|
||||
const remoteIsYaml = isYamlContent(newSpecContent);
|
||||
@@ -801,23 +865,6 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively extracts all key paths from a parsed JSON value (dot-notation).
|
||||
// Used to compare JSON body structure/schema without comparing values.
|
||||
const extractJsonKeys = (obj, prefix = '') => {
|
||||
const keys = [];
|
||||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(fullKey);
|
||||
keys.push(...extractJsonKeys(obj[key], fullKey));
|
||||
}
|
||||
} else if (Array.isArray(obj) && obj.length > 0) {
|
||||
// Only inspect first element (spec arrays always have one template item)
|
||||
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
// Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files
|
||||
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => {
|
||||
try {
|
||||
@@ -936,113 +983,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
} else {
|
||||
// Compare key fields to detect drift
|
||||
const specRequest = specItem.request;
|
||||
|
||||
// Compare parameters by name:type pairs (catches query<->path type changes)
|
||||
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
|
||||
// Compare headers (by name)
|
||||
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
|
||||
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
|
||||
|
||||
// Check for differences
|
||||
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
|
||||
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
|
||||
|
||||
// Check body mode difference
|
||||
const specBodyMode = specRequest.body?.mode || 'none';
|
||||
const actualBodyMode = actualRequest.body?.mode || 'none';
|
||||
const bodyDiff = specBodyMode !== actualBodyMode;
|
||||
|
||||
// Check auth mode difference
|
||||
const specAuthMode = specRequest.auth?.mode || 'none';
|
||||
const actualAuthMode = actualRequest.auth?.mode || 'none';
|
||||
const authDiff = specAuthMode !== actualAuthMode;
|
||||
|
||||
// Check auth config differences when auth modes match
|
||||
let authConfigDiff = false;
|
||||
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
|
||||
if (specAuthMode === 'apikey') {
|
||||
const specApikey = specRequest.auth?.apikey || {};
|
||||
const actualApikey = actualRequest.auth?.apikey || {};
|
||||
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
|
||||
} else if (specAuthMode === 'oauth2') {
|
||||
const specOauth2 = specRequest.auth?.oauth2 || {};
|
||||
const actualOauth2 = actualRequest.auth?.oauth2 || {};
|
||||
const grantType = specOauth2.grantType || actualOauth2.grantType;
|
||||
const commonFields = ['grantType', 'scope'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
|
||||
implicit: [...commonFields, 'authorizationUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check form field names when body modes match and mode is form-based
|
||||
let formFieldsDiff = false;
|
||||
let specFormFieldNames = [];
|
||||
let actualFormFieldNames = [];
|
||||
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
|
||||
if (specBodyMode === 'multipartForm') {
|
||||
// For multipartForm, compare name:type pairs to catch text<->file changes
|
||||
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
} else {
|
||||
// For formUrlEncoded, all fields are text — compare by name only
|
||||
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
}
|
||||
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
|
||||
}
|
||||
|
||||
// Check JSON body structure when both sides use json mode
|
||||
let jsonBodyDiff = false;
|
||||
if (!bodyDiff && specBodyMode === 'json') {
|
||||
try {
|
||||
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
|
||||
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
|
||||
if (specJson !== null && actualJson !== null) {
|
||||
const specKeys = extractJsonKeys(specJson).sort();
|
||||
const actualKeys = extractJsonKeys(actualJson).sort();
|
||||
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
|
||||
} else if ((specJson === null) !== (actualJson === null)) {
|
||||
jsonBodyDiff = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Malformed JSON — skip structural comparison
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff) {
|
||||
const changes = [];
|
||||
if (paramsDiff) {
|
||||
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
|
||||
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
|
||||
if (addedParams.length) changes.push(`+${addedParams.length} params`);
|
||||
if (removedParams.length) changes.push(`-${removedParams.length} params`);
|
||||
}
|
||||
if (headersDiff) {
|
||||
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
|
||||
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
|
||||
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
|
||||
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
|
||||
}
|
||||
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
|
||||
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
|
||||
if (authConfigDiff) changes.push('auth config');
|
||||
if (formFieldsDiff) {
|
||||
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
|
||||
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
|
||||
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
|
||||
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
|
||||
}
|
||||
if (jsonBodyDiff) changes.push('body schema');
|
||||
const { hasDiff, changes } = compareRequestFields(specItem.request, actualRequest);
|
||||
|
||||
if (hasDiff) {
|
||||
result.modified.push({
|
||||
id,
|
||||
method,
|
||||
|
||||
Reference in New Issue
Block a user