diff --git a/src/formatters/SnapshotDiffFormatter.ts b/src/formatters/SnapshotDiffFormatter.ts new file mode 100644 index 000000000..a90f67e70 --- /dev/null +++ b/src/formatters/SnapshotDiffFormatter.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js'; +import {TreeDiff, type DiffNode} from '../utils/TreeDiff.js'; + +import {SnapshotFormatter} from './SnapshotFormatter.js'; + +export class SnapshotDiffFormatter { + #root: DiffNode; + #oldFormatter: SnapshotFormatter; + #newFormatter: SnapshotFormatter; + + constructor( + root: DiffNode, + oldFormatter: SnapshotFormatter, + newFormatter: SnapshotFormatter, + ) { + this.#root = root; + this.#oldFormatter = oldFormatter; + this.#newFormatter = newFormatter; + } + + toString(): string { + const lines = this.#formatDiffNode(this.#root, 0); + const hasChanges = lines.some(l => l.startsWith('+') || l.startsWith('-')); + if (!hasChanges) { + return ''; + } + return lines.join('').trimEnd(); + } + + #formatDiffNode( + diffNode: DiffNode, + depth: number, + ): string[] { + const chunks: string[] = []; + + if (diffNode.type === 'same') { + const oldLine = this.#oldFormatter.formatNodeSelf( + diffNode.oldNode!, + depth, + ); + const newLine = this.#newFormatter.formatNodeSelf(diffNode.node, depth); + if (oldLine === newLine) { + chunks.push(' ' + newLine); + } else { + chunks.push('- ' + oldLine); + chunks.push('+ ' + newLine); + } + // Children + for (const child of diffNode.children) { + chunks.push(...this.#formatDiffNode(child, depth + 1)); + } + } else if (diffNode.type === 'added') { + chunks.push( + '+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth), + ); + // Recursively add children (they are also 'added' in the tree) + for (const child of diffNode.children) { + chunks.push(...this.#formatDiffNode(child, depth + 1)); + } + } else if (diffNode.type === 'removed') { + chunks.push( + '- ' + this.#oldFormatter.formatNodeSelf(diffNode.node, depth), + ); + // Recursively remove children (they are also 'removed' in the tree) + for (const child of diffNode.children) { + chunks.push(...this.#formatDiffNode(child, depth + 1)); + } + } else if (diffNode.type === 'modified') { + chunks.push( + '- ' + this.#oldFormatter.formatNodeSelf(diffNode.oldNode!, depth), + ); + chunks.push( + '+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth), + ); + } + + return chunks; + } + + toJSON(): object { + return this.#nodeToJSON(this.#root) ?? {}; + } + + #nodeToJSON(diffNode: DiffNode): object | null { + if (diffNode.type === 'same') { + const oldJson = this.#oldFormatter.nodeToJSON(diffNode.oldNode!); + const newJson = this.#newFormatter.nodeToJSON(diffNode.node); + const childrenDiff = diffNode.children + .map(child => this.#nodeToJSON(child)) + .filter(x => x !== null); + + const contentChanged = + JSON.stringify(oldJson) !== JSON.stringify(newJson); + + if (!contentChanged && childrenDiff.length === 0) { + return null; + } + + const result: Record = {}; + if (contentChanged) { + result.type = 'modified'; + result.oldAttributes = oldJson; + result.newAttributes = newJson; + } else { + result.type = 'unchanged'; + result.id = diffNode.node.id; + } + + if (childrenDiff.length > 0) { + result.children = childrenDiff; + } + return result; + } else if (diffNode.type === 'added') { + return { + type: 'added', + node: this.#newFormatter.nodeToJSON(diffNode.node), + }; + } else if (diffNode.type === 'removed') { + return { + type: 'removed', + node: this.#oldFormatter.nodeToJSON(diffNode.node), + }; + } else if (diffNode.type === 'modified') { + return { + type: 'modified', + oldNode: this.#oldFormatter.nodeToJSON(diffNode.oldNode!), + newNode: this.#newFormatter.nodeToJSON(diffNode.node), + }; + } + return null; + } + + static diff( + oldSnapshot: TextSnapshot, + newSnapshot: TextSnapshot, + ): SnapshotDiffFormatter { + const diffRoot = TreeDiff.compute(oldSnapshot.root, newSnapshot.root); + const oldFormatter = new SnapshotFormatter(oldSnapshot); + const newFormatter = new SnapshotFormatter(newSnapshot); + return new SnapshotDiffFormatter(diffRoot, oldFormatter, newFormatter); + } +} diff --git a/src/formatters/SnapshotFormatter.ts b/src/formatters/SnapshotFormatter.ts index 2ec80e751..18f6aef4c 100644 --- a/src/formatters/SnapshotFormatter.ts +++ b/src/formatters/SnapshotFormatter.ts @@ -27,16 +27,24 @@ export class SnapshotFormatter { Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`); } - chunks.push(this.#formatNode(root, 0)); + chunks.push(this.formatNode(root, 0)); return chunks.join(''); } toJSON(): object { - return this.#nodeToJSON(this.#snapshot.root); + return this.nodeToJSON(this.#snapshot.root); } - #formatNode(node: TextSnapshotNode, depth = 0): string { - const chunks: string[] = []; + formatNode(node: TextSnapshotNode, depth = 0): string { + const chunks: string[] = [this.formatNodeSelf(node, depth)]; + + for (const child of node.children) { + chunks.push(this.formatNode(child, depth + 1)); + } + return chunks.join(''); + } + + formatNodeSelf(node: TextSnapshotNode, depth = 0): string { const attributes = this.#getAttributes(node); const line = ' '.repeat(depth * 2) + @@ -45,17 +53,12 @@ Get a verbose snapshot to include all elements if you are interested in the sele ? ' [selected in the DevTools Elements panel]' : '') + '\n'; - chunks.push(line); - - for (const child of node.children) { - chunks.push(this.#formatNode(child, depth + 1)); - } - return chunks.join(''); + return line; } - #nodeToJSON(node: TextSnapshotNode): object { + nodeToJSON(node: TextSnapshotNode): object { const rawAttrs = this.#getAttributesMap(node); - const children = node.children.map(child => this.#nodeToJSON(child)); + const children = node.children.map(child => this.nodeToJSON(child)); const result: Record = structuredClone(rawAttrs); if (children.length > 0) { result.children = children; diff --git a/src/utils/TreeDiff.ts b/src/utils/TreeDiff.ts new file mode 100644 index 000000000..ff5181539 --- /dev/null +++ b/src/utils/TreeDiff.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface Node { + id: string; + children: T[]; +} + +export interface DiffNode { + type: 'same' | 'added' | 'removed' | 'modified'; + node: T; + oldNode?: T; + children: Array>; +} + +export class TreeDiff { + static compute>(oldNode: T, newNode: T): DiffNode { + if (oldNode.id !== newNode.id) { + // Different IDs implies a replacement (remove old, add new). + // We return 'modified' to represent this at the root level, + // but strictly speaking it's a swap. + return { + type: 'modified', + node: newNode, + oldNode: oldNode, + children: [], + }; + } + + const childrenDiff = this.#diffChildren(oldNode.children, newNode.children); + + return { + type: 'same', + node: newNode, + oldNode: oldNode, + children: childrenDiff, + }; + } + + static #diffChildren>( + oldChildren: T[], + newChildren: T[], + ): Array> { + const result: Array> = []; + + // Index old children for O(1) lookup + const oldMap = new Map(); + oldChildren.forEach((node, index) => { + oldMap.set(node.id, {node, index}); + }); + + // Set of new keys for quick existence check + const newKeys = new Set(newChildren.map(n => n.id)); + + let cursor = 0; + + for (const newChild of newChildren) { + const oldEntry = oldMap.get(newChild.id); + + if (oldEntry) { + // Matched by ID + const {node: oldChild, index: oldIndex} = oldEntry; + + // Check for removals of nodes skipped in the old list + if (oldIndex >= cursor) { + for (let i = cursor; i < oldIndex; i++) { + const candidate = oldChildren[i]; + // If the candidate is NOT in the new list, it was removed. + // If it IS in the new list, it was moved (we'll see it later). + if (!newKeys.has(candidate.id)) { + result.push({ + type: 'removed', + node: candidate, + children: this.#allRemoved(candidate.children), + }); + } + } + cursor = oldIndex + 1; + } + + // Recurse on the match + result.push(this.compute(oldChild, newChild)); + } else { + // Added + result.push({ + type: 'added', + node: newChild, + children: this.#allAdded(newChild.children), + }); + } + } + + // Append any remaining removals from the end of the old list + if (cursor < oldChildren.length) { + for (let i = cursor; i < oldChildren.length; i++) { + const candidate = oldChildren[i]; + if (!newKeys.has(candidate.id)) { + result.push({ + type: 'removed', + node: candidate, + children: this.#allRemoved(candidate.children), + }); + } + } + } + + return result; + } + + static #allAdded>(nodes: T[]): Array> { + return nodes.map(node => ({ + type: 'added', + node: node, + children: this.#allAdded(node.children), + })); + } + + static #allRemoved>(nodes: T[]): Array> { + return nodes.map(node => ({ + type: 'removed', + node: node, + children: this.#allRemoved(node.children), + })); + } +} diff --git a/tests/formatters/SnapshotDiffFormater.test.js.snapshot b/tests/formatters/SnapshotDiffFormater.test.js.snapshot new file mode 100644 index 000000000..80774dd43 --- /dev/null +++ b/tests/formatters/SnapshotDiffFormater.test.js.snapshot @@ -0,0 +1,288 @@ +exports[`SnapshotDiffFormatter > toJSON > detects added nodes 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + }, + { + "id": "3", + "role": "generic" + } + ] + }, + "children": [ + { + "type": "added", + "node": { + "id": "3", + "role": "generic" + } + } + ] +} +`; + +exports[`SnapshotDiffFormatter > toJSON > detects added nodes with children 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + }, + { + "id": "3", + "role": "generic", + "children": [ + { + "id": "4", + "role": "generic" + } + ] + } + ] + }, + "children": [ + { + "type": "added", + "node": { + "id": "3", + "role": "generic", + "children": [ + { + "id": "4", + "role": "generic" + } + ] + } + } + ] +} +`; + +exports[`SnapshotDiffFormatter > toJSON > detects modified nodes (attributes) 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic", + "name": "old" + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic", + "name": "new" + } + ] + }, + "children": [ + { + "type": "modified", + "oldAttributes": { + "id": "2", + "role": "generic", + "name": "old" + }, + "newAttributes": { + "id": "2", + "role": "generic", + "name": "new" + } + } + ] +} +`; + +exports[`SnapshotDiffFormatter > toJSON > detects removed nodes 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + }, + { + "id": "3", + "role": "generic" + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + } + ] + }, + "children": [ + { + "type": "removed", + "node": { + "id": "3", + "role": "generic" + } + } + ] +} +`; + +exports[`SnapshotDiffFormatter > toJSON > detects removed nodes with children 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + }, + { + "id": "3", + "role": "generic", + "children": [ + { + "id": "4", + "role": "generic" + } + ] + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + } + ] + }, + "children": [ + { + "type": "removed", + "node": { + "id": "3", + "role": "generic", + "children": [ + { + "id": "4", + "role": "generic" + } + ] + } + } + ] +} +`; + +exports[`SnapshotDiffFormatter > toJSON > detects reordering (as remove + add) 1`] = ` +{ + "type": "modified", + "oldAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "2", + "role": "generic" + }, + { + "id": "3", + "role": "generic" + } + ] + }, + "newAttributes": { + "id": "1", + "role": "generic", + "children": [ + { + "id": "3", + "role": "generic" + }, + { + "id": "2", + "role": "generic" + } + ] + } +} +`; + +exports[`SnapshotDiffFormatter > toJSON > shows no changes for identical snapshots 1`] = ` +{} +`; + +exports[`SnapshotDiffFormatter > toString > detects added nodes 1`] = ` +" uid=1 generic [selected in the DevTools Elements panel]\\n uid=2 generic\\n+ uid=3 generic" +`; + +exports[`SnapshotDiffFormatter > toString > detects added nodes with children 1`] = ` +" uid=1 generic [selected in the DevTools Elements panel]\\n uid=2 generic\\n+ uid=3 generic\\n+ uid=4 generic" +`; + +exports[`SnapshotDiffFormatter > toString > detects modified nodes (attributes) 1`] = ` +" uid=1 generic [selected in the DevTools Elements panel]\\n- uid=2 generic \\"old\\"\\n+ uid=2 generic \\"new\\"" +`; + +exports[`SnapshotDiffFormatter > toString > detects removed nodes 1`] = ` +" uid=1 generic [selected in the DevTools Elements panel]\\n uid=2 generic\\n- uid=3 generic" +`; + +exports[`SnapshotDiffFormatter > toString > detects removed nodes with children 1`] = ` +" uid=1 generic [selected in the DevTools Elements panel]\\n uid=2 generic\\n- uid=3 generic\\n- uid=4 generic" +`; + +exports[`SnapshotDiffFormatter > toString > detects reordering (as remove + add) 1`] = ` +"" +`; + +exports[`SnapshotDiffFormatter > toString > shows no changes for identical snapshots 1`] = ` +"" +`; diff --git a/tests/formatters/SnapshotDiffFormater.test.ts b/tests/formatters/SnapshotDiffFormater.test.ts new file mode 100644 index 000000000..f423c53cf --- /dev/null +++ b/tests/formatters/SnapshotDiffFormater.test.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import {SnapshotDiffFormatter} from '../../src/formatters/SnapshotDiffFormatter.js'; +import { + type TextSnapshot, + type TextSnapshotNode, +} from '../../src/McpContext.js'; + +function createNode( + id: string, + children: TextSnapshotNode[] = [], + name?: string, +): TextSnapshotNode { + return { + id, + children, + name, + role: 'generic', + elementHandle: async () => null, + }; +} + +function createSnapshot(root: TextSnapshotNode): TextSnapshot { + return { + root, + hasSelectedElement: false, + selectedElementUid: '1', + verbose: false, + snapshotId: '1', + idToNode: new Map(), + }; +} + +describe('SnapshotDiffFormatter', () => { + const methods = ['toString', 'toJSON'] as const; + for (const method of methods) { + describe(method, () => { + it('shows no changes for identical snapshots', t => { + const snapshot = createSnapshot( + createNode('1', [createNode('2'), createNode('3')]), + ); + + const diff = SnapshotDiffFormatter.diff(snapshot, snapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects added nodes', t => { + const oldSnapshot = createSnapshot(createNode('1', [createNode('2')])); + const newSnapshot = createSnapshot( + createNode('1', [createNode('2'), createNode('3')]), + ); + + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects added nodes with children', t => { + const oldSnapshot = createSnapshot(createNode('1', [createNode('2')])); + const newSnapshot = createSnapshot( + createNode('1', [ + createNode('2'), + createNode('3', [createNode('4')]), + ]), + ); + + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects removed nodes', t => { + const oldSnapshot = createSnapshot( + createNode('1', [createNode('2'), createNode('3')]), + ); + const newSnapshot = createSnapshot(createNode('1', [createNode('2')])); + + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects removed nodes with children', t => { + const oldSnapshot = createSnapshot( + createNode('1', [ + createNode('2'), + createNode('3', [createNode('4')]), + ]), + ); + const newSnapshot = createSnapshot(createNode('1', [createNode('2')])); + + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects modified nodes (attributes)', t => { + const oldSnapshot = createSnapshot( + createNode('1', [createNode('2', [], 'old')]), + ); + const newSnapshot = createSnapshot( + createNode('1', [createNode('2', [], 'new')]), + ); + + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + + it('detects reordering (as remove + add)', t => { + const oldSnapshot = createSnapshot( + createNode('1', [createNode('2'), createNode('3')]), + ); + const newSnapshot = createSnapshot( + createNode('1', [createNode('3'), createNode('2')]), + ); + const diff = SnapshotDiffFormatter.diff(oldSnapshot, newSnapshot); + // Re-order should not be detected as a change + // with justifications that re-orders on the sites seem + // less likely and the position of an element in a list + // is not as important for acting on the element. + const output = diff[method](); + t.assert.snapshot?.(JSON.stringify(output, null, 2)); + }); + }); + } +}); diff --git a/tests/utils/TreeDiff.test.ts b/tests/utils/TreeDiff.test.ts new file mode 100644 index 000000000..829bb7b74 --- /dev/null +++ b/tests/utils/TreeDiff.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {TreeDiff} from '../../src/utils/TreeDiff.js'; + +interface TestNode { + id: string; + children: TestNode[]; + val?: string; +} + +function node(id: string, children: TestNode[] = [], val?: string): TestNode { + return {id, children, val}; +} + +describe('TreeDiff', () => { + it('returns "same" for identical trees', () => { + const root = node('1', [node('2'), node('3')]); + const diff = TreeDiff.compute(root, root); + assert.strictEqual(diff.type, 'same'); + assert.strictEqual(diff.node, root); + assert.strictEqual(diff.children.length, 2); + assert.strictEqual(diff.children[0].type, 'same'); + assert.strictEqual(diff.children[1].type, 'same'); + }); + + it('returns "modified" (root swap) if root IDs differ', () => { + const oldRoot = node('1'); + const newRoot = node('2'); + const diff = TreeDiff.compute(oldRoot, newRoot); + assert.strictEqual(diff.type, 'modified'); + assert.strictEqual(diff.node, newRoot); + assert.strictEqual(diff.oldNode, oldRoot); + }); + + it('detects added children', () => { + const oldRoot = node('1', [node('2')]); + const newRoot = node('1', [node('2'), node('3')]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.type, 'same'); + assert.strictEqual(diff.children.length, 2); + + assert.strictEqual(diff.children[0].type, 'same'); + assert.strictEqual(diff.children[0].node.id, '2'); + + assert.strictEqual(diff.children[1].type, 'added'); + assert.strictEqual(diff.children[1].node.id, '3'); + }); + + it('detects removed children', () => { + const oldRoot = node('1', [node('2'), node('3')]); + const newRoot = node('1', [node('2')]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.type, 'same'); + assert.strictEqual(diff.children.length, 2); + + assert.strictEqual(diff.children[0].type, 'same'); + assert.strictEqual(diff.children[0].node.id, '2'); + + assert.strictEqual(diff.children[1].type, 'removed'); + assert.strictEqual(diff.children[1].node.id, '3'); + }); + + it('detects removal before match', () => { + const oldRoot = node('1', [node('2'), node('3')]); + const newRoot = node('1', [node('3')]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.children.length, 2); + assert.strictEqual(diff.children[0].type, 'removed'); + assert.strictEqual(diff.children[0].node.id, '2'); + + assert.strictEqual(diff.children[1].type, 'same'); + assert.strictEqual(diff.children[1].node.id, '3'); + }); + + it('detects reordering (A, B -> B, A)', () => { + const oldRoot = node('1', [node('A'), node('B')]); + const newRoot = node('1', [node('B'), node('A')]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.children.length, 2); + assert.strictEqual(diff.children[0].type, 'same'); + assert.strictEqual(diff.children[0].node.id, 'B'); + + assert.strictEqual(diff.children[1].type, 'same'); + assert.strictEqual(diff.children[1].node.id, 'A'); + }); + + it('recurses into children', () => { + // Old: 1(2(3)) + // New: 1(2(3, 4)) -> 4 added + const oldRoot = node('1', [node('2', [node('3')])]); + const newRoot = node('1', [node('2', [node('3'), node('4')])]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.type, 'same'); + const child2 = diff.children[0]; + assert.strictEqual(child2.type, 'same'); + assert.strictEqual(child2.children.length, 2); + assert.strictEqual(child2.children[0].type, 'same'); // 3 + assert.strictEqual(child2.children[1].type, 'added'); // 4 + }); + + it('marks entire subtree as added when parent is added', () => { + const oldRoot = node('1'); + const newRoot = node('1', [node('2', [node('3')])]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.children.length, 1); + const node2 = diff.children[0]; + assert.strictEqual(node2.type, 'added'); + assert.strictEqual(node2.node.id, '2'); + + assert.strictEqual(node2.children.length, 1); + const node3 = node2.children[0]; + assert.strictEqual(node3.type, 'added'); + assert.strictEqual(node3.node.id, '3'); + }); + + it('handles removed children deeply', () => { + const oldRoot = node('1', [node('2', [node('3')])]); + const newRoot = node('1', [node('2', [])]); + const diff = TreeDiff.compute(oldRoot, newRoot); + + const child2 = diff.children[0]; + assert.strictEqual(child2.children.length, 1); + assert.strictEqual(child2.children[0].type, 'removed'); + assert.strictEqual(child2.children[0].node.id, '3'); + }); + + it('marks entire subtree as removed when parent is removed', () => { + const oldRoot = node('1', [node('2', [node('3')])]); + const newRoot = node('1'); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.children.length, 1); + const node2 = diff.children[0]; + assert.strictEqual(node2.type, 'removed'); + assert.strictEqual(node2.node.id, '2'); + + assert.strictEqual(node2.children.length, 1); + const node3 = node2.children[0]; + assert.strictEqual(node3.type, 'removed'); + assert.strictEqual(node3.node.id, '3'); + }); + + it('does not compare other properties', () => { + const oldRoot = node('1', [], 'foo'); + const newRoot = node('1', [], 'bar'); + const diff = TreeDiff.compute(oldRoot, newRoot); + + assert.strictEqual(diff.type, 'same'); + assert.strictEqual(diff.node.val, 'bar'); + }); +});