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:
Francisco Solla 2026-08-24 13:53:51 -03:00
parent 3b32dbc814
commit 9af4b92851
16 changed files with 500 additions and 76 deletions

View file

@ -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

View file

@ -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:

View file

@ -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

View 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.

View 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.

View file

@ -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(

View file

@ -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'

View file

@ -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.`,
)
}

View file

@ -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.
{

View file

@ -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: [],

View file

@ -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,
})

View 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'])
}

View file

@ -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'")
})
})

View file

@ -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)
}
})
})

View file

@ -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: [] })