1
0

drupal.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. /**
  2. * Tests for Drupal framework resolver.
  3. *
  4. * Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve().
  5. * Integration tests use a real CodeGraph instance with a temporary Drupal project layout.
  6. */
  7. import * as fs from 'fs';
  8. import * as os from 'os';
  9. import * as path from 'path';
  10. import { afterEach, beforeAll, describe, expect, it } from 'vitest';
  11. import { CodeGraph } from '../src';
  12. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  13. import { drupalResolver } from '../src/resolution/frameworks/drupal';
  14. import type { ResolutionContext } from '../src/resolution/types';
  15. // ---------------------------------------------------------------------------
  16. // Helpers
  17. // ---------------------------------------------------------------------------
  18. function makeContext(
  19. overrides: Partial<ResolutionContext> = {},
  20. ): ResolutionContext {
  21. return {
  22. getNodesInFile: () => [],
  23. getNodesByName: () => [],
  24. getNodesByQualifiedName: () => [],
  25. getNodesByKind: () => [],
  26. fileExists: () => false,
  27. readFile: () => null,
  28. getProjectRoot: () => '/project',
  29. getAllFiles: () => [],
  30. getNodesByLowerName: () => [],
  31. getImportMappings: () => [],
  32. ...overrides,
  33. };
  34. }
  35. // ---------------------------------------------------------------------------
  36. // detect()
  37. // ---------------------------------------------------------------------------
  38. describe('drupalResolver.detect', () => {
  39. it('returns true when composer.json has a drupal/ dependency', () => {
  40. const ctx = makeContext({
  41. readFile: (f) =>
  42. f === 'composer.json'
  43. ? JSON.stringify({
  44. require: {
  45. 'drupal/core-recommended': '~10.5',
  46. 'drush/drush': '^13',
  47. },
  48. })
  49. : null,
  50. });
  51. expect(drupalResolver.detect(ctx)).toBe(true);
  52. });
  53. it('returns true when drupal/ dependency is in require-dev', () => {
  54. const ctx = makeContext({
  55. readFile: (f) =>
  56. f === 'composer.json'
  57. ? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } })
  58. : null,
  59. });
  60. expect(drupalResolver.detect(ctx)).toBe(true);
  61. });
  62. it('returns false when composer.json has no drupal/ dependencies', () => {
  63. const ctx = makeContext({
  64. readFile: (f) =>
  65. f === 'composer.json'
  66. ? JSON.stringify({
  67. require: { 'laravel/framework': '^10', php: '>=8.1' },
  68. })
  69. : null,
  70. });
  71. expect(drupalResolver.detect(ctx)).toBe(false);
  72. });
  73. it('returns false when composer.json is absent', () => {
  74. const ctx = makeContext({ readFile: () => null });
  75. expect(drupalResolver.detect(ctx)).toBe(false);
  76. });
  77. it('returns false when composer.json is malformed JSON', () => {
  78. const ctx = makeContext({ readFile: () => '{ bad json' });
  79. expect(drupalResolver.detect(ctx)).toBe(false);
  80. });
  81. it('returns true for a contrib module with empty require (composer name/type)', () => {
  82. const ctx = makeContext({
  83. readFile: (f) =>
  84. f === 'composer.json'
  85. ? JSON.stringify({
  86. name: 'drupal/admin_toolbar',
  87. type: 'drupal-module',
  88. require: {},
  89. })
  90. : null,
  91. });
  92. expect(drupalResolver.detect(ctx)).toBe(true);
  93. });
  94. it('returns true via the *.info.yml fallback when composer.json is absent', () => {
  95. const ctx = makeContext({
  96. readFile: () => null,
  97. getAllFiles: () => [
  98. 'mymodule/mymodule.info.yml',
  99. 'mymodule/mymodule.routing.yml',
  100. ],
  101. });
  102. expect(drupalResolver.detect(ctx)).toBe(true);
  103. });
  104. it('returns false for a stray *.info.yml with no Drupal PHP/route file', () => {
  105. const ctx = makeContext({
  106. readFile: () => null,
  107. getAllFiles: () => ['some/unrelated.info.yml'],
  108. });
  109. expect(drupalResolver.detect(ctx)).toBe(false);
  110. });
  111. });
  112. describe('drupalResolver.claimsReference', () => {
  113. it('claims FQCN handler refs and hook names the pre-filter would drop', () => {
  114. expect(drupalResolver.claimsReference!('\\Drupal\\m\\Form\\SettingsForm')).toBe(true);
  115. expect(drupalResolver.claimsReference!('\\Drupal\\m\\Controller\\C:setNoJsCookie')).toBe(true);
  116. expect(drupalResolver.claimsReference!('hook_form_alter')).toBe(true);
  117. });
  118. it('does not claim ordinary identifiers or entity-handler dotted refs', () => {
  119. expect(drupalResolver.claimsReference!('someHelperFunction')).toBe(false);
  120. expect(drupalResolver.claimsReference!('comment.default')).toBe(false);
  121. });
  122. });
  123. // ---------------------------------------------------------------------------
  124. // extract() — routing.yml
  125. // ---------------------------------------------------------------------------
  126. describe('drupalResolver.extract — routing.yml', () => {
  127. const routing = `
  128. mymodule.example:
  129. path: '/mymodule/example'
  130. defaults:
  131. _controller: '\\Drupal\\mymodule\\Controller\\MyController::build'
  132. _title: 'Example page'
  133. requirements:
  134. _permission: 'access content'
  135. `;
  136. it('emits a route node for each YAML route', () => {
  137. const { nodes } = drupalResolver.extract!(
  138. 'mymodule/mymodule.routing.yml',
  139. routing,
  140. );
  141. expect(nodes).toHaveLength(1);
  142. expect(nodes[0]!.kind).toBe('route');
  143. expect(nodes[0]!.name).toBe('/mymodule/example');
  144. });
  145. it('sets qualifiedName to filePath::routeName', () => {
  146. const { nodes } = drupalResolver.extract!(
  147. 'mymodule/mymodule.routing.yml',
  148. routing,
  149. );
  150. expect(nodes[0]!.qualifiedName).toBe(
  151. 'mymodule/mymodule.routing.yml::mymodule.example',
  152. );
  153. });
  154. it('emits a references edge to the controller FQCN', () => {
  155. const { references } = drupalResolver.extract!(
  156. 'mymodule/mymodule.routing.yml',
  157. routing,
  158. );
  159. expect(references).toHaveLength(1);
  160. expect(references[0]!.referenceName).toBe(
  161. '\\Drupal\\mymodule\\Controller\\MyController::build',
  162. );
  163. expect(references[0]!.referenceKind).toBe('references');
  164. });
  165. it('emits a references edge to a _form handler', () => {
  166. const src = `
  167. mymodule.settings_form:
  168. path: '/admin/config/mymodule'
  169. defaults:
  170. _form: '\\Drupal\\mymodule\\Form\\SettingsForm'
  171. _title: 'MyModule settings'
  172. requirements:
  173. _permission: 'administer site configuration'
  174. `;
  175. const { nodes, references } = drupalResolver.extract!(
  176. 'mymodule/mymodule.routing.yml',
  177. src,
  178. );
  179. expect(nodes).toHaveLength(1);
  180. expect(references[0]!.referenceName).toBe(
  181. '\\Drupal\\mymodule\\Form\\SettingsForm',
  182. );
  183. });
  184. it('handles multiple routes in one file', () => {
  185. const src = `
  186. mod.page_one:
  187. path: '/page-one'
  188. defaults:
  189. _controller: '\\Drupal\\mod\\Controller\\PageController::one'
  190. requirements:
  191. _permission: 'access content'
  192. mod.page_two:
  193. path: '/page-two'
  194. defaults:
  195. _controller: '\\Drupal\\mod\\Controller\\PageController::two'
  196. requirements:
  197. _permission: 'access content'
  198. `;
  199. const { nodes, references } = drupalResolver.extract!(
  200. 'mod/mod.routing.yml',
  201. src,
  202. );
  203. expect(nodes).toHaveLength(2);
  204. expect(nodes.map((n) => n.name)).toContain('/page-one');
  205. expect(nodes.map((n) => n.name)).toContain('/page-two');
  206. expect(references).toHaveLength(2);
  207. });
  208. it('skips commented-out lines', () => {
  209. const src = `
  210. mod.page:
  211. path: '/page'
  212. defaults:
  213. #_controller: '\\Drupal\\mod\\Controller\\Old::build'
  214. _controller: '\\Drupal\\mod\\Controller\\New::build'
  215. requirements:
  216. _permission: 'access content'
  217. `;
  218. const { references } = drupalResolver.extract!('mod/mod.routing.yml', src);
  219. expect(references).toHaveLength(1);
  220. expect(references[0]!.referenceName).toContain('New');
  221. });
  222. it('includes HTTP methods in the route node name when present', () => {
  223. const src = `
  224. mod.api:
  225. path: '/api/resource'
  226. defaults:
  227. _controller: '\\Drupal\\mod\\Controller\\ApiController::get'
  228. methods: [GET, POST]
  229. requirements:
  230. _permission: 'access content'
  231. `;
  232. const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src);
  233. expect(nodes[0]!.name).toContain('GET');
  234. expect(nodes[0]!.name).toContain('POST');
  235. });
  236. it('returns empty result for non-routing-yml files', () => {
  237. const { nodes, references } = drupalResolver.extract!(
  238. 'mymodule.module',
  239. '<?php\n',
  240. );
  241. // Module files go through hook detection, not route extraction
  242. expect(nodes).toHaveLength(0);
  243. });
  244. it('returns empty result for files with no valid routes', () => {
  245. const { nodes, references } = drupalResolver.extract!(
  246. 'some.routing.yml',
  247. '# empty\n',
  248. );
  249. expect(nodes).toHaveLength(0);
  250. expect(references).toHaveLength(0);
  251. });
  252. });
  253. // ---------------------------------------------------------------------------
  254. // extract() — hook detection in .module files
  255. // ---------------------------------------------------------------------------
  256. describe('drupalResolver.extract — hook detection', () => {
  257. it('detects hook implementation via docblock (Strategy A)', () => {
  258. const src = `<?php
  259. /**
  260. * Implements hook_form_alter().
  261. */
  262. function mymodule_form_alter(&$form, $form_state, $form_id) {
  263. // ...
  264. }
  265. `;
  266. const { references } = drupalResolver.extract!(
  267. 'web/modules/custom/mymodule/mymodule.module',
  268. src,
  269. );
  270. const hookRef = references.find(
  271. (r) => r.referenceName === 'hook_form_alter',
  272. );
  273. expect(hookRef).toBeDefined();
  274. expect(hookRef!.referenceKind).toBe('references');
  275. });
  276. it('detects hook implementation via name pattern (Strategy B)', () => {
  277. const src = `<?php
  278. function mymodule_views_data() {
  279. return [];
  280. }
  281. `;
  282. const { references } = drupalResolver.extract!(
  283. 'web/modules/custom/mymodule/mymodule.module',
  284. src,
  285. );
  286. const hookRef = references.find(
  287. (r) => r.referenceName === 'hook_views_data',
  288. );
  289. expect(hookRef).toBeDefined();
  290. });
  291. it('does not emit a hook ref for non-hook helper functions', () => {
  292. // 'other_module_helper' doesn't start with 'mymodule_', so no hook ref
  293. const src = `<?php
  294. function other_module_helper() {}
  295. `;
  296. const { references } = drupalResolver.extract!(
  297. 'web/modules/custom/mymodule/mymodule.module',
  298. src,
  299. );
  300. expect(references).toHaveLength(0);
  301. });
  302. it('detects hooks in .install files', () => {
  303. const src = `<?php
  304. /**
  305. * Implements hook_schema().
  306. */
  307. function mymodule_schema() {
  308. return [];
  309. }
  310. `;
  311. const { references } = drupalResolver.extract!(
  312. 'web/modules/custom/mymodule/mymodule.install',
  313. src,
  314. );
  315. const hookRef = references.find((r) => r.referenceName === 'hook_schema');
  316. expect(hookRef).toBeDefined();
  317. });
  318. it('detects hooks in .theme files', () => {
  319. const src = `<?php
  320. /**
  321. * Implements hook_preprocess_node().
  322. */
  323. function mytheme_preprocess_node(&$variables) {}
  324. `;
  325. const { references } = drupalResolver.extract!(
  326. 'web/themes/custom/mytheme/mytheme.theme',
  327. src,
  328. );
  329. const hookRef = references.find(
  330. (r) => r.referenceName === 'hook_preprocess_node',
  331. );
  332. expect(hookRef).toBeDefined();
  333. });
  334. it('does not duplicate refs when both docblock and name pattern match', () => {
  335. // Strategy A matches first and adds to docblockMatched set;
  336. // Strategy B skips already-matched functions.
  337. const src = `<?php
  338. /**
  339. * Implements hook_form_alter().
  340. */
  341. function mymodule_form_alter(&$form, $form_state, $form_id) {}
  342. `;
  343. const { references } = drupalResolver.extract!(
  344. 'web/modules/custom/mymodule/mymodule.module',
  345. src,
  346. );
  347. const hookRefs = references.filter(
  348. (r) => r.referenceName === 'hook_form_alter',
  349. );
  350. expect(hookRefs).toHaveLength(1);
  351. });
  352. });
  353. // ---------------------------------------------------------------------------
  354. // resolve()
  355. // ---------------------------------------------------------------------------
  356. describe('drupalResolver.resolve', () => {
  357. it('resolves a _controller FQCN with ::method to the method node', () => {
  358. const methodNode = {
  359. id: 'method:abc123',
  360. kind: 'method' as const,
  361. name: 'build',
  362. qualifiedName: 'MyController::build',
  363. filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
  364. language: 'php' as const,
  365. startLine: 10,
  366. endLine: 20,
  367. startColumn: 0,
  368. endColumn: 0,
  369. updatedAt: 0,
  370. };
  371. const classNode = {
  372. id: 'class:def456',
  373. kind: 'class' as const,
  374. name: 'MyController',
  375. qualifiedName: 'MyController',
  376. filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
  377. language: 'php' as const,
  378. startLine: 5,
  379. endLine: 30,
  380. startColumn: 0,
  381. endColumn: 0,
  382. updatedAt: 0,
  383. };
  384. const ctx = makeContext({
  385. getNodesByName: (name) => (name === 'MyController' ? [classNode] : []),
  386. getNodesInFile: () => [classNode, methodNode],
  387. });
  388. const ref = {
  389. fromNodeId: 'route:x',
  390. referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build',
  391. referenceKind: 'references' as const,
  392. line: 1,
  393. column: 0,
  394. filePath: 'mymodule.routing.yml',
  395. language: 'yaml' as const,
  396. };
  397. const resolved = drupalResolver.resolve(ref, ctx);
  398. expect(resolved).not.toBeNull();
  399. expect(resolved!.targetNodeId).toBe('method:abc123');
  400. expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85);
  401. });
  402. it('resolves a _form FQCN (no ::method) to the class node', () => {
  403. const classNode = {
  404. id: 'class:form123',
  405. kind: 'class' as const,
  406. name: 'SettingsForm',
  407. qualifiedName: 'SettingsForm',
  408. filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php',
  409. language: 'php' as const,
  410. startLine: 1,
  411. endLine: 50,
  412. startColumn: 0,
  413. endColumn: 0,
  414. updatedAt: 0,
  415. };
  416. const ctx = makeContext({
  417. getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []),
  418. });
  419. const ref = {
  420. fromNodeId: 'route:x',
  421. referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm',
  422. referenceKind: 'references' as const,
  423. line: 1,
  424. column: 0,
  425. filePath: 'mymodule.routing.yml',
  426. language: 'yaml' as const,
  427. };
  428. const resolved = drupalResolver.resolve(ref, ctx);
  429. expect(resolved).not.toBeNull();
  430. expect(resolved!.targetNodeId).toBe('class:form123');
  431. });
  432. it('returns null when the target class cannot be found', () => {
  433. const ctx = makeContext({ getNodesByName: () => [] });
  434. const ref = {
  435. fromNodeId: 'route:x',
  436. referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method',
  437. referenceKind: 'references' as const,
  438. line: 1,
  439. column: 0,
  440. filePath: 'mymodule.routing.yml',
  441. language: 'yaml' as const,
  442. };
  443. expect(drupalResolver.resolve(ref, ctx)).toBeNull();
  444. });
  445. it('resolves a single-colon controller-service ref (Class:method)', () => {
  446. const methodNode = {
  447. id: 'method:nojs1',
  448. kind: 'method' as const,
  449. name: 'setNoJsCookie',
  450. qualifiedName: 'BigPipeController::setNoJsCookie',
  451. filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
  452. language: 'php' as const,
  453. startLine: 10,
  454. endLine: 20,
  455. startColumn: 0,
  456. endColumn: 0,
  457. updatedAt: 0,
  458. };
  459. const classNode = {
  460. id: 'class:nojs2',
  461. kind: 'class' as const,
  462. name: 'BigPipeController',
  463. qualifiedName: 'BigPipeController',
  464. filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
  465. language: 'php' as const,
  466. startLine: 5,
  467. endLine: 30,
  468. startColumn: 0,
  469. endColumn: 0,
  470. updatedAt: 0,
  471. };
  472. const ctx = makeContext({
  473. getNodesByName: (name) => (name === 'BigPipeController' ? [classNode] : []),
  474. getNodesInFile: () => [classNode, methodNode],
  475. });
  476. const ref = {
  477. fromNodeId: 'route:x',
  478. referenceName: '\\Drupal\\big_pipe\\Controller\\BigPipeController:setNoJsCookie',
  479. referenceKind: 'references' as const,
  480. line: 1,
  481. column: 0,
  482. filePath: 'big_pipe.routing.yml',
  483. language: 'yaml' as const,
  484. };
  485. const resolved = drupalResolver.resolve(ref, ctx);
  486. expect(resolved).not.toBeNull();
  487. expect(resolved!.targetNodeId).toBe('method:nojs1');
  488. });
  489. });
  490. // ---------------------------------------------------------------------------
  491. // End-to-end integration test
  492. // ---------------------------------------------------------------------------
  493. beforeAll(async () => {
  494. await initGrammars();
  495. await loadAllGrammars();
  496. });
  497. describe('Drupal end-to-end — route node linked to controller method', () => {
  498. let tmpDir: string | undefined;
  499. afterEach(() => {
  500. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  501. tmpDir = undefined;
  502. });
  503. it('creates a route→controller edge from routing.yml to PHP class', async () => {
  504. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-'));
  505. // Minimal composer.json to trigger Drupal detection
  506. fs.writeFileSync(
  507. path.join(tmpDir, 'composer.json'),
  508. JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }),
  509. );
  510. // Module directory structure
  511. const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module');
  512. fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true });
  513. // routing.yml
  514. fs.writeFileSync(
  515. path.join(modDir, 'my_module.routing.yml'),
  516. [
  517. 'my_module.hello:',
  518. " path: '/hello'",
  519. ' defaults:',
  520. " _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'",
  521. " _title: 'Hello'",
  522. ' requirements:',
  523. " _permission: 'access content'",
  524. ].join('\n') + '\n',
  525. );
  526. // PHP controller
  527. fs.writeFileSync(
  528. path.join(modDir, 'src', 'Controller', 'HelloController.php'),
  529. [
  530. '<?php',
  531. 'namespace Drupal\\my_module\\Controller;',
  532. 'use Drupal\\Core\\Controller\\ControllerBase;',
  533. 'class HelloController extends ControllerBase {',
  534. ' public function build() {',
  535. " return ['#markup' => 'Hello'];",
  536. ' }',
  537. '}',
  538. ].join('\n') + '\n',
  539. );
  540. const cg = CodeGraph.initSync(tmpDir);
  541. await cg.indexAll();
  542. // Route node must exist
  543. const routes = cg.getNodesByKind('route');
  544. expect(routes.length).toBeGreaterThan(0);
  545. const route = routes.find((n) => n.name.includes('/hello'));
  546. expect(route).toBeDefined();
  547. // Controller method must be indexed
  548. const methods = cg.getNodesByKind('method');
  549. const buildMethod = methods.find((n) => n.name === 'build');
  550. expect(buildMethod).toBeDefined();
  551. // Edge: route → build method (or class fallback)
  552. const edges = cg.getOutgoingEdges(route!.id);
  553. expect(edges.length).toBeGreaterThan(0);
  554. cg.close();
  555. });
  556. });