diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts new file mode 100644 index 0000000000..ce5bdfb540 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { stringify } from 'yaml'; + +import { computeFieldChanges, computeVisualDiff, diffByKey, ownFields } from './diff-engine'; + +const file = (collection: unknown[]) => + stringify({ type: 'collection.insomnia.rest/5.0', name: 'C', meta: { id: 'wrk_1' }, collection }); + +describe('computeVisualDiff', () => { + it('is unparseable when one side has content that is not valid YAML (eg. conflict markers)', () => { + const before = file([{ name: 'r', url: 'https://a', meta: { id: 'req_1' } }]); + const conflicted = `${before}<<<<<<< HEAD\n - url: https://ours\n=======\n - url: https://theirs\n>>>>>>> main\n`; + + expect(computeVisualDiff(before, conflicted).unparseable).toBe(true); + }); + + it('treats an empty side as a legitimately absent file (new or deleted), not unparseable', () => { + const after = file([{ name: 'r', url: 'https://a', meta: { id: 'req_1' } }]); + + const result = computeVisualDiff('', after); + + expect(result.unparseable).toBe(false); + expect(result.entities.map(e => [e.id, e.status])).toEqual([ + ['req_1', 'added'], + ['file:workspace', 'added'], + ]); + }); +}); + +describe('ownFields', () => { + it('drops nested entities, which are shown as their own cards', () => { + const folder = { name: 'F', meta: { id: 'fld_1' }, children: [{ name: 'r', meta: { id: 'req_1' } }] }; + const env = { name: 'Base', data: { a: 1 }, subEnvironments: [{ name: 'Sub' }] }; + + expect(ownFields(folder)).toEqual({ name: 'F', meta: { id: 'fld_1' } }); + expect(ownFields(env)).toEqual({ name: 'Base', data: { a: 1 } }); + }); +}); + +describe('computeFieldChanges - meta', () => { + it('reports each changed meta field under its own path, ignoring volatile ones', () => { + const before = { meta: { id: 'req_1', modified: 1, description: 'same', isPrivate: false } }; + const after = { meta: { id: 'req_1', modified: 2, description: 'same', isPrivate: true } }; + + expect(computeFieldChanges(before, after)).toEqual([ + { path: 'meta.isPrivate', label: 'Is Private', before: false, after: true }, + ]); + }); +}); + +describe('computeVisualDiff - file-level sections', () => { + const mock = (extra: Record) => + stringify({ type: 'mock.insomnia.rest/5.0', name: 'Mock', meta: { id: 'wrk_m' }, ...extra }); + + it('diffs each file-level section as one whole entity', () => { + const before = mock({ server: { url: 'https://old', useInsomniaCloud: false } }); + const after = mock({ + name: 'Mock v2', + server: { url: 'https://new', useInsomniaCloud: false }, + testSuites: [{ name: 'suite', meta: { id: 'uts_1' }, tests: [] }], + }); + + const entities = computeVisualDiff(before, after).entities; + + expect(entities.map(e => [e.id, e.type, e.status])).toEqual([ + ['file:workspace', 'workspace', 'modified'], + ['file:server', 'mock_server', 'modified'], + ['file:testSuites', 'unit_tests', 'added'], + ]); + expect(entities[1].fieldChanges.map(c => [c.path, c.before, c.after])).toEqual([ + ['url', 'https://old', 'https://new'], + ]); + }); + + it('does not treat a non-Insomnia YAML file as a workspace', () => { + expect( + computeVisualDiff(stringify({ name: 'a', rules: {} }), stringify({ name: 'b', rules: {} })).entities, + ).toEqual([]); + }); +}); + +describe('diffByKey', () => { + const userAgent = { name: 'User-Agent', value: 'insomnia/12.1.0', disabled: false }; + const customUserAgent = { name: 'User-Agent', value: '123', disabled: true }; + const accept = { name: 'Accept', value: '*/*' }; + const rows = (before: unknown[], after: unknown[]) => + diffByKey(before as any[], after as any[], 'name').map(({ key, status }) => `${key}: ${status}`); + + it('tells same-named items apart by their order, so a change to either one shows', () => { + const changedFirst = { ...userAgent, disabled: true }; + const changedSecond = { ...customUserAgent, value: '456' }; + + expect(rows([userAgent, customUserAgent], [changedFirst, customUserAgent])).toEqual(['User-Agent (1): modified']); + expect(rows([userAgent, customUserAgent], [changedFirst, changedSecond])).toEqual([ + 'User-Agent (1): modified', + 'User-Agent (2): modified', + ]); + }); + + it('numbers a name repeated on only one side on both, so adding or removing the last one reads as such', () => { + expect(rows([userAgent, accept], [userAgent, accept, customUserAgent])).toEqual(['User-Agent (2): added']); + expect(rows([userAgent, customUserAgent], [userAgent])).toEqual(['User-Agent (2): removed']); + }); + + it('keeps the plain name for items whose name is not repeated', () => { + const before = [userAgent, customUserAgent, accept]; + expect(rows(before, [userAgent, customUserAgent, { ...accept, value: 'a' }])).toEqual(['Accept: modified']); + }); +}); diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts new file mode 100644 index 0000000000..320667d015 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/visual-diff/diff-engine.ts @@ -0,0 +1,531 @@ +import { parse as parseYaml } from 'yaml'; + +/** + * Visual diff card coverage tracker. + * + * A git-tracked file is a whole Insomnia v5 workspace export (see + * `common/import-v5-parser.ts`), containing many entities in one YAML blob. + * This module walks both sides of the diff, matches entities by `meta.id`, + * and produces one `EntityDiff` per changed/added/removed entity so the UI + * can render a dedicated card per entity instead of one big text diff. + * + * Entities without a dedicated card fall back to `GenericEntityDiffCard` + * (bullet list of raw field changes). Status of dedicated visual cards: + * + * - [x] Request (HTTP) -> RequestDiffCard + * - [x] Environment -> EnvironmentDiffCard + * - [x] Request Group (folder) -> RequestGroupDiffCard + * - [x] WebSocket Request -> WebSocketDiffCard + * - [x] gRPC Request -> GrpcDiffCard + * - [x] Socket.IO Request -> SocketIODiffCard + * - [x] MCP Request -> McpDiffCard + * - [x] Mock Route -> MockRouteDiffCard + * - [x] Cookie Jar -> CookieJarDiffCard + * + * File-level sections (see `FILE_SECTIONS`: workspace identity, mock server, + * API spec, unit tests, CA certificates) are each one whole entity and render + * via `GenericEntityDiffCard`. + */ + +// TODO: Bind type from Insomnia v5 entity definitions (eg. `Request`, `Environment`, etc.) rather than hardcoding strings. +// TODO: What if the type changes for an existing entity? How should the diff engine handle it? + +export type VisualDiffEntityType = + | 'request' + | 'grpc_request' + | 'websocket_request' + | 'socketio_request' + | 'request_group' + | 'environment' + | 'mock_route' + | 'mcp_request' + | 'cookie_jar' + | 'workspace' + | 'mock_server' + | 'api_spec' + | 'unit_tests' + | 'ca_certificates' + | 'unknown'; + +// Top-level parts of a file that aren't entity collections. Each is diffed — and +// staged/discarded — as one whole entity (eg. every test suite at once), keyed by +// a fixed id. Together with the entity collections they cover every top-level key. +export const FILE_SECTIONS: readonly { id: string; type: VisualDiffEntityType; keys: readonly string[] }[] = [ + { id: 'file:workspace', type: 'workspace', keys: ['type', 'schema_version', 'name', 'meta'] }, + { id: 'file:server', type: 'mock_server', keys: ['server'] }, + { id: 'file:spec', type: 'api_spec', keys: ['spec'] }, + { id: 'file:testSuites', type: 'unit_tests', keys: ['testSuites'] }, + { id: 'file:certificates', type: 'ca_certificates', keys: ['certificates'] }, +]; + +export type EntityChangeStatus = 'added' | 'removed' | 'modified'; + +export interface FieldChange { + path: string; + label: string; + before: unknown; + after: unknown; +} + +export interface EntityDiff { + id: string; + type: VisualDiffEntityType; + status: EntityChangeStatus; + name: string; + before: any; + after: any; + fieldChanges: FieldChange[]; +} + +export interface VisualDiffResult { + entities: EntityDiff[]; + // True when neither side could be parsed as a recognizable Insomnia v5 file. + unparseable: boolean; +} + +interface CollectedEntity { + type: VisualDiffEntityType; + name: string; + node: any; +} + +// Recursively sorts object keys (and maps over arrays) so two structurally +// equal values serialize to the same JSON string regardless of key order. +function sortDeep(value: any): any { + if (Array.isArray(value)) { + return value.map(sortDeep); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortDeep(value[key])]), + ); + } + return value; +} + +// undefined, null and empty string are treated as equivalent so schema defaults don't create noise +function emptyValueReplacer(_key: string, value: any) { + if (value === null || value === '') { + return; + } + return value; +} + +// Deep-equality check used throughout the diff engine: key order doesn't +// matter (via sortDeep) and null/undefined/'' are treated as equivalent +// (via emptyValueReplacer). +export function valuesEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(sortDeep(a), emptyValueReplacer) === JSON.stringify(sortDeep(b), emptyValueReplacer); +} + +// Strips volatile/structural meta fields (modified/created/sortKey/id) before +// comparing two entities' meta blocks, so only user-visible fields (eg. +// description) are treated as a meaningful change. +function cleanMeta(meta: any) { + if (!meta || typeof meta !== 'object') { + return meta; + } + const { modified, created, sortKey, id, ...rest } = meta; + return rest; +} + +// Keys that are structural (handled by entity matching itself) rather than displayable fields +const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments']); + +// An entity node minus its nested entities (eg. a folder's `children`), which get their own cards. +export function ownFields(node: any): any { + if (!node || typeof node !== 'object') { + return node; + } + return Object.fromEntries(Object.entries(node).filter(([key]) => !STRUCTURAL_KEYS.has(key))); +} + +// Compares two entity nodes field-by-field and returns one FieldChange per +// differing key, skipping structural keys (handled by entity matching) and +// reporting `meta` per sub-field (eg. `meta.description`), minus volatile ones. +export function computeFieldChanges(before: any, after: any): FieldChange[] { + const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); + const changes: FieldChange[] = []; + + for (const key of keys) { + if (STRUCTURAL_KEYS.has(key)) { + continue; + } + + if (key === 'meta') { + const beforeMeta = cleanMeta(before?.meta) ?? {}; + const afterMeta = cleanMeta(after?.meta) ?? {}; + for (const metaKey of new Set([...Object.keys(beforeMeta), ...Object.keys(afterMeta)])) { + if (!valuesEqual(beforeMeta[metaKey], afterMeta[metaKey])) { + changes.push({ + path: `meta.${metaKey}`, + label: humanizeKey(metaKey), + before: beforeMeta[metaKey], + after: afterMeta[metaKey], + }); + } + } + continue; + } + + const beforeValue = before?.[key]; + const afterValue = after?.[key]; + if (!valuesEqual(beforeValue, afterValue)) { + changes.push({ path: key, label: humanizeKey(key), before: beforeValue, after: afterValue }); + } + } + + return changes; +} + +// Turns a camelCase/snake_case/kebab-case field name into a human-readable +// label (eg. `pathParameters` -> "Path parameters") for display in a card. +export function humanizeKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/^./, c => c.toUpperCase()); +} + +export interface KeyedDiffRow { + key: string; + status: EntityChangeStatus; + before?: unknown; + after?: unknown; +} + +// Single status representing a set of rows (eg. for a tab/chip badge): all-added +// or all-removed collapse to that status, anything mixed (or any modified row) +// reads as 'modified'. +export function dominantStatus(statuses: EntityChangeStatus[]): EntityChangeStatus { + const hasAdded = statuses.includes('added'); + const hasRemoved = statuses.includes('removed'); + const hasModified = statuses.includes('modified'); + if (hasModified || (hasAdded && hasRemoved)) { + return 'modified'; + } + if (hasAdded) { + return 'added'; + } + if (hasRemoved) { + return 'removed'; + } + return 'modified'; +} + +// Status for a single before/after block (eg. a request's body or auth config) +// based only on which side has content — content only on one side reads as +// added/removed even when the entity itself is 'modified'. +export function sideStatus(hasBefore: boolean, hasAfter: boolean): EntityChangeStatus { + if (!hasBefore && hasAfter) { + return 'added'; + } + if (hasBefore && !hasAfter) { + return 'removed'; + } + return 'modified'; +} + +// Diffs two arrays of objects by matching a key field (eg. header/parameter `name`) instead of position +export function diffByKey>( + before: T[] = [], + after: T[] = [], + keyField: keyof T = 'name' as keyof T, +): KeyedDiffRow[] { + const rows: KeyedDiffRow[] = []; + + const keyOf = (item: T, i: number) => String(item?.[keyField] ?? `#${i}`); + const repeatedKeys = new Set([...findRepeatedKeys(before, keyOf), ...findRepeatedKeys(after, keyOf)]); + const beforeMap = mapByKey(before, keyOf, repeatedKeys); + const afterMap = mapByKey(after, keyOf, repeatedKeys); + + afterMap.forEach((afterItem, key) => { + const beforeItem = beforeMap.get(key); + if (!beforeItem) { + rows.push({ key, status: 'added', after: afterItem }); + } else if (!valuesEqual(beforeItem, afterItem)) { + rows.push({ key, status: 'modified', before: beforeItem, after: afterItem }); + } + }); + + beforeMap.forEach((beforeItem, key) => { + if (!afterMap.has(key)) { + rows.push({ key, status: 'removed', before: beforeItem }); + } + }); + + return rows; +} + +function findRepeatedKeys(items: T[] = [], keyOf: (item: T, i: number) => string): Set { + const seen = new Set(); + const repeated = new Set(); + (items ?? []).forEach((item, i) => { + const key = keyOf(item, i); + (seen.has(key) ? repeated : seen).add(key); + }); + return repeated; +} + +// Items sharing a key (eg. two `User-Agent` headers) are told apart by their order among that key, +// as "User-Agent (1)" and "User-Agent (2)" — otherwise the later one would hide the earlier one. A +// key repeated on either side is numbered on both, so the first still matches the first when a +// second one is added or removed. +function mapByKey( + items: T[] = [], + keyOf: (item: T, i: number) => string, + repeatedKeys: ReadonlySet, +): Map { + const map = new Map(); + const occurrences = new Map(); + (items ?? []).forEach((item, i) => { + const key = keyOf(item, i); + if (!repeatedKeys.has(key)) { + map.set(key, item); + return; + } + const occurrence = (occurrences.get(key) ?? 0) + 1; + occurrences.set(key, occurrence); + map.set(`${key} (${occurrence})`, item); + }); + return map; +} + +// Diffs a plain key/value record (eg. environment `data`) by key +export function diffRecord(before: Record = {}, after: Record = {}): KeyedDiffRow[] { + const rows: KeyedDiffRow[] = []; + const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); + + keys.forEach(key => { + const hasBefore = before != null && Object.prototype.hasOwnProperty.call(before, key); + const hasAfter = after != null && Object.prototype.hasOwnProperty.call(after, key); + const beforeValue = before?.[key]; + const afterValue = after?.[key]; + + if (!hasBefore) { + rows.push({ key, status: 'added', after: afterValue }); + } else if (!hasAfter) { + rows.push({ key, status: 'removed', before: beforeValue }); + } else if (!valuesEqual(beforeValue, afterValue)) { + rows.push({ key, status: 'modified', before: beforeValue, after: afterValue }); + } + }); + + return rows; +} + +// Determines a collection tree node's entity type from its `meta.id` prefix +// (eg. `req_` -> request, `fld_` -> folder), falling back to shape-sniffing +// (`children` array / `method` string) if the id is missing or unrecognized. +function classifyCollectionNode(node: any): VisualDiffEntityType { + const id: string = node?.meta?.id ?? ''; + if (id.startsWith('ws-req')) { + return 'websocket_request'; + } + if (id.startsWith('socketio-req')) { + return 'socketio_request'; + } + if (id.startsWith('greq')) { + return 'grpc_request'; + } + if (id.startsWith('fld')) { + return 'request_group'; + } + if (id.startsWith('req')) { + return 'request'; + } + // Fall back to structural shape in case an id is missing/unrecognized + if (Array.isArray(node?.children)) { + return 'request_group'; + } + if (typeof node?.method === 'string') { + return 'request'; + } + return 'unknown'; +} + +// Walks a parsed Insomnia v5 file (collection tree, environments, mock +// routes, MCP request, cookie jar) and flattens every entity it finds into a +// single `id -> CollectedEntity` map, so both sides of a diff can be matched +// up by id regardless of where in the tree they live. +function collectEntities(file: any): Map { + const map = new Map(); + if (!file || typeof file !== 'object') { + return map; + } + + // Recursively registers a collection-tree node (request/folder/etc.) and its children. + const addCollectionNode = (node: any) => { + if (!node || typeof node !== 'object') { + return; + } + const id = node.meta?.id; + if (id) { + map.set(id, { type: classifyCollectionNode(node), name: node.name || 'Untitled', node }); + } + if (Array.isArray(node.children)) { + node.children.forEach(addCollectionNode); + } + }; + + // Registers a base environment and each of its sub-environments as separate entities. + const addEnvironmentTree = (env: any, fallbackName: string) => { + if (!env || typeof env !== 'object') { + return; + } + const id = env.meta?.id; + if (id) { + map.set(id, { type: 'environment', name: env.name || fallbackName, node: env }); + } + (env.subEnvironments ?? []).forEach((sub: any, index: number) => { + const subId = sub?.meta?.id; + if (subId) { + map.set(subId, { type: 'environment', name: sub.name || `Environment ${index}`, node: sub }); + } + }); + }; + + if (Array.isArray(file.collection)) { + file.collection.forEach(addCollectionNode); + } + + if (file.environments) { + addEnvironmentTree(file.environments, 'Base Environment'); + } + + if (Array.isArray(file.routes)) { + file.routes.forEach((route: any) => { + const id = route?.meta?.id; + if (id) { + map.set(id, { type: 'mock_route', name: route.name || route.pattern || 'Mock Route', node: route }); + } + }); + } + + if (file.mcpRequest) { + const id = file.mcpRequest.meta?.id; + if (id) { + map.set(id, { type: 'mcp_request', name: file.mcpRequest.name || 'MCP Request', node: file.mcpRequest }); + } + } + + if (file.cookieJar) { + const id = file.cookieJar.meta?.id ?? 'cookie-jar'; + map.set(id, { type: 'cookie_jar', name: file.cookieJar.name || 'Cookie Jar', node: file.cookieJar }); + } + + // Only real Insomnia files: any other YAML in the repo would otherwise surface as a "Workspace". + if (typeof file.type === 'string' && file.type.includes('insomnia')) { + const workspaceName = file.name || 'Untitled'; + for (const section of FILE_SECTIONS) { + if (section.keys.every(key => file[key] === undefined)) { + continue; + } + // Object sections (eg. `server`) are shown field by field; arrays and multi-key sections are + // wrapped so the card reads "Test Suites: old -> new" rather than per-array-index rows. + const single = section.keys.length === 1 ? file[section.keys[0]] : undefined; + const node = + single && typeof single === 'object' && !Array.isArray(single) + ? single + : Object.fromEntries(section.keys.filter(key => file[key] !== undefined).map(key => [key, file[key]])); + const name = section.type === 'mock_server' ? file.server?.url || 'Mock Server' : workspaceName; + map.set(section.id, { type: section.type, name, node }); + } + } + + // TODO: Handle more Insomnia v5 entities in the future + + return map; +} + +// Parses YAML text into a plain object, swallowing parse errors and +// returning `undefined` instead so a malformed/empty side of the diff +// degrades gracefully rather than throwing. +function safeParseYaml(text: string): any { + if (!text) { + return undefined; + } + try { + return parseYaml(text); + } catch { + return undefined; + } +} + +// Entry point of the diff engine: parses both sides of a git-tracked file as +// YAML, collects every entity on each side, matches them up by id, and +// returns the added/removed/modified `EntityDiff` list the UI renders cards +// from. +export function computeVisualDiff(beforeText: string, afterText: string): VisualDiffResult { + const beforeFile = safeParseYaml(beforeText); + const afterFile = safeParseYaml(afterText); + + const beforeEntities = collectEntities(beforeFile); + const afterEntities = collectEntities(afterFile); + + const entities: EntityDiff[] = []; + const visited = new Set(); + + afterEntities.forEach((afterEntity, id) => { + visited.add(id); + const beforeEntity = beforeEntities.get(id); + + if (!beforeEntity) { + entities.push({ + id, + type: afterEntity.type, + status: 'added', + name: afterEntity.name, + before: undefined, + after: afterEntity.node, + fieldChanges: [], + }); + return; + } + + const fieldChanges = computeFieldChanges(beforeEntity.node, afterEntity.node); + if (fieldChanges.length === 0) { + return; + } + + entities.push({ + id, + type: afterEntity.type, + status: 'modified', + name: afterEntity.name, + before: beforeEntity.node, + after: afterEntity.node, + fieldChanges, + }); + }); + + beforeEntities.forEach((beforeEntity, id) => { + if (visited.has(id)) { + return; + } + entities.push({ + id, + type: beforeEntity.type, + status: 'removed', + name: beforeEntity.name, + before: beforeEntity.node, + after: undefined, + fieldChanges: [], + }); + }); + + // A side with content that doesn't parse (eg. merge-conflict markers) can't be + // reasoned about per entity — every entity would look added/removed, and + // acting on one would rewrite the whole file from a partial view. + const sideUnparseable = (text: string, file: unknown) => Boolean(text) && (file === undefined || file === null || typeof file !== 'object'); + + return { + entities, + unparseable: + (beforeFile === undefined && afterFile === undefined) || + sideUnparseable(beforeText, beforeFile) || + sideUnparseable(afterText, afterFile), + }; +}