redux-thunk-synthesizer.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. import { CodeGraph } from '../src';
  6. /**
  7. * End-to-end test for the redux-thunk dispatch-chain synthesizer.
  8. *
  9. * `createAsyncThunk(prefix, async (a, api) => {...})` passes the async body as an argument, so
  10. * tree-sitter never makes it its own function node — the thunk `constant`'s body calls (incl.
  11. * `dispatch(nextThunk(...))`) are orphaned and `callees(thunk)` is empty. Verify the synthesizer
  12. * body-scans each thunk constant and links it → each dispatched thunk, so the chain
  13. * `outer → inner → deep` connects end-to-end; and that a non-thunk constant is skipped.
  14. */
  15. describe('redux-thunk synthesizer', () => {
  16. let dir: string;
  17. beforeEach(() => {
  18. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redux-thunk-fixture-'));
  19. });
  20. afterEach(() => {
  21. fs.rmSync(dir, { recursive: true, force: true });
  22. });
  23. it('links each thunk constant to the thunks it dispatches, and skips non-thunks', async () => {
  24. fs.writeFileSync(
  25. path.join(dir, 'package.json'),
  26. JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
  27. );
  28. fs.writeFileSync(
  29. path.join(dir, 'thunks.ts'),
  30. `import { createAsyncThunk } from '@reduxjs/toolkit';
  31. export const deepThunk = createAsyncThunk('app/deep', async (n: number) => {
  32. return n * 2;
  33. });
  34. export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
  35. return dispatch(deepThunk(n));
  36. });
  37. export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
  38. await dispatch(innerThunk(n));
  39. });
  40. // Non-thunk constant that only MENTIONS dispatch in a string — must be skipped.
  41. export const notAThunk = 'dispatch(innerThunk())';
  42. `
  43. );
  44. const cg = await CodeGraph.init(dir, { silent: true });
  45. await cg.indexAll();
  46. const db = (cg as any).db.db;
  47. const rows = db
  48. .prepare(
  49. `SELECT s.name source_name, s.kind source_kind, t.name target_name,
  50. json_extract(e.metadata,'$.via') via,
  51. json_extract(e.metadata,'$.registeredAt') registeredAt
  52. FROM edges e
  53. JOIN nodes s ON s.id = e.source
  54. JOIN nodes t ON t.id = e.target
  55. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'`
  56. )
  57. .all();
  58. cg.close?.();
  59. // The dispatch chain connects: outer → inner → deep.
  60. const pairs = new Set(rows.map((r: any) => `${r.source_name}>${r.target_name}`));
  61. expect(pairs.has('outerThunk>innerThunk')).toBe(true);
  62. expect(pairs.has('innerThunk>deepThunk')).toBe(true);
  63. // Sources are thunk constants; the non-thunk string constant is never a source.
  64. expect(rows.every((r: any) => r.source_kind === 'constant')).toBe(true);
  65. expect(rows.some((r: any) => r.source_name === 'notAThunk')).toBe(false);
  66. // Edges are 'calls' with the wiring site surfaced for the agent.
  67. const outer = rows.find((r: any) => r.source_name === 'outerThunk');
  68. expect(outer.via).toBe('innerThunk');
  69. expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/);
  70. });
  71. it('on a name collision, a dispatch resolves to the THUNK, not a same-named service function', async () => {
  72. // Regression for the octo-call case: `leaveCall` exists as BOTH a `createAsyncThunk`
  73. // const and an unrelated service function. `dispatch(leaveCall())` targets the thunk,
  74. // but the old first-match resolver could pick the function. The resolver now prefers a
  75. // thunk-signature const > other const > same-file > first.
  76. fs.writeFileSync(
  77. path.join(dir, 'package.json'),
  78. JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
  79. );
  80. // A plain service function that shares the name `leaveCall` with the thunk below.
  81. fs.writeFileSync(path.join(dir, 'service.ts'), `export function leaveCall(id: string) { return id; }\n`);
  82. fs.writeFileSync(
  83. path.join(dir, 'thunks.ts'),
  84. `import { createAsyncThunk } from '@reduxjs/toolkit';
  85. export const leaveCall = createAsyncThunk('call/leave', async () => {
  86. return 1;
  87. });
  88. export const logout = createAsyncThunk('user/logout', async (_: void, { dispatch }) => {
  89. dispatch(leaveCall());
  90. });
  91. `
  92. );
  93. const cg = await CodeGraph.init(dir, { silent: true });
  94. await cg.indexAll();
  95. const db = (cg as any).db.db;
  96. const row = db
  97. .prepare(
  98. `SELECT t.kind target_kind, t.file_path target_file
  99. FROM edges e
  100. JOIN nodes s ON s.id = e.source
  101. JOIN nodes t ON t.id = e.target
  102. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'
  103. AND s.name = 'logout' AND t.name = 'leaveCall'`
  104. )
  105. .get();
  106. cg.close?.();
  107. expect(row).toBeTruthy();
  108. // Resolved to the createAsyncThunk constant in thunks.ts, NOT service.ts's function.
  109. expect(row.target_kind).toBe('constant');
  110. expect(row.target_file).toMatch(/thunks\.ts$/);
  111. });
  112. });