diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index 4fd3675f..bfe5207b 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -39,9 +39,15 @@ antes do deploy**. `--no-deps --project=`). - **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 diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 8f667975..9ad2c0c1 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -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: diff --git a/AGENTS-infra.md b/AGENTS-infra.md index 032f8f3b..709b21d6 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -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` / `teqo_wt_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 -- ` ou `--no-deps --project=`; 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 -- ` ou `--no-deps --project=`; 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 `) 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. diff --git a/AGENTS.md b/AGENTS.md index bde58169..210389d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/curated, skips via `ci-scope.mjs`; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechado `unmapped-risk`, e unit/int `changed` com seleção vazia cai em full via `vitest-changed-or-full.mjs`); its check-run `CI (PR) / checks` is the required check of `main` — literal de match na proteção: `checks` (o GitHub casa pelo nome do check-run/job; a UI exibe `CI (PR) / checks` — pin ao vivo PR #742) — and the gate of the **native auto-merge** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`, GraphQL `enablePullRequestAutoMerge`, rebase — armed with the `AUTOMERGE_PAT` real-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen as `github-actions[bot]`, whose events never create workflow runs — the post-merge `closed` flips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with two jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container; the repo comes from `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, public). `pnpm push` pushes to `origin` — **the GitHub remote** (one-time local cutover: `git remote set-url origin git@github.com:fsolla/teqo.git`; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs`) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejo `main` still holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10`tick cost 144 full`pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo\*1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore`(dump in`/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL`in`~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media`collection uploads via`@payloadcms/storage-s3`when the`S3\*\*`envs are set, and Payload proxies the files through`/api/media/file/...`(the bucket stays private). Production env lives in`~/stack/teqo-1313.env`(bucket`teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3\__`config aborts the boot, and no`S3\_\*`means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em`/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg`e`jorgesolla.jpg`) — re-executar via `pnpm media:recover`(restaura objetos sem tocar no DB; runbook em`docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1`— guard de intenção explícita para escrita no bucket,`--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`\_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/changelog/2026-08-24-ops86.md b/docs/changelog/2026-08-24-ops86.md new file mode 100644 index 00000000..4569db17 --- /dev/null +++ b/docs/changelog/2026-08-24-ops86.md @@ -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 --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. diff --git a/docs/plans/ops86-blast-radius-testes-prs-impl.md b/docs/plans/ops86-blast-radius-testes-prs-impl.md new file mode 100644 index 00000000..946d60eb --- /dev/null +++ b/docs/plans/ops86-blast-radius-testes-prs-impl.md @@ -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?
migrações/coleções/globals/
harness/classificador} + B -- sim --> C[e2e: mode=curated
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
docs-only] + D -- sim --> F{path casa prefixo
do manifest?} + F -- sim --> G[e2e: mode=selected
specs mapeadas] + F -- não --> H{path em área de risco?
access/ · schemas/ · push · ai/} + H -- sim --> I[e2e: mode=unmapped-risk
CI FALHA listando arquivos] + H -- não --> J[e2e: mode=selected
fallback smoke campaignHomeActions] + U1 --> K[unit/int: mode=changed] + G --> K + J --> K + K --> L{--changed selecionou 0?
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 --changed --list` (lista de arquivos sem executar; config unit/int conforme o arg); se 0 arquivos → roda a suíte full; senão → roda `--changed --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/-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. diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 514d8b5c..4a4f70ff 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -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( diff --git a/scripts/e2e-affected.mjs b/scripts/e2e-affected.mjs index f53d0bbf..32a1602e 100644 --- a/scripts/e2e-affected.mjs +++ b/scripts/e2e-affected.mjs @@ -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/.e2e.spec.ts for each name in `specs` - * mode=none → skip e2e for this diff + * mode=selected → run tests/e2e/.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' diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index 509d5be5..442dfabc 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -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.`, ) } diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 259ac363..86546e85 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -4,9 +4,46 @@ * of truth. Granularity is the DOMAIN prefix on purpose — finer grains rot. * Spec names exist as tests/e2e/.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. { diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 5225b5ae..4716417e 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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: [], diff --git a/scripts/run-e2e-affected.mjs b/scripts/run-e2e-affected.mjs index 116d0e6a..ff2c1006 100644 --- a/scripts/run-e2e-affected.mjs +++ b/scripts/run-e2e-affected.mjs @@ -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, }) diff --git a/scripts/vitest-changed-or-full.mjs b/scripts/vitest-changed-or-full.mjs new file mode 100644 index 00000000..85b74360 --- /dev/null +++ b/scripts/vitest-changed-or-full.mjs @@ -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 + * --passWithNoTests` exits green with zero executions when the diff touches + * no spec file. This wrapper lists the changed specs first + * (`vitest list --changed --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 + * 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 ') +} +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']) +} diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 593aa34d..46d16327 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -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'") + }) }) diff --git a/tests/unit/e2eAffectedManifest.unit.spec.ts b/tests/unit/e2eAffectedManifest.unit.spec.ts index 8a024333..0cd9ea99 100644 --- a/tests/unit/e2eAffectedManifest.unit.spec.ts +++ b/tests/unit/e2eAffectedManifest.unit.spec.ts @@ -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) + } + }) }) diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index c33ddabb..bcb3ca61 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -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: [] })