324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
|
|
const assert = require('assert');
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const { execSync } = require('child_process');
|
||
|
|
const GraphStore = require('../graph');
|
||
|
|
|
||
|
|
let passed = 0;
|
||
|
|
let total = 0;
|
||
|
|
|
||
|
|
function runTest(name, fn) {
|
||
|
|
total++;
|
||
|
|
try {
|
||
|
|
fn();
|
||
|
|
console.log(`✅ PASS ${name}`);
|
||
|
|
passed++;
|
||
|
|
} catch (err) {
|
||
|
|
console.log(`❌ FAIL ${name}: ${err.message}`);
|
||
|
|
// console.error(err.stack); // uncomment if debugging is needed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper to create mock extract results
|
||
|
|
const mockFile1 = {
|
||
|
|
file: 'file1.ts',
|
||
|
|
language: 'typescript',
|
||
|
|
entities: [
|
||
|
|
{ id: 'file1.ts', type: 'Module', name: 'file1.ts', visibility: 'public' },
|
||
|
|
{ id: 'func1', type: 'Function', name: 'func1', visibility: 'public' }
|
||
|
|
],
|
||
|
|
relationships: [
|
||
|
|
{ type: 'CONTAINS', source: 'file1.ts', target: 'func1' },
|
||
|
|
{ type: 'CALLS', source: 'func1', target: 'func2' }
|
||
|
|
]
|
||
|
|
};
|
||
|
|
|
||
|
|
const mockFile2 = {
|
||
|
|
file: 'file2.ts',
|
||
|
|
language: 'typescript',
|
||
|
|
entities: [
|
||
|
|
{ id: 'file2.ts', type: 'Module', name: 'file2.ts', visibility: 'public' },
|
||
|
|
{ id: 'func2', type: 'Function', name: 'func2', visibility: 'private' }
|
||
|
|
],
|
||
|
|
relationships: [
|
||
|
|
{ type: 'CONTAINS', source: 'file2.ts', target: 'func2' },
|
||
|
|
{ type: 'IMPORTS', source: 'file2.ts', target: 'file1.ts' },
|
||
|
|
{ type: 'CALLS', source: 'func2', target: 'func1' },
|
||
|
|
// Duplicate edge from file1 to test deduplication
|
||
|
|
{ type: 'CALLS', source: 'func1', target: 'func2' }
|
||
|
|
]
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- 1. buildGraph() ---
|
||
|
|
|
||
|
|
runTest('buildGraph: Empty input (no results) -> empty graph', () => {
|
||
|
|
const graph = GraphStore.buildGraph([]);
|
||
|
|
assert.strictEqual(graph.nodes.size, 0);
|
||
|
|
assert.strictEqual(graph.edges.length, 0);
|
||
|
|
assert.strictEqual(graph.fileIndex.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('buildGraph: Single file extraction -> correct node count, edge count', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1]);
|
||
|
|
assert.strictEqual(graph.nodes.size, 2);
|
||
|
|
assert.strictEqual(graph.edges.length, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('buildGraph: Multiple file extractions -> merges correctly, no duplicate nodes', () => {
|
||
|
|
// Pass mockFile1 twice to test node deduplication by ID Map
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2, mockFile1]);
|
||
|
|
assert.strictEqual(graph.nodes.size, 4); // file1.ts, func1, file2.ts, func2
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('buildGraph: Duplicate edges from multiple files are deduplicated', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
// Expected edges:
|
||
|
|
// file1 CONTAINS func1
|
||
|
|
// func1 CALLS func2
|
||
|
|
// file2 CONTAINS func2
|
||
|
|
// file2 IMPORTS file1
|
||
|
|
// func2 CALLS func1
|
||
|
|
// The second func1 CALLS func2 in mockFile2 should be ignored
|
||
|
|
assert.strictEqual(graph.edges.length, 5);
|
||
|
|
const callsFunc2 = graph.edges.filter(e => e.source === 'func1' && e.target === 'func2' && e.type === 'CALLS');
|
||
|
|
assert.strictEqual(callsFunc2.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('buildGraph: fileIndex is correctly populated', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
assert.strictEqual(graph.fileIndex.size, 2);
|
||
|
|
assert.ok(graph.fileIndex.get('file1.ts').has('func1'));
|
||
|
|
assert.ok(graph.fileIndex.get('file2.ts').has('func2'));
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 2. saveSnapshot() / loadSnapshot() ---
|
||
|
|
|
||
|
|
const SNAPSHOT_PATH = path.join(__dirname, 'test-snapshot.json');
|
||
|
|
|
||
|
|
runTest('saveSnapshot/loadSnapshot: Round-trip -> verify nodes, edges, fileIndex match exactly', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
GraphStore.saveSnapshot(graph, SNAPSHOT_PATH);
|
||
|
|
|
||
|
|
const loaded = GraphStore.loadSnapshot(SNAPSHOT_PATH);
|
||
|
|
assert.strictEqual(loaded.nodes.size, graph.nodes.size);
|
||
|
|
assert.strictEqual(loaded.edges.length, graph.edges.length);
|
||
|
|
assert.strictEqual(loaded.fileIndex.size, graph.fileIndex.size);
|
||
|
|
|
||
|
|
assert.deepStrictEqual(loaded.nodes.get('func1'), graph.nodes.get('func1'));
|
||
|
|
assert.deepStrictEqual(loaded.edges, graph.edges);
|
||
|
|
assert.deepStrictEqual(Array.from(loaded.fileIndex.get('file1.ts')), Array.from(graph.fileIndex.get('file1.ts')));
|
||
|
|
|
||
|
|
if (fs.existsSync(SNAPSHOT_PATH)) fs.unlinkSync(SNAPSHOT_PATH);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('saveSnapshot/loadSnapshot: Save creates valid JSON file', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1]);
|
||
|
|
GraphStore.saveSnapshot(graph, SNAPSHOT_PATH);
|
||
|
|
|
||
|
|
const content = fs.readFileSync(SNAPSHOT_PATH, 'utf8');
|
||
|
|
assert.doesNotThrow(() => JSON.parse(content));
|
||
|
|
|
||
|
|
if (fs.existsSync(SNAPSHOT_PATH)) fs.unlinkSync(SNAPSHOT_PATH);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('saveSnapshot/loadSnapshot: Load from non-existent file throws', () => {
|
||
|
|
assert.throws(() => {
|
||
|
|
GraphStore.loadSnapshot('does-not-exist.json');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 3. query() ---
|
||
|
|
|
||
|
|
runTest('query: Query existing entity -> returns entity + correct incoming/outgoing edges', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const result = GraphStore.query(graph, 'func1');
|
||
|
|
|
||
|
|
assert.ok(result);
|
||
|
|
assert.strictEqual(result.entity.id, 'func1');
|
||
|
|
|
||
|
|
// Incoming: file1 CONTAINS func1, func2 CALLS func1
|
||
|
|
assert.strictEqual(result.incoming.length, 2);
|
||
|
|
|
||
|
|
// Outgoing: func1 CALLS func2
|
||
|
|
assert.strictEqual(result.outgoing.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('query: Query non-existent entity -> returns null', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const result = GraphStore.query(graph, 'non-existent');
|
||
|
|
assert.strictEqual(result, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('query: Entity with no edges -> returns entity with empty incoming/outgoing arrays', () => {
|
||
|
|
const isolatedEntity = {
|
||
|
|
file: 'iso.ts',
|
||
|
|
entities: [{ id: 'iso', type: 'Module' }],
|
||
|
|
relationships: []
|
||
|
|
};
|
||
|
|
const graph = GraphStore.buildGraph([isolatedEntity]);
|
||
|
|
const result = GraphStore.query(graph, 'iso');
|
||
|
|
|
||
|
|
assert.ok(result);
|
||
|
|
assert.strictEqual(result.incoming.length, 0);
|
||
|
|
assert.strictEqual(result.outgoing.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 4. findCallers() ---
|
||
|
|
|
||
|
|
runTest('findCallers: Function with multiple callers -> returns all', () => {
|
||
|
|
const multiCall = {
|
||
|
|
file: 'multi.ts',
|
||
|
|
entities: [{ id: 'target' }, { id: 'c1' }, { id: 'c2' }],
|
||
|
|
relationships: [
|
||
|
|
{ type: 'CALLS', source: 'c1', target: 'target' },
|
||
|
|
{ type: 'CALLS', source: 'c2', target: 'target' }
|
||
|
|
]
|
||
|
|
};
|
||
|
|
const graph = GraphStore.buildGraph([multiCall]);
|
||
|
|
const callers = GraphStore.findCallers(graph, 'target');
|
||
|
|
assert.strictEqual(callers.length, 2);
|
||
|
|
const ids = callers.map(c => c.id);
|
||
|
|
assert.ok(ids.includes('c1') && ids.includes('c2'));
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('findCallers: Function with no callers -> returns empty array', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const callers = GraphStore.findCallers(graph, 'func1');
|
||
|
|
assert.strictEqual(callers.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('findCallers: Only returns CALLS edges, not CONTAINS or IMPORTS', () => {
|
||
|
|
const mixedEdges = {
|
||
|
|
file: 'mixed.ts',
|
||
|
|
entities: [{ id: 'target' }, { id: 'c1' }, { id: 'c2' }],
|
||
|
|
relationships: [
|
||
|
|
{ type: 'CALLS', source: 'c1', target: 'target' },
|
||
|
|
{ type: 'CONTAINS', source: 'c2', target: 'target' },
|
||
|
|
{ type: 'IMPORTS', source: 'c2', target: 'target' }
|
||
|
|
]
|
||
|
|
};
|
||
|
|
const graph = GraphStore.buildGraph([mixedEdges]);
|
||
|
|
const callers = GraphStore.findCallers(graph, 'target');
|
||
|
|
assert.strictEqual(callers.length, 1);
|
||
|
|
assert.strictEqual(callers[0].id, 'c1');
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 5. findDependents() ---
|
||
|
|
|
||
|
|
runTest('findDependents: Module with dependents -> returns all', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const dependents = GraphStore.findDependents(graph, 'file1.ts');
|
||
|
|
assert.strictEqual(dependents.length, 1);
|
||
|
|
assert.strictEqual(dependents[0].id, 'file2.ts');
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('findDependents: Module with no dependents -> returns empty array', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const dependents = GraphStore.findDependents(graph, 'file2.ts');
|
||
|
|
assert.strictEqual(dependents.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 6. getExports() ---
|
||
|
|
|
||
|
|
runTest('getExports: File with mix of public/private entities -> returns only public', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const exportsFile1 = GraphStore.getExports(graph, 'file1.ts');
|
||
|
|
assert.strictEqual(exportsFile1.length, 2); // Both file1.ts and func1 are public
|
||
|
|
|
||
|
|
const exportsFile2 = GraphStore.getExports(graph, 'file2.ts');
|
||
|
|
assert.strictEqual(exportsFile2.length, 1); // Only file2.ts is public, func2 is private
|
||
|
|
assert.strictEqual(exportsFile2[0].id, 'file2.ts');
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('getExports: Non-existent file -> returns empty array', () => {
|
||
|
|
const graph = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const exports = GraphStore.getExports(graph, 'missing.ts');
|
||
|
|
assert.strictEqual(exports.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 7. diffSnapshots() ---
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Identical graphs -> empty diff', () => {
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
assert.strictEqual(diff.entities.added.length, 0);
|
||
|
|
assert.strictEqual(diff.entities.removed.length, 0);
|
||
|
|
assert.strictEqual(diff.entities.modified.length, 0);
|
||
|
|
assert.strictEqual(diff.relationships.added.length, 0);
|
||
|
|
assert.strictEqual(diff.relationships.removed.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Added entities -> appear in diff.entities.added', () => {
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
assert.strictEqual(diff.entities.added.length, 2); // file2.ts, func2
|
||
|
|
const ids = diff.entities.added.map(e => e.id);
|
||
|
|
assert.ok(ids.includes('file2.ts') && ids.includes('func2'));
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Removed entities -> appear in diff.entities.removed', () => {
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
assert.strictEqual(diff.entities.removed.length, 2);
|
||
|
|
const ids = diff.entities.removed.map(e => e.id);
|
||
|
|
assert.ok(ids.includes('file2.ts') && ids.includes('func2'));
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Modified entities (e.g. changed line_range) -> appear in diff.entities.modified', () => {
|
||
|
|
const mockFile1Mod = JSON.parse(JSON.stringify(mockFile1));
|
||
|
|
mockFile1Mod.entities[1].line_range = [10, 20];
|
||
|
|
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1Mod]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
assert.strictEqual(diff.entities.modified.length, 1);
|
||
|
|
assert.strictEqual(diff.entities.modified[0].old.id, 'func1');
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Added relationships -> appear in diff.relationships.added', () => {
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
// file2 relationships will be added (3 of them: CONTAINS, IMPORTS, CALLS)
|
||
|
|
assert.strictEqual(diff.relationships.added.length, 3);
|
||
|
|
});
|
||
|
|
|
||
|
|
runTest('diffSnapshots: Removed relationships -> appear in diff.relationships.removed', () => {
|
||
|
|
const graph1 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||
|
|
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||
|
|
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||
|
|
|
||
|
|
assert.strictEqual(diff.relationships.removed.length, 3);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 8. Integration test ---
|
||
|
|
|
||
|
|
runTest('Integration test: extract.js on mask-api-key.ts -> buildGraph -> query', () => {
|
||
|
|
const extractCmd = `node ${path.join(__dirname, '../extract.js')} /app/src/utils/mask-api-key.ts /app/src`;
|
||
|
|
const output = execSync(extractCmd, { encoding: 'utf8' });
|
||
|
|
const resultObj = JSON.parse(output);
|
||
|
|
|
||
|
|
const graph = GraphStore.buildGraph([resultObj]);
|
||
|
|
const entityId = 'utils/mask-api-key.ts:maskApiKey';
|
||
|
|
|
||
|
|
const queryResult = GraphStore.query(graph, entityId);
|
||
|
|
assert.ok(queryResult);
|
||
|
|
assert.strictEqual(queryResult.entity.name, 'maskApiKey');
|
||
|
|
assert.strictEqual(queryResult.outgoing.length, 2); // CALLS value.trim, CALLS trimmed.slice
|
||
|
|
|
||
|
|
const callTargets = queryResult.outgoing.map(e => e.target);
|
||
|
|
assert.ok(callTargets.includes('value.trim'));
|
||
|
|
assert.ok(callTargets.includes('trimmed.slice'));
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log(`\n${passed}/${total} tests passed.`);
|
||
|
|
if (passed !== total) process.exit(1);
|