mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
ops(OPS86): blast radius dos testes — curated e2e em high-risk, fail-closed para risco sem mapping, unit/int full na seleção vazia (Closes #832)
This commit is contained in:
parent
51ad84583d
commit
1dfe73b07c
16 changed files with 500 additions and 76 deletions
|
|
@ -39,9 +39,15 @@ antes do deploy**.
|
|||
`--no-deps --project=<família>`).
|
||||
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
|
||||
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
|
||||
classifica `full` → o PR não roda e2e: o full fica para o `verify` do
|
||||
deploy manual (antes de publicar) e, nesse mesmo diff, o espelho local
|
||||
roda full.
|
||||
classifica **`curated`** (OPS86): o PR roda o conjunto curado de e2e —
|
||||
nunca zero; o full fica para o `verify` do deploy manual (antes de
|
||||
publicar) e, nesse mesmo diff, o espelho local roda full. Arquivo de área
|
||||
de risco sem entry no manifesto (`src/utilities/access`, `src/lib/schemas`,
|
||||
`campaignPushClient`, `src/utilities/ai`) classifica `unmapped-risk` e o PR
|
||||
**falha** listando os arquivos — a correção é adicionar a entry no
|
||||
`E2E_AFFECTED_MANIFEST` (`scripts/lib/e2e-affected-manifest.mjs`).
|
||||
Unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`:
|
||||
seleção vazia cai na suíte full — nunca verde com 0 testes.
|
||||
- **Limitação da #72 (S3-FOLLOWUP):** e2e local com `--no-deps` + projetos
|
||||
paralelos (`--project=a --project=b`) colide no `seedTestUser` (delete +
|
||||
create concorrentes nos `beforeAll` → `ValidationError: email` e falhas
|
||||
|
|
|
|||
71
.github/workflows/ci-pr.yml
vendored
71
.github/workflows/ci-pr.yml
vendored
|
|
@ -14,12 +14,18 @@
|
|||
#
|
||||
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
|
||||
# as a step; its outputs drive `if:` on the heavy steps (code/build/test:
|
||||
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR
|
||||
# runs ONLY the blast radius — the specs the classifier detected as touched
|
||||
# (mode `selected`), never `full`. A high-risk diff (schema/lockfile/test
|
||||
# harness) classifies as `full` → the PR simply runs no e2e: the local agent
|
||||
# mirror (`pnpm test:e2e:affected`) runs full in that same case, and the
|
||||
# deploy `verify` job runs the full suite before publishing.
|
||||
# none/changed/full; e2e: none/selected/curated/unmapped-risk). e2e policy
|
||||
# (OPS72 + OPS86): the PR runs ONLY the blast radius — the specs the
|
||||
# classifier detected as touched (mode `selected`) — never `full`. A
|
||||
# high-risk diff (schema/lockfile/test harness) classifies as `curated`: the
|
||||
# PR runs the curated e2e cross-section (never zero), the local agent mirror
|
||||
# (`pnpm test:e2e:affected`) runs full in that same case, and the deploy
|
||||
# `verify` job runs the full suite before publishing. Risk-area files
|
||||
# (`src/utilities/access`, `src/lib/schemas`, push, AI) without a manifest
|
||||
# entry classify `unmapped-risk` and the job FAILS closed listing them —
|
||||
# touched code never passes CI on zero e2e. The unit/int `changed` steps go
|
||||
# through `vitest-changed-or-full.mjs`: a zero selection falls back to the
|
||||
# full suite instead of green with no tests (OPS86).
|
||||
#
|
||||
# Schema migrations are NOT serialized across PRs (migration-lock removed
|
||||
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
|
||||
|
|
@ -108,6 +114,19 @@ jobs:
|
|||
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"
|
||||
echo "e2e_unmapped=$(echo "$result" | jq -r '[.e2e.unmapped[]] | join(" ")')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# OPS86 fail-closed: risk-area files without a manifest entry must
|
||||
# never pass green with zero e2e. Fail fast, before the expensive steps.
|
||||
- name: Fail on unmapped risk paths
|
||||
if: steps.scope.outputs.e2e_mode == 'unmapped-risk'
|
||||
run: |
|
||||
echo "::error::Risk-area files touched by this PR have no e2e manifest mapping:"
|
||||
for file in ${{ steps.scope.outputs.e2e_unmapped }}; do
|
||||
echo "::error::- $file"
|
||||
done
|
||||
echo "::error::Add an entry in scripts/lib/e2e-affected-manifest.mjs (E2E_AFFECTED_MANIFEST) covering these paths."
|
||||
exit 1
|
||||
|
||||
# Content guards are PR-scoped and cheap — run them before the suite so
|
||||
# a guard failure fails fast instead of after 20 minutes of tests.
|
||||
|
|
@ -145,9 +164,9 @@ jobs:
|
|||
- name: Unit tests (full suite)
|
||||
if: steps.scope.outputs.test_mode == 'full'
|
||||
run: pnpm test:unit
|
||||
- name: Unit tests (changed since PR base)
|
||||
- name: Unit tests (changed since PR base, full on empty selection)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
run: node scripts/vitest-changed-or-full.mjs --suite unit --base origin/${{ github.base_ref }}
|
||||
- name: Prepare database URL (int service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -170,23 +189,23 @@ jobs:
|
|||
# CPU-relative (no DB).
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int
|
||||
- name: Integration tests (changed since PR base)
|
||||
- name: Integration tests (changed since PR base, full on empty selection)
|
||||
if: steps.scope.outputs.test_mode == 'changed'
|
||||
env:
|
||||
VITEST_MAX_WORKERS: 4
|
||||
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
|
||||
run: node scripts/vitest-changed-or-full.mjs --suite int --base origin/${{ github.base_ref }}
|
||||
|
||||
- name: Prepare database URL (build/e2e service)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
|
||||
- name: Migrate (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
run: pnpm migrate
|
||||
- name: Seed minimal (build/e2e database)
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
|
||||
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
run: pnpm db:seed:minimal
|
||||
|
||||
- name: Build (against the local service database)
|
||||
|
|
@ -194,7 +213,7 @@ jobs:
|
|||
run: pnpm build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -202,23 +221,25 @@ jobs:
|
|||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
- name: Install Playwright chromium
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build for e2e
|
||||
# Selected mode needs the build too (the Playwright webServer serves
|
||||
# the `.next-e2e` production build). NEXT_PUBLIC_SITE_URL is inlined
|
||||
# at build time, so the canonical `.test` origin must reach THIS build
|
||||
# — it lets the agenda feed dialog generate a link under the
|
||||
# production build when AgendaFeed is in the blast radius (OPS83).
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
# Selected and curated modes need the build too (the Playwright
|
||||
# webServer serves the `.next-e2e` production build).
|
||||
# NEXT_PUBLIC_SITE_URL is inlined at build time, so the canonical
|
||||
# `.test` origin must reach THIS build — it lets the agenda feed
|
||||
# dialog generate a link under the production build when AgendaFeed
|
||||
# is in the blast radius (OPS83).
|
||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_DIST_DIR: .next-e2e
|
||||
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
|
||||
- name: E2E tests (blast radius — manifest-selected specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected'
|
||||
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
|
||||
# skips this step by design (local mirror + deploy verify cover it).
|
||||
- name: E2E tests (blast radius — manifest-selected or curated specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
# OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs
|
||||
# the curated cross-section (`curated`, never zero) and the full suite
|
||||
# stays in the deploy verify; `unmapped-risk` failed the job earlier.
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# 4 workers in one process replace the former 2 shards × 2 workers.
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the
|
|||
|
||||
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use the homeserver prod DB for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt<slot>` / `teqo_wt<slot>_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names.
|
||||
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (modo `selected` via `ci-scope.mjs` — **nunca `full`**; diff high-risk classifica `full` e o PR simplesmente não roda e2e). O e2e **full** roda no `verify` do deploy manual (gateia merge e deploy — rede de segurança final antes de publicar).
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72 + OPS86):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (via `ci-scope.mjs` — **nunca `full`**): diff mapeado roda `selected`, diff **high-risk** (coleções/globals/migrações/lockfile/harness) roda o **`curated`** — conjunto fixo de 5 specs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`, congelado por pin em `e2eAffectedManifest.unit.spec.ts`) — **nunca zero e2e no PR**; arquivo de **área de risco sem entry no manifesto** (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica **`unmapped-risk` e o PR FALHA** listando os arquivos (fail-closed; resolver = adicionar entry no `E2E_AFFECTED_MANIFEST`); src/ não-mapeado não-risco roda o smoke `campaignHomeActions`. Os passos unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`: seleção vazia cai em **suíte full** — nunca "0 testes, verde". O e2e **full** roda no `verify` do deploy manual (gateia merge e deploy — rede de segurança final antes de publicar); localmente, diff high-risk segue rodando full no `pnpm test:e2e:affected`.
|
||||
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
|
||||
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
|
||||
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
docs/changelog/2026-08-24-ops86.md
Normal file
1
docs/changelog/2026-08-24-ops86.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS86 — blast radius dos testes: PRs quebram testes que não rodaram no CI do PR (2026-08-24):** o CI de PR ficava verde com zero e2e nos diffs de maior risco (high-risk → `full` → nenhum e2e por design, OPS72) e verde com zero unit/int quando a seleção `--changed` vinha vazia (`--passWithNoTests`). Agora nenhum PR de código passa sem executar o que o diff pode quebrar: (1) **`curated`** — diff high-risk roda um conjunto curado de 5 specs e2e (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter` — as specs órfãs do manifesto que cobrem RBAC/visibilidade/IA/agenda/newsletter), congelado por pin em `e2eAffectedManifest.unit.spec.ts`; (2) **`unmapped-risk` fail-closed** — arquivo de área de risco sem entry no manifesto (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) faz o job **falhar** listando os arquivos (passo novo no `ci-pr.yml`), e as áreas de risco ganharam entries no `E2E_AFFECTED_MANIFEST`; (3) **src/ não-mapeado não-risco** roda o smoke `campaignHomeActions` em vez de "nenhum e2e" com aviso de log; (4) **`vitest-changed-or-full.mjs`** — os passos unit/int `changed` (CI e `gate-ci.mjs`) listam os specs mudados (`vitest list --changed <base> --json`) e caem na **suíte full** quando a seleção é vazia. Guardrails mantidos: PR nunca roda e2e full (o `verify` do deploy segue sendo o full; o espelho local `test:e2e:affected` segue rodando full em high-risk), contrato JSON do `ci-scope.mjs` só ganhou valores/enums (`e2e_unmapped` aditivo), `deploy.yml` intocado, custo médio estável (curated só em high-risk, minoria dos PRs). Verificação: pins atualizados no mesmo commit (`testAffected`/`e2eAffectedManifest`/`ciSkipInvariants` — o classificador é self-touching e o próprio PR roda curated no CI); `test:unit` 2448 verdes; classificador exercitado à mão nos 4 modes; wrapper testado com seleção não-vazia e vazia. Fora de escopo (registrado): demais specs órfãs do manifesto (`campaignAgendaGoogleSync`, `campaignAgendaMobile`, `campaignAiChatFollowUps`, `campaignHomePixel`, `campaignIosInputZoom`, `campaignMunicipalityDeliberation`, `campaignMunicipalityResponsiveColumns`, `campaignRegisterDemand`, `campaignTableStickyHeader`, `campaignUpdatesMobile`) seguem full-only como hoje — fora das áreas de risco nomeadas pela intenção.
|
||||
96
docs/plans/ops86-blast-radius-testes-prs-impl.md
Normal file
96
docs/plans/ops86-blast-radius-testes-prs-impl.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Impl: OPS86 — Blast radius dos testes: PRs quebram testes que não rodaram no CI do PR
|
||||
|
||||
Status: aprovado (gate humano 2026-08-24)
|
||||
Atualizado em: 2026-08-24
|
||||
Issue: #832
|
||||
Intenção: docs/plans/ops86-blast-radius-testes-prs.md
|
||||
Appetite restante: herdado (~1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** nenhum PR de código passa no CI sem executar exatamente os testes que o diff pode quebrar — e2e e unit/int —, com falha visível (e lista de specs) quando a cobertura não existe. Concretamente: (1) diff de alto risco (coleções/globals/migrações/acesso) executa no PR um conjunto curado de e2e — nunca zero; (2) módulos de risco (acesso, schemas, push, IA) nunca caem em "nenhum e2e" sem explicação; (3) seleção vazia unit/int dispara fallback explícito (nunca "0 testes, verde"); (4) código sem cobertura declarada roda fallback ou falha apontando a lacuna.
|
||||
- **O que NÃO negociar:** guardrails — PR nunca roda e2e full (full só no verify do deploy); custo médio do job não sobe; classificador e mapa só mudam com os testes de invariante existentes verdes; `deploy.yml` intocado; guards de banco local inegociáveis.
|
||||
- **O que reavaliar:** onde o fallback de seleção vazia vive (classificador vs YAML vs script wrapper); a semântica do mode `full` para e2e no PR (hoje "zero e2e por design" — OPS72); o raio do fail-closed (só áreas de risco, não "mapa de tudo").
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[diff files vs merge-base] --> B{high-risk path?<br/>migrações/coleções/globals/<br/>harness/classificador}
|
||||
B -- sim --> C[e2e: mode=curated<br/>5 specs curadas órfãs]
|
||||
B -- sim --> U1[unit/int: mode=full]
|
||||
B -- não --> D{src/ ou tests/ tocado?}
|
||||
D -- não --> E[e2e: none · unit/int: none<br/>docs-only]
|
||||
D -- sim --> F{path casa prefixo<br/>do manifest?}
|
||||
F -- sim --> G[e2e: mode=selected<br/>specs mapeadas]
|
||||
F -- não --> H{path em área de risco?<br/>access/ · schemas/ · push · ai/}
|
||||
H -- sim --> I[e2e: mode=unmapped-risk<br/>CI FALHA listando arquivos]
|
||||
H -- não --> J[e2e: mode=selected<br/>fallback smoke campaignHomeActions]
|
||||
U1 --> K[unit/int: mode=changed]
|
||||
G --> K
|
||||
J --> K
|
||||
K --> L{--changed selecionou 0?<br/>wrapper --list}
|
||||
L -- sim --> M[unit/int full — nunca verde com 0]
|
||||
L -- não --> N[unit/int changed + passWithNoTests]
|
||||
C --> O[verify deploy: full true — intocado]
|
||||
I --> O
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C (por decisão, abaixo)
|
||||
**Recomendação:** o conjunto de mudanças abaixo — curadoria explícita vivendo no manifesto (Decisão 1: A), mode novo `curated` sem redefinir `full` (Decisão 2: B), mapeamento das áreas de risco + fail-closed só para área de risco (Decisão 3: A+B), fallback unit/int full na seleção vazia via wrapper (Decisão 4: A), órfãs viram o conjunto curado (Decisão 5: mapear) — porque cada mudança fica onde o contrato correspondente já vive (manifesto + núcleo puro + YAML + gate), é pinned pelos mesmos testes de invariante e não reescreve nenhuma semântica de deploy.
|
||||
**Rejeitadas:** hardcode no YAML (Decisão 1: B); redefinir `full` no PR (Decisão 2: A); fail-closed para todo `src/` (Decisão 3: B isolado); rodar "só a superfície" ou falhar pedindo mapeamento no unit/int (Decisão 4: B/C); deixar as órfãs declaradas (Decisão 5: não-mapear).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`E2E_CURATED_SPECS`** (novo export, `scripts/lib/e2e-affected-manifest.mjs`): o conjunto curado de e2e high-risk — congelado nas 5 specs órfãs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`). Crescer exige editar o pin de invariante (intencional). São exatamente as specs que hoje "só rodam em full" e cobrem as áreas de risco da intenção: RBAC, visibilidade por demanda, IA/transcrição, agenda, newsletter.
|
||||
- **`E2E_RISK_PREFIXES`** (novo export, mesmo módulo): o predicado de "área de risco" do fail-closed — `src/utilities/access/`, `src/utilities/campaignAccess.ts`, `src/lib/schemas/`, `src/utilities/campaignPushClient.ts`, `src/utilities/ai/` (auth já está mapeado no manifest). Curado e pequeno; **não** é "todo src/".
|
||||
- **Entradas novas no `E2E_AFFECTED_MANIFEST`** (mesmo módulo): prefixos de risco → specs correspondentes (ex. `src/utilities/access/` + `src/utilities/campaignAccess.ts` → `campaignPermissionProfile`; `src/utilities/ai/` → `campaignAiTranscribe`; `src/lib/schemas/` → smoke de formulários; `src/utilities/campaignPushClient.ts` → `campaignNewsletter`; agenda → `campaignAgendaFeed`). Reusa o formato `{prefixes, specs}` e os pins existentes (`e2eAffectedManifest.unit.spec.ts`: spec existe no disco, prefixo `src/` sem trailing slash).
|
||||
- **`selectE2eSpecs`** (`scripts/lib/test-affected-core.mjs`): ramo high-risk (linhas 197-204) passa a retornar `mode: 'curated'` com `specs: E2E_CURATED_SPECS` (e `reason` explicando o curado); arquivo `src/` sem match no manifest → se `E2E_RISK_PREFIXES` casa, retorna `mode: 'unmapped-risk'` (specs `[]`, `unmapped` preenchido — o CI falha com a lista); senão (não-risco), mantém o warning `unmapped` mas retorna `mode: 'selected'` com `specs: ['campaignHomeActions']` (fallback smoke — o mesmo smoke que o manifest já usa para domínios sem família e2e). Assimétrico com `classifyTestScope` (src/ novo → `full` em unit/int) mas deliberadamente: e2e nunca fica em `none` com código tocado.
|
||||
- **`classifyTestScope`** (`test-affected-core.mjs`): **intocado** — `full`/`changed`/`none` unit/int já está correto; o fallback de seleção vazia vive no wrapper, não no classificador.
|
||||
- **`scripts/vitest-changed-or-full.mjs`** (novo): wrapper que decide entre `--changed` e full: roda `vitest <suite> --changed <base> --list` (lista de arquivos sem executar; config unit/int conforme o arg); se 0 arquivos → roda a suíte full; senão → roda `--changed <base> --passWithNoTests` (comportamento atual). Fallback documentado se `--list` não honrar `--changed` na versão do vitest: rodar `--reporter=json` + `--passWithNoTests` e contar `numTotalTestSuites` (a execução já é a entrega; o custo da contagem é zero quando há testes). Spawna via `pnpm test:unit`/`pnpm test:int` (env e guards atuais preservados — o `DATABASE_URL` inválido do script unit e o `VITEST_MAX_WORKERS` da CI seguem vindo do ambiente).
|
||||
- **`.github/workflows/ci-pr.yml`**: passo e2e e preparação do banco build/e2e passam a gatear em `e2e_mode == 'selected' || e2e_mode == 'curated'` (linhas 182, 188, 191, 199, 207, 215, 221); passo novo `Fail on unmapped risk paths` com `if: e2e_mode == 'unmapped-risk'` que emite `::error::` com a lista (`e2e_unmapped` vira output novo do passo scope, além de `e2e_mode`/`e2e_specs` — aditivo, sem renomear) e `exit 1`; passos unit/int `changed` (linhas 152, 179) passam a chamar o wrapper em vez do vitest cru.
|
||||
- **`scripts/gate-ci.mjs`**: ramos `changed` de unit/int (linhas 106-112, 137-143) chamam o wrapper; seção e2e informativa (linhas 155-165) ganha mensagens para `curated` (specs que o CI vai rodar) e `unmapped-risk` (lista de arquivos + aviso de que o CI falhará). O gate local segue sem rodar e2e (OPS72) — mirror honesto do que o CI fará.
|
||||
- **`scripts/run-e2e-affected.mjs`** (espelho local): `curated` → roda full local (preserva o contrato documentado do OPS72: localmente high-risk roda a suíte toda; o custo local é irrelevante, é passo de skill); `unmapped-risk` → imprime a lista e roda o curado com warning (divergência documentada: CI falha, local avisa — ferramenta de debug é soft, o gate é o CI).
|
||||
- **Pins de invariante** (atualizados no MESMO PR — o classificador é self-touching via `HIGH_RISK_EXACT`): `tests/unit/testAffected.unit.spec.ts` (linhas 120-121: high-risk deixa de pinar `mode: 'full'` e passa a pinar `'curated'` + specs; novos casos: risco-unmapped → `unmapped-risk`, não-risco-unmapped → `selected` com smoke), `tests/unit/e2eAffectedManifest.unit.spec.ts` (pins do `E2E_CURATED_SPECS`: não-vazio, todo no disco, tamanho congelado), `tests/unit/ciSkipInvariants.unit.spec.ts` (linhas 93-108: `HIGH_RISK_EXACT` ganha `scripts/vitest-changed-or-full.mjs`; linhas 123-142: cobertura de domínios segue, agora com as entradas de risco).
|
||||
- **Docs**: AGENTS.md (parágrafo da política e2e OPS72 — high-risk não é mais "zero e2e no PR"; é curado no PR, full no local e no verify), headers de `test-affected-core.mjs`/`ci-pr.yml`/`gate-ci.mjs`/`run-e2e-affected.mjs`, e entrada curta em `docs/changelog/<data>-ops86-impl.md` + `pnpm changelog:build` (append-only).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum (mexe só no classificador/CI). **UI:** Impeccable A — N/A.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A (sem UI)
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — núcleo** (quota principal): `E2E_CURATED_SPECS` + `E2E_RISK_PREFIXES` + entradas de risco no manifesto; ramos novos em `selectE2eSpecs`; wrapper `vitest-changed-or-full.mjs`; pins de invariante atualizados. Verificações: `pnpm test:unit` verde (pins novos e alterados); classificador rodado à mão sobre diffs sintéticos (high-risk → curated com as 5 specs; `src/utilities/access/novo.ts` → selected RBAC; `src/utilities/access/` sem entry → unmapped-risk; componente novo não-risco → smoke) e sobre um diff real de alto risco; `--list` honrando `--changed` (ou fallback JSON contado — decidido aqui, com o resultado no PR).
|
||||
2. **Wiring CI + gate**: ci-pr.yml (condições `selected || curated`, passo de falha `unmapped-risk`, output `e2e_unmapped`, wrapper nos passos changed) + gate-ci.mjs (wrapper + mensagens) + run-e2e-affected.mjs (curated→full, unmapped-risk soft). Verificações: `pnpm gate:ci` espelhando o CI; `pnpm test:e2e:affected` num diff high-risk rodando full; dry-run do JSON do `ci-scope.mjs` com os 4 modes novos.
|
||||
3. **Docs + entrega**: AGENTS.md, headers, changelog + `pnpm changelog:build`; **Gates** — `pnpm gate:fast` na iteração (e `pnpm gate:ci` completo antes do push); push via `pnpm push` (gate `gate:push` = `gate:ci` — self-touching garante que a suíte cheia roda no CI do próprio PR).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Não tocar `deploy.yml`** nem o verify full (contrato de deploy intacto).
|
||||
- Não criar "mapa de tudo": fail-closed cobre só `E2E_RISK_PREFIXES`; módulos não-risco sem mapeamento rodam o smoke, não falham (corte da intenção).
|
||||
- Não mudar `classifyTestScope` (unit/int já distingue `full`/`changed`/`none` corretamente; o problema é só o `--passWithNoTests`).
|
||||
- Não mexer nas demais specs órfãs (agenda google sync/mobile, ai follow-ups, home pixel, ioss zoom, deliberation, responsive columns, register demand, table sticky, updates mobile, seedParity/setup): ficam full-only como hoje — gap conhecido, fora das áreas de risco nomeadas pela intenção (registrar no changelog, não engenhar).
|
||||
- Não adicionar lock/coordenação entre ci-pr e verify (sem migração concorrente nova).
|
||||
- Não mudar o contrato JSON do `ci-scope.mjs` além de valores novos de enum + campo `e2e_unmapped` (aditivo).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Classificador é self-touching** (`test-affected-core.mjs` está em `HIGH_RISK_EXACT`): o PR desta entrega roda a suíte full (unit/int) — esperado e desejado; pins de invariante devem ir no mesmo commit do núcleo, senão o CI quebra vermelho.
|
||||
- **Contrato JSON do `ci-scope.mjs`** consumido por ci-pr.yml E gate-ci.mjs: mudanças aditivas (novos valores de `e2e.mode`, output `e2e_unmapped`), nunca renomear campos existentes; validar os dois consumidores no tracer.
|
||||
- **`--list` do vitest pode não honrar `--changed`** na versão instalada: tracer verifica primeiro; fallback `--reporter=json` documentado (a contagem é a própria execução — sem custo extra).
|
||||
- **Custo médio do job**: high-risk passa de 0 e2e para 5 specs curadas (~+3 min nos PRs de alto risco, que são minoria); unmapped não-risco ganha 1 spec smoke; unit full na seleção vazia é barato (suíte unit rápida); int full na seleção vazia é raro (mode `changed` exige diff src/tests E nenhum spec int depender do arquivo tocado) e limitado por suíte — congelar o curado em 5 specs via pin impede deriva.
|
||||
- **knip (`exports: error`)**: o novo wrapper é entry via glob `scripts/*.mjs` e é chamado por ambos os consumidores; `E2E_CURATED_SPECS`/`E2E_RISK_PREFIXES` são importados pelo core e pelos pins — sem exports mortos se os pins forem escritos junto.
|
||||
- **Divergência local vs CI no `unmapped-risk`** (local avisa, CI falha): documentada no header do `run-e2e-affected.mjs` e no AGENTS.md — o gate de verdade é o CI.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (4 aceites: curado nunca-zero no high-risk; risco sempre mapeado ou falhando; seleção vazia unit/int com fallback; código sem cobertura roda fallback ou falha — com lista de specs visível no check)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (deploy.yml intocado; guards de banco local intocados; contrato `ci-scope` aditivo; knip/cycles/lint verdes; changelog append-only)
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: pins de `testAffected`/`e2eAffectedManifest`/`ciSkipInvariants` atualizados no mesmo PR + tracer manual do classificador (4 modes) + wrapper verificado com seleção vazia e não-vazia
|
||||
|
||||
---
|
||||
|
||||
### Self-score de decision-quality: 4/5
|
||||
|
||||
Todas as 5 decisões têm Opções/Recomendação/Rejeitadas ancoradas em linhas verificadas do código, e cada recomendação respeita os guardrails (full nunca no PR, custo estável, contrato aditivo, pins no mesmo PR). Não dou 5 porque duas incertezas ficam para o tracer resolver (comportamento do `--list` do vitest com `--changed`; raio exato do predicado de risco frente a novos módulos de auth/push que a curadoria pode não antecipar) — resolvidas por fase de verificação explícita, não por decisão assumida.
|
||||
|
|
@ -5,11 +5,15 @@
|
|||
* classify* / selectE2eSpecs result. Used by the `scope` step (id: scope) in
|
||||
* ci-pr.yml — its outputs drive `if:` on the heavy steps.
|
||||
*
|
||||
* e2e modes (OPS86): none | selected | curated | unmapped-risk — the PR CI
|
||||
* gates the e2e step on `selected || curated` and fails closed on
|
||||
* `unmapped-risk`; the full suite stays in the deploy verify.
|
||||
*
|
||||
* 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 { E2E_AFFECTED_MANIFEST, E2E_CURATED_SPECS } from './lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
|
|
@ -33,7 +37,9 @@ try {
|
|||
code: { mode: 'code', reason: full.reason },
|
||||
build: { mode: 'build', reason: full.reason },
|
||||
test: { ...full },
|
||||
e2e: { mode: 'full', specs: [], reason: full.reason, unmapped: [] },
|
||||
// The PR can never run e2e full — without a merge-base the safest
|
||||
// never-zero answer is the curated cross-section (OPS86).
|
||||
e2e: { mode: 'curated', specs: [...E2E_CURATED_SPECS], reason: full.reason, unmapped: [] },
|
||||
}),
|
||||
)
|
||||
process.exit(0)
|
||||
|
|
@ -52,9 +58,11 @@ const files = raw
|
|||
|
||||
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 ')}`,
|
||||
)
|
||||
const label =
|
||||
e2e.mode === 'unmapped-risk'
|
||||
? '[ci-scope] RISK-AREA paths with no e2e manifest mapping (CI will fail closed):'
|
||||
: '[ci-scope] src/ paths with no e2e manifest mapping (running the home smoke):'
|
||||
console.error(`${label}\n ${e2e.unmapped.join('\n ')}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
* curated manifest and prints JSON on stdout:
|
||||
* { mode, specs, reason, unmapped, base }.
|
||||
*
|
||||
* mode=full → run the whole e2e suite
|
||||
* mode=selected → run tests/e2e/<name>.e2e.spec.ts for each name in `specs`
|
||||
* mode=none → skip e2e for this diff
|
||||
* mode=selected → run tests/e2e/<name>.e2e.spec.ts for each name in `specs`
|
||||
* mode=curated → high-risk diff; the local runner treats it as full
|
||||
* mode=unmapped-risk → risk-area files without a manifest entry (CI fails
|
||||
* closed; the local runner warns and runs the curated set)
|
||||
* mode=none → skip e2e for this diff
|
||||
* mode=full → only the no-merge-base fallback (local) — run everything
|
||||
*
|
||||
* `unmapped` src/ files go to stderr as a warning — never a failure (e2e is a
|
||||
* thin layer over int; a human judges whether the manifest needs the prefix).
|
||||
* `unmapped` src/ files go to stderr as a warning — never a failure here (the
|
||||
* CI scope step is the gate; e2e is a thin layer over int).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
/**
|
||||
* Local mirror of `.github/workflows/ci-pr.yml` (serial): phase-1 cheap checks,
|
||||
* then phase-2 expensive (int/build). e2e is NOT part of the local mirror
|
||||
* (OPS59 + OPS72): the PR CI runs only the e2e blast radius (mode `selected`,
|
||||
* never `full`), the deploy `verify` job runs the full suite before
|
||||
* publishing, and the local affected run is a skill step (discretionary):
|
||||
* `pnpm test:e2e:affected`.
|
||||
* (OPS59 + OPS72): the PR CI runs only the e2e blast radius (mode `selected`
|
||||
* or the high-risk `curated` cross-section — never `full`, OPS86), the deploy
|
||||
* `verify` job runs the full suite before publishing, and the local affected
|
||||
* run is a skill step (discretionary): `pnpm test:e2e:affected`.
|
||||
* The unit/int `changed` steps go through `vitest-changed-or-full.mjs`
|
||||
* (OPS86): a zero selection falls back to the full suite, never green with 0.
|
||||
* Docs guards (OPS63): the `docs-guards` checks (changelog append-only,
|
||||
* aggregate sync, conflict markers) also run here — cheap git diffs, same
|
||||
* scripts the CI job runs (the `changelog-rewrite:` escape stays CI-only,
|
||||
|
|
@ -102,12 +104,12 @@ const main = async () => {
|
|||
if (scope.test.mode === 'full') {
|
||||
run('test:unit (full)', 'pnpm', ['test:unit'])
|
||||
} else if (scope.test.mode === 'changed') {
|
||||
run('test:unit (changed)', 'pnpm', [
|
||||
'test:unit',
|
||||
'--',
|
||||
'--changed',
|
||||
run('test:unit (changed-or-full)', 'node', [
|
||||
'scripts/vitest-changed-or-full.mjs',
|
||||
'--suite',
|
||||
'unit',
|
||||
'--base',
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
} else {
|
||||
console.log('\n[gate:ci] ⊘ test:unit skipped (no src/tests blast radius)')
|
||||
|
|
@ -133,12 +135,12 @@ const main = async () => {
|
|||
if (scope.test.mode === 'full') {
|
||||
run('test:int (full)', 'pnpm', ['test:int'])
|
||||
} else {
|
||||
run('test:int (changed)', 'pnpm', [
|
||||
'test:int',
|
||||
'--',
|
||||
'--changed',
|
||||
run('test:int (changed-or-full)', 'node', [
|
||||
'scripts/vitest-changed-or-full.mjs',
|
||||
'--suite',
|
||||
'int',
|
||||
'--base',
|
||||
scope.base,
|
||||
'--passWithNoTests',
|
||||
])
|
||||
}
|
||||
} else {
|
||||
|
|
@ -157,9 +159,13 @@ const main = async () => {
|
|||
console.log(
|
||||
`\n[gate:ci] ▶ e2e: CI runs the blast radius (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); full lives in deploy verify`,
|
||||
)
|
||||
} else {
|
||||
} else if (scope.e2e.mode === 'curated') {
|
||||
console.log(
|
||||
'\n[gate:ci] ▶ e2e: diff is high-risk (full mode) — no e2e in this PR by design (OPS72); run `pnpm test:e2e:affected` locally; deploy verify runs full before publishing',
|
||||
`\n[gate:ci] ▶ e2e: diff is high-risk — CI runs the curated cross-section (${scope.e2e.specs.join(', ')}) — not in this gate (OPS72); run \`pnpm test:e2e:affected\` locally (runs full); deploy verify runs full before publishing`,
|
||||
)
|
||||
} else if (scope.e2e.mode === 'unmapped-risk') {
|
||||
console.log(
|
||||
`\n[gate:ci] ✗ e2e: RISK-AREA files without a manifest mapping — CI WILL FAIL on:\n ${scope.e2e.unmapped.join('\n ')}\n Add an e2e manifest entry for them before pushing.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,46 @@
|
|||
* of truth. Granularity is the DOMAIN prefix on purpose — finer grains rot.
|
||||
* Spec names exist as tests/e2e/<name>.e2e.spec.ts (pinned by unit test).
|
||||
* Prefixes are repo-relative POSIX paths; matching is startsWith.
|
||||
*
|
||||
* OPS86 additions: `E2E_CURATED_SPECS` (the never-zero e2e set for high-risk
|
||||
* PR diffs) and `E2E_RISK_PREFIXES` (surfaces whose blast radius is the whole
|
||||
* product — RBAC, form schemas, push, AI). Both are pinned by unit invariants
|
||||
* (`e2eAffectedManifest.unit.spec.ts`): the curated set is frozen and every
|
||||
* risk prefix must stay covered by a manifest entry, so a diff in those areas
|
||||
* can never select zero e2e in silence.
|
||||
*/
|
||||
const CAMPAIGN_APP = 'src/app/(campaign)/campanha/(app)'
|
||||
|
||||
/**
|
||||
* Curated e2e cross-section for high-risk diffs (OPS86). High-risk used to
|
||||
* mean zero e2e on the PR (OPS72); this set is the smallest stable coverage of
|
||||
* the risk surfaces — RBAC/permission profiles, demand visibility, AI
|
||||
* transcription, agenda feed, newsletter capture. Frozen by design: growing
|
||||
* it requires editing the invariant pin on purpose.
|
||||
*/
|
||||
export const E2E_CURATED_SPECS = [
|
||||
'campaignPermissionProfile',
|
||||
'campaignDemandVisibility',
|
||||
'campaignAiTranscribe',
|
||||
'campaignAgendaFeed',
|
||||
'campaignNewsletter',
|
||||
]
|
||||
|
||||
/**
|
||||
* Risk-area source prefixes (OPS86). A diff touching them must never select
|
||||
* zero e2e: the entries below map each prefix to specs, and the classifier
|
||||
* fails closed (mode `unmapped-risk`) if a matching file has no entry — the
|
||||
* net for manifest drift. Deliberately NOT "all of src/" (the intention's
|
||||
* "map everything" rabbit hole).
|
||||
*/
|
||||
export const E2E_RISK_PREFIXES = [
|
||||
'src/utilities/access',
|
||||
'src/utilities/campaignAccess.ts',
|
||||
'src/lib/schemas',
|
||||
'src/utilities/campaignPushClient.ts',
|
||||
'src/utilities/ai',
|
||||
]
|
||||
|
||||
export const E2E_AFFECTED_MANIFEST = [
|
||||
{
|
||||
prefixes: ['src/app/(payload)'],
|
||||
|
|
@ -142,6 +179,34 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
],
|
||||
specs: ['campaignContacts'],
|
||||
},
|
||||
// OPS86 — risk surfaces. RBAC/visibility specs were orphaned (only ran in
|
||||
// `full`); these entries map the whole access surface plus form schemas,
|
||||
// push and AI transcription. Each prefix is in `E2E_RISK_PREFIXES` and the
|
||||
// invariant pins require the pair to stay in sync.
|
||||
{
|
||||
prefixes: ['src/utilities/access', 'src/utilities/campaignAccess.ts'],
|
||||
specs: ['campaignPermissionProfile', 'campaignDemandVisibility'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/utilities/ai'],
|
||||
specs: ['campaignAiTranscribe'],
|
||||
},
|
||||
{
|
||||
// Zod input schemas surface in the browser through form flows; the
|
||||
// public-site and newsletter specs exercise those flows end to end.
|
||||
prefixes: ['src/lib/schemas'],
|
||||
specs: ['frontend', 'campaignNewsletter'],
|
||||
},
|
||||
{
|
||||
// Web Push client — the opt-in toast mounts on the campaign shell, so a
|
||||
// push diff wakes the home smoke (console-error fail-fast covers it).
|
||||
prefixes: ['src/utilities/campaignPushClient.ts'],
|
||||
specs: ['campaignHomeActions'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/app/(campaign)/campanha/agenda/ical', 'src/utilities/calendarFeed'],
|
||||
specs: ['campaignAgendaFeed'],
|
||||
},
|
||||
// Domains without a dedicated e2e family still wake campaign home smoke so
|
||||
// the affected classifier cannot return mode=none on an unmapped domain dir.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,8 +4,18 @@
|
|||
* Unit-pinned in tests/unit/testAffected.unit.spec.ts,
|
||||
* tests/unit/ciSkipInvariants.unit.spec.ts, and
|
||||
* tests/unit/e2eAffectedManifest.unit.spec.ts.
|
||||
*
|
||||
* OPS86: e2e selection is never silently zero when code changed — high-risk
|
||||
* diffs run the curated cross-section (`curated`), risk-area files without a
|
||||
* manifest entry fail closed (`unmapped-risk`), and any other unmapped src/
|
||||
* file wakes the home smoke.
|
||||
*/
|
||||
|
||||
import { E2E_CURATED_SPECS, E2E_RISK_PREFIXES } from './e2e-affected-manifest.mjs'
|
||||
|
||||
/** Generic e2e smoke for unmapped non-risk src/ files (OPS86 fallback). */
|
||||
export const E2E_SMOKE_FALLBACK_SPEC = 'campaignHomeActions'
|
||||
|
||||
/**
|
||||
* Paths whose blast radius is the whole app: schema, test harness, lockfile,
|
||||
* skip classifiers. Any diff touching them runs the FULL suites — selection
|
||||
|
|
@ -28,6 +38,7 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'scripts/test-affected.mjs',
|
||||
'scripts/e2e-affected.mjs',
|
||||
'scripts/run-e2e-affected.mjs',
|
||||
'scripts/vitest-changed-or-full.mjs',
|
||||
'scripts/ci-scope.mjs',
|
||||
'scripts/check-test-locations.mjs',
|
||||
'scripts/check-plans-only-pr-closes.mjs',
|
||||
|
|
@ -190,15 +201,26 @@ 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[] }}
|
||||
* @returns {{ mode: 'curated' | 'selected' | 'unmapped-risk' | 'none',
|
||||
* specs: string[], reason: string, unmapped: string[] }}
|
||||
*
|
||||
* Modes (OPS86):
|
||||
* - `curated` — high-risk diff: run `E2E_CURATED_SPECS`, never zero (the
|
||||
* full suite stays in the deploy verify).
|
||||
* - `unmapped-risk` — a risk-area file (`E2E_RISK_PREFIXES`) matched no
|
||||
* manifest entry: the CI fails closed listing the files instead of going
|
||||
* green with zero e2e.
|
||||
* - `selected` — manifest-selected specs; an unmapped non-risk src/ file
|
||||
* still wakes the home smoke so code never ships on zero e2e.
|
||||
* - `none` — no e2e-relevant changes (docs-only).
|
||||
*/
|
||||
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)',
|
||||
mode: 'curated',
|
||||
specs: [...E2E_CURATED_SPECS],
|
||||
reason:
|
||||
'diff touches a high-risk path (schema/lockfile/test harness) — curated e2e cross-section; full suite stays in the deploy verify',
|
||||
unmapped: [],
|
||||
}
|
||||
}
|
||||
|
|
@ -222,15 +244,32 @@ export function selectE2eSpecs(files, manifest) {
|
|||
}
|
||||
for (const match of matches) for (const spec of match.specs) specs.add(spec)
|
||||
}
|
||||
const unmappedRisk = unmapped.filter((path) =>
|
||||
E2E_RISK_PREFIXES.some((prefix) => path.startsWith(prefix)),
|
||||
)
|
||||
if (unmappedRisk.length > 0) {
|
||||
return {
|
||||
mode: 'unmapped-risk',
|
||||
specs: [],
|
||||
reason: 'risk-area files have no e2e manifest mapping — add an entry (fail-closed)',
|
||||
unmapped,
|
||||
}
|
||||
}
|
||||
if (specs.size === 0) {
|
||||
if (unmapped.length > 0) {
|
||||
return {
|
||||
mode: 'selected',
|
||||
specs: [E2E_SMOKE_FALLBACK_SPEC],
|
||||
reason:
|
||||
'src/ changes with no e2e manifest mapping — running the home smoke (never zero e2e)',
|
||||
unmapped,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'none',
|
||||
specs: [],
|
||||
reason:
|
||||
unmapped.length > 0
|
||||
? 'src/ changes with no e2e manifest mapping (see unmapped list)'
|
||||
: 'no e2e-relevant changes',
|
||||
unmapped,
|
||||
reason: 'no e2e-relevant changes',
|
||||
unmapped: [],
|
||||
}
|
||||
}
|
||||
// The `setup` spec is dev-mode-only: the setup project is dropped under
|
||||
|
|
@ -238,8 +277,17 @@ export function selectE2eSpecs(files, manifest) {
|
|||
// run `playwright test -- tests/e2e/setup.e2e.spec.ts` against zero projects
|
||||
// and fail with "No tests found" (OPS39 — first setup-only PR hit it). In a
|
||||
// mixed set the spec is harmless (it just matches no project), so only the
|
||||
// setup-only case drops out.
|
||||
// setup-only case drops out — and an unmapped src/ file still wakes the
|
||||
// smoke instead (OPS86: never zero e2e on touched code).
|
||||
if (specs.size === 1 && specs.has('setup')) {
|
||||
if (unmapped.length > 0) {
|
||||
return {
|
||||
mode: 'selected',
|
||||
specs: [E2E_SMOKE_FALLBACK_SPEC],
|
||||
reason: 'setup-only spec plus unmapped src/ changes — running the home smoke',
|
||||
unmapped,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'none',
|
||||
specs: [],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-e2e-affected — local mirror of the CI (PR) e2e job:
|
||||
* 1. `e2e-affected.mjs` picks full vs manifest-selected vs skip (same base ref).
|
||||
* 1. `e2e-affected.mjs` picks the scope vs the same base ref.
|
||||
* 2. migrate + db:seed:minimal when anything would run.
|
||||
* 3. optional production build into `.next-e2e` when `E2E_PROD=1`.
|
||||
* 4. `pnpm test:e2e` (set `CI=1` and `E2E_PROD=1` together for prod mode).
|
||||
*
|
||||
* Mode behavior (OPS86):
|
||||
* - selected → run the mapped specs (filtered, `--no-deps`).
|
||||
* - curated (high-risk) → run the FULL suite locally — the local contract
|
||||
* from OPS72 stays: local high-risk = full; the CI runs only the curated
|
||||
* cross-section, and the deploy verify runs full before publishing.
|
||||
* - unmapped-risk → SOFT locally (a debugging tool never gates): print the
|
||||
* file list and run the curated cross-section with a warning. The CI
|
||||
* fails closed on this mode.
|
||||
* - full (no merge-base fallback) → run the full suite.
|
||||
*
|
||||
* Extra Playwright args are forwarded verbatim (e.g. a single spec while
|
||||
* debugging): pnpm consumes the first `--` separator, so the script only ever
|
||||
* strips a leading `--` left by direct `node scripts/...` invocations
|
||||
|
|
@ -23,6 +33,7 @@ import { execFileSync, spawnSync } from 'node:child_process'
|
|||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { E2E_CURATED_SPECS } from './lib/e2e-affected-manifest.mjs'
|
||||
import { buildPlaywrightE2eArgs, parsePassthroughArgs } from './lib/playwright-e2e-args.mjs'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
|
@ -43,6 +54,12 @@ if (scope.mode === 'none') {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
if (scope.mode === 'unmapped-risk') {
|
||||
console.warn(
|
||||
`[e2e:affected] ⚠ RISK-AREA files without a manifest mapping (CI FAILS closed on this; running the curated set locally):\n ${scope.unmapped.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const run = (command, args, env = {}) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
|
|
@ -60,9 +77,15 @@ if (process.env.E2E_PROD === '1') {
|
|||
run('pnpm', ['build'], { NEXT_DIST_DIR: '.next-e2e' })
|
||||
}
|
||||
|
||||
const curatedSpecPaths = E2E_CURATED_SPECS.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
const scopeSpecPaths =
|
||||
scope.mode === 'selected'
|
||||
? scope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`)
|
||||
: scope.mode === 'unmapped-risk'
|
||||
? curatedSpecPaths
|
||||
: [] // curated/full → run the whole suite locally
|
||||
const playwrightArgs = buildPlaywrightE2eArgs({
|
||||
scopeSpecPaths:
|
||||
scope.mode === 'selected' ? scope.specs.map((name) => `tests/e2e/${name}.e2e.spec.ts`) : [],
|
||||
scopeSpecPaths,
|
||||
passthroughArgs,
|
||||
})
|
||||
|
||||
|
|
|
|||
79
scripts/vitest-changed-or-full.mjs
Normal file
79
scripts/vitest-changed-or-full.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* vitest-changed-or-full (OPS86) — the CI/gate `changed` unit/int steps must
|
||||
* never run zero tests in silence: `vitest run --changed <base>
|
||||
* --passWithNoTests` exits green with zero executions when the diff touches
|
||||
* no spec file. This wrapper lists the changed specs first
|
||||
* (`vitest list --changed <base> --json`) and falls back to the FULL suite
|
||||
* when the selection is empty. Full unit is cheap; full int is rare (only
|
||||
* when src/tests changed yet no int spec depends on them).
|
||||
*
|
||||
* Usage: node scripts/vitest-changed-or-full.mjs --suite unit|int --base <git-ref>
|
||||
* Exit code is the test run's — the wrapper never turns red into green.
|
||||
*
|
||||
* The env/config pairs mirror the `test:unit` / `test:int` package.json
|
||||
* scripts; keep them in sync (both are pinned by ciSkipInvariants).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const SUITES = {
|
||||
unit: {
|
||||
config: './vitest.unit.config.mts',
|
||||
runScript: 'test:unit',
|
||||
// Same invalid URL as the `test:unit` script: unit specs must not touch a DB.
|
||||
env: { DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect' },
|
||||
},
|
||||
int: {
|
||||
config: './vitest.config.mts',
|
||||
runScript: 'test:int',
|
||||
env: {},
|
||||
},
|
||||
}
|
||||
|
||||
const args = {}
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
if (process.argv[i] === '--suite') args.suite = process.argv[++i]
|
||||
else if (process.argv[i] === '--base') args.base = process.argv[++i]
|
||||
else throw new Error(`unknown arg: ${process.argv[i]}`)
|
||||
}
|
||||
if (!args.suite || !SUITES[args.suite] || !args.base) {
|
||||
throw new Error('usage: vitest-changed-or-full.mjs --suite unit|int --base <git-ref>')
|
||||
}
|
||||
const suite = SUITES[args.suite]
|
||||
|
||||
const listResult = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'vitest', 'list', '--config', suite.config, '--changed', args.base, '--json'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...suite.env, NODE_OPTIONS: '--no-deprecation' },
|
||||
},
|
||||
)
|
||||
if (listResult.status !== 0) {
|
||||
console.error(`[vitest-changed-or-full] ✗ vitest list failed (exit ${listResult.status ?? 1})`)
|
||||
process.exit(listResult.status ?? 1)
|
||||
}
|
||||
const changedSpecs = JSON.parse(listResult.stdout)
|
||||
|
||||
const run = (label, argv, env = process.env) => {
|
||||
console.log(`\n[vitest-changed-or-full] ▶ ${args.suite} ${label}`)
|
||||
const result = spawnSync('pnpm', argv, { cwd: repoRoot, stdio: 'inherit', env })
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
if (changedSpecs.length === 0) {
|
||||
console.log(
|
||||
`[vitest-changed-or-full] --changed ${args.base} selected zero ${args.suite} specs — running the FULL suite (never green with 0 tests, OPS86)`,
|
||||
)
|
||||
run('(full fallback)', [suite.runScript])
|
||||
} else {
|
||||
console.log(
|
||||
`[vitest-changed-or-full] ${changedSpecs.length} ${args.suite} spec(s) changed — running the selection`,
|
||||
)
|
||||
run('(changed)', [suite.runScript, '--', '--changed', args.base, '--passWithNoTests'])
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ describe('ciSkipInvariants', () => {
|
|||
'scripts/test-affected.mjs',
|
||||
'scripts/e2e-affected.mjs',
|
||||
'scripts/run-e2e-affected.mjs',
|
||||
'scripts/vitest-changed-or-full.mjs',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
'tests/unit/testAffected.unit.spec.ts',
|
||||
]) {
|
||||
|
|
@ -140,4 +141,15 @@ describe('ciSkipInvariants', () => {
|
|||
)
|
||||
expect(missing, `unmapped domains:\n${missing.join('\n')}`).toEqual([])
|
||||
})
|
||||
|
||||
it('ci-pr wires the OPS86 never-zero fallbacks (curated e2e, unmapped-risk fail, changed-or-full)', () => {
|
||||
// The fail-closed behavior IS the workflow wiring — pin it like the
|
||||
// other CI contracts in this file.
|
||||
const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8')
|
||||
expect(ciPr).toContain("e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'")
|
||||
expect(ciPr).toContain("e2e_mode == 'unmapped-risk'")
|
||||
expect(ciPr).toContain('vitest-changed-or-full.mjs')
|
||||
// The PR never runs e2e full.
|
||||
expect(ciPr).not.toContain("e2e_mode == 'full'")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { join } from 'node:path'
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { E2E_AFFECTED_MANIFEST } from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
E2E_AFFECTED_MANIFEST,
|
||||
E2E_CURATED_SPECS,
|
||||
E2E_RISK_PREFIXES,
|
||||
} from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
|
||||
const E2E_DIR = join(__dirname, '../e2e')
|
||||
|
||||
|
|
@ -42,4 +46,30 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
|
|||
const mapped = new Set(E2E_AFFECTED_MANIFEST.flatMap((entry) => entry.specs))
|
||||
expect(mapped.size).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('freezes the curated high-risk e2e set (OPS86 — deliberate growth only)', () => {
|
||||
// The curated set is the never-zero answer for high-risk PR diffs; change
|
||||
// it on purpose (cost of the PR CI high-risk path), not by drift.
|
||||
expect(E2E_CURATED_SPECS).toEqual([
|
||||
'campaignPermissionProfile',
|
||||
'campaignDemandVisibility',
|
||||
'campaignAiTranscribe',
|
||||
'campaignAgendaFeed',
|
||||
'campaignNewsletter',
|
||||
])
|
||||
const onDisk = new Set(specNamesOnDisk())
|
||||
for (const spec of E2E_CURATED_SPECS) {
|
||||
expect(onDisk.has(spec), spec).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every risk prefix covered by a manifest entry (OPS86 fail-closed net)', () => {
|
||||
// The classifier fails closed when a risk-area file matches no entry; if
|
||||
// the entries drift out of sync with the risk prefixes, that fail-closed
|
||||
// fires on every diff in the area. Pin the pair together.
|
||||
const mappedPrefixes = new Set(E2E_AFFECTED_MANIFEST.flatMap((entry) => entry.prefixes))
|
||||
for (const prefix of E2E_RISK_PREFIXES) {
|
||||
expect(mappedPrefixes.has(prefix), prefix).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { E2E_CURATED_SPECS } from '../../scripts/lib/e2e-affected-manifest.mjs'
|
||||
import {
|
||||
classifyBuildScope,
|
||||
classifyStaticScope,
|
||||
classifyTestScope,
|
||||
E2E_SMOKE_FALLBACK_SPEC,
|
||||
HIGH_RISK_EXACT,
|
||||
HIGH_RISK_PREFIXES,
|
||||
selectE2eSpecs,
|
||||
|
|
@ -116,17 +118,41 @@ describe('selectE2eSpecs (OPS5)', () => {
|
|||
expect(result).toMatchObject({ mode: 'selected', specs: ['campaignMunicipalities'] })
|
||||
})
|
||||
|
||||
it('runs the full suite on high-risk paths', () => {
|
||||
expect(selectE2eSpecs([changed('tests/e2e/fixtures/auth.ts')], manifest).mode).toBe('full')
|
||||
expect(selectE2eSpecs([changed('src/migrations/x.ts')], manifest).mode).toBe('full')
|
||||
it('runs the curated e2e cross-section on high-risk paths (never zero, OPS86)', () => {
|
||||
for (const path of ['tests/e2e/fixtures/auth.ts', 'src/migrations/x.ts']) {
|
||||
const result = selectE2eSpecs([changed(path)], manifest)
|
||||
expect(result.mode, path).toBe('curated')
|
||||
expect(result.specs).toEqual(E2E_CURATED_SPECS)
|
||||
expect(result.unmapped).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('reports unmapped src paths without failing and skips the run', () => {
|
||||
it('wakes the home smoke for unmapped non-risk src files (never zero, OPS86)', () => {
|
||||
const result = selectE2eSpecs([changed('src/utilities/brandNewModule.ts')], manifest)
|
||||
expect(result.mode).toBe('none')
|
||||
expect(result.mode).toBe('selected')
|
||||
expect(result.specs).toEqual([E2E_SMOKE_FALLBACK_SPEC])
|
||||
expect(result.unmapped).toEqual(['src/utilities/brandNewModule.ts'])
|
||||
})
|
||||
|
||||
it('fails closed (unmapped-risk) for risk-area files without a manifest entry', () => {
|
||||
const result = selectE2eSpecs([changed('src/utilities/access/brandNewPolicy.ts')], manifest)
|
||||
expect(result.mode).toBe('unmapped-risk')
|
||||
expect(result.specs).toEqual([])
|
||||
expect(result.unmapped).toEqual(['src/utilities/access/brandNewPolicy.ts'])
|
||||
})
|
||||
|
||||
it('fails closed even when other specs would run (risk file is uncovered)', () => {
|
||||
const result = selectE2eSpecs(
|
||||
[
|
||||
changed('src/components/campaign/municipality/MunicipalityTable.tsx'),
|
||||
changed('src/lib/schemas/brandNewForm.ts'),
|
||||
],
|
||||
manifest,
|
||||
)
|
||||
expect(result.mode).toBe('unmapped-risk')
|
||||
expect(result.unmapped).toEqual(['src/lib/schemas/brandNewForm.ts'])
|
||||
})
|
||||
|
||||
it('skips e2e for diffs with no src/e2e changes', () => {
|
||||
const result = selectE2eSpecs([changed('docs/AGENT-OPS.md')], manifest)
|
||||
expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] })
|
||||
|
|
|
|||
Loading…
Reference in a new issue