Skip to content

Commit 05167f3

Browse files
committed
test: add tests for custom tools patch and helpers
1 parent 576d130 commit 05167f3

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

src/patches/customTools.test.ts

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { writeCustomTools } from './customTools';
3+
import {
4+
findBuildToolFunc,
5+
getCwdFuncName,
6+
clearReactVarCache,
7+
clearRequireFuncNameCache,
8+
} from './helpers';
9+
import type { CustomTool } from '../types';
10+
11+
// ============================================================================
12+
// SHARED FIXTURES
13+
// ============================================================================
14+
15+
// Minimal synthetic minified bundle satisfying all helpers writeCustomTools uses.
16+
// Each piece is crafted to match exactly one helper's detection pattern.
17+
const MOCK_BASE =
18+
// getModuleLoaderFunction (NPM bundle): shortest 3-param arrow function
19+
'var T=(H,$,A)=>{A=H!=null?' +
20+
// getReactModuleNameNonBun: var X=Y((Z)=>{var W=Symbol.for("react.element")
21+
'var rM=X((Z)=>{var W=Symbol.for("react.element")' +
22+
// getReactVar non-bun: [^$\w]R=T(rM(),1) — semicolon is the non-word prefix
23+
';R=T(rM(),1)' +
24+
// findTextComponent: function NAME({color:A,backgroundColor:B,dimColor:C=!1,bold:D=!1,...})
25+
'function Tx({color:a,backgroundColor:b,dimColor:c=!1,bold:d=!1}){}' +
26+
// findBoxComponent method 2: function NAME({children:T,flexWrap:F...}){...createElement("ink-box"...}
27+
'function Bx({children:ch,flexWrap:fw}){return R.createElement("ink-box",null,ch)}' +
28+
// getCwdFuncName three-step chain
29+
'var ST={cwd:"/tmp"};' +
30+
'function gCS(){return ST.cwd}' +
31+
'function pwdF(){return gCS()}' +
32+
'function getCwdFn(){try{return pwdF()}catch(e){return"/"}}' +
33+
// findBuildToolFunc: function NAME(PARAM){return{...DEFAULTS,userFacingName:()=>PARAM.name,...PARAM}}
34+
'const DEF={isEnabled:()=>!0};function bT(D1){return{...DEF,userFacingName:()=>D1.name,...D1}}';
35+
36+
// Strategy B fixture: original one-liner tool aggregation
37+
const MOCK_STRATEGY_B = MOCK_BASE + 'let TOOLS=agg(ctx,state.tools,opts),x=1;';
38+
39+
// Strategy A fixture: toolsets patch has already rewritten into if/else
40+
const MOCK_STRATEGY_A =
41+
MOCK_BASE +
42+
'if(ts){TOOLS=agg(ctx,state.tools,opts).filter(t=>ts.includes(t.name));' +
43+
'} else {TOOLS=agg(ctx,state.tools,opts);}let REST=1;';
44+
45+
const MINIMAL_TOOL: CustomTool = {
46+
name: 'MyTool',
47+
description: 'A test tool',
48+
parameters: {
49+
msg: { type: 'string', description: 'The message', required: true },
50+
},
51+
command: 'echo {{msg}}',
52+
};
53+
54+
const OPTIONAL_PARAM_TOOL: CustomTool = {
55+
name: 'OptTool',
56+
description: 'Tool with optional param',
57+
parameters: {
58+
flag: { type: 'boolean', description: 'A flag', required: false },
59+
},
60+
command: 'run --flag={{flag}}',
61+
shell: 'bash',
62+
timeout: 5000,
63+
workingDir: '/tmp/work',
64+
env: { MY_VAR: 'hello' },
65+
};
66+
67+
// ============================================================================
68+
// HELPER TESTS
69+
// ============================================================================
70+
71+
describe('findBuildToolFunc', () => {
72+
it('detects buildTool in the mock bundle', () => {
73+
expect(findBuildToolFunc(MOCK_BASE)).toBe('bT');
74+
});
75+
76+
it('handles different variable names', () => {
77+
const code =
78+
'function xY$(p1){return{...DEFS,userFacingName:()=>p1.name,...p1}}';
79+
expect(findBuildToolFunc(code)).toBe('xY$');
80+
});
81+
82+
it('returns undefined when absent', () => {
83+
expect(findBuildToolFunc('const x=1;')).toBeUndefined();
84+
});
85+
});
86+
87+
describe('getCwdFuncName', () => {
88+
it('detects the full three-step chain', () => {
89+
expect(getCwdFuncName(MOCK_BASE)).toBe('getCwdFn');
90+
});
91+
92+
it('falls back to pwd when no try-catch wrapper exists', () => {
93+
const code =
94+
'var ST={cwd:"/x"};function gCS(){return ST.cwd}function pwdF(){return gCS()}';
95+
expect(getCwdFuncName(code)).toBe('pwdF');
96+
});
97+
98+
it('falls back to getCwdState when no pwd wrapper exists either', () => {
99+
const code = 'var ST={cwd:"/x"};function gCS(){return ST.cwd}';
100+
expect(getCwdFuncName(code)).toBe('gCS');
101+
});
102+
103+
it('returns undefined when getCwdState is absent', () => {
104+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
105+
try {
106+
expect(getCwdFuncName('const x=1;')).toBeUndefined();
107+
} finally {
108+
spy.mockRestore();
109+
}
110+
});
111+
});
112+
113+
// ============================================================================
114+
// writeCustomTools TESTS
115+
// ============================================================================
116+
117+
describe('writeCustomTools', () => {
118+
beforeEach(() => {
119+
clearReactVarCache();
120+
clearRequireFuncNameCache();
121+
});
122+
123+
afterEach(() => {
124+
clearReactVarCache();
125+
clearRequireFuncNameCache();
126+
});
127+
128+
describe('no-op cases', () => {
129+
it('returns the original file when customTools is empty', () => {
130+
expect(writeCustomTools(MOCK_STRATEGY_B, [])).toBe(MOCK_STRATEGY_B);
131+
});
132+
});
133+
134+
describe('collision guard', () => {
135+
it('returns null and logs error for a built-in tool name', () => {
136+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
137+
try {
138+
const result = writeCustomTools(MOCK_STRATEGY_B, [
139+
{ ...MINIMAL_TOOL, name: 'Bash' },
140+
]);
141+
expect(result).toBeNull();
142+
expect(err).toHaveBeenCalledWith(expect.stringContaining('"Bash"'));
143+
} finally {
144+
err.mockRestore();
145+
}
146+
});
147+
});
148+
149+
describe('missing helper patterns', () => {
150+
it('returns null when buildTool is not found', () => {
151+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
152+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
153+
try {
154+
const noBuildTool = MOCK_STRATEGY_B.replace(
155+
/function bT\(D1\)\{return\{\.\.\.DEF,userFacingName:\(\)=>D1\.name,\.\.\.D1\}\}/,
156+
''
157+
);
158+
expect(writeCustomTools(noBuildTool, [MINIMAL_TOOL])).toBeNull();
159+
expect(err).toHaveBeenCalledWith(expect.stringContaining('buildTool'));
160+
} finally {
161+
err.mockRestore();
162+
warn.mockRestore();
163+
}
164+
});
165+
166+
it('returns null when the tool aggregation pattern is not found', () => {
167+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
168+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
169+
try {
170+
const noAgg = MOCK_BASE + 'const x=1;';
171+
expect(writeCustomTools(noAgg, [MINIMAL_TOOL])).toBeNull();
172+
expect(err).toHaveBeenCalledWith(
173+
expect.stringContaining('tool aggregation pattern')
174+
);
175+
} finally {
176+
err.mockRestore();
177+
warn.mockRestore();
178+
}
179+
});
180+
});
181+
182+
describe('Strategy B — original code injection', () => {
183+
it('produces a non-null result', () => {
184+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
185+
try {
186+
expect(
187+
writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])
188+
).not.toBeNull();
189+
} finally {
190+
warn.mockRestore();
191+
}
192+
});
193+
194+
it('spreads custom tools into the tool aggregation variable', () => {
195+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
196+
try {
197+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
198+
expect(result).toContain(
199+
'let TOOLS=[...agg(ctx,state.tools,opts),...['
200+
);
201+
} finally {
202+
warn.mockRestore();
203+
}
204+
});
205+
206+
it('calls buildTool (bT) to construct the custom tool', () => {
207+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
208+
try {
209+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
210+
expect(result).toContain('bT({');
211+
} finally {
212+
warn.mockRestore();
213+
}
214+
});
215+
216+
it('embeds the tool name in the generated object', () => {
217+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
218+
try {
219+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
220+
expect(result).toContain('"MyTool"');
221+
} finally {
222+
warn.mockRestore();
223+
}
224+
});
225+
226+
it('uses React.createElement (R) with Text (Tx) and Box (Bx) for rendering', () => {
227+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
228+
try {
229+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
230+
expect(result).toContain('R.createElement(Bx,');
231+
expect(result).toContain('R.createElement(Tx,');
232+
} finally {
233+
warn.mockRestore();
234+
}
235+
});
236+
237+
it('uses the detected cwd function for workingDir', () => {
238+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
239+
try {
240+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
241+
expect(result).toContain('getCwdFn()');
242+
} finally {
243+
warn.mockRestore();
244+
}
245+
});
246+
247+
it('uses explicit workingDir when provided', () => {
248+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
249+
try {
250+
const result = writeCustomTools(MOCK_STRATEGY_B, [
251+
OPTIONAL_PARAM_TOOL,
252+
])!;
253+
expect(result).toContain('"/tmp/work"');
254+
} finally {
255+
warn.mockRestore();
256+
}
257+
});
258+
259+
it('delegates checkPermissions to BashTool', () => {
260+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
261+
try {
262+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
263+
expect(result).toContain(
264+
'context.options.tools.find(t=>t.name==="Bash")'
265+
);
266+
expect(result).toContain('bashTool.checkPermissions(');
267+
} finally {
268+
warn.mockRestore();
269+
}
270+
});
271+
272+
it('includes validateInput for required parameters', () => {
273+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
274+
try {
275+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
276+
expect(result).toContain('"msg is required"');
277+
expect(result).toContain('"msg must be a string"');
278+
} finally {
279+
warn.mockRestore();
280+
}
281+
});
282+
283+
it('does not add required check for optional params', () => {
284+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
285+
try {
286+
const result = writeCustomTools(MOCK_STRATEGY_B, [
287+
OPTIONAL_PARAM_TOOL,
288+
])!;
289+
expect(result).not.toContain('"flag is required"');
290+
expect(result).toContain('"flag must be a boolean"');
291+
} finally {
292+
warn.mockRestore();
293+
}
294+
});
295+
296+
it('injects the command template into the generated code', () => {
297+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
298+
try {
299+
const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!;
300+
expect(result).toContain('"echo {{msg}}"');
301+
} finally {
302+
warn.mockRestore();
303+
}
304+
});
305+
306+
it('handles multiple tools', () => {
307+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
308+
try {
309+
const tool2: CustomTool = {
310+
name: 'SecondTool',
311+
description: 'Another tool',
312+
parameters: {},
313+
command: 'ls',
314+
};
315+
const result = writeCustomTools(MOCK_STRATEGY_B, [
316+
MINIMAL_TOOL,
317+
tool2,
318+
])!;
319+
expect(result).toContain('"MyTool"');
320+
expect(result).toContain('"SecondTool"');
321+
} finally {
322+
warn.mockRestore();
323+
}
324+
});
325+
});
326+
327+
describe('Strategy A — post-toolsets injection', () => {
328+
it('produces a non-null result', () => {
329+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
330+
try {
331+
expect(
332+
writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])
333+
).not.toBeNull();
334+
} finally {
335+
warn.mockRestore();
336+
}
337+
});
338+
339+
it('appends custom tools to the toolset variable after the else block', () => {
340+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
341+
try {
342+
const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!;
343+
// The injection code TOOLS=[...TOOLS,...[...]] should appear before `let REST`
344+
expect(result).toContain('TOOLS=[...TOOLS,...[');
345+
const injectionIdx = result.indexOf('TOOLS=[...TOOLS,...[');
346+
const letRestIdx = result.indexOf('let REST');
347+
expect(injectionIdx).toBeLessThan(letRestIdx);
348+
} finally {
349+
warn.mockRestore();
350+
}
351+
});
352+
353+
it('does NOT use the Strategy B pattern when Strategy A matches', () => {
354+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
355+
try {
356+
const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!;
357+
// Strategy B would produce `let TOOLS=[...agg(...` — should not appear
358+
expect(result).not.toContain('let TOOLS=[...agg(');
359+
} finally {
360+
warn.mockRestore();
361+
}
362+
});
363+
364+
it('still uses buildTool in Strategy A', () => {
365+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
366+
try {
367+
const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!;
368+
expect(result).toContain('bT({');
369+
} finally {
370+
warn.mockRestore();
371+
}
372+
});
373+
});
374+
});

0 commit comments

Comments
 (0)