| |
| |
| |
|
|
|
|
| import * as fs from 'fs'
|
| import * as path from 'path'
|
| import {execSync} from 'child_process'
|
| import {als, PRIXContext} from '../context'
|
| import {setCommenterContext} from '../commenter'
|
| import {octokit, setOctokit} from '../octokit'
|
| import {run as reviewPipeline} from '../review'
|
| import {Options} from '../options'
|
| import {Prompts} from '../prompts'
|
| import {MockGitHubLayer, suppressGitHubCalls} from './mock-github'
|
| import {
|
| TestCase,
|
| ExpectedResult,
|
| TestOutput,
|
| DiffFile,
|
| MockPRContext
|
| } from './types'
|
|
|
| export interface TestRunnerOptions {
|
| testsDir: string
|
| outputDir: string
|
| verbose?: boolean
|
| skipAutoPR?: boolean
|
| mockAI?: boolean
|
| }
|
|
|
| export class TestRunner {
|
| private options: TestRunnerOptions
|
| private currentTest: string | null = null
|
|
|
| constructor(options: TestRunnerOptions) {
|
| this.options = {
|
| verbose: false,
|
| skipAutoPR: true,
|
| mockAI: false,
|
| ...options
|
| }
|
|
|
|
|
| if (!fs.existsSync(this.options.outputDir)) {
|
| fs.mkdirSync(this.options.outputDir, {recursive: true})
|
| }
|
| }
|
|
|
| |
| |
|
|
| loadTestCases(): TestCase[] {
|
| const tests: TestCase[] = []
|
| const categories = fs
|
| .readdirSync(this.options.testsDir)
|
| .filter(f =>
|
| fs.statSync(path.join(this.options.testsDir, f)).isDirectory()
|
| )
|
|
|
| for (const category of categories) {
|
| const categoryPath = path.join(this.options.testsDir, category)
|
| const testDirs = fs
|
| .readdirSync(categoryPath)
|
| .filter(f => fs.statSync(path.join(categoryPath, f)).isDirectory())
|
|
|
| for (const testDir of testDirs) {
|
| const testPath = path.join(categoryPath, testDir)
|
| const expectedPath = path.join(testPath, 'expected.json')
|
|
|
| if (!fs.existsSync(expectedPath)) {
|
| console.warn(`⚠️ Skipping ${testDir}: no expected.json found`)
|
| continue
|
| }
|
|
|
| const expected: ExpectedResult = JSON.parse(
|
| fs.readFileSync(expectedPath, 'utf8')
|
| )
|
|
|
| tests.push({
|
| name: testDir,
|
| path: testPath,
|
| category,
|
| basePath: path.join(testPath, 'base'),
|
| headPath: path.join(testPath, 'head'),
|
| expected
|
| })
|
| }
|
| }
|
|
|
| return tests
|
| }
|
|
|
| |
| |
|
|
| async runTestCase(testCase: TestCase): Promise<TestOutput> {
|
| const startTime = Date.now()
|
| this.currentTest = testCase.name
|
|
|
| console.log(`\n🧪 Running test: ${testCase.category}/${testCase.name}`)
|
|
|
| try {
|
|
|
| const files = this.loadDiffFiles(testCase)
|
|
|
| if (files.length === 0) {
|
| throw new Error('No files found in base/head directories')
|
| }
|
|
|
|
|
| const diffFiles = this.generateDiffs(files)
|
|
|
|
|
| const mockContext: MockPRContext = {
|
| number: 1,
|
| title: `[EVAL] ${testCase.name}`,
|
| body: 'Test PR for evaluation',
|
| base: {ref: testCase.basePath, sha: 'base-sha'},
|
| head: {ref: testCase.headPath, sha: 'head-sha'},
|
| files: diffFiles
|
| }
|
|
|
|
|
| const logFile = path.join(this.options.outputDir, `${testCase.name}.log`)
|
| const mockLayer = new MockGitHubLayer(mockContext, {logFile})
|
| const probotContext = mockLayer.createMockProbotContext()
|
|
|
|
|
| suppressGitHubCalls()
|
|
|
|
|
| const mockOctokit = mockLayer.createMockOctokit()
|
| const store: PRIXContext = {
|
| probotContext,
|
| octokit: mockOctokit,
|
| repo: {owner: 'mock-owner', repo: 'mock-repo'},
|
| workingDir: testCase.headPath
|
| }
|
|
|
| let output: TestOutput = {
|
| detectedIssues: false,
|
| autoPRTriggered: false,
|
| suggestions: [],
|
| comments: [],
|
| errors: [],
|
| executionTime: 0,
|
| changedLines: 0
|
| }
|
|
|
|
|
| await als.run(store, async () => {
|
|
|
| setCommenterContext(probotContext)
|
| setOctokit(probotContext.octokit)
|
|
|
|
|
| const options = new Options(
|
| this.options.verbose || false,
|
| false,
|
| true,
|
| '0',
|
| false,
|
| false,
|
| null,
|
| '',
|
| 'openai/gpt-oss-120b:free',
|
| 'openai/gpt-oss-120b:free',
|
| '0.0',
|
| '3',
|
| '120000',
|
| '6',
|
| '6',
|
| 'https://openrouter.ai/api/v1',
|
| 'en-US',
|
| false,
|
| !this.options.skipAutoPR,
|
| false
|
| )
|
|
|
| const prompts = new Prompts()
|
|
|
| try {
|
|
|
| await reviewPipeline(probotContext, options, prompts)
|
|
|
|
|
| const comments = mockLayer.getComments()
|
| const prs = mockLayer.getCreatedPRs()
|
|
|
|
|
| const changedLines = diffFiles.reduce((sum, f) => {
|
| return (
|
| sum +
|
| f.diff
|
| .split('\n')
|
| .filter(l => l.startsWith('+') || l.startsWith('-')).length
|
| )
|
| }, 0)
|
|
|
|
|
| const suggestions = this.parseSuggestionsFromComments(comments)
|
|
|
| output = {
|
| detectedIssues: comments.length > 0 || suggestions.length > 0,
|
| issueType: this.inferIssueType(comments),
|
| confidence: this.inferConfidence(comments),
|
| autoPRTriggered: prs.length > 0,
|
| suggestions,
|
| comments,
|
| errors: [],
|
| executionTime: Date.now() - startTime,
|
| changedLines
|
| }
|
| } catch (err: any) {
|
| output.errors.push(err.message)
|
| console.error(`❌ Pipeline error: ${err.message}`)
|
| }
|
| })
|
|
|
| return output
|
| } catch (err: any) {
|
| return {
|
| detectedIssues: false,
|
| autoPRTriggered: false,
|
| suggestions: [],
|
| comments: [],
|
| errors: [err.message],
|
| executionTime: Date.now() - startTime,
|
| changedLines: 0
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| private loadDiffFiles(
|
| testCase: TestCase
|
| ): Array<{filename: string; base: string; head: string}> {
|
| const files: Array<{filename: string; base: string; head: string}> = []
|
|
|
| if (!fs.existsSync(testCase.headPath)) {
|
| return files
|
| }
|
|
|
| const headFiles = this.getAllFiles(testCase.headPath)
|
|
|
| for (const relPath of headFiles) {
|
| const headFile = path.join(testCase.headPath, relPath)
|
| const baseFile = path.join(testCase.basePath, relPath)
|
|
|
| const headContent = fs.readFileSync(headFile, 'utf8')
|
| const baseContent = fs.existsSync(baseFile)
|
| ? fs.readFileSync(baseFile, 'utf8')
|
| : ''
|
|
|
| files.push({
|
| filename: relPath,
|
| base: baseContent,
|
| head: headContent
|
| })
|
| }
|
|
|
| return files
|
| }
|
|
|
| |
| |
|
|
| private getAllFiles(dir: string, basePath: string = ''): string[] {
|
| const files: string[] = []
|
| const items = fs.readdirSync(dir)
|
|
|
| for (const item of items) {
|
| const fullPath = path.join(dir, item)
|
| const relPath = basePath ? path.join(basePath, item) : item
|
|
|
| if (fs.statSync(fullPath).isDirectory()) {
|
| files.push(...this.getAllFiles(fullPath, relPath))
|
| } else {
|
| files.push(relPath)
|
| }
|
| }
|
|
|
| return files
|
| }
|
|
|
| |
| |
|
|
| private generateDiffs(
|
| files: Array<{filename: string; base: string; head: string}>
|
| ): DiffFile[] {
|
| return files.map(f => {
|
| const diff = this.createUnifiedDiff(f.filename, f.base, f.head)
|
| return {
|
| filename: f.filename,
|
| baseContent: f.base,
|
| headContent: f.head,
|
| diff
|
| }
|
| })
|
| }
|
|
|
| |
| |
|
|
| private createUnifiedDiff(
|
| filename: string,
|
| base: string,
|
| head: string
|
| ): string {
|
| const baseLines = base.split('\n')
|
| const headLines = head.split('\n')
|
|
|
| let diff = `--- a/${filename}\n+++ b/${filename}\n`
|
|
|
|
|
| const maxLen = Math.max(baseLines.length, headLines.length)
|
|
|
| for (let i = 0; i < maxLen; i++) {
|
| const baseLine = baseLines[i] || ''
|
| const headLine = headLines[i] || ''
|
|
|
| if (baseLine !== headLine) {
|
| if (baseLine !== undefined) {
|
| diff += `-${baseLine}\n`
|
| }
|
| if (headLine !== undefined) {
|
| diff += `+${headLine}\n`
|
| }
|
| } else {
|
| diff += ` ${baseLine}\n`
|
| }
|
| }
|
|
|
| return diff
|
| }
|
|
|
| |
| |
|
|
| private parseSuggestionsFromComments(comments: any[]): any[] {
|
| const suggestions: any[] = []
|
|
|
| for (const comment of comments) {
|
|
|
| const suggestionMatch = comment.body.match(
|
| /```suggestion\n([\s\S]*?)\n```/
|
| )
|
| if (suggestionMatch) {
|
| suggestions.push({
|
| filename: comment.path,
|
| startLine: comment.line || 1,
|
| endLine: comment.line || 1,
|
| suggestion: suggestionMatch[1].trim()
|
| })
|
| }
|
| }
|
|
|
| return suggestions
|
| }
|
|
|
| |
| |
|
|
| private inferIssueType(comments: any[]): any {
|
| const types = ['security', 'bug', 'performance', 'style']
|
| const text = comments
|
| .map(c => c.body)
|
| .join(' ')
|
| .toLowerCase()
|
|
|
| for (const type of types) {
|
| if (text.includes(type)) return type
|
| }
|
| return 'style'
|
| }
|
|
|
| |
| |
|
|
| private inferConfidence(comments: any[]): any {
|
| const text = comments
|
| .map(c => c.body)
|
| .join(' ')
|
| .toLowerCase()
|
|
|
| if (text.includes('high confidence') || text.includes('critical'))
|
| return 'high'
|
| if (text.includes('low confidence') || text.includes('minor')) return 'low'
|
| return 'medium'
|
| }
|
| }
|
|
|
| |
| |
|
|
| export async function runTestCase(
|
| testPath: string,
|
| options?: Partial<TestRunnerOptions>
|
| ): Promise<TestOutput> {
|
| const expectedPath = path.join(testPath, 'expected.json')
|
|
|
| if (!fs.existsSync(expectedPath)) {
|
| throw new Error(`No expected.json found at ${testPath}`)
|
| }
|
|
|
| const expected = JSON.parse(fs.readFileSync(expectedPath, 'utf8'))
|
|
|
| const testCase: TestCase = {
|
| name: path.basename(testPath),
|
| path: testPath,
|
| category: path.basename(path.dirname(testPath)),
|
| basePath: path.join(testPath, 'base'),
|
| headPath: path.join(testPath, 'head'),
|
| expected
|
| }
|
|
|
| const runner = new TestRunner({
|
| testsDir: path.dirname(path.dirname(testPath)),
|
| outputDir: path.join(testPath, '..', '..', 'output'),
|
| ...options
|
| })
|
|
|
| return runner.runTestCase(testCase)
|
| }
|
|
|