mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(OPS46-S1): hook ESM tenta resolução bare do next/cache antes do remap (Closes #789)
This commit is contained in:
parent
8eb127bebc
commit
dddbd920e0
5 changed files with 115 additions and 1 deletions
|
|
@ -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).
|
||||
|
|
|
|||
1
docs/changelog/2026-08-23-ops46-s1.md
Normal file
1
docs/changelog/2026-08-23-ops46-s1.md
Normal file
|
|
@ -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`.
|
||||
56
docs/plans/ops46-s1-hook-esm-bare-first-impl.md
Normal file
56
docs/plans/ops46-s1-hook-esm-bare-first-impl.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
46
tests/unit/e2eEsmResolve.unit.spec.ts
Normal file
46
tests/unit/e2eEsmResolve.unit.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue