feat(regex): implement .NET regex testing via PowerShell and enhance UI components

This commit is contained in:
Sam Chau
2025-08-27 02:38:01 +09:30
parent ef86fa251f
commit 77f996f8c5
9 changed files with 281 additions and 186 deletions

View File

@@ -259,25 +259,29 @@ def run_tests(category):
try:
data = request.get_json()
if not data:
logger.warning("Rejected test request - no JSON data provided")
logger.warning("Test request rejected: no JSON data")
return jsonify({"error": "No JSON data provided"}), 400
tests = data.get('tests', [])
if not tests:
logger.warning("Rejected test request - no test cases provided")
logger.warning("Test request rejected: no tests provided")
return jsonify({"error":
"At least one test case is required"}), 400
if category == 'regex_pattern':
pattern = data.get('pattern')
logger.info(f"Processing regex test request - Pattern: {pattern}")
if not pattern:
logger.warning("Rejected test request - missing pattern")
logger.warning("Test request rejected: missing pattern")
return jsonify({"error": "Pattern is required"}), 400
success, message, updated_tests = test_regex_pattern(
pattern, tests)
if success and updated_tests:
passed = sum(1 for t in updated_tests if t.get('passes'))
total = len(updated_tests)
logger.info(f"Tests completed: {passed}/{total} passed")
elif category == 'custom_format':
conditions = data.get('conditions', [])
@@ -300,10 +304,8 @@ def run_tests(category):
return jsonify(
{"error": "Testing not supported for this category"}), 400
logger.info(f"Test execution completed - Success: {success}")
if not success:
logger.warning(f"Test execution failed - {message}")
logger.error(f"Test execution failed: {message}")
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": True, "tests": updated_tests}), 200

View File

@@ -542,76 +542,67 @@ def test_regex_pattern(
pattern: str,
tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]:
"""
Test a regex pattern against a list of test cases using PCRE2 compatible engine.
Test a regex pattern against a list of test cases using .NET regex engine via PowerShell.
Returns match information along with test results.
"""
logger.info(f"Starting regex pattern test - Pattern: {pattern}")
try:
try:
compiled_pattern = regex.compile(pattern,
regex.V1 | regex.IGNORECASE)
logger.info(
"Pattern compiled successfully with PCRE2 compatibility")
except regex.error as e:
logger.warning(f"Invalid regex pattern: {str(e)}")
return False, f"Invalid regex pattern: {str(e)}", tests
current_time = datetime.now().isoformat()
logger.info(f"Processing {len(tests)} test cases")
for test in tests:
test_id = test.get('id', 'unknown')
test_input = test.get('input', '')
expected = test.get('expected', False)
try:
match = compiled_pattern.search(test_input)
matches = bool(match)
# Update test result with basic fields
test['passes'] = matches == expected
test['lastRun'] = current_time
# Add match information
if match:
test['matchedContent'] = match.group(0)
test['matchSpan'] = {
'start': match.start(),
'end': match.end()
}
# Get all capture groups if they exist
test['matchedGroups'] = [g for g in match.groups()
] if match.groups() else []
else:
test['matchedContent'] = None
test['matchSpan'] = None
test['matchedGroups'] = []
logger.info(
f"Test {test_id} {'passed' if test['passes'] else 'failed'} - Match: {matches}, Expected: {expected}"
)
except Exception as e:
logger.error(f"Error running test {test_id}: {str(e)}")
test['passes'] = False
test['lastRun'] = current_time
test['matchedContent'] = None
test['matchSpan'] = None
test['matchedGroups'] = []
# Log overall results
passed_tests = sum(1 for test in tests if test.get('passes', False))
logger.info(
f"Test execution complete - {passed_tests}/{len(tests)} tests passed"
# Get the path to the test.ps1 script
script_path = os.path.join('/app', 'scripts', 'test.ps1')
if not os.path.exists(script_path):
# Fallback for local development
script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'scripts', 'test.ps1')
# Prepare the input data
input_data = {
'pattern': pattern,
'tests': tests
}
# Run PowerShell script
result = subprocess.run(
['pwsh', '-File', script_path],
input=json.dumps(input_data),
capture_output=True,
text=True,
timeout=10
)
return True, "", tests
if result.returncode != 0 and not result.stdout:
logger.error(f"PowerShell script failed: {result.stderr}")
return False, "Failed to run tests", tests
# Parse JSON output
try:
output = json.loads(result.stdout.strip())
except json.JSONDecodeError:
# Try to find JSON in the output
lines = result.stdout.strip().split('\n')
for line in reversed(lines):
if line.strip():
try:
output = json.loads(line)
break
except json.JSONDecodeError:
continue
else:
logger.error(f"No valid JSON found in output: {result.stdout}")
return False, "Failed to parse test results", tests
if output.get('success'):
return True, "Tests completed successfully", output.get('tests', tests)
else:
return False, output.get('message', 'Tests failed'), tests
except subprocess.TimeoutExpired:
logger.error("Test execution timed out")
return False, "Test execution timed out", tests
except FileNotFoundError:
logger.error("PowerShell (pwsh) not found")
return False, "PowerShell is not available", tests
except Exception as e:
logger.error(f"Unexpected error in test_regex_pattern: {str(e)}",
exc_info=True)
return False, str(e), tests
logger.error(f"Error running tests: {e}")
return False, f"Test error: {str(e)}", tests
def test_format_conditions(conditions: List[Dict],

107
backend/scripts/test.ps1 Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env pwsh
# Run regex tests against a pattern
# Set output encoding to UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
# Read from stdin
$inputText = $input
if (-not $inputText) {
$inputText = [System.Console]::In.ReadToEnd()
}
if (-not $inputText) {
Write-Output (ConvertTo-Json @{
success = $false
message = "No input provided"
} -Compress)
exit 0
}
try {
$data = $inputText | ConvertFrom-Json
$Pattern = $data.pattern
$tests = $data.tests
}
catch {
Write-Output (ConvertTo-Json @{
success = $false
message = "Failed to parse input JSON: $_"
} -Compress)
exit 0
}
# Ensure we have required inputs
if ([string]::IsNullOrWhiteSpace($Pattern)) {
Write-Output (ConvertTo-Json @{
success = $false
message = "No pattern provided"
} -Compress)
exit 0
}
if (-not $tests -or $tests.Count -eq 0) {
Write-Output (ConvertTo-Json @{
success = $false
message = "No tests provided"
} -Compress)
exit 0
}
try {
# Create the regex object with case-insensitive option
$regex = [System.Text.RegularExpressions.Regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
# Process each test
$results = @()
foreach ($test in $tests) {
$match = $regex.Match($test.input)
$passes = ($match.Success -eq $test.expected)
$result = @{
id = $test.id
input = $test.input
expected = $test.expected
passes = $passes
}
if ($match.Success) {
# Include match details for highlighting (using original format)
$result.matchedContent = $match.Value
$result.matchSpan = @{
start = $match.Index
end = $match.Index + $match.Length
}
# Include capture groups if any
$groups = @()
for ($i = 1; $i -lt $match.Groups.Count; $i++) {
if ($match.Groups[$i].Success) {
$groups += $match.Groups[$i].Value
}
}
$result.matchedGroups = $groups
}
else {
$result.matchedContent = $null
$result.matchSpan = $null
$result.matchedGroups = @()
}
$results += $result
}
Write-Output (ConvertTo-Json @{
success = $true
tests = $results
} -Compress -Depth 10)
}
catch {
Write-Output (ConvertTo-Json @{
success = $false
message = $_.Exception.Message
} -Compress)
}

View File

@@ -23,15 +23,13 @@ const AddUnitTestModal = ({isOpen, onClose, onAdd, tests, editTest = null}) => {
const handleSubmit = () => {
const getNextTestId = testArray => {
if (!testArray || testArray.length === 0) return 1;
return Math.max(...testArray.map(test => test.id)) + 1;
return Math.max(...testArray.map(test => test.id || 0)) + 1;
};
const testData = {
id: editTest ? editTest.id : getNextTestId(tests),
input,
expected: shouldMatch,
passes: false,
lastRun: null
expected: shouldMatch
};
onAdd(testData);

View File

@@ -6,7 +6,7 @@ import RegexTestingTab from './RegexTestingTab';
import {useRegexModal} from '@hooks/useRegexModal';
import {RegexPatterns} from '@api/data';
import Alert from '@ui/Alert';
import {Loader, Play} from 'lucide-react';
import {Loader, Play, Save, Trash2, Check} from 'lucide-react';
const RegexModal = ({
pattern: initialPattern,
@@ -84,12 +84,13 @@ const RegexModal = ({
{initialPattern && !isCloning && (
<button
onClick={handleDelete}
className={`px-4 py-2 text-white rounded transition-colors ${
isDeleting
? 'bg-red-600 hover:bg-red-700'
: 'bg-red-500 hover:bg-red-600'
}`}>
{isDeleting ? 'Confirm Delete' : 'Delete'}
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
{isDeleting ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Trash2 className="w-4 h-4 text-red-500" />
)}
<span>Delete</span>
</button>
)}
<div className='flex gap-2'>
@@ -97,20 +98,20 @@ const RegexModal = ({
<button
onClick={() => handleRunTests(patternValue, tests)}
disabled={isRunningTests}
className='inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700
disabled:bg-green-600/50 text-white rounded transition-colors'>
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 disabled:opacity-50 transition-colors'>
{isRunningTests ? (
<Loader className='w-4 h-4 mr-2 animate-spin' />
<Loader className="w-4 h-4 text-yellow-500 animate-spin" />
) : (
<Play className='w-4 h-4 mr-2' />
<Play className="w-4 h-4 text-green-500" />
)}
Run Tests
<span>Run Tests</span>
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded transition-colors'>
Save
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
<Save className="w-4 h-4 text-blue-500" />
<span>Save</span>
</button>
</div>
</div>

View File

@@ -13,52 +13,47 @@ const RegexTestingTab = ({
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTest, setEditingTest] = useState(null);
const [testResults, setTestResults] = useState({});
// Wrapped run tests function that stores results
const handleRunTests = useCallback(async (testPattern, testData) => {
const results = await onRunTests(testPattern, testData);
if (results && Array.isArray(results)) {
// Store results by test ID
const resultsMap = {};
results.forEach(result => {
resultsMap[result.id] = result;
});
setTestResults(resultsMap);
}
return results;
}, [onRunTests]);
useEffect(() => {
const needsAutoRun =
tests?.length > 0 &&
pattern &&
tests.some(test => test.passes !== undefined && !test.matchSpan);
if (needsAutoRun && !isRunningTests) {
onRunTests(pattern, tests);
// Run tests when pattern or tests change
if (tests?.length > 0 && pattern && !isRunningTests) {
handleRunTests(pattern, tests);
}
}, []);
}, [pattern]); // Only re-run when pattern changes
const handleAddOrUpdateTest = useCallback(
testData => {
let updatedTests;
if (editingTest) {
updatedTests = tests.map(test =>
test.id === testData.id
? {
...testData,
passes: false,
lastRun: null,
matchedContent: null,
matchSpan: null,
matchedGroups: []
}
: test
test.id === testData.id ? testData : test
);
} else {
updatedTests = [
...tests,
{
...testData,
passes: false,
lastRun: null,
matchedContent: null,
matchSpan: null,
matchedGroups: []
}
];
updatedTests = [...tests, testData];
}
onTestsChange(updatedTests);
onRunTests(pattern, updatedTests);
// Run tests automatically after adding/updating
if (pattern) {
handleRunTests(pattern, updatedTests);
}
setEditingTest(null);
},
[tests, onTestsChange, onRunTests, pattern, editingTest]
[tests, onTestsChange, handleRunTests, pattern, editingTest]
);
const handleEditTest = useCallback(test => {
@@ -80,72 +75,81 @@ const RegexTestingTab = ({
}, []);
const totalTests = tests?.length || 0;
const passedTests = tests?.filter(test => test.passes)?.length || 0;
const passedTests = tests?.filter(test => {
const result = testResults[test.id];
return result?.passes;
})?.length || 0;
return (
<div className='flex flex-col h-full'>
{/* Header with Progress Bar */}
<div className='flex items-center justify-between pb-4 pr-2'>
{/* Header */}
<div className='flex items-center justify-between pb-4'>
<div>
<h2 className='text-xl font-semibold text-gray-900 dark:text-white mb-3'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-white mb-1'>
Unit Tests
</h2>
<div className='flex items-center gap-3'>
<div className='h-1.5 w-32 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
<div
className='h-full bg-emerald-500 rounded-full transition-all duration-300'
style={{
width: `${
totalTests
? (passedTests / totalTests) * 100
: 0
}%`
}}
/>
</div>
<span className='text-sm text-gray-600 dark:text-gray-300'>
{totalTests > 0
? `${passedTests}/${totalTests} tests passing`
: 'No tests added yet'}
</span>
</div>
{totalTests > 0 && (
<p className='text-sm text-gray-600 dark:text-gray-400'>
{passedTests} of {totalTests} tests passing
{totalTests > 0 && ` (${Math.round((passedTests / totalTests) * 100)}%)`}
</p>
)}
</div>
<div className='flex items-center gap-2'>
{tests?.length > 0 && (
<button
onClick={() => onRunTests(pattern, tests)}
onClick={() => handleRunTests(pattern, tests)}
disabled={isRunningTests}
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-green-600/50 text-white'>
className='inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 disabled:opacity-50 transition-colors'>
{isRunningTests ? (
<Loader className='w-4 h-4 mr-2 animate-spin' />
<Loader className='w-3.5 h-3.5 text-yellow-500 animate-spin' />
) : (
<Play className='w-4 h-4 mr-2' />
<Play className='w-3.5 h-3.5 text-green-500' />
)}
Run Tests
<span>Run Tests</span>
</button>
)}
<button
onClick={() => setIsModalOpen(true)}
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 text-white'>
<Plus className='w-4 h-4 mr-2' />
Add Test
className='inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
<Plus className='w-3.5 h-3.5 text-blue-500' />
<span>Add Test</span>
</button>
</div>
</div>
{/* Progress Bar */}
{totalTests > 0 && (
<div className='mb-4'>
<div className='h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
<div
className='h-full bg-emerald-500 transition-all duration-500 ease-out'
style={{width: `${(passedTests / totalTests) * 100}%`}}
/>
</div>
</div>
)}
{/* Test List */}
<div className='flex-1 overflow-y-auto pr-2'>
{tests?.length > 0 ? (
<div className='space-y-3'>
{tests.map(test => (
<UnitTest
key={test.id}
test={test}
pattern={pattern}
onDelete={() => handleDeleteTest(test.id)}
onEdit={() => handleEditTest(test)}
/>
))}
{tests.map(test => {
// Merge saved test with runtime results
const testWithResults = {
...test,
...testResults[test.id]
};
return (
<UnitTest
key={test.id}
test={testWithResults}
pattern={pattern}
onDelete={() => handleDeleteTest(test.id)}
onEdit={() => handleEditTest(test)}
/>
);
})}
</div>
) : (
<div className='text-center py-12 rounded-lg'>
@@ -173,15 +177,7 @@ RegexTestingTab.propTypes = {
PropTypes.shape({
id: PropTypes.number.isRequired,
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string,
matchedContent: PropTypes.string,
matchedGroups: PropTypes.arrayOf(PropTypes.string),
matchSpan: PropTypes.shape({
start: PropTypes.number,
end: PropTypes.number
})
expected: PropTypes.bool.isRequired
})
),
onTestsChange: PropTypes.func.isRequired,

View File

@@ -68,11 +68,7 @@ const UnitTest = ({test, pattern, onDelete, onEdit}) => {
: 'Should Not Match'}
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-xs text-gray-500 dark:text-gray-400'>
Last run: {test.lastRun}
</span>
<div className='flex gap-2'>
<div className='flex gap-2'>
<button
onClick={onEdit}
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
@@ -83,7 +79,6 @@ const UnitTest = ({test, pattern, onDelete, onEdit}) => {
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
<Trash2 className='w-4 h-4 text-gray-500 dark:text-gray-400' />
</button>
</div>
</div>
</div>
@@ -112,7 +107,6 @@ UnitTest.propTypes = {
input: PropTypes.string.isRequired,
expected: PropTypes.bool.isRequired,
passes: PropTypes.bool.isRequired,
lastRun: PropTypes.string,
matchedContent: PropTypes.string,
matchedGroups: PropTypes.arrayOf(PropTypes.string),
matchSpan: PropTypes.shape({

View File

@@ -79,12 +79,19 @@ export const useRegexModal = (initialPattern, onSave) => {
}
try {
// Clean tests to only include saved data
const cleanTests = tests.map((test, index) => ({
id: test.id || index + 1,
input: test.input,
expected: test.expected
}));
const data = {
name,
pattern: patternValue,
description,
tags,
tests
tests: cleanTests
};
if (initialPattern && !isCloning) {
@@ -111,15 +118,16 @@ export const useRegexModal = (initialPattern, onSave) => {
const handleRunTests = useCallback(
async (pattern, tests) => {
try {
const updatedTests = await runTests(pattern, tests);
if (updatedTests) {
setTests(updatedTests);
}
const testResults = await runTests(pattern, tests);
// We don't update the tests state with results
// Results are only used for display, not saved
return testResults;
} catch (error) {
console.error('Error running tests:', error);
Alert.error(
error.message || 'Failed to run tests. Please try again.'
);
return null;
}
},
[runTests]

View File

@@ -34,14 +34,12 @@ export const useRegexTesting = onUpdateTests => {
}
);
// Update tests through the callback
if (onUpdateTests) {
onUpdateTests(result.tests);
}
// Return the test results (with match information)
// Don't save these results, just return them for display
return result.tests;
} else {
Alert.error(result.message || 'Failed to run tests');
return tests;
return null;
}
} catch (error) {
console.error('Error running tests:', error);