ci(pr): cascade phase1→phase2 with blast-radius skips
Add scope classifiers for code/build jobs, always-on test-location guardrails, and gate:ci parity. Default affected base is origin/main.
This commit is contained in:
parent
503a775b9f
commit
9d810d1564
10 changed files with 657 additions and 168 deletions
237
.github/workflows/ci-pr.yml
vendored
237
.github/workflows/ci-pr.yml
vendored
|
|
@ -1,14 +1,13 @@
|
|||
# PR gate: feature → stage (and stage → main promote PRs).
|
||||
# Parallel jobs with `needs` only where a real dependency exists (Postgres for
|
||||
# int/build/e2e). Build runs here (local service DB only) and NEVER in
|
||||
# ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL it
|
||||
# sees, so it is forbidden anywhere near stage/prod URLs.
|
||||
# PR gate: feature → main (also stage until cutover). Two-phase cascade:
|
||||
# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit
|
||||
# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A
|
||||
# phase-1 failure skips phase 2 via `needs` + explicit if. `migration-lock`
|
||||
# stays parallel to phase 1 (cheap, independent).
|
||||
#
|
||||
# Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF`
|
||||
# and e2e runs the manifest-selected specs; a diff touching a high-risk path
|
||||
# (schema, lockfile, test harness, seed manifest — see
|
||||
# scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to
|
||||
# stage/main always runs everything (ci-stage.yml / ci.yml).
|
||||
# High-risk diffs (schema, lockfile, classifiers — see
|
||||
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
|
||||
# always runs the full suite in ci.yml (no affected skips).
|
||||
name: CI (PR)
|
||||
|
||||
on:
|
||||
|
|
@ -25,6 +24,38 @@ env:
|
|||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
code_mode: ${{ steps.classify.outputs.code_mode }}
|
||||
build_mode: ${{ steps.classify.outputs.build_mode }}
|
||||
test_mode: ${{ steps.classify.outputs.test_mode }}
|
||||
e2e_mode: ${{ steps.classify.outputs.e2e_mode }}
|
||||
e2e_specs: ${{ steps.classify.outputs.e2e_specs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Classify PR blast radius
|
||||
id: classify
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/ci-scope.mjs)
|
||||
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
|
||||
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
|
@ -36,6 +67,8 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Check test file locations
|
||||
run: node scripts/check-test-locations.mjs
|
||||
- run: pnpm lint
|
||||
|
||||
format:
|
||||
|
|
@ -54,6 +87,8 @@ jobs:
|
|||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -67,6 +102,8 @@ jobs:
|
|||
knip:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -80,6 +117,8 @@ jobs:
|
|||
cycles:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.code_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -93,6 +132,8 @@ jobs:
|
|||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [scope]
|
||||
if: needs.scope.outputs.test_mode != 'none'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -104,33 +145,30 @@ jobs:
|
|||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Compute affected test scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/test-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Unit tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Unit tests (changed since PR base)
|
||||
if: steps.scope.outputs.mode == 'changed'
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Unit tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No src/ or tests/ changes in this PR — nothing to run."
|
||||
|
||||
int:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.test_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
# Matches production Neon major (17) and docker-compose.yml.
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: teqo
|
||||
|
|
@ -153,39 +191,31 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Compute affected test scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/test-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Migrate test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm migrate
|
||||
|
||||
- name: Seed minimal test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm db:seed:minimal
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
|
||||
- name: Integration tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:int
|
||||
|
||||
- name: Integration tests (changed since PR base)
|
||||
if: steps.scope.outputs.mode == 'changed'
|
||||
if: needs.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
|
||||
- name: Integration tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No src/ or tests/ changes in this PR — nothing to run."
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.build_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
|
|
@ -208,13 +238,23 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (against the local service database)
|
||||
run: pnpm build
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [scope, lint, format, typecheck, knip, cycles, unit]
|
||||
if: |
|
||||
always()
|
||||
&& !cancelled()
|
||||
&& needs.scope.outputs.e2e_mode != 'none'
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs.format.result == 'success'
|
||||
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
|
||||
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
|
||||
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
|
||||
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
|
|
@ -239,91 +279,31 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Compute affected e2e scope
|
||||
id: scope
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
result=$(node scripts/e2e-affected.mjs)
|
||||
echo "scope: $(echo "$result" | jq -r .reason)"
|
||||
echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT"
|
||||
echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Migrate test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm migrate
|
||||
|
||||
- name: Seed minimal test database
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
# CI e2e runs against the production build (see playwright.config.ts
|
||||
# webServer.command); the dist dir must match the webServer env.
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
if: steps.scope.outputs.mode != 'none'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next/e2e
|
||||
|
||||
- name: E2E tests (full suite)
|
||||
if: steps.scope.outputs.mode == 'full'
|
||||
if: needs.scope.outputs.e2e_mode == 'full'
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: E2E tests (manifest-selected specs)
|
||||
if: steps.scope.outputs.mode == 'selected'
|
||||
run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }}
|
||||
|
||||
- name: E2E tests skipped
|
||||
if: steps.scope.outputs.mode == 'none'
|
||||
run: echo "No e2e-relevant changes in this PR — nothing to run."
|
||||
|
||||
# Branch protection on `stage`/`main` still requires the legacy context name
|
||||
# `checks` from the pre-parallel monolithic job. This rollup satisfies that
|
||||
# contract without re-serializing the suite.
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint
|
||||
- format
|
||||
- typecheck
|
||||
- knip
|
||||
- cycles
|
||||
- unit
|
||||
- int
|
||||
- build
|
||||
- e2e
|
||||
- migration-lock
|
||||
if: always()
|
||||
steps:
|
||||
- name: All parallel checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
if: needs.scope.outputs.e2e_mode == 'selected'
|
||||
run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }}
|
||||
|
||||
migration-lock:
|
||||
# At most ONE open PR may touch schema (migrations or generated types).
|
||||
# Two in flight guarantees a broken rebase and a poisoned drizzle snapshot
|
||||
# chain; the second PR must wait or rebase.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
# gh pr list against the default GITHUB_TOKEN.
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
# gh expands --json files via git, so it needs the repo checked out.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Count open PRs touching schema files
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -337,3 +317,30 @@ jobs:
|
|||
echo "::error::More than one open PR touches src/migrations/ or payload-types.ts. Merge or close the other first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- scope
|
||||
- lint
|
||||
- format
|
||||
- typecheck
|
||||
- knip
|
||||
- cycles
|
||||
- unit
|
||||
- int
|
||||
- build
|
||||
- e2e
|
||||
- migration-lock
|
||||
if: always()
|
||||
steps:
|
||||
- name: All cascade checks green
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS" | jq -e '
|
||||
to_entries
|
||||
| all(.value.result == "success" or .value.result == "skipped")
|
||||
'
|
||||
|
|
|
|||
79
scripts/check-test-locations.mjs
Normal file
79
scripts/check-test-locations.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-test-locations — always-on guard against misplaced *.spec|*.test files
|
||||
* that the affected classifier would miss (false-green skip). Walks the repo
|
||||
* (ignoring node_modules, .git, build artifacts) and fails if any spec/test
|
||||
* file lives outside the three canonical trees.
|
||||
*
|
||||
* Pure predicates live in scripts/lib/test-affected-core.mjs; this CLI is the
|
||||
* fs walk used by the lint job and by ciSkipInvariants.
|
||||
*/
|
||||
import { readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { findMisplacedSpecPaths } from './lib/test-affected-core.mjs'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const IGNORE_DIRS = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'dist',
|
||||
'build',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
'.playwright-cli',
|
||||
'.aider.tags.cache.v4',
|
||||
'data',
|
||||
])
|
||||
|
||||
function walk(dir, out = []) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env.test') {
|
||||
if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue
|
||||
if (entry.isDirectory() && entry.name !== '.github' && entry.name !== '.cursor') continue
|
||||
}
|
||||
if (IGNORE_DIRS.has(entry.name)) continue
|
||||
const absolute = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolute, out)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
out.push(path.relative(repoRoot, absolute).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function collectRepoRelativeFiles(root = repoRoot) {
|
||||
return walk(root)
|
||||
}
|
||||
|
||||
export function checkTestLocations(root = repoRoot) {
|
||||
const files = collectRepoRelativeFiles(root)
|
||||
return findMisplacedSpecPaths(files)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
|
||||
if (isMain) {
|
||||
const misplaced = checkTestLocations()
|
||||
if (misplaced.length > 0) {
|
||||
console.error(
|
||||
`[check-test-locations] misplaced spec/test files (must live under tests/{unit,int,e2e} with the canonical suffix):\n ${misplaced.join('\n ')}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[check-test-locations] ok — all specs in canonical trees')
|
||||
}
|
||||
67
scripts/ci-scope.mjs
Normal file
67
scripts/ci-scope.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ci-scope — single classifier for PR job-level skips. Prints JSON:
|
||||
* { base, code, build, test, e2e } where each of code/build/test/e2e is the
|
||||
* classify* / selectE2eSpecs result. Used by the `scope` job in ci-pr.yml.
|
||||
*
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
classifyTestScope,
|
||||
selectE2eSpecs,
|
||||
} from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
mergeBase = execFileSync('git', ['merge-base', 'HEAD', base], { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
const full = {
|
||||
mode: 'full',
|
||||
reason: `no merge-base with ${base} (shallow clone?)`,
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
base,
|
||||
code: { mode: 'code', reason: full.reason },
|
||||
build: { mode: 'build', reason: full.reason },
|
||||
test: { ...full },
|
||||
e2e: { mode: 'full', specs: [], reason: full.reason, unmapped: [] },
|
||||
}),
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const raw = execFileSync('git', ['diff', '--name-status', `${mergeBase}...HEAD`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const files = raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, path] = line.split('\t')
|
||||
return { status, path }
|
||||
})
|
||||
|
||||
const e2e = selectE2eSpecs(files, E2E_AFFECTED_MANIFEST)
|
||||
if (e2e.unmapped.length > 0) {
|
||||
console.error(
|
||||
`[ci-scope] src/ paths with no e2e manifest mapping:\n ${e2e.unmapped.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
base,
|
||||
code: classifyStaticScope(files),
|
||||
build: classifyBuildScope(files),
|
||||
test: classifyTestScope(files),
|
||||
e2e,
|
||||
}),
|
||||
)
|
||||
|
|
@ -16,7 +16,7 @@ import { execFileSync } from 'node:child_process'
|
|||
import { E2E_AFFECTED_MANIFEST } from './lib/e2e-affected-manifest.mjs'
|
||||
import { selectE2eSpecs } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): static checks always;
|
||||
* unit/int/e2e follow the same blast-radius selection as CI (`test-affected.mjs`,
|
||||
* `e2e-affected.mjs` vs `origin/stage`). Build always runs. `migration-lock` is
|
||||
* GitHub-only.
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
|
||||
* then phase-2 expensive (int/build/e2e). Skips align with `scripts/ci-scope.mjs`
|
||||
* (`origin/main` by default). `migration-lock` is GitHub-only.
|
||||
*
|
||||
* Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape:
|
||||
* `git push --no-verify`.
|
||||
* Preflight checks only `teqo_test` (ci-pr never touches the dev DB). Escape
|
||||
* during pipeline cutover: `git push --no-verify` (documented in AGENT-OPS).
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
|
@ -43,7 +42,7 @@ const readJsonScript = (script) => {
|
|||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
})
|
||||
return JSON.parse(raw.trim())
|
||||
return JSON.parse(raw.trim().split('\n').filter(Boolean).at(-1))
|
||||
}
|
||||
|
||||
const run = (label, command, args, env = process.env) => {
|
||||
|
|
@ -60,56 +59,68 @@ const run = (label, command, args, env = process.env) => {
|
|||
}
|
||||
|
||||
const main = async () => {
|
||||
const testScope = readJsonScript('scripts/test-affected.mjs')
|
||||
const e2eScope = readJsonScript('scripts/e2e-affected.mjs')
|
||||
const scope = readJsonScript('scripts/ci-scope.mjs')
|
||||
|
||||
console.log(`[gate:ci] vitest: ${testScope.mode} — ${testScope.reason} (base ${testScope.base})`)
|
||||
console.log(`[gate:ci] e2e: ${e2eScope.mode} — ${e2eScope.reason} (base ${e2eScope.base})`)
|
||||
if (e2eScope.unmapped?.length > 0) {
|
||||
console.error(`[gate:ci] e2e unmapped src/ paths:\n ${e2eScope.unmapped.join('\n ')}`)
|
||||
}
|
||||
|
||||
console.log('\n[gate:ci] ▶ preflight (teqo_test)')
|
||||
const dbOk = await diagnoseDatabaseTarget({
|
||||
label: 'test',
|
||||
databaseUrl: dbEnv.DATABASE_URL,
|
||||
})
|
||||
if (!dbOk) {
|
||||
console.error('\n[gate:ci] ✗ preflight (teqo_test) failed — run `pnpm db:start`')
|
||||
process.exit(1)
|
||||
console.log(
|
||||
`[gate:ci] scope base=${scope.base} code=${scope.code.mode} build=${scope.build.mode} test=${scope.test.mode} e2e=${scope.e2e.mode}`,
|
||||
)
|
||||
if (scope.e2e.unmapped?.length > 0) {
|
||||
console.error(`[gate:ci] e2e unmapped src/ paths:\n ${scope.e2e.unmapped.join('\n ')}`)
|
||||
}
|
||||
|
||||
// --- Phase 1 (cheap) ---
|
||||
run('check-test-locations', 'node', ['scripts/check-test-locations.mjs'])
|
||||
run('lint', 'pnpm', ['lint'])
|
||||
run('format:check', 'pnpm', ['format:check'])
|
||||
run('typecheck', 'pnpm', ['typecheck'])
|
||||
run('knip', 'pnpm', ['exec', 'knip'])
|
||||
run('check:cycles', 'pnpm', ['check:cycles'])
|
||||
|
||||
if (testScope.mode === 'full') {
|
||||
if (scope.code.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)')
|
||||
} else {
|
||||
run('typecheck', 'pnpm', ['typecheck'])
|
||||
run('knip', 'pnpm', ['exec', 'knip'])
|
||||
run('check:cycles', 'pnpm', ['check:cycles'])
|
||||
}
|
||||
|
||||
if (scope.test.mode === 'full') {
|
||||
run('test:unit (full)', 'pnpm', ['test:unit'])
|
||||
} else if (testScope.mode === 'changed') {
|
||||
} else if (scope.test.mode === 'changed') {
|
||||
run('test:unit (changed)', 'pnpm', [
|
||||
'test:unit',
|
||||
'--',
|
||||
'--changed',
|
||||
testScope.base,
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
} else {
|
||||
console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)')
|
||||
}
|
||||
|
||||
if (testScope.mode !== 'none') {
|
||||
// --- Phase 2 (expensive) — only when phase 1 would have been green ---
|
||||
const needsDb =
|
||||
scope.test.mode !== 'none' || scope.build.mode !== 'none' || scope.e2e.mode !== 'none'
|
||||
if (needsDb) {
|
||||
console.log('\n[gate:ci] ▶ preflight (teqo_test)')
|
||||
const dbOk = await diagnoseDatabaseTarget({
|
||||
label: 'test',
|
||||
databaseUrl: dbEnv.DATABASE_URL,
|
||||
})
|
||||
if (!dbOk) {
|
||||
console.error('\n[gate:ci] ✗ preflight (teqo_test) failed — run `pnpm db:start`')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.test.mode !== 'none') {
|
||||
run('migrate', 'pnpm', ['migrate'], dbEnv)
|
||||
run('db:seed:minimal', 'pnpm', ['db:seed:minimal'], dbEnv)
|
||||
if (testScope.mode === 'full') {
|
||||
if (scope.test.mode === 'full') {
|
||||
run('test:int (full)', 'pnpm', ['test:int'])
|
||||
} else {
|
||||
run('test:int (changed)', 'pnpm', [
|
||||
'test:int',
|
||||
'--',
|
||||
'--changed',
|
||||
testScope.base,
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
}
|
||||
|
|
@ -117,9 +128,13 @@ const main = async () => {
|
|||
console.log('\n[gate:ci] ⊘ test:int skipped (no src/tests blast radius)')
|
||||
}
|
||||
|
||||
run('build', 'pnpm', ['build'], dbEnv)
|
||||
if (scope.build.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ build skipped (no build surface)')
|
||||
} else {
|
||||
run('build', 'pnpm', ['build'], dbEnv)
|
||||
}
|
||||
|
||||
if (e2eScope.mode === 'none') {
|
||||
if (scope.e2e.mode === 'none') {
|
||||
console.log('\n[gate:ci] ⊘ e2e skipped (no e2e blast radius)')
|
||||
} else {
|
||||
run('playwright install chromium', 'pnpm', ['exec', 'playwright', 'install', 'chromium'])
|
||||
|
|
@ -128,12 +143,12 @@ const main = async () => {
|
|||
run('e2e build', 'pnpm', ['build'], { ...dbEnv, NEXT_DIST_DIR: '.next/e2e' })
|
||||
|
||||
const e2eEnv = { ...dbEnv, E2E_PROD: '1', CI: '1', NEXT_DIST_DIR: '.next/e2e' }
|
||||
if (e2eScope.mode === 'full') {
|
||||
if (scope.e2e.mode === 'full') {
|
||||
run('test:e2e (full)', 'pnpm', ['test:e2e'], e2eEnv)
|
||||
} else {
|
||||
const specPaths = e2eScope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
const specPaths = scope.e2e.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
run(
|
||||
`test:e2e (selected: ${e2eScope.specs.join(', ')})`,
|
||||
`test:e2e (selected: ${scope.e2e.specs.join(', ')})`,
|
||||
'pnpm',
|
||||
['test:e2e', '--', ...specPaths],
|
||||
e2eEnv,
|
||||
|
|
@ -141,7 +156,7 @@ const main = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, affected scope)')
|
||||
console.log('\n[gate:ci] ✓ all checks passed (ci-pr mirror, cascade + affected scope)')
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
specs: ['campaignLeaderships'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/territorios`, 'src/utilities/territory'],
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/territorios`,
|
||||
'src/components/campaign/tour',
|
||||
'src/utilities/territory',
|
||||
],
|
||||
specs: ['campaignTerritories'],
|
||||
},
|
||||
{
|
||||
|
|
@ -83,4 +87,29 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
],
|
||||
specs: ['campaign-pwa', 'campaignWizardChrome'],
|
||||
},
|
||||
// Domains without a dedicated e2e family still wake campaign home smoke so
|
||||
// the affected classifier cannot return mode=none on an unmapped domain dir.
|
||||
{
|
||||
prefixes: [
|
||||
'src/components/campaign/advisor',
|
||||
'src/components/campaign/demand',
|
||||
'src/components/campaign/invite',
|
||||
'src/components/campaign/organization',
|
||||
'src/components/campaign/stateDeputy',
|
||||
'src/components/campaign/suggestion',
|
||||
'src/components/campaign/supporter',
|
||||
'src/components/campaign/votePledge',
|
||||
'src/components/campaign/auth',
|
||||
`${CAMPAIGN_APP}/acoes`,
|
||||
`${CAMPAIGN_APP}/apoiadores`,
|
||||
`${CAMPAIGN_APP}/assessores`,
|
||||
`${CAMPAIGN_APP}/contatos`,
|
||||
`${CAMPAIGN_APP}/demandas`,
|
||||
`${CAMPAIGN_APP}/dobradinhas`,
|
||||
`${CAMPAIGN_APP}/organizacoes`,
|
||||
`${CAMPAIGN_APP}/perfil`,
|
||||
`${CAMPAIGN_APP}/quadro`,
|
||||
],
|
||||
specs: ['campaignHomeActions'],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
/**
|
||||
* 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 and
|
||||
* 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.
|
||||
* Any diff touching them runs the FULL suites — selection is unsafe there.
|
||||
* 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',
|
||||
|
|
@ -20,6 +22,15 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'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/gate-ci.mjs',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
'tests/unit/testAffected.unit.spec.ts',
|
||||
'.env.test',
|
||||
])
|
||||
|
||||
|
|
@ -31,18 +42,105 @@ export const HIGH_RISK_PREFIXES = [
|
|||
'tests/e2e/fixtures/',
|
||||
]
|
||||
|
||||
function isHighRisk(path) {
|
||||
/** 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 }}
|
||||
* full — high-risk path or new src file (nothing imports it yet, so
|
||||
* --changed cannot find it; silent green otherwise);
|
||||
* changed — vitest --changed <base> is meaningful;
|
||||
* none — no src/tests touched (docs-only PR); skip the jobs.
|
||||
*/
|
||||
export function classifyTestScope(files) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
|
|
@ -57,6 +155,31 @@ export function classifyTestScope(files) {
|
|||
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$/
|
||||
|
||||
/**
|
||||
|
|
@ -65,9 +188,6 @@ const E2E_SPEC_PATTERN = /^tests\/e2e\/[^/]+\.e2e\.spec\.ts$/
|
|||
* @param {{ prefixes: string[], specs: string[] }[]} manifest prefix → spec names.
|
||||
* @returns {{ mode: 'full' | 'selected' | 'none', specs: string[], reason: string,
|
||||
* unmapped: string[] }}
|
||||
* `specs` are spec NAMES (no path, no .e2e.spec.ts). `unmapped` lists src/
|
||||
* files with no manifest prefix — logged in CI, never a failure (e2e is a
|
||||
* thin layer over int; the gap is for a human to judge).
|
||||
*/
|
||||
export function selectE2eSpecs(files, manifest) {
|
||||
if (files.some(({ path }) => isHighRisk(path))) {
|
||||
|
|
@ -113,3 +233,48 @@ export function selectE2eSpecs(files, manifest) {
|
|||
unmapped,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
* mode=changed → run `vitest --changed <base>`
|
||||
* mode=none → nothing to run (docs-only diff); CI skips the step
|
||||
*
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/stage locally. No DB, no
|
||||
* Base ref: $GITHUB_BASE_REF (PR target) or origin/main locally. No DB, no
|
||||
* Payload — pure git + the pure core in scripts/lib/test-affected-core.mjs.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { classifyTestScope } from './lib/test-affected-core.mjs'
|
||||
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'stage'}`
|
||||
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
||||
|
||||
let mergeBase
|
||||
try {
|
||||
|
|
|
|||
97
tests/unit/ciSkipInvariants.unit.spec.ts
Normal file
97
tests/unit/ciSkipInvariants.unit.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
CANONICAL_E2E_SPEC_SUFFIX,
|
||||
CANONICAL_E2E_TEST_DIR,
|
||||
CANONICAL_INT_INCLUDE,
|
||||
CANONICAL_UNIT_INCLUDE,
|
||||
findMisplacedSpecPaths,
|
||||
findUncoveredE2eDomainPrefixes,
|
||||
HIGH_RISK_EXACT,
|
||||
isCanonicalSpecPath,
|
||||
isMisplacedSpecPath,
|
||||
} from '../../scripts/lib/test-affected-core.mjs'
|
||||
import { checkTestLocations } from '../../scripts/check-test-locations.mjs'
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
||||
|
||||
describe('ciSkipInvariants', () => {
|
||||
it('rejects misplaced *.spec|*.test files outside the three canonical trees', () => {
|
||||
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.ts')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/unit/foo.unit.spec.tsx')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/int/foo.int.spec.ts')).toBe(true)
|
||||
expect(isCanonicalSpecPath('tests/e2e/foo.e2e.spec.ts')).toBe(true)
|
||||
|
||||
for (const path of [
|
||||
'src/utilities/foo.spec.ts',
|
||||
'scripts/bar.test.ts',
|
||||
'tests/unit/wrong.spec.ts',
|
||||
'tests/int/wrong.spec.ts',
|
||||
'tests/e2e/nested/foo.e2e.spec.ts',
|
||||
'foo.test.tsx',
|
||||
]) {
|
||||
expect(isMisplacedSpecPath(path), path).toBe(true)
|
||||
}
|
||||
|
||||
expect(findMisplacedSpecPaths(['tests/unit/ok.unit.spec.ts', 'src/x.spec.ts'])).toEqual([
|
||||
'src/x.spec.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it('repo walk finds no misplaced specs (same check as the lint job)', () => {
|
||||
expect(checkTestLocations(repoRoot)).toEqual([])
|
||||
})
|
||||
|
||||
it('vitest/playwright harness globs match the canonical constants', () => {
|
||||
const unitConfig = readFileSync(join(repoRoot, 'vitest.unit.config.mts'), 'utf8')
|
||||
const intConfig = readFileSync(join(repoRoot, 'vitest.config.mts'), 'utf8')
|
||||
const playwrightConfig = readFileSync(join(repoRoot, 'playwright.config.ts'), 'utf8')
|
||||
|
||||
expect(unitConfig).toContain(`'${CANONICAL_UNIT_INCLUDE}'`)
|
||||
expect(intConfig).toContain(`'${CANONICAL_INT_INCLUDE}'`)
|
||||
expect(playwrightConfig).toContain(`'./${CANONICAL_E2E_TEST_DIR}'`)
|
||||
expect(CANONICAL_E2E_SPEC_SUFFIX).toBe('.e2e.spec.ts')
|
||||
})
|
||||
|
||||
it('skip classifiers and invariant specs are high-risk (force full suite)', () => {
|
||||
for (const path of [
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/test-affected.mjs',
|
||||
'scripts/e2e-affected.mjs',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
'tests/unit/testAffected.unit.spec.ts',
|
||||
]) {
|
||||
expect(HIGH_RISK_EXACT.has(path), path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('every campaign domain dir is covered by the e2e affected manifest', () => {
|
||||
const componentDirs = readdirSync(join(repoRoot, 'src/components/campaign'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
|
||||
const appRouteDirs = readdirSync(join(repoRoot, 'src/app/(campaign)/campanha/(app)'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
|
||||
const { missing } = findUncoveredE2eDomainPrefixes(
|
||||
componentDirs,
|
||||
appRouteDirs,
|
||||
E2E_AFFECTED_MANIFEST,
|
||||
)
|
||||
expect(missing, `unmapped domains:\n${missing.join('\n')}`).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
classifyTestScope,
|
||||
HIGH_RISK_EXACT,
|
||||
HIGH_RISK_PREFIXES,
|
||||
|
|
@ -19,7 +21,9 @@ describe('classifyTestScope (OPS5)', () => {
|
|||
'pnpm-lock.yaml',
|
||||
'src/collections/Post.ts',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'tests/helpers/campaignFixtures.ts',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
]) {
|
||||
expect(classifyTestScope([changed(path)]).mode, path).toBe('full')
|
||||
}
|
||||
|
|
@ -49,6 +53,32 @@ describe('classifyTestScope (OPS5)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('classifyStaticScope', () => {
|
||||
it('runs for src/tests and type/graph config diffs', () => {
|
||||
expect(classifyStaticScope([changed('src/lib/foo.ts')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('tsconfig.json')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('package.json')]).mode).toBe('code')
|
||||
expect(classifyStaticScope([changed('eslint.config.mjs')]).mode).toBe('code')
|
||||
})
|
||||
|
||||
it('skips for docs-only diffs', () => {
|
||||
expect(classifyStaticScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyBuildScope', () => {
|
||||
it('runs for build surface diffs', () => {
|
||||
expect(classifyBuildScope([changed('src/app/(frontend)/page.tsx')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('public/favicon.ico')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('next.config.mjs')]).mode).toBe('build')
|
||||
expect(classifyBuildScope([changed('package.json')]).mode).toBe('build')
|
||||
})
|
||||
|
||||
it('skips for docs-only diffs', () => {
|
||||
expect(classifyBuildScope([changed('docs/AGENT-OPS.md')]).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectE2eSpecs (OPS5)', () => {
|
||||
const manifest = [
|
||||
{ prefixes: ['src/components/campaign/municipality'], specs: ['campaignMunicipalities'] },
|
||||
|
|
|
|||
Loading…
Reference in a new issue