import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../../manifest/schema'; import { DependencyCycleError, buildModuleGraph, levelsOf, topologicalOrder, transitiveConsumers, } from './dep-graph'; function makeManifest( id: string, opts: { provides?: string[]; requires?: string[]; optional?: string[]; } = {}, ): ModuleManifest { return { id, name: id, version: '1.0.0', celilo_contract: '1.0', provides: opts.provides ? { capabilities: opts.provides.map((name) => ({ name, version: '1.0.0', data: {}, functions: [], })), } : { capabilities: [] }, requires: opts.requires ? { capabilities: opts.requires.map((name) => ({ name, version: '1.0.0' })) } : { capabilities: [] }, optional: opts.optional ? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) } : undefined, } as unknown as ModuleManifest; } describe('buildModuleGraph', () => { test('empty input → empty graph', () => { const g = buildModuleGraph([]); expect(g.nodes.size).toBe(0); expect(g.edges.size).toBe(0); }); test('module with no deps → node with no edges', () => { const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]); expect(g.nodes.size).toBe(1); expect(g.edges.get('iptables')?.size).toBe(0); }); test('builds consumer → provider edge', () => { const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]); expect([...(g.edges.get('caddy') ?? [])]).toEqual(['iptables']); expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']); }); test('optional capabilities also create edges', () => { const g = buildModuleGraph([ makeManifest('namecheap', { provides: ['dns_registrar'] }), makeManifest('caddy', { optional: ['dns_registrar'] }), ]); expect([...(g.edges.get('caddy') ?? [])]).toEqual(['namecheap']); }); test('skips edges to missing providers (no provider for declared capability)', () => { const g = buildModuleGraph([makeManifest('caddy', { requires: ['nonexistent'] })]); expect(g.edges.get('caddy')?.size).toBe(0); }); test('skips self-edges when a module consumes its own capability', () => { const g = buildModuleGraph([makeManifest('weird', { provides: ['x'], requires: ['x'] })]); expect(g.edges.get('weird')?.size).toBe(0); }); // Previously asserted "first declared provider wins". That was the behaviour, // and it was a defect: `capability-loader.ts` resolves a `firewall` consumer // through `buildFirewallChain()`, which wires a chain across EVERY provider — // the edge device that owns egress plus the downstream layers. First-wins made // the others invisible, so `module pause --cascade greenwave` omitted `caddy` // even though caddy requires `firewall` and greenwave provides it, and the // remove guard (which reads all providers) then refused the removal the // cascade was supposed to enable. test('a consumer depends on EVERY provider of a capability, not just the first', () => { const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('greenwave', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]); expect([...(g.edges.get('caddy') ?? [])].sort()).toEqual(['greenwave', 'iptables']); }); test('and every provider sees that consumer in its reverse index', () => { // This is the half `transitiveConsumers` walks, so it is what decides // whether a cascade reaches the module at all. const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('greenwave', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]); expect([...(g.reverseEdges.get('greenwave') ?? [])]).toEqual(['caddy']); expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']); }); test('an optional consumer also edges to every provider', () => { const g = buildModuleGraph([ makeManifest('greenwave', { provides: ['dhcp_server'] }), makeManifest('router2', { provides: ['dhcp_server'] }), makeManifest('technitium', { optional: ['dhcp_server'] }), ]); expect([...(g.edges.get('technitium') ?? [])].sort()).toEqual(['greenwave', 'router2']); }); }); describe('topologicalOrder', () => { test('empty graph yields empty list', () => { expect(topologicalOrder(buildModuleGraph([]))).toEqual([]); }); test('places provider before consumer', () => { const g = buildModuleGraph([ makeManifest('caddy', { requires: ['firewall'] }), makeManifest('iptables', { provides: ['firewall'] }), ]); const order = topologicalOrder(g); expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy')); }); test('respects multi-level chain', () => { const g = buildModuleGraph([ makeManifest('namecheap', { provides: ['dns_registrar'] }), makeManifest('caddy', { requires: ['firewall', 'dns_registrar'], provides: ['public_web'] }), makeManifest('iptables', { provides: ['firewall'] }), makeManifest('lunacycle', { requires: ['public_web'] }), ]); const order = topologicalOrder(g); expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy')); expect(order.indexOf('namecheap')).toBeLessThan(order.indexOf('caddy')); expect(order.indexOf('caddy')).toBeLessThan(order.indexOf('lunacycle')); }); test('alphabetical tiebreak among siblings', () => { const g = buildModuleGraph([ makeManifest('zebra'), makeManifest('alpha'), makeManifest('mango'), ]); expect(topologicalOrder(g)).toEqual(['alpha', 'mango', 'zebra']); }); test('throws DependencyCycleError on a 2-cycle', () => { const g = buildModuleGraph([ makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }), makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }), ]); expect(() => topologicalOrder(g)).toThrow(DependencyCycleError); }); test('cycle error contains the cycle path', () => { const g = buildModuleGraph([ makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }), makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }), ]); try { topologicalOrder(g); throw new Error('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(DependencyCycleError); expect((err as DependencyCycleError).cycle.length).toBeGreaterThan(0); } }); }); describe('transitiveConsumers', () => { test('returns all downstream modules', () => { const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }), makeManifest('lunacycle', { requires: ['public_web'] }), makeManifest('celilo-website', { requires: ['public_web'] }), ]); expect(transitiveConsumers(g, 'iptables').sort()).toEqual([ 'caddy', 'celilo-website', 'lunacycle', ]); expect(transitiveConsumers(g, 'caddy').sort()).toEqual(['celilo-website', 'lunacycle']); expect(transitiveConsumers(g, 'lunacycle')).toEqual([]); }); test('returns empty for a module with no consumers', () => { const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]); expect(transitiveConsumers(g, 'iptables')).toEqual([]); }); test('returns empty for a module not in the graph', () => { const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]); expect(transitiveConsumers(g, 'nonexistent')).toEqual([]); }); }); describe('levelsOf', () => { test('flat dependency tree → all level 0', () => { const g = buildModuleGraph([makeManifest('a'), makeManifest('b'), makeManifest('c')]); const levels = levelsOf(g); expect(levels[0].sort()).toEqual(['a', 'b', 'c']); expect(levels.length).toBe(1); }); test('linear chain assigns level per depth', () => { const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }), makeManifest('lunacycle', { requires: ['public_web'] }), ]); const levels = levelsOf(g); expect(levels[0]).toEqual(['iptables']); expect(levels[1]).toEqual(['caddy']); expect(levels[2]).toEqual(['lunacycle']); }); test('module depending on multiple providers gets max(deps)+1', () => { const g = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('namecheap', { provides: ['dns_registrar'] }), makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }), makeManifest('mixed', { requires: ['public_web', 'dns_registrar'] }), ]); const levels = levelsOf(g); // iptables, namecheap → 0; caddy → 1; mixed → 2 (max of caddy:1, namecheap:0) expect(levels[0].sort()).toEqual(['iptables', 'namecheap']); expect(levels[1]).toEqual(['caddy']); expect(levels[2]).toEqual(['mixed']); }); });