test_aider_linter.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import os
  2. from unittest.mock import MagicMock, patch
  3. import pytest
  4. from openhands.runtime.plugins.agent_skills.utils.aider import Linter, LintResult
  5. def get_parent_directory(levels=3):
  6. current_file = os.path.abspath(__file__)
  7. parent_directory = current_file
  8. for _ in range(levels):
  9. parent_directory = os.path.dirname(parent_directory)
  10. return parent_directory
  11. print(f'\nRepo root folder: {get_parent_directory()}\n')
  12. @pytest.fixture
  13. def temp_file(tmp_path):
  14. # Fixture to create a temporary file
  15. temp_name = os.path.join(tmp_path, 'lint-test.py')
  16. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  17. tmp_file.write("""def foo():
  18. print("Hello, World!")
  19. foo()
  20. """)
  21. tmp_file.close()
  22. yield temp_name
  23. os.remove(temp_name)
  24. @pytest.fixture
  25. def temp_ruby_file_errors(tmp_path):
  26. # Fixture to create a temporary file
  27. temp_name = os.path.join(tmp_path, 'lint-test.rb')
  28. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  29. tmp_file.write("""def foo():
  30. print("Hello, World!")
  31. foo()
  32. """)
  33. tmp_file.close()
  34. yield temp_name
  35. os.remove(temp_name)
  36. @pytest.fixture
  37. def temp_ruby_file_errors_parentheses(tmp_path):
  38. # Fixture to create a temporary file
  39. temp_name = os.path.join(tmp_path, 'lint-test.rb')
  40. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  41. tmp_file.write("""def print_hello_world()\n puts 'Hello World'\n""")
  42. tmp_file.close()
  43. yield temp_name
  44. os.remove(temp_name)
  45. @pytest.fixture
  46. def temp_ruby_file_correct(tmp_path):
  47. # Fixture to create a temporary file
  48. temp_name = os.path.join(tmp_path, 'lint-test.rb')
  49. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  50. tmp_file.write("""def foo
  51. puts "Hello, World!"
  52. end
  53. foo
  54. """)
  55. tmp_file.close()
  56. yield temp_name
  57. os.remove(temp_name)
  58. @pytest.fixture
  59. def linter(tmp_path):
  60. return Linter(root=tmp_path)
  61. @pytest.fixture
  62. def temp_typescript_file_errors(tmp_path):
  63. # Fixture to create a temporary TypeScript file with errors
  64. temp_name = os.path.join(tmp_path, 'lint-test.ts')
  65. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  66. tmp_file.write("""function foo() {
  67. console.log("Hello, World!")
  68. foo()
  69. """)
  70. tmp_file.close()
  71. yield temp_name
  72. os.remove(temp_name)
  73. @pytest.fixture
  74. def temp_typescript_file_errors_semicolon(tmp_path):
  75. # Fixture to create a temporary TypeScript file with missing semicolon
  76. temp_name = os.path.join(tmp_path, 'lint-test.ts')
  77. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  78. tmp_file.write("""function printHelloWorld() {
  79. console.log('Hello World')
  80. }""")
  81. tmp_file.close()
  82. yield temp_name
  83. os.remove(temp_name)
  84. @pytest.fixture
  85. def temp_typescript_file_correct(tmp_path):
  86. # Fixture to create a temporary TypeScript file with correct code
  87. temp_name = os.path.join(tmp_path, 'lint-test.ts')
  88. with open(temp_name, 'w', encoding='utf-8') as tmp_file:
  89. tmp_file.write("""function foo(): void {
  90. console.log("Hello, World!");
  91. }
  92. foo();
  93. """)
  94. tmp_file.close()
  95. yield temp_name
  96. os.remove(temp_name)
  97. @pytest.fixture
  98. def temp_typescript_file_eslint_pass(tmp_path):
  99. temp_name = tmp_path / 'lint-test-pass.ts'
  100. temp_name.write_text("""
  101. function greet(name: string): void {
  102. console.log(`Hello, ${name}!`);
  103. }
  104. greet("World");
  105. """)
  106. return str(temp_name)
  107. @pytest.fixture
  108. def temp_typescript_file_eslint_fail(tmp_path):
  109. temp_name = tmp_path / 'lint-test-fail.ts'
  110. temp_name.write_text("""
  111. function greet(name) {
  112. console.log("Hello, " + name + "!")
  113. var unused = "This variable is never used";
  114. }
  115. greet("World")
  116. """)
  117. return str(temp_name)
  118. @pytest.fixture
  119. def temp_react_file_pass(tmp_path):
  120. temp_name = tmp_path / 'react-component-pass.tsx'
  121. temp_name.write_text("""
  122. import React, { useState } from 'react';
  123. interface Props {
  124. name: string;
  125. }
  126. const Greeting: React.FC<Props> = ({ name }) => {
  127. const [count, setCount] = useState(0);
  128. return (
  129. <div>
  130. <h1>Hello, {name}!</h1>
  131. <p>You clicked {count} times</p>
  132. <button onClick={() => setCount(count + 1)}>
  133. Click me
  134. </button>
  135. </div>
  136. );
  137. };
  138. export default Greeting;
  139. """)
  140. return str(temp_name)
  141. @pytest.fixture
  142. def temp_react_file_fail(tmp_path):
  143. temp_name = tmp_path / 'react-component-fail.tsx'
  144. temp_name.write_text("""
  145. import React from 'react';
  146. const Greeting = (props) => {
  147. return (
  148. <div>
  149. <h1>Hello, {props.name}!</h1>
  150. <button onClick={() => console.log('Clicked')}>
  151. Click me
  152. </button>
  153. </div>
  154. );
  155. };
  156. export default Greeting;
  157. """)
  158. return str(temp_name)
  159. def test_get_rel_fname(linter, temp_file, tmp_path):
  160. # Test get_rel_fname method
  161. rel_fname = linter.get_rel_fname(temp_file)
  162. assert rel_fname == os.path.relpath(temp_file, tmp_path)
  163. def test_run_cmd(linter, temp_file):
  164. # Test run_cmd method with a simple command
  165. result = linter.run_cmd('echo', temp_file, '')
  166. assert result is None # echo command should return zero exit status
  167. def test_set_linter(linter):
  168. # Test set_linter method
  169. def custom_linter(fname, rel_fname, code):
  170. return LintResult(text='Custom Linter', lines=[1])
  171. linter.set_linter('custom', custom_linter)
  172. assert 'custom' in linter.languages
  173. assert linter.languages['custom'] == custom_linter
  174. def test_py_lint(linter, temp_file):
  175. # Test py_lint method
  176. result = linter.py_lint(
  177. temp_file, linter.get_rel_fname(temp_file), "print('Hello, World!')\n"
  178. )
  179. assert result is None # No lint errors expected for this simple code
  180. def test_py_lint_fail(linter, temp_file):
  181. # Test py_lint method
  182. result = linter.py_lint(
  183. temp_file, linter.get_rel_fname(temp_file), "print('Hello, World!')\n"
  184. )
  185. assert result is None
  186. def test_basic_lint(temp_file):
  187. from openhands.runtime.plugins.agent_skills.utils.aider.linter import basic_lint
  188. poorly_formatted_code = """
  189. def foo()
  190. print("Hello, World!")
  191. print("Wrong indent")
  192. foo(
  193. """
  194. result = basic_lint(temp_file, poorly_formatted_code)
  195. assert isinstance(result, LintResult)
  196. assert result.text.startswith(f'{temp_file}:2:9')
  197. assert 2 in result.lines
  198. def test_basic_lint_fail_returns_text_and_lines(temp_file):
  199. from openhands.runtime.plugins.agent_skills.utils.aider.linter import basic_lint
  200. poorly_formatted_code = """
  201. def foo()
  202. print("Hello, World!")
  203. print("Wrong indent")
  204. foo(
  205. """
  206. result = basic_lint(temp_file, poorly_formatted_code)
  207. assert isinstance(result, LintResult)
  208. assert result.text.startswith(f'{temp_file}:2:9')
  209. assert 2 in result.lines
  210. def test_lint_python_compile(temp_file):
  211. from openhands.runtime.plugins.agent_skills.utils.aider.linter import (
  212. lint_python_compile,
  213. )
  214. result = lint_python_compile(temp_file, "print('Hello, World!')\n")
  215. assert result is None
  216. def test_lint_python_compile_fail_returns_text_and_lines(temp_file):
  217. from openhands.runtime.plugins.agent_skills.utils.aider.linter import (
  218. lint_python_compile,
  219. )
  220. poorly_formatted_code = """
  221. def foo()
  222. print("Hello, World!")
  223. print("Wrong indent")
  224. foo(
  225. """
  226. result = lint_python_compile(temp_file, poorly_formatted_code)
  227. assert temp_file in result.text
  228. assert 1 in result.lines
  229. def test_lint(linter, temp_file):
  230. result = linter.lint(temp_file)
  231. assert result is None
  232. def test_lint_fail(linter, temp_file):
  233. # Test lint method
  234. with open(temp_file, 'w', encoding='utf-8') as lint_file:
  235. lint_file.write("""
  236. def foo()
  237. print("Hello, World!")
  238. print("Wrong indent")
  239. foo(
  240. """)
  241. errors = linter.lint(temp_file)
  242. assert errors is not None
  243. def test_lint_pass_ruby(linter, temp_ruby_file_correct):
  244. result = linter.lint(temp_ruby_file_correct)
  245. assert result is None
  246. def test_lint_fail_ruby(linter, temp_ruby_file_errors):
  247. errors = linter.lint(temp_ruby_file_errors)
  248. assert errors is not None
  249. def test_lint_fail_ruby_no_parentheses(linter, temp_ruby_file_errors_parentheses):
  250. errors = linter.lint(temp_ruby_file_errors_parentheses)
  251. assert errors is not None
  252. def test_lint_pass_typescript(linter, temp_typescript_file_correct):
  253. if linter.ts_installed:
  254. with patch.object(linter, 'root', return_value=get_parent_directory()):
  255. result = linter.lint(temp_typescript_file_correct)
  256. assert result is None
  257. def test_lint_fail_typescript(linter, temp_typescript_file_errors):
  258. if linter.ts_installed:
  259. errors = linter.lint(temp_typescript_file_errors)
  260. assert errors is not None
  261. def test_lint_fail_typescript_missing_semicolon(
  262. linter, temp_typescript_file_errors_semicolon
  263. ):
  264. if linter.ts_installed:
  265. with patch.dict(os.environ, {'ENABLE_AUTO_LINT': 'True'}):
  266. errors = linter.lint(temp_typescript_file_errors_semicolon)
  267. assert errors is not None
  268. def test_ts_eslint_pass(linter, temp_typescript_file_eslint_pass):
  269. with patch.object(linter, 'eslint_installed', return_value=True):
  270. with patch.object(linter, 'root', return_value=get_parent_directory()):
  271. with patch.object(linter, 'run_cmd') as mock_run_cmd:
  272. mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
  273. result = linter.ts_eslint(
  274. temp_typescript_file_eslint_pass, 'lint-test-pass.ts', ''
  275. )
  276. assert result is None # No lint errors expected
  277. def test_ts_eslint_not_installed(linter, temp_typescript_file_eslint_pass):
  278. with patch.object(linter, 'eslint_installed', return_value=False):
  279. with patch.object(linter, 'root', return_value=get_parent_directory()):
  280. result = linter.lint(temp_typescript_file_eslint_pass)
  281. assert result is None # Should return None when ESLint is not installed
  282. def test_ts_eslint_run_cmd_error(linter, temp_typescript_file_eslint_pass):
  283. with patch.object(linter, 'eslint_installed', return_value=True):
  284. with patch.object(linter, 'run_cmd', side_effect=FileNotFoundError):
  285. result = linter.ts_eslint(
  286. temp_typescript_file_eslint_pass, 'lint-test-pass.ts', ''
  287. )
  288. assert result is None # Should return None when run_cmd raises an exception
  289. def test_ts_eslint_react_pass(linter, temp_react_file_pass):
  290. if not linter.eslint_installed:
  291. pytest.skip('ESLint is not installed. Skipping this test.')
  292. with patch.object(linter, 'eslint_installed', return_value=True):
  293. with patch.object(linter, 'run_cmd') as mock_run_cmd:
  294. mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
  295. result = linter.ts_eslint(
  296. temp_react_file_pass, 'react-component-pass.tsx', ''
  297. )
  298. assert result is None # No lint errors expected
  299. def test_ts_eslint_react_fail(linter, temp_react_file_fail):
  300. if not linter.eslint_installed:
  301. pytest.skip('ESLint is not installed. Skipping this test.')
  302. with patch.object(linter, 'run_cmd') as mock_run_cmd:
  303. mock_eslint_output = """[
  304. {
  305. "filePath": "react-component-fail.tsx",
  306. "messages": [
  307. {
  308. "ruleId": "react/prop-types",
  309. "severity": 1,
  310. "message": "Missing prop type for 'name'",
  311. "line": 5,
  312. "column": 22,
  313. "nodeType": "Identifier",
  314. "messageId": "missingPropType",
  315. "endLine": 5,
  316. "endColumn": 26
  317. },
  318. {
  319. "ruleId": "no-console",
  320. "severity": 1,
  321. "message": "Unexpected console statement.",
  322. "line": 7,
  323. "column": 29,
  324. "nodeType": "MemberExpression",
  325. "messageId": "unexpected",
  326. "endLine": 7,
  327. "endColumn": 40
  328. }
  329. ],
  330. "errorCount": 0,
  331. "warningCount": 2,
  332. "fixableErrorCount": 0,
  333. "fixableWarningCount": 0,
  334. "source": "..."
  335. }
  336. ]"""
  337. mock_run_cmd.return_value = MagicMock(text=mock_eslint_output)
  338. linter.root = get_parent_directory()
  339. result = linter.ts_eslint(temp_react_file_fail, 'react-component-fail.tsx', '')
  340. if not linter.eslint_installed:
  341. assert result is None
  342. return
  343. assert isinstance(result, LintResult)
  344. assert (
  345. "react-component-fail.tsx:5:22: Missing prop type for 'name' (react/prop-types)"
  346. in result.text
  347. )
  348. assert (
  349. 'react-component-fail.tsx:7:29: Unexpected console statement. (no-console)'
  350. in result.text
  351. )
  352. assert 5 in result.lines
  353. assert 7 in result.lines
  354. def test_ts_eslint_react_config(linter, temp_react_file_pass):
  355. if not linter.eslint_installed:
  356. pytest.skip('ESLint is not installed. Skipping this test.')
  357. with patch.object(linter, 'root', return_value=get_parent_directory()):
  358. with patch.object(linter, 'run_cmd') as mock_run_cmd:
  359. mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
  360. linter.root = get_parent_directory()
  361. result = linter.ts_eslint(
  362. temp_react_file_pass, 'react-component-pass.tsx', ''
  363. )
  364. assert result is None
  365. # Check if the ESLint command includes React-specific configuration
  366. called_cmd = mock_run_cmd.call_args[0][0]
  367. assert 'resolve-plugins-relative-to' in called_cmd
  368. # Additional assertions to ensure React configuration is present
  369. assert '--config /tmp/' in called_cmd
  370. def test_ts_eslint_react_missing_semicolon(linter, tmp_path):
  371. if not linter.eslint_installed:
  372. pytest.skip('ESLint is not installed. Skipping this test.')
  373. temp_react_file = tmp_path / 'App.tsx'
  374. temp_react_file.write_text("""import React, { useState, useEffect, useCallback } from 'react'
  375. import './App.css'
  376. function App() {
  377. const [darkMode, setDarkMode] = useState(false);
  378. const toggleDarkMode = () => {
  379. setDarkMode(!darkMode);
  380. document.body.classList.toggle('dark-mode');
  381. };
  382. return (
  383. <div className={`App ${darkMode ? 'dark-mode' : ''}`}>
  384. <button onClick={toggleDarkMode}>
  385. {darkMode ? 'Light Mode' : 'Dark Mode'}
  386. </button>
  387. </div>
  388. )
  389. }
  390. export default App
  391. """)
  392. linter.root = get_parent_directory()
  393. result = linter.ts_eslint(str(temp_react_file), str(temp_react_file), '')
  394. assert isinstance(result, LintResult)
  395. if 'JSONDecodeError' in result.text:
  396. linter.print_lint_result(result)
  397. pytest.skip(
  398. 'ESLint returned a JSONDecodeError. This might be due to a configuration issue.'
  399. )
  400. if 'eslint-plugin-react' in result.text and "wasn't found" in result.text:
  401. linter.print_lint_result(result)
  402. pytest.skip(
  403. 'eslint-plugin-react is not installed. This test requires the React ESLint plugin.'
  404. )
  405. assert any(
  406. 'Missing semicolon' in message for message in result.text.split('\n')
  407. ), "Expected 'Missing semicolon' error not found"
  408. assert 1 in result.lines, 'Expected line 1 to be flagged for missing semicolon'
  409. assert 21 in result.lines, 'Expected line 21 to be flagged for missing semicolon'