feat(openapi-sync): preserve user-configured request values on sync (#8204)

Reconcile request structure against the spec on sync while preserving the
user's values (JSON body, params, headers, auth) and {{var}} references for
fields that still exist. A "Preserve values" toggle (default on) on the Spec
Updates review controls it; turning it off lets spec values overwrite. The diff
preview's EXPECTED column shows the post-merge result so unchanged values do not
render as changes.

- field-level merge for JSON body (by key path), form fields and params/headers
  (by name, duplicate names paired positionally), preserving value + enabled
- {{var}} masking so interpolated bodies parse, merge and restore safely,
  using a private-use sentinel that never collides with body text
- auth merged by mode: same mode keeps the user's values and adds spec-introduced
  fields; a mode change takes the spec. compareRequestFields compares auth by
  mode only, so preserved auth values no longer mark the collection out of sync
- preserveValues threaded through apply and diff-preview IPC handlers
- reset path left unchanged; scripts/tests/assertions preserved in sync and reset
- 67 unit tests covering the merge helpers and masking edge cases
This commit is contained in:
Sundram
2026-06-16 20:01:52 +05:30
committed by GitHub
parent 07c7348666
commit 05ab2661fa
6 changed files with 942 additions and 53 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconChevronRight,
@@ -15,7 +15,7 @@ import Help from 'components/Help';
import EndpointVisualDiff from './EndpointVisualDiff';
// Expandable row - can be used with or without decision buttons
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => {
const dispatch = useDispatch();
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
const isExpanded = useSelector((state) => {
@@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
const [diffData, setDiffData] = useState(null);
const [error, setError] = useState(null);
const loadDiffData = useCallback(async () => {
if (diffData) return;
// Monotonic id so a superseded in-flight fetch (e.g. the user flips the
// Preserve toggle mid-request) can't overwrite the latest result.
const requestIdRef = useRef(0);
const loadDiffData = useCallback(async () => {
// No internal diffData guard: both callers (the expand effect and handleToggle)
// already gate on !diffData. Guarding here would capture a stale diffData from
// the render that recreated this callback and silently skip the toggle re-fetch.
const requestId = ++requestIdRef.current;
setIsLoading(true);
setError(null);
@@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
collectionPath,
endpointId: endpoint.id,
newSpec
newSpec,
preserveValues
});
if (requestId !== requestIdRef.current) return; // superseded by a newer fetch
if (result.error) {
setError(result.error);
} else {
setDiffData(result);
}
} catch (err) {
if (requestId !== requestIdRef.current) return;
setError(formatIpcError(err) || 'Failed to load diff data');
} finally {
if (requestId === requestIdRef.current) setIsLoading(false);
}
}, [collectionPath, endpoint.id, newSpec, preserveValues]);
// Re-fetch the preview when the preserve toggle changes — the EXPECTED column
// depends on it. Expanded rows re-fetch in place (the old diff stays visible
// and swaps when the new one arrives, so the row never blanks). Collapsed rows
// just drop their cache so the next expand fetches fresh — invisible to the user.
const didMountPreserve = useRef(false);
useEffect(() => {
if (!didMountPreserve.current) {
didMountPreserve.current = true;
return;
}
if (isExpanded) {
loadDiffData(); // bumps requestId, keeps old diff until the new one lands
} else {
requestIdRef.current++; // invalidate any in-flight fetch
setDiffData(null);
setError(null);
setIsLoading(false);
}
}, [collectionPath, endpoint.id, newSpec]);
// Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData
// changes, which the dedicated load effect + handleToggle already cover.
}, [preserveValues]);
// Load diff data when expanded (e.g. restored from Redux state)
useEffect(() => {
@@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
{isExpanded && (
<div className="review-row-diff">
{isLoading && (
{/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve)
keeps the previous diff visible and swaps it in place, so the row
never blanks/flickers. */}
{isLoading && !diffData && !error && (
<div className="diff-loading">
<IconLoader2 size={16} className="spinning" />
<span>Loading diff...</span>
</div>
)}
{error && (
{error && !diffData && (
<div className="diff-error">
Error: {error}
</div>
)}
{diffData && !isLoading && !error && (
{diffData && !error && (
<EndpointVisualDiff
oldData={diffData.oldData}
newData={diffData.newData}

View File

@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
.bulk-actions {
display: flex;
gap: 0.5rem;
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
}
.bulk-btn {
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
}
}
/* the three Preserve elements read as one control: label + info + toggle */
.preserve-values-control {
display: inline-flex;
align-items: center;
margin-right: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text};
.preserve-values-label {
white-space: nowrap;
}
/* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it
so the info icon sits tight to the label. It is a hover-only tooltip
affordance, not a button — use a help cursor and never show a
click/focus box around it. */
svg {
margin-left: 4px;
cursor: help;
}
svg:focus,
svg:focus-visible,
span:focus,
span:focus-visible {
outline: none;
box-shadow: none;
background: transparent;
}
/* compact themed track + knob toggle, sized to the button row height */
.preserve-toggle {
margin-right: 4px; /* space between the toggle and the label */
width: 26px;
height: 14px;
border-radius: 7px;
border: none;
padding: 0;
flex-shrink: 0;
position: relative;
cursor: pointer;
transition: background 0.2s;
background: ${(props) => props.theme.colors.text.muted};
&.active {
background: ${(props) => props.theme.button2.color.primary.bg};
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.button2.color.primary.bg};
outline-offset: 2px;
}
.preserve-toggle-knob {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
&.active .preserve-toggle-knob {
left: 14px;
}
}
}
.sync-review-body {
flex: 1;
overflow-y: auto;

View File

@@ -88,6 +88,7 @@ const SyncReviewPage = ({
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const [preserveValues, setPreserveValues] = useState(true);
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
@@ -210,7 +211,8 @@ const SyncReviewPage = ({
newToCollection: filteredAddedEndpoints,
specUpdates: filteredSpecChanges,
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'),
preserveValues
});
};
@@ -238,6 +240,21 @@ const SyncReviewPage = ({
</div>
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
<div className="preserve-values-control">
<button
type="button"
role="switch"
aria-pressed={preserveValues}
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
onClick={() => setPreserveValues((v) => !v)}
>
<span className="preserve-toggle-knob" />
</button>
<span className="preserve-values-label">Preserve values</span>
<Help icon="info" size={12} placement="top" width={260}>
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
</Help>
</div>
{specDrift?.unifiedDiff && (
<button
className="bulk-btn"
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>

View File

@@ -14,7 +14,7 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
@@ -71,7 +71,8 @@ const useSyncFlow = ({
localOnlyToRemove,
driftedToReset,
mode,
endpointDecisions: decisions
endpointDecisions: decisions,
preserveValues
});
setPendingSyncMode(null);
@@ -102,7 +103,7 @@ const useSyncFlow = ({
const handleApplySync = (selections) => {
const mode = pendingSyncMode || 'sync';
setPendingSyncMode(null);
performSync(selections, mode);
performSync(selections, mode, selections?.preserveValues ?? true);
};
const cancelConfirmModal = () => {

View File

@@ -433,6 +433,215 @@ const cleanupSpecFilesForCollection = (collectionPath) => {
}
};
/**
* Replace {{var}} tokens in a JSON string with placeholder strings so the
* result can be passed to JSON.parse without syntax errors.
*
* A token is emitted with a position-tagged sentinel so unmasking is
* unambiguous and never disturbs surrounding JSON syntax:
* - inside a string value -> bare `<prefix>_S_<idx>` (stays in the string)
* - as a bare JSON value -> quoted `"<prefix>_V_<idx>"` (valid JSON value)
* The S/V tag lets unmask strip exactly the quotes it added for V tokens while
* leaving the surrounding string delimiters of S tokens intact. The sentinel is
* wrapped in a Unicode private-use delimiter (U+E000) that cannot realistically
* appear in a request body, so it never collides with real text and survives
* JSON.parse/stringify unescaped. Sentinels are transient — they only exist
* between mask and unmask, never on disk.
*
* Returns { masked, vars } where vars is the ordered list of original tokens.
*/
// U+E000 (private use area) delimiter — effectively un-typeable in a real body.
const SENTINEL = String.fromCharCode(0xe000);
const maskJsonInterpolations = (str, prefix = 'BRUNO_VAR') => {
const vars = [];
let out = '';
let inString = false;
let i = 0;
while (i < str.length) {
const ch = str[i];
if (ch === '"') {
// Count preceding backslashes: an even count means this quote is not
// escaped (handles string values ending in a literal backslash, e.g. "C:\\").
let backslashes = 0;
let j = i - 1;
while (j >= 0 && str[j] === '\\') {
backslashes++; j--;
}
if (backslashes % 2 === 0) inString = !inString;
out += ch;
i++;
continue;
}
if (ch === '{' && str[i + 1] === '{') {
const end = str.indexOf('}}', i + 2);
if (end !== -1) {
const token = str.slice(i, end + 2);
const idx = vars.length;
vars.push(token);
out += inString
? `${SENTINEL}${prefix}_S_${idx}${SENTINEL}`
: `"${SENTINEL}${prefix}_V_${idx}${SENTINEL}"`;
i = end + 2;
continue;
}
}
out += ch;
i++;
}
return { masked: out, vars };
};
/**
* Replace placeholders injected by maskJsonInterpolations back with the
* original {{var}} tokens.
*
* Value-position (V) sentinels are matched WITH the quotes mask added and
* restored to a bare {{var}}. In-string (S) sentinels are matched bare so the
* string's own delimiters are left untouched. The S/V tag prevents the unmask
* from ever consuming a real JSON string quote.
*/
const unmaskJsonInterpolations = (str, vars, prefix = 'BRUNO_VAR') => {
const restore = (n, m) => (vars[Number(n)] !== undefined ? vars[Number(n)] : m);
return str
.replace(new RegExp(`"${SENTINEL}${prefix}_V_(\\d+)${SENTINEL}"`, 'g'), (m, n) => restore(n, m))
.replace(new RegExp(`${SENTINEL}${prefix}_S_(\\d+)${SENTINEL}`, 'g'), (m, n) => restore(n, m));
};
// ---------------------------------------------------------------------------
// JSON value merge helpers
// ---------------------------------------------------------------------------
/** Returns true if v is a plain (non-null, non-array) object. */
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/**
* Recursively merge a user JSON value with a spec JSON value.
*
* Rules (when preserveValues=true):
* - Object: walk spec keys; keep user value when present, use spec default otherwise.
* Keys present in user but absent in spec are dropped.
* - Array: if user array is empty, return spec template.
* Otherwise re-shape each user element against the first spec element as template.
* - Scalar: keep user value unless it is undefined.
*
* When preserveValues=false the spec value is returned as-is.
*/
const mergeJsonValues = (userVal, specVal, preserveValues = true) => {
if (!preserveValues) return specVal;
if (isPlainObject(specVal) && isPlainObject(userVal)) {
const out = {};
for (const key of Object.keys(specVal)) {
out[key] = key in userVal
? mergeJsonValues(userVal[key], specVal[key], preserveValues)
: specVal[key];
}
return out;
}
if (Array.isArray(specVal) && Array.isArray(userVal)) {
if (userVal.length === 0) return specVal;
const template = specVal.length > 0 ? specVal[0] : undefined;
if (template === undefined) return userVal;
return userVal.map((el) => mergeJsonValues(el, template, preserveValues));
}
return userVal === undefined ? specVal : userVal;
};
/**
* Merge a user request body (mode=json) with a spec request body.
*
* Uses disjoint mask prefixes (BRU_U / BRU_S) so user and spec {{var}}
* tokens never collide. Falls back to the verbatim user body when the user
* JSON is unparseable (e.g. contains a work-in-progress template).
*/
const mergeJsonBody = (userBody, specBody, preserveValues = true) => {
if (!preserveValues) return specBody;
if (!userBody?.json || !specBody?.json) return specBody;
try {
const u = maskJsonInterpolations(userBody.json, 'BRU_U');
const s = maskJsonInterpolations(specBody.json, 'BRU_S');
const merged = mergeJsonValues(JSON.parse(u.masked), JSON.parse(s.masked), preserveValues);
let json = JSON.stringify(merged, null, 2);
json = unmaskJsonInterpolations(json, u.vars, 'BRU_U');
json = unmaskJsonInterpolations(json, s.vars, 'BRU_S');
return { ...specBody, mode: 'json', json };
} catch (e) {
console.warn('[openapi-sync] mergeJsonBody fallback to verbatim user body:', e.message);
return { ...userBody };
}
};
/**
* Merge a spec-defined list of {name,value,enabled,...} entries with the user's
* entries. Spec defines membership (add new, drop removed). For matched names
* the user's `value` and `enabled` win. Duplicate names pair positionally.
*/
const mergeFieldListPreserving = (specItems, existingItems, preserveValues = true) => {
const spec = specItems || [];
if (!preserveValues) return spec;
const existing = existingItems || [];
const cursorByName = {};
return spec.map((specEntry) => {
const matches = existing.filter((e) => e.name === specEntry.name);
const cursor = cursorByName[specEntry.name] || 0;
const picked = matches[cursor];
if (!picked) return specEntry;
cursorByName[specEntry.name] = cursor + 1;
return { ...specEntry, value: picked.value, enabled: picked.enabled ?? specEntry.enabled };
});
};
/**
* Merge auth field-by-field for the active mode, mirroring the JSON-body merge:
* - same mode -> additive merge: take the spec's field set as the base, then
* let the user's values win on shared fields AND keep the user's own fields
* (so spec-introduced auth fields appear, user values + credentials survive).
* - different mode -> spec wins (the mode change is surfaced by detection).
* - none/inherit -> nothing to preserve, keep spec.
*
* Deliberate deviation from the body merge: we do NOT delete user fields that the
* spec lacks. The OpenAPI securityScheme is sparse and does not express user
* credentials (clientId/secret/token/username/password/PKCE/etc.), so removing
* "spec-dropped" auth fields would wipe real user data. Field removals therefore
* only take effect when preserve is OFF (full spec overwrite).
*/
const mergeAuth = (userAuth, specAuth, preserveValues = true) => {
if (!preserveValues) return specAuth;
const userMode = userAuth?.mode || 'none';
const specMode = specAuth?.mode || 'none';
if (userMode !== specMode) return specAuth;
if (specMode === 'none' || specMode === 'inherit') return specAuth;
const userSub = userAuth?.[specMode];
if (userSub == null) return specAuth; // null or undefined -> nothing to preserve, keep spec
const specSub = specAuth?.[specMode] || {};
// spec fields as base + user fields/values on top (user wins on overlap).
// New object so the merged result never aliases the caller's stored request.
return { ...specAuth, [specMode]: { ...specSub, ...userSub } };
};
/**
* Merge a request body: same mode -> field-level merge per mode; different mode
* -> spec wins. Raw text modes keep the user's body verbatim.
*/
const mergeBody = (userBody, specBody, preserveValues = true) => {
if (!preserveValues || !userBody || !specBody) return specBody;
const specMode = specBody.mode || 'none';
const userMode = userBody.mode || 'none';
if (specMode !== userMode) return specBody;
if (specMode === 'json') return mergeJsonBody(userBody, specBody, preserveValues);
if (specMode === 'formUrlEncoded') {
return { ...specBody, formUrlEncoded: mergeFieldListPreserving(specBody.formUrlEncoded, userBody.formUrlEncoded, preserveValues) };
}
if (specMode === 'multipartForm') {
return { ...specBody, multipartForm: mergeFieldListPreserving(specBody.multipartForm, userBody.multipartForm, preserveValues) };
}
// graphql stores a nested { query, variables } object — keep the user's, but
// fall back to the spec's when the user body has none, and clone so the merged
// result never aliases the caller's stored request.
if (specMode === 'graphql') return { ...userBody, graphql: { ...(userBody.graphql || specBody.graphql) } };
// other raw modes (xml / text / sparql) hold a string payload — shallow copy is safe
return { ...userBody };
};
/**
* Merge spec params/headers with existing user values.
* Matches by name + value to correctly handle enum-expanded params (multiple entries with same name).
@@ -454,11 +663,10 @@ const mergeWithUserValues = (specItems, existingItems) => {
* fullReset: true = spec replaces entire request section (reset mode)
* false = only override url/body/auth from spec (sync mode)
*/
const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } = {}) => {
const mergedParams = mergeWithUserValues(specItem.request.params, existingRequest.request?.params);
const mergedHeaders = mergeWithUserValues(specItem.request.headers, existingRequest.request?.headers);
const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false, preserveValues = true } = {}) => {
if (fullReset) {
const mergedParams = mergeWithUserValues(specItem.request.params, existingRequest.request?.params);
const mergedHeaders = mergeWithUserValues(specItem.request.headers, existingRequest.request?.headers);
return {
...existingRequest,
request: {
@@ -474,15 +682,16 @@ const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } =
};
}
// Sync mode: reconcile structure to the spec while preserving the user's values.
return {
...existingRequest,
request: {
...existingRequest.request,
url: specItem.request.url,
body: specItem.request.body,
auth: specItem.request.auth,
params: mergedParams || existingRequest.request?.params || [],
headers: mergedHeaders || existingRequest.request?.headers || []
url: specItem.request.url, // Option A: URL always follows the spec
body: mergeBody(existingRequest.request?.body, specItem.request.body, preserveValues),
auth: mergeAuth(existingRequest.request?.auth, specItem.request.auth, preserveValues),
params: mergeFieldListPreserving(specItem.request.params, existingRequest.request?.params, preserveValues),
headers: mergeFieldListPreserving(specItem.request.headers, existingRequest.request?.headers, preserveValues)
}
};
};
@@ -582,29 +791,6 @@ const compareRequestFields = (specRequest, actualRequest) => {
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 = [];
@@ -638,7 +824,7 @@ const compareRequestFields = (specRequest, actualRequest) => {
}
}
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || formFieldsDiff || jsonBodyDiff;
const changes = [];
if (hasDiff) {
@@ -656,7 +842,6 @@ const compareRequestFields = (specRequest, actualRequest) => {
}
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));
@@ -1062,7 +1247,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Get endpoint diff data for visual comparison (spec vs collection)
ipcMain.handle('renderer:get-endpoint-diff-data', async (event, { collectionPath, endpointId, newSpec }) => {
ipcMain.handle('renderer:get-endpoint-diff-data', async (event, { collectionPath, endpointId, newSpec, preserveValues = true }) => {
try {
let brunoConfig;
try {
@@ -1144,11 +1329,23 @@ const registerOpenAPISyncIpc = (mainWindow) => {
};
};
// EXPECTED column = what sync will actually produce. For an endpoint that
// already exists in the collection, that's the merged result (user values +
// structural changes), not the raw spec. New endpoints (no actualRequest)
// have nothing to preserve, so show the spec as-is.
// NOTE: actualRequest is the full parsed item (has a .request property),
// matching the shape that mergeSpecIntoRequest expects as its first argument.
let specItemForDisplay = specItem;
if (specItem && actualRequest) {
const merged = mergeSpecIntoRequest(actualRequest, specItem, { preserveValues });
specItemForDisplay = { ...specItem, request: merged.request };
}
return {
error: null,
// oldData = current collection state, newData = expected from spec
// oldData = current collection state, newData = expected from sync
oldData: transformToVisualFormat(actualRequest),
newData: transformToVisualFormat(specItem)
newData: transformToVisualFormat(specItemForDisplay)
};
} catch (error) {
console.error('Error getting endpoint diff data:', error);
@@ -1157,7 +1354,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Sync modes: 'spec-only' | 'reset' | 'sync' (default)
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {}, preserveValues = true }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
@@ -1361,7 +1558,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem, { preserveValues });
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
} else {
@@ -1398,7 +1595,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (newItem && existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem, { preserveValues });
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
}
@@ -1696,3 +1893,18 @@ const registerOpenAPISyncIpc = (mainWindow) => {
module.exports = registerOpenAPISyncIpc;
module.exports.saveSpecAndUpdateMetadata = saveSpecAndUpdateMetadata;
module.exports.cleanupSpecFilesForCollection = cleanupSpecFilesForCollection;
/* istanbul ignore next */
if (process.env.NODE_ENV === 'test') {
module.exports._test = {
maskJsonInterpolations,
unmaskJsonInterpolations,
mergeJsonValues,
mergeJsonBody,
mergeFieldListPreserving,
mergeAuth,
mergeBody,
mergeSpecIntoRequest,
compareRequestFields
};
}

View File

@@ -0,0 +1,550 @@
/**
* Tests for openapi-sync merge helpers (Batch A: Tasks 1-4).
* Run: cd packages/bruno-electron && npx jest tests/ipc/openapi-sync-merge.spec.js
*/
const { describe, it, expect, beforeAll } = require('@jest/globals');
process.env.NODE_ENV = 'test';
const syncModule = require('../../src/ipc/openapi-sync');
let helpers;
beforeAll(() => {
helpers = syncModule._test;
});
// ---------------------------------------------------------------------------
// Task 1: Smoke — each exported helper is a function
// ---------------------------------------------------------------------------
describe('_test exports', () => {
it('exports maskJsonInterpolations as a function', () => {
expect(typeof helpers.maskJsonInterpolations).toBe('function');
});
it('exports unmaskJsonInterpolations as a function', () => {
expect(typeof helpers.unmaskJsonInterpolations).toBe('function');
});
it('exports mergeJsonValues as a function', () => {
expect(typeof helpers.mergeJsonValues).toBe('function');
});
it('exports mergeJsonBody as a function', () => {
expect(typeof helpers.mergeJsonBody).toBe('function');
});
it('exports mergeSpecIntoRequest as a function', () => {
expect(typeof helpers.mergeSpecIntoRequest).toBe('function');
});
it('exports compareRequestFields as a function', () => {
expect(typeof helpers.compareRequestFields).toBe('function');
});
});
// ---------------------------------------------------------------------------
// Task 2: maskJsonInterpolations / unmaskJsonInterpolations
// ---------------------------------------------------------------------------
describe('maskJsonInterpolations', () => {
it('quotes a bare-value variable so result parses as JSON', () => {
const { masked, vars } = helpers.maskJsonInterpolations('{"id": {{userId}}}');
expect(() => JSON.parse(masked)).not.toThrow();
expect(vars).toEqual(['{{userId}}']);
});
it('leaves an in-string variable unquoted (bearer prefix preserved)', () => {
const { masked } = helpers.maskJsonInterpolations('{"auth": "Bearer {{token}}"}');
const parsed = JSON.parse(masked);
expect(parsed.auth).toMatch(/^Bearer /);
});
it('round-trips bare and in-string vars', () => {
const src = '{"id": {{userId}}, "auth": "Bearer {{token}}"}';
const { masked, vars } = helpers.maskJsonInterpolations(src);
const roundTripped = helpers.unmaskJsonInterpolations(JSON.stringify(JSON.parse(masked)), vars);
expect(roundTripped).toContain('{{userId}}');
expect(roundTripped).toContain('Bearer {{token}}');
expect(roundTripped).toMatch(/"id":\s*{{userId}}/);
});
it('handles a string value ending with a backslash (escaped quote disambiguation)', () => {
const src = '{"path": "C:\\\\", "id": {{userId}}}';
const { masked, vars } = helpers.maskJsonInterpolations(src);
expect(() => JSON.parse(masked)).not.toThrow();
expect(vars).toEqual(['{{userId}}']);
});
// Round-trip helper: mask -> parse -> stringify -> unmask, then assert the
// result is still valid once vars are stubbed out (catches eaten delimiters).
const roundTrip = (src) => {
const { masked, vars } = helpers.maskJsonInterpolations(src);
const restored = helpers.unmaskJsonInterpolations(JSON.stringify(JSON.parse(masked), null, 2), vars);
return restored;
};
const isValidWithVarsStubbed = (json) => {
try {
JSON.parse(json.replace(/\{\{[^}]+\}\}/g, '1')); return true;
} catch (e) { return false; }
};
it('keeps the closing quote when a var is at the END of a string value', () => {
const out = roundTrip('{"auth": "Bearer {{token}}"}');
expect(out).toContain('"Bearer {{token}}"');
expect(isValidWithVarsStubbed(out)).toBe(true);
});
it('keeps quotes when a var is the ENTIRE string value', () => {
const out = roundTrip('{"tok": "{{token}}"}');
expect(out).toContain('"{{token}}"');
expect(isValidWithVarsStubbed(out)).toBe(true);
});
it('keeps a bare-value var unquoted', () => {
const out = roundTrip('{"id": {{userId}}}');
expect(out).toMatch(/"id":\s*{{userId}}/);
expect(isValidWithVarsStubbed(out)).toBe(true);
});
it('preserves a var used as an object key', () => {
const out = roundTrip('{"{{dynKey}}": "v"}');
expect(out).toContain('"{{dynKey}}"');
expect(isValidWithVarsStubbed(out)).toBe(true);
});
it('does not corrupt a body literal that resembles the internal sentinel', () => {
// Both the legacy shape and the current tagged shape must pass through untouched —
// the real sentinel is wrapped in a U+E000 delimiter the user cannot type.
const out = roundTrip('{"msg": "__BRUNO_VAR_0__ and __BRUNO_VAR_S_0__ literal", "id": {{userId}}}');
expect(out).toContain('__BRUNO_VAR_0__ and __BRUNO_VAR_S_0__ literal');
expect(out).toContain('{{userId}}');
expect(isValidWithVarsStubbed(out)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Task 3: mergeJsonValues
// ---------------------------------------------------------------------------
describe('mergeJsonValues', () => {
it('keeps user value for shared key', () => {
expect(helpers.mergeJsonValues({ id: 10 }, { id: 0 }, true)).toEqual({ id: 10 });
});
it('adds key spec introduces', () => {
expect(helpers.mergeJsonValues({ id: 10 }, { id: 0, name: '' }, true)).toEqual({ id: 10, name: '' });
});
it('removes key spec dropped', () => {
expect(helpers.mergeJsonValues({ id: 10, old: 'x' }, { id: 0 }, true)).toEqual({ id: 10 });
});
it('merges nested objects', () => {
expect(
helpers.mergeJsonValues({ addr: { city: 'NYC', zip: '1' } }, { addr: { city: '', country: '' } }, true)
).toEqual({ addr: { city: 'NYC', country: '' } });
});
it('array element shape vs template keeping user values', () => {
expect(
helpers.mergeJsonValues(
{ items: [{ id: 1, gone: true }, { id: 2, gone: true }] },
{ items: [{ id: 0, qty: 0 }] },
true
)
).toEqual({ items: [{ id: 1, qty: 0 }, { id: 2, qty: 0 }] });
});
it('empty user array uses spec template', () => {
expect(helpers.mergeJsonValues({ items: [] }, { items: [{ id: 0 }] }, true)).toEqual({
items: [{ id: 0 }]
});
});
it('keeps a user array of primitives as-is against a single-element template', () => {
expect(helpers.mergeJsonValues({ tags: [1, 2, 3] }, { tags: [0] }, true)).toEqual({ tags: [1, 2, 3] });
});
it('preserveValues=false takes spec', () => {
expect(helpers.mergeJsonValues({ id: 10 }, { id: 0, name: '' }, false)).toEqual({ id: 0, name: '' });
});
});
// ---------------------------------------------------------------------------
// Task 4: mergeJsonBody
// ---------------------------------------------------------------------------
describe('mergeJsonBody', () => {
it('preserves user field values, adds new, drops removed', () => {
const userBody = { mode: 'json', json: '{"id":10,"old":"x"}' };
const specBody = { mode: 'json', json: '{"id":0,"name":""}' };
const merged = helpers.mergeJsonBody(userBody, specBody, true);
expect(JSON.parse(merged.json)).toEqual({ id: 10, name: '' });
});
it('preserves {{envVar}} references', () => {
const userBody = { mode: 'json', json: '{"id": {{userId}}, "tok": "Bearer {{t}}"}' };
const specBody = { mode: 'json', json: '{"id": 0, "tok": "", "extra": 1}' };
const merged = helpers.mergeJsonBody(userBody, specBody, true);
expect(merged.json).toContain('{{userId}}');
expect(merged.json).toContain('"Bearer {{t}}"'); // quotes intact around the in-string var
expect(merged.json).toContain('extra');
// result must stay structurally valid JSON once vars are stubbed
expect(() => JSON.parse(merged.json.replace(/\{\{[^}]+\}\}/g, '1'))).not.toThrow();
});
it('unparseable user json falls back verbatim', () => {
const userBody = { mode: 'json', json: '{ not valid json' };
const specBody = { mode: 'json', json: '{"id":0}' };
const merged = helpers.mergeJsonBody(userBody, specBody, true);
expect(merged.json).toBe('{ not valid json');
});
it('preserveValues=false returns spec body', () => {
const userBody = { mode: 'json', json: '{"id":10}' };
const specBody = { mode: 'json', json: '{"id":0,"name":""}' };
const merged = helpers.mergeJsonBody(userBody, specBody, false);
expect(merged).toBe(specBody);
});
});
// ---------------------------------------------------------------------------
// Task 5 smoke: _test exports new helpers
// ---------------------------------------------------------------------------
describe('_test exports (Batch B)', () => {
it('exports mergeFieldListPreserving as a function', () => {
expect(typeof helpers.mergeFieldListPreserving).toBe('function');
});
it('exports mergeAuth as a function', () => {
expect(typeof helpers.mergeAuth).toBe('function');
});
it('exports mergeBody as a function', () => {
expect(typeof helpers.mergeBody).toBe('function');
});
});
// ---------------------------------------------------------------------------
// Task 5: mergeFieldListPreserving
// ---------------------------------------------------------------------------
describe('mergeFieldListPreserving', () => {
it('keeps user value+enabled for matching name', () => {
const spec = [{ name: 'q', value: '', enabled: true }];
const user = [{ name: 'q', value: 'hello', enabled: false }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out).toEqual([{ name: 'q', value: 'hello', enabled: false }]);
});
it('adds spec entries user lacks', () => {
const spec = [{ name: 'q', value: '' }, { name: 'page', value: '1' }];
const user = [{ name: 'q', value: 'hi' }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out.map((e) => e.name)).toEqual(['q', 'page']);
expect(out[1].value).toBe('1');
});
it('drops user entries not in spec', () => {
const spec = [{ name: 'q', value: '' }];
const user = [{ name: 'q', value: 'hi' }, { name: 'gone', value: 'x' }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out.map((e) => e.name)).toEqual(['q']);
});
it('pairs duplicate names positionally', () => {
const spec = [{ name: 'X', value: '' }, { name: 'X', value: '' }];
const user = [{ name: 'X', value: 'a' }, { name: 'X', value: 'b' }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out.map((e) => e.value)).toEqual(['a', 'b']);
});
it('preserves multipart file value (array) by name', () => {
const spec = [{ name: 'f', type: 'file', value: [] }];
const user = [{ name: 'f', type: 'file', value: ['/tmp/a.png'], enabled: true }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out[0].value).toEqual(['/tmp/a.png']);
});
it('preserveValues=false returns spec entries unchanged', () => {
const spec = [{ name: 'q', value: '' }];
const user = [{ name: 'q', value: 'hi' }];
const out = helpers.mergeFieldListPreserving(spec, user, false);
expect(out).toEqual(spec);
});
it('falls back to the spec enabled default when the user entry has no enabled flag', () => {
const spec = [{ name: 'q', value: '', enabled: true }];
const user = [{ name: 'q', value: 'hi' }];
const out = helpers.mergeFieldListPreserving(spec, user);
expect(out[0].enabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Task 6: mergeAuth
// ---------------------------------------------------------------------------
describe('mergeAuth', () => {
it('preserves user auth values when mode matches', () => {
const user = { mode: 'oauth2', oauth2: { accessTokenUrl: '{{url}}', scope: 'read' } };
const spec = { mode: 'oauth2', oauth2: { accessTokenUrl: 'https://x', scope: '' } };
const out = helpers.mergeAuth(user, spec);
expect(out).toEqual({ mode: 'oauth2', oauth2: { accessTokenUrl: '{{url}}', scope: 'read' } });
});
it('takes spec auth when mode differs', () => {
const user = { mode: 'apikey', apikey: { key: 'X' } };
const spec = { mode: 'oauth2', oauth2: { scope: 'read' } };
const out = helpers.mergeAuth(user, spec);
expect(out).toEqual(spec);
});
it('takes spec auth for none/inherit', () => {
const out = helpers.mergeAuth({ mode: 'inherit' }, { mode: 'inherit' }, true);
expect(out).toEqual({ mode: 'inherit' });
});
it('preserveValues=false takes spec', () => {
const user = { mode: 'oauth2', oauth2: { scope: 'read' } };
const spec = { mode: 'oauth2', oauth2: { scope: '' } };
const out = helpers.mergeAuth(user, spec, false);
expect(out).toEqual(spec);
});
it('does not alias the user auth sub-object (defensive clone)', () => {
const user = { mode: 'oauth2', oauth2: { scope: 'read' } };
const spec = { mode: 'oauth2', oauth2: { scope: '' } };
const out = helpers.mergeAuth(user, spec);
expect(out.oauth2).not.toBe(user.oauth2);
});
it('falls back to spec when the user sub-object is null (not just undefined)', () => {
const user = { mode: 'oauth2', oauth2: null };
const spec = { mode: 'oauth2', oauth2: { accessTokenUrl: 'https://x', scope: 'read' } };
const out = helpers.mergeAuth(user, spec);
expect(out).toEqual(spec);
});
it('ON: adds spec-introduced auth fields, keeps user values + user-only fields', () => {
const user = { mode: 'oauth2', oauth2: { scope: 'read:pet', callbackUrl: '{{cb}}', clientSecret: '{{secret}}' } };
const spec = { mode: 'oauth2', oauth2: { scope: 'read:pets', authorizationUrl: 'https://x/v1', refreshTokenUrl: '{{rt}}' } };
const out = helpers.mergeAuth(user, spec).oauth2;
expect(out.scope).toBe('read:pet'); // user value wins on shared field
expect(out.authorizationUrl).toBe('https://x/v1'); // spec-added field appears
expect(out.refreshTokenUrl).toBe('{{rt}}'); // spec-added field appears
expect(out.callbackUrl).toBe('{{cb}}'); // user-only field kept (not deleted)
expect(out.clientSecret).toBe('{{secret}}'); // user credential kept
});
it('ON: does NOT delete a user field the spec dropped (no-delete safety)', () => {
const user = { mode: 'oauth2', oauth2: { scope: 'read', callbackUrl: '{{cb}}' } };
const spec = { mode: 'oauth2', oauth2: { scope: 'read' } }; // spec has no callbackUrl
expect(helpers.mergeAuth(user, spec).oauth2.callbackUrl).toBe('{{cb}}');
});
it('OFF: full spec overwrite drops user-only fields (removals applied)', () => {
const user = { mode: 'oauth2', oauth2: { scope: 'read:pet', callbackUrl: '{{cb}}', clientSecret: '{{secret}}' } };
const spec = { mode: 'oauth2', oauth2: { scope: 'read:pets', authorizationUrl: 'https://x/v1' } };
const out = helpers.mergeAuth(user, spec, false).oauth2;
expect(out).toEqual({ scope: 'read:pets', authorizationUrl: 'https://x/v1' });
expect(out.callbackUrl).toBeUndefined(); // removed under overwrite
});
});
// ---------------------------------------------------------------------------
// Task 6: mergeBody
// ---------------------------------------------------------------------------
describe('mergeBody', () => {
it('dispatches json bodies to field-level merge', () => {
const user = { mode: 'json', json: '{"id":10}' };
const spec = { mode: 'json', json: '{"id":0,"name":""}' };
const out = helpers.mergeBody(user, spec);
expect(JSON.parse(out.json)).toEqual({ id: 10, name: '' });
});
it('merges formUrlEncoded by name', () => {
const user = { mode: 'formUrlEncoded', formUrlEncoded: [{ name: 'a', value: 'mine' }] };
const spec = { mode: 'formUrlEncoded', formUrlEncoded: [{ name: 'a', value: '' }, { name: 'b', value: '' }] };
const out = helpers.mergeBody(user, spec);
expect(out.formUrlEncoded.find((e) => e.name === 'a').value).toBe('mine');
expect(out.formUrlEncoded.map((e) => e.name)).toEqual(['a', 'b']);
});
it('keeps user raw body verbatim for matching text/xml modes', () => {
const user = { mode: 'xml', xml: '<a>{{v}}</a>' };
const spec = { mode: 'xml', xml: '<a></a>' };
const out = helpers.mergeBody(user, spec);
expect(out).toEqual(user);
});
it('takes spec body when body mode differs', () => {
const user = { mode: 'json', json: '{"id":10}' };
const spec = { mode: 'formUrlEncoded', formUrlEncoded: [] };
const out = helpers.mergeBody(user, spec);
expect(out).toEqual(spec);
});
it('keeps the user graphql body but does not alias its nested object', () => {
const user = { mode: 'graphql', graphql: { query: '{ me {{id}} }', variables: '{}' } };
const spec = { mode: 'graphql', graphql: { query: '{ me }', variables: '' } };
const out = helpers.mergeBody(user, spec);
expect(out.graphql).toEqual(user.graphql);
expect(out.graphql).not.toBe(user.graphql);
});
it('falls back to the spec graphql body when the user has none', () => {
const user = { mode: 'graphql' }; // no graphql sub-object
const spec = { mode: 'graphql', graphql: { query: '{ me }', variables: '' } };
const out = helpers.mergeBody(user, spec);
expect(out.graphql).toEqual(spec.graphql);
});
});
// ---------------------------------------------------------------------------
// Task 7: mergeSpecIntoRequest (sync mode)
// ---------------------------------------------------------------------------
describe('mergeSpecIntoRequest (sync mode)', () => {
const existing = {
name: 'r', type: 'http-request',
request: {
method: 'post', url: '{{old}}/x',
params: [{ name: 'q', value: 'mine', enabled: true }],
headers: [{ name: 'H', value: 'mine', enabled: true }],
body: { mode: 'json', json: '{"id":10}' },
auth: { mode: 'oauth2', oauth2: { scope: 'read' } },
script: { req: 'console.log(1)' }, tests: 'expect(1)', assertions: [{ k: 'a' }]
}
};
const specItem = {
name: 'r', type: 'http-request',
request: {
method: 'post', url: '{{spec}}/x',
params: [{ name: 'q', value: '', enabled: true }, { name: 'p', value: '', enabled: true }],
headers: [{ name: 'H', value: '', enabled: true }],
body: { mode: 'json', json: '{"id":0,"name":""}' },
auth: { mode: 'oauth2', oauth2: { scope: '' } }
}
};
it('url always follows the spec (Option A)', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
expect(out.request.url).toBe('{{spec}}/x');
});
it('body: merges user json values into spec structure', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
expect(JSON.parse(out.request.body.json)).toEqual({ id: 10, name: '' });
});
it('params: preserves user value for existing param, adds new param from spec', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
const q = out.request.params.find((p) => p.name === 'q');
expect(q.value).toBe('mine');
expect(out.request.params.map((p) => p.name)).toEqual(['q', 'p']);
});
it('headers: preserves user value for an existing header', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
const h = out.request.headers.find((x) => x.name === 'H');
expect(h.value).toBe('mine');
});
it('auth: preserves user auth values when mode matches', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
expect(out.request.auth.oauth2.scope).toBe('read');
});
it('body: spec wins when the body mode changed (json -> formUrlEncoded)', () => {
const specFormItem = {
...specItem,
request: { ...specItem.request, body: { mode: 'formUrlEncoded', formUrlEncoded: [{ name: 'a', value: '' }] } }
};
const out = helpers.mergeSpecIntoRequest(existing, specFormItem);
expect(out.request.body.mode).toBe('formUrlEncoded');
expect(out.request.body.formUrlEncoded.map((e) => e.name)).toEqual(['a']);
});
it('preserves script, tests, and assertions unchanged', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem);
expect(out.request.script).toEqual({ req: 'console.log(1)' });
expect(out.request.tests).toBe('expect(1)');
expect(out.request.assertions).toEqual([{ k: 'a' }]);
});
it('with preserveValues=false: body comes from spec', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { preserveValues: false });
expect(JSON.parse(out.request.body.json)).toEqual({ id: 0, name: '' });
});
it('with preserveValues=false: param values come from spec', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { preserveValues: false });
const q = out.request.params.find((p) => p.name === 'q');
expect(q.value).toBe('');
});
});
// ---------------------------------------------------------------------------
// Task 7: mergeSpecIntoRequest (reset mode unchanged)
// ---------------------------------------------------------------------------
describe('mergeSpecIntoRequest (reset mode unchanged)', () => {
const existing = {
name: 'r', type: 'http-request',
request: {
method: 'post', url: '{{old}}/x',
params: [{ name: 'q', value: 'mine', enabled: true }],
headers: [{ name: 'H', value: 'mine', enabled: true }],
body: { mode: 'json', json: '{"id":10}' },
auth: { mode: 'oauth2', oauth2: { scope: 'read' } },
script: { req: 'console.log(1)' }, tests: 'expect(1)', assertions: [{ k: 'a' }]
}
};
const specItem = {
name: 'r', type: 'http-request',
request: {
method: 'post', url: '{{spec}}/x',
params: [{ name: 'q', value: '', enabled: true }, { name: 'p', value: '', enabled: true }],
headers: [{ name: 'H', value: '', enabled: true }],
body: { mode: 'json', json: '{"id":0,"name":""}' },
auth: { mode: 'oauth2', oauth2: { scope: '' } }
}
};
it('body comes straight from spec in reset mode', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { fullReset: true });
expect(out.request.body).toEqual(specItem.request.body);
});
it('auth comes straight from spec in reset mode', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { fullReset: true });
expect(out.request.auth).toEqual(specItem.request.auth);
});
it('method comes from spec in reset mode', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { fullReset: true });
expect(out.request.method).toBe('post');
});
it('preserves script, tests, and assertions in reset mode', () => {
const out = helpers.mergeSpecIntoRequest(existing, specItem, { fullReset: true });
expect(out.request.script).toEqual({ req: 'console.log(1)' });
expect(out.request.tests).toBe('expect(1)');
expect(out.request.assertions).toEqual([{ k: 'a' }]);
});
});
// ---------------------------------------------------------------------------
// Task 8: compareRequestFields auth comparison
// ---------------------------------------------------------------------------
describe('compareRequestFields auth comparison', () => {
const base = { params: [], headers: [], body: { mode: 'none' } };
it('same auth mode with different config values -> hasDiff === false', () => {
const spec = { ...base, auth: { mode: 'oauth2', oauth2: { accessTokenUrl: 'https://x', scope: '' } } };
const actual = { ...base, auth: { mode: 'oauth2', oauth2: { accessTokenUrl: '{{url}}', scope: 'read' } } };
expect(helpers.compareRequestFields(spec, actual).hasDiff).toBe(false);
});
it('different auth modes -> hasDiff true and changes mention "auth"', () => {
const spec = { ...base, auth: { mode: 'oauth2', oauth2: {} } };
const actual = { ...base, auth: { mode: 'apikey', apikey: {} } };
const result = helpers.compareRequestFields(spec, actual);
expect(result.hasDiff).toBe(true);
expect(result.changes.join(',')).toContain('auth');
});
});