/** * Laravel event-dispatch bridge (PHP). * * Laravel decouples an event dispatch from its listener(s), linked by the event class: * `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it * (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every * listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)` * (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an * EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS * dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only * `event(new X)` is matched and jobs are excluded. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { CodeGraph } from '../src'; describe('laravel-event synthesizer', () => { let dir: string; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); const write = (rel: string, body: string) => { const p = path.join(dir, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); }; it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => { for (const [name, body] of [ ['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'], ['LibraryChanged', ''], ['ScanDone', ''], ['OwnerTest', ''], ['UserTest', ''], ] as const) { write(`app/Events/${name}.php`, ` [ PruneLibrary::class, ], ]; } `); write('app/Services/SongService.php', ` edges.filter((r: any) => r.source === s); const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1]; expect(edges.length).toBe(6); expect(edges.every((r: any) => r.target === 'handle')).toBe(true); // (A) typed handle. expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]); // (B) untyped handle via the $listen map. expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]); // Fan-out: ScanDone → both listeners. expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale'])); // Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers, // so they aren't deduped to a single source→target edge). expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]); expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]); // PRECISION: a queued job (::dispatch / dispatch()) produces nothing. expect(edges.some((r: any) => r.source === 'process')).toBe(false); cg.close?.(); }); it('produces no edges in a PHP project with no Laravel events (clean control)', async () => { write('src/Client.php', `