mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: add analyze-pr job to QA pipeline
Add Gemini Pro-powered PR analysis that generates targeted QA guides from the full PR thread (description, comments, screenshots, diff). The analyze-pr job runs on lightweight ubuntu before recordings start, producing qa-guide-before.json and qa-guide-after.json that are downloaded by recording jobs to produce more focused test steps. Graceful fallback: if analysis fails, recordings proceed without guides.
This commit is contained in:
101
.github/workflows/pr-qa.yaml
vendored
101
.github/workflows/pr-qa.yaml
vendored
@@ -71,12 +71,70 @@ jobs:
|
||||
fi
|
||||
echo "Mode: $([ "$FULL" = "true" ] && echo full || echo focused)"
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Analyze PR — deep analysis via Gemini Pro to generate QA guides
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
analyze-pr:
|
||||
needs: [resolve-matrix]
|
||||
if: needs.resolve-matrix.outputs.skip != 'true' && needs.resolve-matrix.outputs.mode == 'focused'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
|
||||
- name: Setup frontend (scripts only)
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: false
|
||||
|
||||
- name: Resolve PR number
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
NUM=$(gh pr list --repo "${{ github.repository }}" \
|
||||
--head "${{ github.ref_name }}" --state open \
|
||||
--json number --jq '.[0].number // empty')
|
||||
echo "number=${NUM}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run PR analysis
|
||||
if: steps.pr.outputs.number
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p qa-guides
|
||||
pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
--pr-number "${{ steps.pr.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--output-dir qa-guides
|
||||
|
||||
- name: Upload QA guides
|
||||
if: always() && steps.pr.outputs.number
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6.2.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides/qa-guide-*.json
|
||||
retention-days: 14
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BEFORE recording — main branch frontend on its own runner
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
qa-before:
|
||||
needs: resolve-matrix
|
||||
if: needs.resolve-matrix.outputs.skip != 'true' && needs.resolve-matrix.outputs.mode == 'focused'
|
||||
needs: [resolve-matrix, analyze-pr]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.resolve-matrix.outputs.skip != 'true' &&
|
||||
needs.resolve-matrix.outputs.mode == 'focused' &&
|
||||
(needs.analyze-pr.result == 'success' || needs.analyze-pr.result == 'skipped')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -138,6 +196,13 @@ jobs:
|
||||
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
|
||||
sed 's|diff --git a/||;s| b/.*||' | sort -u
|
||||
|
||||
- name: Download QA guide
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides
|
||||
|
||||
- name: Start server with main branch frontend
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
@@ -168,12 +233,18 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
run: |
|
||||
QA_GUIDE_FLAG=""
|
||||
if [ -f qa-guides/qa-guide-before.json ]; then
|
||||
echo "Using QA guide for before recording"
|
||||
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-before.json"
|
||||
fi
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode before \
|
||||
--diff "${{ runner.temp }}/pr-diff.txt" \
|
||||
--output-dir "$QA_ARTIFACTS" \
|
||||
--url http://127.0.0.1:8188 \
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md \
|
||||
$QA_GUIDE_FLAG
|
||||
|
||||
- name: Collect artifacts
|
||||
if: always()
|
||||
@@ -195,8 +266,11 @@ jobs:
|
||||
# AFTER recording — PR branch frontend on its own runner
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
qa-after:
|
||||
needs: resolve-matrix
|
||||
if: needs.resolve-matrix.outputs.skip != 'true'
|
||||
needs: [resolve-matrix, analyze-pr]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.resolve-matrix.outputs.skip != 'true' &&
|
||||
(needs.analyze-pr.result == 'success' || needs.analyze-pr.result == 'skipped')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -244,6 +318,13 @@ jobs:
|
||||
grep '^diff --git' "${{ runner.temp }}/pr-diff.txt" | \
|
||||
sed 's|diff --git a/||;s| b/.*||' | sort -u
|
||||
|
||||
- name: Download QA guide
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: qa-guides-${{ github.run_id }}
|
||||
path: qa-guides
|
||||
|
||||
- name: Start server with PR branch frontend
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
@@ -274,12 +355,18 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
run: |
|
||||
QA_GUIDE_FLAG=""
|
||||
if [ -f qa-guides/qa-guide-after.json ]; then
|
||||
echo "Using QA guide for after recording"
|
||||
QA_GUIDE_FLAG="--qa-guide qa-guides/qa-guide-after.json"
|
||||
fi
|
||||
pnpm exec tsx scripts/qa-record.ts \
|
||||
--mode after \
|
||||
--diff "${{ runner.temp }}/pr-diff.txt" \
|
||||
--output-dir "$QA_ARTIFACTS" \
|
||||
--url http://127.0.0.1:8188 \
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md
|
||||
--test-plan .claude/skills/comfy-qa/SKILL.md \
|
||||
$QA_GUIDE_FLAG
|
||||
|
||||
- name: Collect artifacts
|
||||
if: always()
|
||||
@@ -301,7 +388,7 @@ jobs:
|
||||
# Report — merges artifacts, runs Gemini video review, deploys
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
report:
|
||||
needs: [resolve-matrix, qa-before, qa-after]
|
||||
needs: [resolve-matrix, analyze-pr, qa-before, qa-after]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.qa-after.result == 'success' || needs.qa-before.result == 'success') &&
|
||||
|
||||
84
scripts/qa-analyze-pr.test.ts
Normal file
84
scripts/qa-analyze-pr.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractMediaUrls } from './qa-analyze-pr'
|
||||
|
||||
describe('extractMediaUrls', () => {
|
||||
it('extracts markdown image URLs', () => {
|
||||
const text = ''
|
||||
expect(extractMediaUrls(text)).toEqual(['https://example.com/image.png'])
|
||||
})
|
||||
|
||||
it('extracts multiple markdown images', () => {
|
||||
const text = [
|
||||
'',
|
||||
'Some text',
|
||||
''
|
||||
].join('\n')
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://example.com/before.png',
|
||||
'https://example.com/after.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts raw URLs with media extensions', () => {
|
||||
const text = 'Check this: https://cdn.example.com/demo.mp4 for details'
|
||||
expect(extractMediaUrls(text)).toEqual(['https://cdn.example.com/demo.mp4'])
|
||||
})
|
||||
|
||||
it('extracts GitHub user-attachments URLs', () => {
|
||||
const text =
|
||||
'https://github.com/user-attachments/assets/abc12345-6789-0def-1234-567890abcdef'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://github.com/user-attachments/assets/abc12345-6789-0def-1234-567890abcdef'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts private-user-images URLs', () => {
|
||||
const text =
|
||||
'https://private-user-images.githubusercontent.com/12345/abcdef-1234?jwt=token123'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://private-user-images.githubusercontent.com/12345/abcdef-1234?jwt=token123'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts URLs with query parameters', () => {
|
||||
const text = 'https://example.com/image.png?w=800&h=600'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://example.com/image.png?w=800&h=600'
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates URLs', () => {
|
||||
const text = [
|
||||
'',
|
||||
'',
|
||||
'Also https://example.com/same.png'
|
||||
].join('\n')
|
||||
expect(extractMediaUrls(text)).toEqual(['https://example.com/same.png'])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractMediaUrls('')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for text with no media URLs', () => {
|
||||
expect(extractMediaUrls('Just some text without any URLs')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles mixed media types', () => {
|
||||
const text = [
|
||||
'',
|
||||
'Video: https://example.com/demo.webm',
|
||||
''
|
||||
].join('\n')
|
||||
const urls = extractMediaUrls(text)
|
||||
expect(urls).toContain('https://example.com/screenshot.png')
|
||||
expect(urls).toContain('https://example.com/demo.webm')
|
||||
expect(urls).toContain('https://example.com/animation.gif')
|
||||
})
|
||||
|
||||
it('ignores non-http URLs in markdown', () => {
|
||||
const text = ''
|
||||
expect(extractMediaUrls(text)).toEqual([])
|
||||
})
|
||||
})
|
||||
510
scripts/qa-analyze-pr.ts
Normal file
510
scripts/qa-analyze-pr.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* QA PR Analysis Script
|
||||
*
|
||||
* Deeply analyzes a PR using Gemini Pro to generate targeted QA guides
|
||||
* for before/after recording sessions. Fetches PR thread, extracts media,
|
||||
* and produces structured test plans.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
* --pr-number 10270 \
|
||||
* --repo owner/repo \
|
||||
* --output-dir qa-guides/ \
|
||||
* [--model gemini-2.5-pro]
|
||||
*
|
||||
* Env: GEMINI_API_KEY (required)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface QaGuideStep {
|
||||
action: string
|
||||
description: string
|
||||
expected_before?: string
|
||||
expected_after?: string
|
||||
}
|
||||
|
||||
interface QaGuide {
|
||||
summary: string
|
||||
test_focus: string
|
||||
prerequisites: string[]
|
||||
steps: QaGuideStep[]
|
||||
visual_checks: string[]
|
||||
}
|
||||
|
||||
interface PrThread {
|
||||
title: string
|
||||
body: string
|
||||
labels: string[]
|
||||
issueComments: string[]
|
||||
reviewComments: string[]
|
||||
reviews: string[]
|
||||
diff: string
|
||||
}
|
||||
|
||||
interface Options {
|
||||
prNumber: string
|
||||
repo: string
|
||||
outputDir: string
|
||||
model: string
|
||||
apiKey: string
|
||||
mediaBudgetBytes: number
|
||||
maxVideoBytes: number
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Partial<Options> = {
|
||||
model: 'gemini-2.5-pro',
|
||||
apiKey: process.env.GEMINI_API_KEY || '',
|
||||
mediaBudgetBytes: 20 * 1024 * 1024,
|
||||
maxVideoBytes: 10 * 1024 * 1024
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--pr-number':
|
||||
opts.prNumber = args[++i]
|
||||
break
|
||||
case '--repo':
|
||||
opts.repo = args[++i]
|
||||
break
|
||||
case '--output-dir':
|
||||
opts.outputDir = args[++i]
|
||||
break
|
||||
case '--model':
|
||||
opts.model = args[++i]
|
||||
break
|
||||
case '--help':
|
||||
console.warn(
|
||||
'Usage: qa-analyze-pr.ts --pr-number <num> --repo <owner/repo> --output-dir <path> [--model <model>]'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.prNumber || !opts.repo || !opts.outputDir) {
|
||||
console.error(
|
||||
'Required: --pr-number <num> --repo <owner/repo> --output-dir <path>'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!opts.apiKey) {
|
||||
console.error('GEMINI_API_KEY environment variable is required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return opts as Options
|
||||
}
|
||||
|
||||
// ── PR thread fetching ──
|
||||
|
||||
function ghExec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
} catch (err) {
|
||||
console.warn(`gh command failed: ${cmd}`)
|
||||
console.warn((err as Error).message)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPrThread(prNumber: string, repo: string): PrThread {
|
||||
console.warn('Fetching PR thread...')
|
||||
|
||||
const prView = ghExec(
|
||||
`gh pr view ${prNumber} --repo ${repo} --json title,body,labels`
|
||||
)
|
||||
const prData = prView
|
||||
? JSON.parse(prView)
|
||||
: { title: '', body: '', labels: [] }
|
||||
|
||||
const issueCommentsRaw = ghExec(
|
||||
`gh api repos/${repo}/issues/${prNumber}/comments --paginate`
|
||||
)
|
||||
const issueComments: string[] = issueCommentsRaw
|
||||
? JSON.parse(issueCommentsRaw).map((c: { body: string }) => c.body)
|
||||
: []
|
||||
|
||||
const reviewCommentsRaw = ghExec(
|
||||
`gh api repos/${repo}/pulls/${prNumber}/comments --paginate`
|
||||
)
|
||||
const reviewComments: string[] = reviewCommentsRaw
|
||||
? JSON.parse(reviewCommentsRaw).map((c: { body: string }) => c.body)
|
||||
: []
|
||||
|
||||
const reviewsRaw = ghExec(
|
||||
`gh api repos/${repo}/pulls/${prNumber}/reviews --paginate`
|
||||
)
|
||||
const reviews: string[] = reviewsRaw
|
||||
? JSON.parse(reviewsRaw)
|
||||
.filter((r: { body: string }) => r.body)
|
||||
.map((r: { body: string }) => r.body)
|
||||
: []
|
||||
|
||||
const diff = ghExec(`gh pr diff ${prNumber} --repo ${repo}`)
|
||||
|
||||
console.warn(
|
||||
`PR #${prNumber}: "${prData.title}" | ` +
|
||||
`${issueComments.length} issue comments, ` +
|
||||
`${reviewComments.length} review comments, ` +
|
||||
`${reviews.length} reviews, ` +
|
||||
`diff: ${diff.length} chars`
|
||||
)
|
||||
|
||||
return {
|
||||
title: prData.title || '',
|
||||
body: prData.body || '',
|
||||
labels: (prData.labels || []).map((l: { name: string }) => l.name),
|
||||
issueComments,
|
||||
reviewComments,
|
||||
reviews,
|
||||
diff
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media extraction ──
|
||||
|
||||
const MEDIA_EXTENSIONS = /\.(png|jpg|jpeg|gif|webp|mp4|webm|mov)$/i
|
||||
|
||||
const MEDIA_URL_PATTERNS = [
|
||||
// Markdown images: 
|
||||
/!\[[^\]]*\]\(([^)]+)\)/g,
|
||||
// GitHub user-attachments
|
||||
/https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+/g,
|
||||
// Private user images
|
||||
/https:\/\/private-user-images\.githubusercontent\.com\/[^\s)"]+/g,
|
||||
// Raw URLs with media extensions (standalone or in text)
|
||||
/(?<!="|=')https?:\/\/[^\s)<>"]+\.(?:png|jpg|jpeg|gif|webp|mp4|webm|mov)(?:\?[^\s)<>"]*)?/gi
|
||||
]
|
||||
|
||||
export function extractMediaUrls(text: string): string[] {
|
||||
if (!text) return []
|
||||
|
||||
const urls = new Set<string>()
|
||||
|
||||
for (const pattern of MEDIA_URL_PATTERNS) {
|
||||
// Reset lastIndex for global patterns
|
||||
pattern.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// For markdown images, the URL is in capture group 1
|
||||
const url = match[1] || match[0]
|
||||
// Clean trailing markdown/html artifacts
|
||||
const cleaned = url.replace(/[)>"'\s]+$/, '')
|
||||
if (cleaned.startsWith('http')) {
|
||||
urls.add(cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
// ── Media downloading ──
|
||||
|
||||
async function downloadMedia(
|
||||
urls: string[],
|
||||
outputDir: string,
|
||||
budgetBytes: number,
|
||||
maxVideoBytes: number
|
||||
): Promise<Array<{ path: string; mimeType: string }>> {
|
||||
const downloaded: Array<{ path: string; mimeType: string }> = []
|
||||
let totalBytes = 0
|
||||
|
||||
const mediaDir = resolve(outputDir, 'media')
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
|
||||
for (const url of urls) {
|
||||
if (totalBytes >= budgetBytes) {
|
||||
console.warn(
|
||||
`Media budget exhausted (${totalBytes} bytes), skipping rest`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: { Accept: 'image/*,video/*' }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to download ${url}: ${response.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
// Skip oversized videos
|
||||
const isVideo =
|
||||
contentType.startsWith('video/') || /\.(mp4|webm|mov)$/i.test(url)
|
||||
if (isVideo && buffer.length > maxVideoBytes) {
|
||||
console.warn(
|
||||
`Skipping large video ${url} (${(buffer.length / 1024 / 1024).toFixed(1)}MB > ${(maxVideoBytes / 1024 / 1024).toFixed(0)}MB cap)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (totalBytes + buffer.length > budgetBytes) {
|
||||
console.warn(`Would exceed budget, skipping ${url}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const ext = guessExtension(url, contentType)
|
||||
const filename = `media-${downloaded.length}${ext}`
|
||||
const filepath = resolve(mediaDir, filename)
|
||||
writeFileSync(filepath, buffer)
|
||||
totalBytes += buffer.length
|
||||
|
||||
const mimeType = contentType.split(';')[0].trim() || guessMimeType(ext)
|
||||
|
||||
downloaded.push({ path: filepath, mimeType })
|
||||
console.warn(
|
||||
`Downloaded: ${url.slice(0, 80)}... (${(buffer.length / 1024).toFixed(0)}KB)`
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`Failed to download ${url}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Downloaded ${downloaded.length}/${urls.length} media files ` +
|
||||
`(${(totalBytes / 1024 / 1024).toFixed(1)}MB)`
|
||||
)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
function guessExtension(url: string, contentType: string): string {
|
||||
const urlMatch = url.match(MEDIA_EXTENSIONS)
|
||||
if (urlMatch) return urlMatch[0].toLowerCase()
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm'
|
||||
}
|
||||
return typeMap[contentType.split(';')[0]] || '.bin'
|
||||
}
|
||||
|
||||
function guessMimeType(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime'
|
||||
}
|
||||
return map[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
// ── Gemini analysis ──
|
||||
|
||||
function buildAnalysisPrompt(thread: PrThread): string {
|
||||
const allText = [
|
||||
`# PR: ${thread.title}`,
|
||||
'',
|
||||
'## Description',
|
||||
thread.body,
|
||||
'',
|
||||
thread.issueComments.length > 0
|
||||
? `## Issue Comments\n${thread.issueComments.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
thread.reviewComments.length > 0
|
||||
? `## Review Comments\n${thread.reviewComments.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
thread.reviews.length > 0
|
||||
? `## Reviews\n${thread.reviews.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
'',
|
||||
'## Diff (truncated)',
|
||||
'```',
|
||||
thread.diff.slice(0, 8000),
|
||||
'```'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return `You are a senior QA engineer analyzing a pull request for ComfyUI frontend (a Vue 3 + TypeScript web application for AI image generation workflows).
|
||||
|
||||
Your task: Generate TWO targeted QA test guides — one for BEFORE the PR (main branch) and one for AFTER (PR branch).
|
||||
|
||||
${allText}
|
||||
|
||||
## Available test actions
|
||||
Each step must use one of these actions:
|
||||
- "openMenu" — clicks the Comfy hamburger menu (top-left C logo)
|
||||
- "hoverMenuItem" — hovers a top-level menu item to open submenu (label required)
|
||||
- "clickMenuItem" — clicks an item in the visible submenu (label required)
|
||||
- "fillDialog" — fills dialog input and presses Enter (text required)
|
||||
- "pressKey" — presses a keyboard key (key required)
|
||||
- "click" — clicks an element by visible text (text required)
|
||||
- "wait" — waits briefly (ms required, max 3000)
|
||||
- "screenshot" — takes a screenshot (name required)
|
||||
|
||||
## Output format
|
||||
Return a JSON object with exactly two keys: "before" and "after", each containing:
|
||||
{
|
||||
"summary": "One sentence: what this PR changes",
|
||||
"test_focus": "Specific behaviors to verify in this recording",
|
||||
"prerequisites": ["e.g. Load default workflow"],
|
||||
"steps": [
|
||||
{
|
||||
"action": "openMenu",
|
||||
"description": "Open the main menu to check file options",
|
||||
"expected_before": "Old behavior description (before key only)",
|
||||
"expected_after": "New behavior description (after key only)"
|
||||
}
|
||||
],
|
||||
"visual_checks": ["Specific visual elements to look for"]
|
||||
}
|
||||
|
||||
## Rules
|
||||
- BEFORE guide: 2-4 steps, under 15 seconds. Show OLD/missing behavior.
|
||||
- AFTER guide: 3-6 steps, under 30 seconds. Prove the fix/feature works.
|
||||
- Focus on the SPECIFIC behavior changed by this PR, not generic testing.
|
||||
- Use information from PR description, screenshots, and comments to understand intended behavior.
|
||||
- Include at least one screenshot step in each guide.
|
||||
- Do NOT include login steps.
|
||||
- Menu pattern: openMenu -> hoverMenuItem -> clickMenuItem or screenshot.
|
||||
- Output ONLY valid JSON, no markdown fences or explanation.`
|
||||
}
|
||||
|
||||
async function analyzeWithGemini(
|
||||
thread: PrThread,
|
||||
media: Array<{ path: string; mimeType: string }>,
|
||||
model: string,
|
||||
apiKey: string
|
||||
): Promise<{ before: QaGuide; after: QaGuide }> {
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const geminiModel = genAI.getGenerativeModel({ model })
|
||||
|
||||
const prompt = buildAnalysisPrompt(thread)
|
||||
|
||||
const parts: Array<
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } }
|
||||
> = [{ text: prompt }]
|
||||
|
||||
// Add media as inline data
|
||||
for (const item of media) {
|
||||
try {
|
||||
const buffer = readFileSync(item.path)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: item.mimeType,
|
||||
data: buffer.toString('base64')
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to read media ${item.path}: ${(err as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Sending to ${model}: ${prompt.length} chars text, ${media.length} media files`
|
||||
)
|
||||
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts }],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 8192,
|
||||
responseMimeType: 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
let text = result.response.text()
|
||||
// Strip markdown fences if present
|
||||
text = text
|
||||
.replace(/^```(?:json)?\n?/gm, '')
|
||||
.replace(/```$/gm, '')
|
||||
.trim()
|
||||
|
||||
console.warn('Gemini response received')
|
||||
const parsed = JSON.parse(text)
|
||||
|
||||
if (!parsed.before || !parsed.after) {
|
||||
throw new Error('Response missing "before" or "after" keys')
|
||||
}
|
||||
|
||||
return { before: parsed.before, after: parsed.after }
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs()
|
||||
mkdirSync(opts.outputDir, { recursive: true })
|
||||
|
||||
// A. Fetch PR thread
|
||||
const thread = fetchPrThread(opts.prNumber, opts.repo)
|
||||
|
||||
// B. Extract media URLs from all text
|
||||
const allText = [
|
||||
thread.body,
|
||||
...thread.issueComments,
|
||||
...thread.reviewComments,
|
||||
...thread.reviews
|
||||
].join('\n')
|
||||
const mediaUrls = extractMediaUrls(allText)
|
||||
console.warn(`Found ${mediaUrls.length} media URLs`)
|
||||
|
||||
// C. Download media
|
||||
const media = await downloadMedia(
|
||||
mediaUrls,
|
||||
opts.outputDir,
|
||||
opts.mediaBudgetBytes,
|
||||
opts.maxVideoBytes
|
||||
)
|
||||
|
||||
// D. Send to Gemini
|
||||
const guides = await analyzeWithGemini(thread, media, opts.model, opts.apiKey)
|
||||
|
||||
// E. Write guides
|
||||
const beforePath = resolve(opts.outputDir, 'qa-guide-before.json')
|
||||
const afterPath = resolve(opts.outputDir, 'qa-guide-after.json')
|
||||
writeFileSync(beforePath, JSON.stringify(guides.before, null, 2))
|
||||
writeFileSync(afterPath, JSON.stringify(guides.after, null, 2))
|
||||
|
||||
console.warn(`Wrote QA guides:`)
|
||||
console.warn(` Before: ${beforePath}`)
|
||||
console.warn(` After: ${afterPath}`)
|
||||
}
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function isExecutedAsScript(metaUrl: string): boolean {
|
||||
const modulePath = fileURLToPath(metaUrl)
|
||||
const scriptPath = process.argv[1] ? resolve(process.argv[1]) : ''
|
||||
return modulePath === scriptPath
|
||||
}
|
||||
|
||||
if (isExecutedAsScript(import.meta.url)) {
|
||||
main().catch((err) => {
|
||||
console.error('PR analysis failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@ interface Options {
|
||||
model: string
|
||||
apiKey: string
|
||||
testPlanFile?: string
|
||||
qaGuideFile?: string
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
@@ -73,9 +74,12 @@ function parseArgs(): Options {
|
||||
case '--test-plan':
|
||||
opts.testPlanFile = args[++i]
|
||||
break
|
||||
case '--qa-guide':
|
||||
opts.qaGuideFile = args[++i]
|
||||
break
|
||||
case '--help':
|
||||
console.warn(
|
||||
'Usage: qa-record.ts --mode before|after --diff <path> --output-dir <path> [--url <url>] [--model <model>] [--test-plan <path>]'
|
||||
'Usage: qa-record.ts --mode before|after --diff <path> --output-dir <path> [--url <url>] [--model <model>] [--test-plan <path>] [--qa-guide <path>]'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -98,12 +102,28 @@ function parseArgs(): Options {
|
||||
|
||||
// ── Gemini test step generation ──
|
||||
|
||||
function buildPrompt(mode: string, diff: string, testPlan?: string): string {
|
||||
function buildPrompt(
|
||||
mode: string,
|
||||
diff: string,
|
||||
testPlan?: string,
|
||||
qaGuide?: string
|
||||
): string {
|
||||
const modeDesc =
|
||||
mode === 'before'
|
||||
? 'BEFORE (main branch). Show the OLD state briefly — under 15 seconds. One quick demonstration of missing feature / old behavior.'
|
||||
: 'AFTER (PR branch). Prove the changes work — 3-6 targeted steps, under 30 seconds.'
|
||||
|
||||
const qaGuideSection = qaGuide
|
||||
? `
|
||||
## QA Analysis Guide (follow closely)
|
||||
A deep analysis of this PR has produced the following targeted test guide.
|
||||
Follow these steps closely — they were generated from the full PR thread,
|
||||
screenshots, and reviewer comments.
|
||||
|
||||
${qaGuide.slice(0, 6000)}
|
||||
`
|
||||
: ''
|
||||
|
||||
const testPlanSection = testPlan
|
||||
? `
|
||||
## QA Test Plan Reference
|
||||
@@ -128,7 +148,7 @@ Each step is an object with an "action" field:
|
||||
- { "action": "click", "text": "Button Text" } — clicks an element by visible text
|
||||
- { "action": "wait", "ms": 1000 } — waits (use sparingly, max 3000ms)
|
||||
- { "action": "screenshot", "name": "step-name" } — takes a screenshot
|
||||
${testPlanSection}
|
||||
${qaGuideSection}${testPlanSection}
|
||||
## PR Diff
|
||||
\`\`\`
|
||||
${diff.slice(0, 3000)}
|
||||
@@ -140,7 +160,7 @@ ${diff.slice(0, 3000)}
|
||||
- Always include at least one screenshot
|
||||
- Do NOT include login steps (handled automatically)
|
||||
- Menu navigation pattern: openMenu → hoverMenuItem → clickMenuItem (or screenshot)
|
||||
- Pick test steps from the QA test plan categories that are most relevant to the diff
|
||||
${qaGuide ? '- Follow the QA Analysis Guide steps closely — they are PR-specific and well-researched' : '- Pick test steps from the QA test plan categories that are most relevant to the diff'}
|
||||
|
||||
## Example output
|
||||
[
|
||||
@@ -160,7 +180,18 @@ async function generateTestSteps(opts: Options): Promise<TestAction[]> {
|
||||
const testPlan = opts.testPlanFile
|
||||
? readFileSync(opts.testPlanFile, 'utf-8')
|
||||
: undefined
|
||||
const prompt = buildPrompt(opts.mode, diff, testPlan)
|
||||
let qaGuide: string | undefined
|
||||
if (opts.qaGuideFile) {
|
||||
try {
|
||||
qaGuide = readFileSync(opts.qaGuideFile, 'utf-8')
|
||||
console.warn(`Using QA guide: ${opts.qaGuideFile}`)
|
||||
} catch {
|
||||
console.warn(
|
||||
`QA guide not found: ${opts.qaGuideFile}, continuing without`
|
||||
)
|
||||
}
|
||||
}
|
||||
const prompt = buildPrompt(opts.mode, diff, testPlan, qaGuide)
|
||||
|
||||
const genAI = new GoogleGenerativeAI(opts.apiKey)
|
||||
const model = genAI.getGenerativeModel({ model: opts.model })
|
||||
|
||||
Reference in New Issue
Block a user