130 lines
4.9 KiB
TypeScript
130 lines
4.9 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* End-to-end test for the redux-thunk dispatch-chain synthesizer.
|
|
*
|
|
* `createAsyncThunk(prefix, async (a, api) => {...})` passes the async body as an argument, so
|
|
* tree-sitter never makes it its own function node — the thunk `constant`'s body calls (incl.
|
|
* `dispatch(nextThunk(...))`) are orphaned and `callees(thunk)` is empty. Verify the synthesizer
|
|
* body-scans each thunk constant and links it → each dispatched thunk, so the chain
|
|
* `outer → inner → deep` connects end-to-end; and that a non-thunk constant is skipped.
|
|
*/
|
|
describe('redux-thunk synthesizer', () => {
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redux-thunk-fixture-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('links each thunk constant to the thunks it dispatches, and skips non-thunks', async () => {
|
|
fs.writeFileSync(
|
|
path.join(dir, 'package.json'),
|
|
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'thunks.ts'),
|
|
`import { createAsyncThunk } from '@reduxjs/toolkit';
|
|
|
|
export const deepThunk = createAsyncThunk('app/deep', async (n: number) => {
|
|
return n * 2;
|
|
});
|
|
|
|
export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
|
|
return dispatch(deepThunk(n));
|
|
});
|
|
|
|
export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
|
|
await dispatch(innerThunk(n));
|
|
});
|
|
|
|
// Non-thunk constant that only MENTIONS dispatch in a string — must be skipped.
|
|
export const notAThunk = 'dispatch(innerThunk())';
|
|
`
|
|
);
|
|
|
|
const cg = await CodeGraph.init(dir, { silent: true });
|
|
await cg.indexAll();
|
|
|
|
const db = (cg as any).db.db;
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
|
|
json_extract(e.metadata,'$.via') via,
|
|
json_extract(e.metadata,'$.registeredAt') registeredAt
|
|
FROM edges e
|
|
JOIN nodes s ON s.id = e.source
|
|
JOIN nodes t ON t.id = e.target
|
|
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'`
|
|
)
|
|
.all();
|
|
cg.close?.();
|
|
|
|
// The dispatch chain connects: outer → inner → deep.
|
|
const pairs = new Set(rows.map((r: any) => `${r.source_name}>${r.target_name}`));
|
|
expect(pairs.has('outerThunk>innerThunk')).toBe(true);
|
|
expect(pairs.has('innerThunk>deepThunk')).toBe(true);
|
|
|
|
// Sources are thunk constants; the non-thunk string constant is never a source.
|
|
expect(rows.every((r: any) => r.source_kind === 'constant')).toBe(true);
|
|
expect(rows.some((r: any) => r.source_name === 'notAThunk')).toBe(false);
|
|
|
|
// Edges are 'calls' with the wiring site surfaced for the agent.
|
|
const outer = rows.find((r: any) => r.source_name === 'outerThunk');
|
|
expect(outer.via).toBe('innerThunk');
|
|
expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/);
|
|
});
|
|
|
|
it('on a name collision, a dispatch resolves to the THUNK, not a same-named service function', async () => {
|
|
// Regression for the octo-call case: `leaveCall` exists as BOTH a `createAsyncThunk`
|
|
// const and an unrelated service function. `dispatch(leaveCall())` targets the thunk,
|
|
// but the old first-match resolver could pick the function. The resolver now prefers a
|
|
// thunk-signature const > other const > same-file > first.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'package.json'),
|
|
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
|
|
);
|
|
// A plain service function that shares the name `leaveCall` with the thunk below.
|
|
fs.writeFileSync(path.join(dir, 'service.ts'), `export function leaveCall(id: string) { return id; }\n`);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'thunks.ts'),
|
|
`import { createAsyncThunk } from '@reduxjs/toolkit';
|
|
|
|
export const leaveCall = createAsyncThunk('call/leave', async () => {
|
|
return 1;
|
|
});
|
|
|
|
export const logout = createAsyncThunk('user/logout', async (_: void, { dispatch }) => {
|
|
dispatch(leaveCall());
|
|
});
|
|
`
|
|
);
|
|
|
|
const cg = await CodeGraph.init(dir, { silent: true });
|
|
await cg.indexAll();
|
|
|
|
const db = (cg as any).db.db;
|
|
const row = db
|
|
.prepare(
|
|
`SELECT t.kind target_kind, t.file_path target_file
|
|
FROM edges e
|
|
JOIN nodes s ON s.id = e.source
|
|
JOIN nodes t ON t.id = e.target
|
|
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'
|
|
AND s.name = 'logout' AND t.name = 'leaveCall'`
|
|
)
|
|
.get();
|
|
cg.close?.();
|
|
|
|
expect(row).toBeTruthy();
|
|
// Resolved to the createAsyncThunk constant in thunks.ts, NOT service.ts's function.
|
|
expect(row.target_kind).toBe('constant');
|
|
expect(row.target_file).toMatch(/thunks\.ts$/);
|
|
});
|
|
});
|