mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-01 10:41:00 -05:00
feat(Git Diff): Add an entity-level diff engine [1/5] (#10615)
* feat(Git Diff): add an entity-level diff engine for Insomnia YAML files Parses both sides of a git-synced Insomnia file, matches requests, folders, environments, mock routes, the MCP request, the cookie jar and the file-level sections by id, and returns the added / removed / modified entities with their field-level changes. Not wired into any UI yet. Co-authored-by: James Gatz <jamesgatzos@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(Git Diff): tell same-named params and headers apart in the diff diffByKey matched items by name only, so of two same-named items (eg. a disabled default User-Agent header next to a custom one) the later hid the earlier: a change to the first was invisible, and adding or removing one read as a modification. Items sharing a name are now numbered by their order among that name, as "User-Agent (1)" and "User-Agent (2)", on both sides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: James Gatz <jamesgatzos@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2 files changed
+640
No files matched your search
@@ -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<string, unknown>) =>
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends Record<string, any>>(
|
||||
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<T>(items: T[] = [], keyOf: (item: T, i: number) => string): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const repeated = new Set<string>();
|
||||
(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<T>(
|
||||
items: T[] = [],
|
||||
keyOf: (item: T, i: number) => string,
|
||||
repeatedKeys: ReadonlySet<string>,
|
||||
): Map<string, T> {
|
||||
const map = new Map<string, T>();
|
||||
const occurrences = new Map<string, number>();
|
||||
(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<string, any> = {}, after: Record<string, any> = {}): 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<string, CollectedEntity> {
|
||||
const map = new Map<string, CollectedEntity>();
|
||||
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<string>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user