Skip to content

Commit a3f8878

Browse files
committed
feat: implement Python testing signal detection and conventions handling
1 parent c68f149 commit a3f8878

File tree

4 files changed

+160
-31
lines changed

4 files changed

+160
-31
lines changed

app/api/scan-repo/route.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import type {
66
RepoScanSummary,
77
RepoStructureSummary,
88
} from "@/types/repo-scan"
9-
import { inferStackFromScan } from "@/lib/scan-to-wizard"
9+
import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
1010
import { loadStackConventions } from "@/lib/conventions"
11+
import { inferStackFromScan } from "@/lib/scan-to-wizard"
1112

1213
const GITHUB_API_BASE_URL = "https://api.github.com"
1314
const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"])
@@ -114,7 +115,10 @@ const detectStructure = (paths: string[]): RepoStructureSummary => {
114115
}
115116
}
116117

117-
const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: string[]; testing: string[]; frameworks: string[] } => {
118+
const detectTooling = async (
119+
paths: string[],
120+
pkg: PackageJson | null,
121+
): Promise<{ tooling: string[]; testing: string[]; frameworks: string[] }> => {
118122
const tooling = new Set<string>()
119123
const testing = new Set<string>()
120124
const frameworks = new Set<string>()
@@ -276,13 +280,88 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
276280
}
277281
}
278282

283+
await detectPythonTestingSignals(paths, pkg, testing)
284+
279285
return {
280286
tooling: dedupeAndSort(tooling),
281287
testing: dedupeAndSort(testing),
282288
frameworks: dedupeAndSort(frameworks),
283289
}
284290
}
285291

292+
type TestingConventionValues = {
293+
unit: string[]
294+
e2e: string[]
295+
}
296+
297+
const testingConventionCache = new Map<string, TestingConventionValues>()
298+
299+
const getTestingConventionValues = async (stackId: string): Promise<TestingConventionValues> => {
300+
const normalized = stackId.trim().toLowerCase()
301+
if (testingConventionCache.has(normalized)) {
302+
return testingConventionCache.get(normalized)!
303+
}
304+
305+
const { conventions } = await loadStackConventions(normalized)
306+
const values: TestingConventionValues = {
307+
unit: collectConventionValues(conventions, "testingUT"),
308+
e2e: collectConventionValues(conventions, "testingE2E"),
309+
}
310+
testingConventionCache.set(normalized, values)
311+
return values
312+
}
313+
314+
const findConventionValue = (values: string[], target: string): string | null => {
315+
const normalizedTarget = normalizeConventionValue(target)
316+
return values.find((value) => normalizeConventionValue(value) === normalizedTarget) ?? null
317+
}
318+
319+
const BEHAVE_DEPENDENCIES = ["behave", "behave-django", "behave-webdriver"]
320+
321+
export const detectPythonTestingSignals = async (
322+
paths: string[],
323+
pkg: PackageJson | null,
324+
testing: Set<string>,
325+
): Promise<void> => {
326+
const { unit } = await getTestingConventionValues("python")
327+
if (unit.length === 0) {
328+
return
329+
}
330+
331+
const behaveValue = findConventionValue(unit, "behave")
332+
const unittestValue = findConventionValue(unit, "unittest")
333+
334+
if (!behaveValue && !unittestValue) {
335+
return
336+
}
337+
338+
const lowerCasePaths = paths.map((path) => path.toLowerCase())
339+
340+
if (behaveValue) {
341+
const hasFeaturesDir = lowerCasePaths.some((path) => path.startsWith("features/") || path.includes("/features/"))
342+
const hasStepsDir = lowerCasePaths.some((path) => path.includes("/steps/"))
343+
const hasEnvironment = lowerCasePaths.some((path) => path.endsWith("/environment.py") || path.endsWith("environment.py"))
344+
const hasDependency = pkg ? dependencyHas(pkg, BEHAVE_DEPENDENCIES) : false
345+
346+
if (hasDependency || (hasFeaturesDir && (hasStepsDir || hasEnvironment))) {
347+
testing.add(behaveValue)
348+
}
349+
}
350+
351+
if (unittestValue) {
352+
const hasUnitFiles = lowerCasePaths.some((path) => {
353+
if (!/(^|\/)(tests?|testcases|specs)\//.test(path)) {
354+
return false
355+
}
356+
return /(^|\/)(test_[^/]+|[^/]+_test)\.py$/.test(path)
357+
})
358+
359+
if (hasUnitFiles) {
360+
testing.add(unittestValue)
361+
}
362+
}
363+
}
364+
286365
const readPackageJson = async (
287366
owner: string,
288367
repo: string,
@@ -770,7 +849,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<RepoScanRe
770849

771850
const packageJson = hasPackageJson ? await readPackageJson(owner, repo, defaultBranch, headers) : null
772851

773-
const { tooling, testing, frameworks } = detectTooling(paths, packageJson)
852+
const { tooling, testing, frameworks } = await detectTooling(paths, packageJson)
774853

775854
if (lowestRateLimit !== null && lowestRateLimit < 5) {
776855
warnings.push(`GitHub API rate limit is low (remaining: ${lowestRateLimit}).`)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { detectPythonTestingSignals } from "@/app/api/scan-repo/route"
4+
5+
type PackageJson = {
6+
dependencies?: Record<string, string>
7+
devDependencies?: Record<string, string>
8+
peerDependencies?: Record<string, string>
9+
optionalDependencies?: Record<string, string>
10+
}
11+
12+
const createPkg = (deps: Partial<PackageJson>): PackageJson => ({ ...deps })
13+
14+
describe("detectPythonTestingSignals", () => {
15+
it("adds behave when features directory structure is present", async () => {
16+
const testing = new Set<string>()
17+
await detectPythonTestingSignals(
18+
["features/example.feature", "features/steps/login_steps.py", "features/environment.py"],
19+
null,
20+
testing,
21+
)
22+
23+
expect(Array.from(testing)).toContain("behave")
24+
})
25+
26+
it("adds behave when dependency is detected", async () => {
27+
const testing = new Set<string>()
28+
await detectPythonTestingSignals(
29+
[],
30+
createPkg({ devDependencies: { behave: "^1.2.3" } }),
31+
testing,
32+
)
33+
34+
expect(Array.from(testing)).toContain("behave")
35+
})
36+
37+
it("adds unittest when Python-style test files exist", async () => {
38+
const testing = new Set<string>()
39+
await detectPythonTestingSignals(["tests/test_example.py", "src/app.py"], null, testing)
40+
41+
expect(Array.from(testing)).toContain("unittest")
42+
})
43+
})

lib/convention-values.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { LoadedConvention } from "@/types/conventions"
2+
import type { WizardResponses } from "@/types/wizard"
3+
4+
export const normalizeConventionValue = (value: string): string => value.trim().toLowerCase()
5+
6+
export const collectConventionValues = (
7+
conventions: LoadedConvention,
8+
key: keyof WizardResponses,
9+
): string[] => {
10+
const values: string[] = []
11+
const seen = new Set<string>()
12+
13+
const pushValue = (candidate: unknown) => {
14+
if (typeof candidate !== "string") {
15+
return
16+
}
17+
const normalized = normalizeConventionValue(candidate)
18+
if (!normalized || seen.has(normalized)) {
19+
return
20+
}
21+
seen.add(normalized)
22+
values.push(candidate)
23+
}
24+
25+
pushValue(conventions.defaults[key])
26+
27+
conventions.rules.forEach((rule) => {
28+
pushValue(rule.set?.[key])
29+
})
30+
31+
return values
32+
}

lib/scan-to-wizard.ts

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
12
import { applyConventionRules, loadStackConventions } from "@/lib/conventions"
23
import { buildStepsForStack } from "@/lib/wizard-summary-data"
34
import type { RepoScanSummary } from "@/types/repo-scan"
@@ -9,32 +10,6 @@ const STACK_FALLBACK = "react"
910
const toLowerArray = (values: string[] | undefined | null) =>
1011
Array.isArray(values) ? values.map((value) => value.toLowerCase()) : []
1112

12-
const normalizeString = (value: string) => value.trim().toLowerCase()
13-
14-
const collectConventionValues = (
15-
conventions: LoadedConvention,
16-
key: keyof WizardResponses,
17-
): string[] => {
18-
const values: string[] = []
19-
const pushValue = (candidate: unknown) => {
20-
if (typeof candidate !== "string") return
21-
const normalizedCandidate = normalizeString(candidate)
22-
if (normalizedCandidate.length === 0) return
23-
if (values.some((existing) => normalizeString(existing) === normalizedCandidate)) {
24-
return
25-
}
26-
values.push(candidate)
27-
}
28-
29-
pushValue(conventions.defaults[key])
30-
31-
conventions.rules.forEach((rule) => {
32-
pushValue(rule.set?.[key])
33-
})
34-
35-
return values
36-
}
37-
3813
const detectFromScanList = (
3914
scanList: string[] | undefined | null,
4015
conventions: LoadedConvention,
@@ -48,10 +23,10 @@ const detectFromScanList = (
4823
return null
4924
}
5025

51-
const normalizedScan = scanList.map((value) => normalizeString(value))
26+
const normalizedScan = scanList.map((value) => normalizeConventionValue(value))
5227

5328
for (const candidate of candidates) {
54-
if (normalizedScan.includes(normalizeString(candidate))) {
29+
if (normalizedScan.includes(normalizeConventionValue(candidate))) {
5530
return candidate
5631
}
5732
}

0 commit comments

Comments
 (0)