sidekiq-dispatch-synthesizer.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * Sidekiq job-dispatch bridge (Ruby).
  3. *
  4. * Sidekiq decouples a job enqueue from the worker's `perform`, linked by the WORKER CLASS
  5. * NAME: `DestroyUserWorker.perform_async(id)` has no static edge to `DestroyUserWorker#perform`
  6. * (usually a different file). This bridges each `Worker.perform_async`/`.perform_in`/`.perform_at`
  7. * site to that worker's instance `perform`, gated on the class including `Sidekiq::Job`/`Worker`.
  8. * Covers both include aliases, the scheduled forms, namespace disambiguation (two `NotifyWorker`s
  9. * in different modules resolve to the right one by qualified name), and the precision boundary: a
  10. * non-worker class with a `perform`, and an ActiveJob `perform_later`, both produce no edge.
  11. */
  12. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  13. import * as fs from 'node:fs';
  14. import * as path from 'node:path';
  15. import * as os from 'node:os';
  16. import { CodeGraph } from '../src';
  17. describe('sidekiq-dispatch synthesizer', () => {
  18. let dir: string;
  19. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidekiq-dispatch-')); });
  20. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  21. const write = (rel: string, body: string) => {
  22. const p = path.join(dir, rel);
  23. fs.mkdirSync(path.dirname(p), { recursive: true });
  24. fs.writeFileSync(p, body);
  25. };
  26. it('bridges perform_async/_in to #perform, disambiguates namespaces, ignores non-workers and ActiveJob', async () => {
  27. write('app/workers/destroy_user_worker.rb', `class DestroyUserWorker
  28. include Sidekiq::Worker
  29. def perform(user_id)
  30. User.find(user_id).destroy!
  31. end
  32. end
  33. `);
  34. // Modern Sidekiq::Job alias + the scheduled form.
  35. write('app/workers/send_email_worker.rb', `class SendEmailWorker
  36. include Sidekiq::Job
  37. def perform(addr)
  38. end
  39. end
  40. `);
  41. // Namespace collision: two NotifyWorkers, same simple name, different modules.
  42. write('app/workers/comments/notify_worker.rb', `module Comments
  43. class NotifyWorker
  44. include Sidekiq::Job
  45. def perform(id)
  46. end
  47. end
  48. end
  49. `);
  50. write('app/workers/articles/notify_worker.rb', `module Articles
  51. class NotifyWorker
  52. include Sidekiq::Job
  53. def perform(id)
  54. end
  55. end
  56. end
  57. `);
  58. // A non-worker class that happens to have a `perform` method — never a target.
  59. write('app/services/report.rb', `class Report
  60. def perform(x)
  61. end
  62. end
  63. `);
  64. // An ActiveJob — dispatched via perform_later, a different shape, not matched.
  65. write('app/jobs/cleanup_job.rb', `class CleanupJob < ApplicationJob
  66. def perform
  67. end
  68. end
  69. `);
  70. write('app/services/user_service.rb', `class UserService
  71. def deactivate(user)
  72. DestroyUserWorker.perform_async(user.id)
  73. SendEmailWorker.perform_in(5, user.email)
  74. Comments::NotifyWorker.perform_async(1)
  75. Articles::NotifyWorker.perform_async(2)
  76. Report.perform_async(3)
  77. CleanupJob.perform_later
  78. end
  79. end
  80. `);
  81. const cg = await CodeGraph.init(dir, { silent: true });
  82. await cg.indexAll();
  83. const db = (cg as any).db.db;
  84. const edges = db
  85. .prepare(
  86. `SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
  87. FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  88. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`
  89. )
  90. .all();
  91. // Four enqueues bridge: both aliases, perform_async + perform_in, two namespaced.
  92. expect(edges.map((r: any) => r.via).sort()).toEqual([
  93. 'Articles::NotifyWorker', 'Comments::NotifyWorker', 'DestroyUserWorker', 'SendEmailWorker',
  94. ]);
  95. expect(edges.every((r: any) => r.target === 'perform' && r.source === 'deactivate')).toBe(true);
  96. // Namespace disambiguation: each NotifyWorker hits its OWN module's file, not the other.
  97. expect(edges.find((r: any) => r.via === 'Comments::NotifyWorker').tf).toMatch(/comments[\\/]notify_worker\.rb$/);
  98. expect(edges.find((r: any) => r.via === 'Articles::NotifyWorker').tf).toMatch(/articles[\\/]notify_worker\.rb$/);
  99. // PRECISION: a non-worker `perform`, and ActiveJob `perform_later`, contribute nothing.
  100. expect(edges.some((r: any) => r.via === 'Report')).toBe(false);
  101. expect(edges.some((r: any) => /Cleanup/.test(r.via))).toBe(false);
  102. cg.close?.();
  103. });
  104. it('produces no edges in a Ruby project with no Sidekiq (clean control)', async () => {
  105. write('lib/calc.rb', `class Calc
  106. def add(a, b)
  107. a + b
  108. end
  109. end
  110. `);
  111. const cg = await CodeGraph.init(dir, { silent: true });
  112. await cg.indexAll();
  113. const db = (cg as any).db.db;
  114. const count = db
  115. .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`)
  116. .get();
  117. expect(count.c).toBe(0);
  118. cg.close?.();
  119. });
  120. });