diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 2804cb0d..099ae789 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (2026-08-23):** débito da revisão estrutural do OPS46 (issue #789): `tests/helpers/e2eEsmResolve.mjs` remapeava `next/cache` → `next/cache.js` sem tentar a resolução bare — quando o next publicar um `exports` map, o remap podia mascarar erro de resolução real (fail-loud vira fail-silent). Fix no branch `next/cache`: `await nextResolve(specifier, context)` primeiro; só em `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; qualquer outro erro propaga. Branch `react-server` intocado (injeção de conditions não mascara erro). Spec unit novo `tests/unit/e2eEsmResolve.unit.spec.ts` (3 casos com `nextResolve` mockado: bare-ok sem remap, `ERR_MODULE_NOT_FOUND` remapeia com `context` intacto, erro de outro código propaga). Verificação: `gate:fast` verde (unit 2432), sanity OPS72: `playwright test --list` coleta 202 testes com o hook carregado. Gatilho de reavaliação: bump major do next (fallback vira código morto e pode ser removido). Impl plan: `docs/plans/ops46-s1-hook-esm-bare-first-impl.md`. + **OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`. **E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23):** a 3ª cópia do efeito de busca async (requestId + `setTimeout(250)` + loading/failed) — introduzida pela C143 em `DemandResponsibleMultiSelect` (demand), e presente em `ResponsibleMultiSelect` (C90) e `AsyncSearchCombobox` — vira o hook `useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed }` (`src/hooks/useAsyncSearchOptions.ts`, novo; constante `ASYNC_SEARCH_DEBOUNCE_MS` exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, `pinnedOptions`, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via `isContactSearchQueryReady`; combobox com gate configurável — o efeito sync `selected←value` fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo `tests/unit/useAsyncSearchOptions.unit.spec.ts` (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan `docs/plans/extrair-hook-busca-assincrona-debounce-impl.md`. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: `WizardMunicipalitySearchStep` (4ª variante, AbortController — defer com gatilho no plano). diff --git a/docs/changelog/2026-08-23-ops46-s1.md b/docs/changelog/2026-08-23-ops46-s1.md new file mode 100644 index 00000000..79066dd3 --- /dev/null +++ b/docs/changelog/2026-08-23-ops46-s1.md @@ -0,0 +1 @@ +**OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (2026-08-23):** débito da revisão estrutural do OPS46 (issue #789): `tests/helpers/e2eEsmResolve.mjs` remapeava `next/cache` → `next/cache.js` sem tentar a resolução bare — quando o next publicar um `exports` map, o remap podia mascarar erro de resolução real (fail-loud vira fail-silent). Fix no branch `next/cache`: `await nextResolve(specifier, context)` primeiro; só em `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; qualquer outro erro propaga. Branch `react-server` intocado (injeção de conditions não mascara erro). Spec unit novo `tests/unit/e2eEsmResolve.unit.spec.ts` (3 casos com `nextResolve` mockado: bare-ok sem remap, `ERR_MODULE_NOT_FOUND` remapeia com `context` intacto, erro de outro código propaga). Verificação: `gate:fast` verde (unit 2432), sanity OPS72: `playwright test --list` coleta 202 testes com o hook carregado. Gatilho de reavaliação: bump major do next (fallback vira código morto e pode ser removido). Impl plan: `docs/plans/ops46-s1-hook-esm-bare-first-impl.md`. diff --git a/docs/plans/ops46-s1-hook-esm-bare-first-impl.md b/docs/plans/ops46-s1-hook-esm-bare-first-impl.md new file mode 100644 index 00000000..b3453dfd --- /dev/null +++ b/docs/plans/ops46-s1-hook-esm-bare-first-impl.md @@ -0,0 +1,56 @@ +# OPS46-S1 — hook ESM mapeia next/cache incondicionalmente (impl) + +Fonte: issue #789 (body = spec). Débito capturado na revisão estrutural do OPS46 (PR #787). + +## Problema + +`tests/helpers/e2eEsmResolve.mjs` remapeia `next/cache` → `next/cache.js` **sem tentar a +resolução bare primeiro**. Quando o `next` publicar um `exports` map (major bump), o remap +pode mascarar um erro de resolução real (fail-loud vira fail-silent). + +## Abordagem recomendada + +No branch `specifier === 'next/cache'`: + +1. `await nextResolve(specifier, context)` primeiro. +2. Só no rejeito com `error.code === 'ERR_MODULE_NOT_FOUND'`, remapear para + `next/cache.js` (comportamento atual do fallback). +3. Qualquer outro erro propaga — fail-loud preservado. + +Branch `react-server` (injeção de conditions) intocado: o issue só cita o remap, e a injeção +não mascarava erro (apenas adiciona condição, espelhando `--conditions=react-server`). + +## Fases + +1. Editar `tests/helpers/e2eEsmResolve.mjs` (~5 linhas; hook já é `async`, então `await` + dentro do try/catch é válido e necessário para capturar a rejeição). +2. Spec unit `tests/unit/e2eEsmResolve.unit.spec.ts` (padrão do repo: vitest importa `.mjs` + direto, ex. `agentPoolModels.unit.spec.ts`): três casos com `nextResolve` mockado — + bare-ok não remapeia; bare `ERR_MODULE_NOT_FOUND` remapeia para `next/cache.js`; erro de + outro código propaga. O repo já testa helpers `.mjs` dessa forma; a regressão + fail-loud→fail-silent é exatamente o tipo que merece pin. +3. `pnpm gate:fast` (lint + typecheck + unit). +4. Sanity opcional (OPS72, discricionário): `pnpm exec playwright test --list` para provar + que a coleta de testes segue funcionando com o hook carregado. + +## Alternativas rejeitadas + +- **Remover o remap de vez**: quebraria `playwright test` bare (VS Code extension, `--list`, + ad-hoc) até o next publicar exports map — a razão de existir do hook (OPS46) continuaria + pendente. +- **Checar `error.message` por `next/cache`**: spec do issue pede só `ERR_MODULE_NOT_FOUND`; + o branch já é escopado ao specifier, e o fallback propaga erros próprios. Menos código. + +## Riscos + +- **Catch largo (baixo)**: um `ERR_MODULE_NOT_FOUND` de import transitivo dentro da + resolução de `next/cache` dispararia o remap — mas só dentro do branch `next/cache`, e o + fallback `next/cache.js` propaga seus próprios erros. Mascaramento residual mínimo, + dentro do spec. +- **Esquecer `await` no try (nulo)**: sem `await` o try não captura rejeição — a spec + unit cobre o caso. + +## Gatilho de reavaliação + +Bump major do `next`: quando o exports map existir, a resolução bare passa e o fallback fica +morto — o remap pode então ser removido. diff --git a/tests/helpers/e2eEsmResolve.mjs b/tests/helpers/e2eEsmResolve.mjs index 625bf3f8..f4dd0436 100644 --- a/tests/helpers/e2eEsmResolve.mjs +++ b/tests/helpers/e2eEsmResolve.mjs @@ -14,10 +14,19 @@ * * Registered in the main process by `playwright.config.ts` and passed to the * worker processes via `NODE_OPTIONS=--import=...` (see `e2eEsmLoader.mjs`). + * + * OPS46-S1 — the `next/cache` remap only fires when the bare resolution + * actually fails with `ERR_MODULE_NOT_FOUND`: once next ships an `exports` + * map, bare resolution succeeds and the remap must not mask a real error. */ export async function resolve(specifier, context, nextResolve) { if (specifier === 'next/cache') { - return nextResolve('next/cache.js', context) + try { + return await nextResolve(specifier, context) + } catch (error) { + if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error + return await nextResolve('next/cache.js', context) + } } if (!context.conditions.includes('react-server')) { return nextResolve(specifier, { diff --git a/tests/unit/e2eEsmResolve.unit.spec.ts b/tests/unit/e2eEsmResolve.unit.spec.ts new file mode 100644 index 00000000..e55cf8d1 --- /dev/null +++ b/tests/unit/e2eEsmResolve.unit.spec.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { resolve } from '../helpers/e2eEsmResolve.mjs' + +const CONTEXT = { conditions: ['react-server', 'node'] } + +type NodeError = Error & { code: string } + +describe('e2eEsmResolve', () => { + it('resolves next/cache bare when the exports map exists (no remap)', async () => { + const nextResolve = async (specifier: string) => { + expect(specifier).toBe('next/cache') + return { url: 'file:///node_modules/next/cache' } + } + await expect(resolve('next/cache', CONTEXT, nextResolve)).resolves.toEqual({ + url: 'file:///node_modules/next/cache', + }) + }) + + it('remaps next/cache to next/cache.js only on ERR_MODULE_NOT_FOUND', async () => { + const nextResolve = async (specifier: string, context: unknown) => { + expect(context).toBe(CONTEXT) + if (specifier === 'next/cache') { + const error = new Error(`Cannot find module '${specifier}'`) as NodeError + error.code = 'ERR_MODULE_NOT_FOUND' + throw error + } + expect(specifier).toBe('next/cache.js') + return { url: 'file:///node_modules/next/cache.js' } + } + await expect(resolve('next/cache', CONTEXT, nextResolve)).resolves.toEqual({ + url: 'file:///node_modules/next/cache.js', + }) + }) + + it('propagates non-ERR_MODULE_NOT_FOUND errors from the bare resolution', async () => { + const broken = async () => { + const error = new Error('boom') as NodeError + error.code = 'ERR_SOMETHING_ELSE' + throw error + } + await expect(resolve('next/cache', CONTEXT, broken)).rejects.toThrow('boom') + }) +})