teqo-backup/scripts/lib/test-affected-core.mjs

299 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pure selection logic for affected-test CI jobs (OPS5). No git, no fs, no
* process — the CLI wrappers feed it the changed file list and it decides.
* Unit-pinned in tests/unit/testAffected.unit.spec.ts,
* tests/unit/ciSkipInvariants.unit.spec.ts, and
* tests/unit/e2eAffectedManifest.unit.spec.ts.
*/
/**
* Paths whose blast radius is the whole app: schema, test harness, lockfile,
* skip classifiers. Any diff touching them runs the FULL suites — selection
* is unsafe there.
*/
export const HIGH_RISK_EXACT = new Set([
'package.json',
'pnpm-lock.yaml',
'playwright.config.ts',
'vitest.config.mts',
'vitest.unit.config.mts',
'vitest.setup.ts',
'src/payload-types.ts',
'src/payload.config.ts',
'scripts/seed-minimal.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'scripts/lib/test-affected-core.mjs',
'scripts/lib/e2e-affected-manifest.mjs',
'scripts/test-affected.mjs',
'scripts/e2e-affected.mjs',
'scripts/ci-scope.mjs',
'scripts/check-test-locations.mjs',
'scripts/check-plans-only-pr-closes.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/gate-ci.mjs',
'tests/unit/ciSkipInvariants.unit.spec.ts',
'tests/unit/testAffected.unit.spec.ts',
'.env.test',
])
export const HIGH_RISK_PREFIXES = [
'src/migrations/',
'src/collections/',
'src/globals/',
'tests/helpers/',
'tests/e2e/fixtures/',
]
/** Canonical vitest/playwright globs — keep in sync with config files. */
export const CANONICAL_UNIT_INCLUDE = 'tests/unit/**/*.unit.spec.{ts,tsx}'
export const CANONICAL_INT_INCLUDE = 'tests/int/**/*.int.spec.ts'
export const CANONICAL_E2E_TEST_DIR = 'tests/e2e'
export const CANONICAL_E2E_SPEC_SUFFIX = '.e2e.spec.ts'
const CODE_CONFIG_EXACT = new Set([
'package.json',
'pnpm-lock.yaml',
'tsconfig.json',
'tsconfig.tsbuildinfo',
'eslint.config.mjs',
'eslint.config.js',
'knip.json',
'knip.ts',
'knip.config.ts',
'.eslintrc.cjs',
'.eslintrc.js',
])
const CODE_CONFIG_PREFIXES = ['tsconfig']
const BUILD_SURFACE_EXACT = new Set([
'package.json',
'pnpm-lock.yaml',
'next.config.js',
'next.config.mjs',
'next.config.ts',
'src/payload.config.ts',
'src/payload-types.ts',
'tsconfig.json',
])
const BUILD_SURFACE_PREFIXES = [
'src/',
'public/',
'src/migrations/',
'src/collections/',
'src/globals/',
'tsconfig',
]
const SPEC_FILE_RE = /\.(?:spec|test)\.(?:ts|tsx)$/
const UNIT_SPEC_RE = /^tests\/unit\/.+\.unit\.spec\.(?:ts|tsx)$/
const INT_SPEC_RE = /^tests\/int\/.+\.int\.spec\.ts$/
const E2E_SPEC_RE = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
/** Cross-cutting dirs under campaign components (no dedicated e2e family). */
export const E2E_MANIFEST_DOMAIN_EXEMPT = new Set(['shared'])
export function isHighRisk(path) {
return HIGH_RISK_EXACT.has(path) || HIGH_RISK_PREFIXES.some((prefix) => path.startsWith(prefix))
}
export function isTestPath(path) {
return path.startsWith('tests/')
}
export function isSrcPath(path) {
return path.startsWith('src/')
}
/**
* Paths that affect typecheck / knip / madge (and therefore those PR jobs).
*/
export function isCodePath(path) {
if (isSrcPath(path) || isTestPath(path)) return true
if (CODE_CONFIG_EXACT.has(path)) return true
if (CODE_CONFIG_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}.`))) {
return true
}
if (path.startsWith('eslint') || path.includes('eslint.config')) return true
if (path.startsWith('knip')) return true
return false
}
/**
* Paths that affect `pnpm build` (Next + Payload surface).
*/
export function isBuildPath(path) {
if (BUILD_SURFACE_EXACT.has(path)) return true
if (isSrcPath(path) || path.startsWith('public/')) return true
if (path.startsWith('next.config')) return true
if (BUILD_SURFACE_PREFIXES.some((prefix) => path.startsWith(prefix))) return true
return false
}
export function isCanonicalSpecPath(path) {
return UNIT_SPEC_RE.test(path) || INT_SPEC_RE.test(path) || E2E_SPEC_RE.test(path)
}
export function isMisplacedSpecPath(path) {
return SPEC_FILE_RE.test(path) && !isCanonicalSpecPath(path)
}
/**
* Decide vitest scope for a PR diff.
* @param {{ path: string, status: string }[]} files git diff --name-status rows.
* @returns {{ mode: 'full' | 'changed' | 'none', reason: string }}
*/
export function classifyTestScope(files) {
if (files.some(({ path }) => isHighRisk(path))) {
return { mode: 'full', reason: 'diff touches a high-risk path (schema/lockfile/test harness)' }
}
if (files.some(({ path, status }) => status === 'A' && path.startsWith('src/'))) {
return { mode: 'full', reason: 'new src/ file — vitest --changed cannot trace tests to it yet' }
}
if (files.some(({ path }) => path.startsWith('src/') || path.startsWith('tests/'))) {
return { mode: 'changed', reason: 'src/tests diff without high-risk paths' }
}
return { mode: 'none', reason: 'no src/ or tests/ changes' }
}
/**
* Decide whether typecheck / knip / cycles should run.
* @returns {{ mode: 'code' | 'none', reason: string }}
*/
export function classifyStaticScope(files) {
if (files.some(({ path }) => isCodePath(path))) {
return { mode: 'code', reason: 'diff touches src/tests or type/graph config' }
}
return { mode: 'none', reason: 'no code/type/graph surface changes' }
}
/**
* Decide whether the PR build job should run.
* @returns {{ mode: 'build' | 'none', reason: string }}
*/
export function classifyBuildScope(files) {
if (files.some(({ path }) => isHighRisk(path) && isBuildPath(path))) {
return { mode: 'build', reason: 'diff touches a high-risk build surface path' }
}
if (files.some(({ path }) => isBuildPath(path))) {
return { mode: 'build', reason: 'diff touches build surface' }
}
return { mode: 'none', reason: 'no build surface changes' }
}
const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
/**
* Decide the Playwright scope for a PR diff.
* @param {{ path: string, status: string }[]} files
* @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names.
* @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string,
* unmapped: string[] }}
*/
export function selectE2eSpecs(files, manifest) {
if (files.some(({ path }) => isHighRisk(path))) {
return {
mode: 'full',
specs: [],
reason: 'diff touches a high-risk path (schema/lockfile/test harness)',
unmapped: [],
}
}
const specs = new Set()
const unmapped = []
for (const { path } of files) {
if (E2E_SPEC_PATTERN.test(path)) {
specs.add(path.slice('tests/e2e/'.length, -'.e2e.spec.ts'.length))
continue
}
if (!path.startsWith('src/')) continue
const matches = manifest.filter((entry) =>
entry.prefixes.some((prefix) => path.startsWith(prefix)),
)
if (matches.length === 0) {
unmapped.push(path)
continue
}
for (const match of matches) for (const spec of match.specs) specs.add(spec)
}
if (specs.size === 0) {
return {
mode: 'none',
specs: [],
reason:
unmapped.length > 0
? 'src/ changes with no e2e manifest mapping (see unmapped list)'
: 'no e2e-relevant changes',
unmapped,
}
}
return {
mode: 'selected',
specs: [...specs].sort(),
reason: `${specs.size} spec(s) selected via manifest and changed specs`,
unmapped,
}
}
/**
* CI job-level parallelism for the e2e suite (OPS34). The full run splits
* across 2 shards (`--shard=N/total`); selected and none stay on a single
* runner — a second build for a handful of specs is pure waste. Measured
* 2026-08-10 (ci.yml run 31412425553): build 172s + tests 196s + ~54s fixed,
* so shard 1 and 2 each land ≈ 5–5.5 min. Revisit at 3 shards if the sharded
* wall drifts past ~6.5 min or the test split gets lopsided; ci.yml hardcodes
* the same [1, 2] / 2 literal with a comment pointing back here.
* @param {'full' | 'selected' | 'none'} mode
* @returns {{ matrix: number[], total: number }}
*/
export function e2eShardConfig(mode) {
return mode === 'full' ? { matrix: [1, 2], total: 2 } : { matrix: [1], total: 1 }
}
/**
* Walk a list of repo-relative paths and return misplaced spec/test files.
* @param {string[]} paths
* @returns {string[]}
*/
export function findMisplacedSpecPaths(paths) {
return paths.filter(isMisplacedSpecPath).sort()
}
/**
* Domain folders that must appear in the e2e affected manifest (or be exempt).
* @param {string[]} componentDirs immediate children of src/components/campaign
* @param {string[]} appRouteDirs immediate children of src/app/(campaign)/campanha/(app)
* @param {{ prefixes: string[] }[]} manifest
* @param {Set<string>} [exempt]
* @returns {{ missing: string[], covered: string[] }}
*/
export function findUncoveredE2eDomainPrefixes(
componentDirs,
appRouteDirs,
manifest,
exempt = E2E_MANIFEST_DOMAIN_EXEMPT,
) {
const prefixes = manifest.flatMap((entry) => entry.prefixes)
const required = []
for (const dir of componentDirs) {
if (exempt.has(dir)) continue
required.push(`src/components/campaign/${dir}`)
}
for (const dir of appRouteDirs) {
if (dir.includes('.')) continue // files like page.tsx / error.tsx
required.push(`src/app/(campaign)/campanha/(app)/${dir}`)
}
const missing = []
const covered = []
for (const path of required) {
const hit = prefixes.some(
(prefix) => prefix === path || prefix.startsWith(`${path}/`) || path.startsWith(prefix),
)
if (hit) covered.push(path)
else missing.push(path)
}
return { missing: missing.sort(), covered: covered.sort() }
}