| """Test code refinement module for conversational refinement of generated tests.""" |
|
|
| import logging |
| from pathlib import Path |
| from typing import List, Optional, Tuple |
|
|
| from qa_config import SYMBOLIC_RULES, get_llm, load_prompt_template |
|
|
| logger = logging.getLogger("ai-natural-language-tests") |
|
|
| FILE_HEADER_PREFIX = "// FILE: " |
|
|
|
|
| def strip_code_fence(text: str) -> str: |
| """ |
| Remove code fence markers from text. |
| |
| Recognizes markers: ```typescript, ```javascript, ```js, ``` |
| If no marker is found, returns the text unchanged (stripped). |
| """ |
| fence_markers = ["```typescript", "```javascript", "```js", "```"] |
| marker = next((item for item in fence_markers if item in text), None) |
| if marker is None: |
| return text.strip() |
| return text.split(marker, 1)[1].split("```", 1)[0].strip() |
|
|
|
|
| def split_file_sections(code_text: str) -> List[Tuple[str, str]]: |
| """ |
| Split code text into (filepath, code) tuples based on FILE_HEADER_PREFIX lines. |
| |
| Returns a list of (filepath, code) tuples in order. |
| If no headers are found, returns an empty list (indicating bare snippet, not multi-file). |
| """ |
| lines = code_text.splitlines(keepends=True) |
| sections = [] |
| current_path = None |
| current_code_lines = [] |
|
|
| for line in lines: |
| if line.strip().startswith(FILE_HEADER_PREFIX): |
| |
| if current_path is not None: |
| current_code = "".join(current_code_lines).rstrip() |
| sections.append((current_path, current_code)) |
| |
| |
| current_path = line.strip()[len(FILE_HEADER_PREFIX):].strip() |
| current_code_lines = [] |
| else: |
| if current_path is not None: |
| current_code_lines.append(line) |
|
|
| |
| if current_path is not None: |
| current_code = "".join(current_code_lines).rstrip() |
| sections.append((current_path, current_code)) |
|
|
| return sections |
|
|
|
|
| def join_file_sections(sections: List[Tuple[str, str]]) -> str: |
| """ |
| Join (filepath, code) tuples back into a single code panel text. |
| |
| Reconstructs the format: FILE_HEADER_PREFIX + filepath + newline + code |
| """ |
| chunks = [] |
| for filepath, code in sections: |
| chunks.append(f"{FILE_HEADER_PREFIX}{filepath}\n{code}") |
| return "\n\n".join(chunks) |
|
|
|
|
| def refine_tests( |
| current_code: str, |
| instruction: str, |
| framework: str, |
| llm_provider: str = "openai", |
| ) -> Tuple[str, List[Tuple[str, bool]]]: |
| """ |
| Refine generated test code based on a user instruction. |
| |
| Args: |
| current_code: The current code panel text (may include FILE_HEADER_PREFIX lines). |
| instruction: Natural language instruction describing the refinement. |
| framework: The test framework ('cypress', 'playwright', 'webdriverio', 'appium'). |
| llm_provider: LLM provider to use ('openai', 'anthropic', 'google'). |
| |
| Returns: |
| Tuple of (revised_panel_text, files_written) |
| - revised_panel_text: The updated code for the panel. |
| - files_written: List of (filepath, success) tuples indicating which files were written. |
| |
| Raises: |
| ValueError: If the refined output is invalid (missing files, empty sections, etc.). |
| """ |
| if not current_code.strip(): |
| raise ValueError("No current code to refine. Generate tests first.") |
|
|
| if not instruction.strip(): |
| raise ValueError("Instruction is empty. Describe the change you want.") |
|
|
| |
| original_sections = split_file_sections(current_code) |
| has_file_headers = len(original_sections) > 0 |
|
|
| |
| prompt = load_prompt_template( |
| "test_refinement.yaml", |
| framework=framework, |
| instruction=instruction, |
| current_code=current_code, |
| symbolic_rules=SYMBOLIC_RULES, |
| ) |
|
|
| logger.info(f"Refining tests with instruction: {instruction[:100]}...") |
| llm = get_llm(llm_provider) |
| response = llm.invoke(prompt) |
| response_content = response.content if hasattr(response, 'content') else str(response) |
|
|
| |
| revised_code = strip_code_fence(response_content) |
|
|
| |
| revised_sections = split_file_sections(revised_code) |
|
|
| |
| if has_file_headers: |
| original_paths = [path for path, _ in original_sections] |
| revised_paths = [path for path, _ in revised_sections] |
|
|
| if original_paths != revised_paths: |
| raise ValueError( |
| f"Refinement error: File list mismatch. " |
| f"Original: {original_paths}, Revised: {revised_paths}. " |
| f"The LLM must not add, remove, or rename files." |
| ) |
|
|
| |
| for filepath, code in revised_sections: |
| if not code.strip(): |
| raise ValueError(f"Refinement error: Empty code section for {filepath}. All files must have content.") |
|
|
| |
| files_written: List[Tuple[str, bool]] = [] |
|
|
| if has_file_headers: |
| for filepath, code in revised_sections: |
| file_path = Path(filepath) |
|
|
| |
| if not file_path.exists(): |
| logger.warning(f"File does not exist on disk, skipping write: {filepath}") |
| files_written.append((filepath, False)) |
| continue |
|
|
| try: |
| file_path.write_text(code, encoding="utf-8") |
| logger.info(f"Updated: {filepath}") |
| files_written.append((filepath, True)) |
| except Exception as e: |
| logger.error(f"Failed to write {filepath}: {e}") |
| files_written.append((filepath, False)) |
|
|
| |
| if has_file_headers: |
| revised_panel_text = join_file_sections(revised_sections) |
| else: |
| |
| revised_panel_text = revised_code |
|
|
| return revised_panel_text, files_written |
|
|