diff --git a/.agents/skills/local-database/SKILL.md b/.agents/skills/local-database/SKILL.md index 33ec8bc4..d905153b 100644 --- a/.agents/skills/local-database/SKILL.md +++ b/.agents/skills/local-database/SKILL.md @@ -30,7 +30,7 @@ Parallel agents must not fight over port 3000, the shared `teqo_test`, or a seco - `slot` = numeric part of the issue code (`C15` → 15, `B164` → 164; hash fallback without digits; bumped on collision among live worktrees). - Dev server port `3100+slot`; databases `teqo_wt` (dev) and `teqo_wt_test` (tests), created on the **one shared container** with the full migration chain applied (one-time, ~30 s each). -- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` (or `pnpm worktree plan` for the /plan-issue worktree) to regenerate. +- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL, NEXT_PUBLIC_SITE_URL, **PLAYWRIGHT_BASE_URL** — both URL vars derived from the same slot port so a parallel e2e run never falls back to :3000; see `worktreeEnvFileContents` in `scripts/lib/worktree-env.mjs`). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` (or `pnpm worktree plan` for the /plan-issue worktree) to regenerate. - `--no-migrate` skips the migration runs; `pnpm worktree kill` drops the worktree's generated databases (best-effort, `DROP DATABASE … WITH (FORCE)`). - Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning and no port override. - Pure derivation: `scripts/lib/worktree-env.mjs` (unit-tested in `tests/unit/worktree.unit.spec.ts`). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 240f92f3..4df6990e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md). **Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md). **Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md). diff --git a/docs/plans/porta-e2e-isolada-por-worktree-impl.md b/docs/plans/porta-e2e-isolada-por-worktree-impl.md new file mode 100644 index 00000000..4485b273 --- /dev/null +++ b/docs/plans/porta-e2e-isolada-por-worktree-impl.md @@ -0,0 +1,68 @@ +# Impl: Isolar a porta do e2e por worktree (e2e não deve disputar a porta 3000) + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #464 +Intenção: docs/plans/porta-e2e-isolada-por-worktree.md +Appetite restante: herdado (~0,5 dia; o caminho cabe folgado) + +## Leitura da intenção + +- **Outcome:** dois `pnpm test:e2e` em worktrees diferentes, no mesmo host, rodam sem `EADDRINUSE` e sem um falar com o servidor do outro; cada run usa banco de teste + porta do slot do próprio worktree (nunca queda em `3000`); o dev server do e2e permanece amarrado ao banco de teste. +- **O que NÃO negociar:** nada de autenticação/rota/DB novo; contratos de URL pública intactos; `playwright.config.ts` fora do escopo (a variável certa já existe e o config já a lê — o que precisa é a variável **chegar** em `.env.test.local`). +- **O que reavaliar:** a hipótese da intenção é que a mudança é “meia linha” no bloco do `.env.test.local`. Confirmei a leitura — `playwright.config.ts:14` carrega `.env.test.local` com `override: true`, `playwright.config.ts:23` deriva `baseURL = PLAYWRIGHT_BASE_URL ?? http://localhost:3000`, e o webServer deriva `PORT` dessa `baseURL` (`playwright.config.ts:24,115`). Logo a meia linha resolve. **Mas** a causa-raiz não é a linha: é que os dois blocos env do provisionamento são mantidos à mão em paralelo (`scripts/worktree.mjs:250-279`) e isso já derivou uma vez — a única linha que faltou está em um lugar e não no outro. Decidi tratar a causa (paridade estrutural) além do sintoma. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[worktree.mjs provision] --> B[worktreeEnvFileContents em scripts/lib/worktree-env.mjs] + B --> C[.env.local: dev + PLAYWRIGHT_BASE_URL] + B --> D[.env.test.local: test + PLAYWRIGHT_BASE_URL -- NOVO] + B -. mesma url do slot .-> E[baseURL + webServer.PORT em playwright.config.ts já transformam em porta do slot] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** B — um único builder puro de **ambos** os arquivos env, com as duas URL (`NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL`) derivadas do **mesmo** `env.devPort`, + testes de unidade na paridade. Troca a cola de dois blocos manuais irmãos (a exata classe de deriva que a intenção narra) por construção estrutural que torna a deriva impossível, e dá ao gate uma verificação automatizada real — a alternativa não teria **nenhuma** (playwright.config.ts não muda e rodar o provision real precisa de Docker fora do `gate:fast`). +**Rejeitadas:** + +- **A — meia linha só** (`PLAYWRIGHT_BASE_URL` no bloco do `.env.test.local` + doc): barato e vale como fix mínimo, mas deixa a estrutura de dois blocos manuais que **causou** o bug; a próxima variável nova cai em um bloco só de novo. Sem nenhum teste cobrindo o fix. +- **C — reescrever/reordenar o fluxo de env do playwright** (templating único, carregar `.env.local` no config, faixa de porta própria para e2e): estoura o aceite e é o rabbit hole que a intenção manda cortar. + +### Componentes / mudanças + +- **`worktreeEnvFileContents` (novo, `scripts/lib/worktree-env.mjs`)**: função pura que recebe `{ branch, issueLabel, generatedBy, env, devUrl, testUrl, payloadSecret, copiedLines }` e devolve `{ dev: string[], test: string[] }`. Constroi o header compartilhado (marker + branch/slot/purpose) e deriva `url = http://localhost:${env.devPort}` **uma vez**, injetando `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` nos dois. Morada conforme o padrão documentado em AGENTS.md: “Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested)”. +- **`provision` (`scripts/worktree.mjs`)**: os dois blocos inline (linhas ~250-279) viram uma chamada ao builder puro; `writeFileSync` de `.env.local`/`.env.test.local` com `devLines`/`testLines`. Contratos de conteúdo **idênticos** aos de antes + `PLAYWRIGHT_BASE_URL` no test. `writeFallbackEnv` (sem Docker) intacto — a intenção exclui a porta compartilhada do fallback. +- **Migration:** sem migration. +- **Access / Consent:** N/A. +- **UI:** N/A — Impeccable A. + +### Testes + +- `tests/unit/worktree.unit.spec.ts`: novo bloco cobrindo (1) arquivo test inclui `PLAYWRIGHT_BASE_URL=http://localhost:` e `NEXT_PUBLIC_SITE_URL` no mesmo port; (2) paridade dev×test das duas URL (o teste que falha se o drift voltar); (3) header/marker presente e valores de DB corretos (dev vs test). + +## Fases verificáveis + +1. **Builder puro + fix** — extrair `worktreeEnvFileContents`, substituir blocos, escrever os testes de unidade. +2. **Gates** — `pnpm gate:fast` (lint, typecheck, test:unit com o novo bloco verde); `pnpm format` se Prettier reclamar; `pnpm exec knip` para ver que nada ficou órfão. +3. **Docs** — `.agents/skills/local-database/SKILL.md:33` (rolagem do `.env.test.local` passa a citar `PLAYWRIGHT_BASE_URL`); entrada curta em `docs/CHANGELOG-AGENTS.md`. + +## Rabbit holes / Não escopo (engenharia) + +- Reordenar o fluxo de env do playwright / mexer no `playwright.config.ts` (fora do escopo da intenção). +- Isolar Blob/Vercel/tmp entre e2e paralelos (intenção: item próprio se medir conflito). +- Sem-Docker fallback ganhar porta própria (intenção: degrada e fica assim). +- `.env.example` documentando `PLAYWRIGHT_BASE_URL` — o config já tem fallback `3000` autocontido. + +## Riscos e mitigação + +- **Quebrar o provisionamento em ambientes reais** (ex.: ordem de linhas muda, mas dotenv não depende de ordem) — mitigação: builder reproduz exatamente as linhas de antes (diff 1:1) + testes de paridade; `kill`/`worktreeDatabaseNamesOf` continuam lendo `DATABASE_URL` dos dois arquivos, preservados. +- **`PLAYWRIGHT_BASE_URL` cai no `.env.local` do worktree mas o e2e não lê esse arquivo em lugar nenhum novo** — esperado e inofensivo: `.env.local` é do dev server (que também passa a subir na porta do slot pelo `PORT` já existente); o config lê `.env.test.local`. +- **Gate sem Docker**: o fix é exercitado pela unit test pura; nada depende do container. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (e2e usa porta+banco do slot; sem fallback `3000`; dev server do e2e no banco de teste) +- [ ] Invariantes AGENTS/engineering-standards (derivação mora em `worktree-env.mjs`; sem tocar contratos públicos) +- [ ] Testes de domínio previstos (unit de paridade dos dois env files — o guard do drift) +- [ ] Modelo: a Issue não declara `model-local:` — registrar `deepseek-v4-flash-high` (pareamento canônico de `composer-2.5`) no corpo da Issue ao fechar. diff --git a/scripts/lib/worktree-env.mjs b/scripts/lib/worktree-env.mjs index 9a3b3bf6..7830662a 100644 --- a/scripts/lib/worktree-env.mjs +++ b/scripts/lib/worktree-env.mjs @@ -65,3 +65,53 @@ export const worktreeEnvironment = ({ branch, code, takenSlots = new Set() }) => testDatabase: testDatabaseForSlot(slot), } } + +/** + * Line arrays for `.env.local` (dev) and `.env.test.local` (test) of a + * provisioned worktree. `NEXT_PUBLIC_SITE_URL` and `PLAYWRIGHT_BASE_URL` must + * point BOTH files at the SAME slot port — the parity is what keeps a parallel + * e2e run in another worktree from falling back to port 3000 (OPS20: they had + * drifted — the dev file carried PLAYWRIGHT_BASE_URL, the test file didn't, and + * playwright.config.ts only reads `.env.test.local`). Building both from one + * `url` makes the drift structurally impossible. + * + * @param {{ branch: string, issueLabel?: string, generatedBy: string, env: { slot: number, devPort: number, devDatabase: string, testDatabase: string }, payloadSecret: string, copiedLines?: string[] }} options + * @returns {{ dev: string[], test: string[] }} + */ +export const worktreeEnvFileContents = ({ + branch, + issueLabel = '', + generatedBy, + env, + payloadSecret, + copiedLines = [], +}) => { + // Both DATABASE_URLs derive from the same slot as the port (module contract). + const devUrl = `postgresql://teqo:teqo@localhost:5432/${env.devDatabase}` + const testUrl = `postgresql://teqo:teqo@localhost:5432/${env.testDatabase}` + const url = `http://localhost:${env.devPort}` + const header = [ + GENERATED_ENV_MARKER, + `# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`, + ] + return { + dev: [ + ...header, + `DATABASE_URL=${devUrl}`, + `PORT=${env.devPort}`, + `NEXT_PUBLIC_SITE_URL=${url}`, + `PLAYWRIGHT_BASE_URL=${url}`, + `PAYLOAD_SECRET=${payloadSecret}`, + ...copiedLines, + '', + ], + test: [ + ...header, + '# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.', + `DATABASE_URL=${testUrl}`, + `NEXT_PUBLIC_SITE_URL=${url}`, + `PLAYWRIGHT_BASE_URL=${url}`, + '', + ], + } +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 44deb70b..58f1f2fe 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -50,6 +50,7 @@ import { DEV_PORT_BASE, GENERATED_ENV_MARKER, isGeneratedDatabaseName, + worktreeEnvFileContents, worktreeEnvironment, } from './lib/worktree-env.mjs' import { branchNameForIssue, planBranchName } from './lib/worktree.mjs' @@ -236,8 +237,6 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo const devDatabase = env.devDatabase const testDatabase = env.testDatabase - const devUrl = `postgresql://teqo:teqo@localhost:5432/${devDatabase}` - const testUrl = `postgresql://teqo:teqo@localhost:5432/${testDatabase}` await ensureDatabases([devDatabase, testDatabase]) const mainEnv = readMainEnv(mainRoot) @@ -247,36 +246,26 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo const issueLabel = issue ? ` · issue #${issue.number}` : '' const generatedBy = `gerado por pnpm worktree ${purpose}` - const devEnv = [ - GENERATED_ENV_MARKER, - `# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`, - `DATABASE_URL=${devUrl}`, - `PORT=${env.devPort}`, - `NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`, - `PLAYWRIGHT_BASE_URL=http://localhost:${env.devPort}`, - `PAYLOAD_SECRET=${payloadSecret}`, - ...copy('BLOB_READ_WRITE_TOKEN'), - ...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'), - ...copy('VAPID_PUBLIC_KEY'), - ...copy('VAPID_PRIVATE_KEY'), - ...copy('VAPID_SUBJECT'), - ...copy('RESEND_API_KEY'), - ...copy('CAMPAIGN_EMAIL_FROM'), - ...copy('CAMPAIGN_EMAIL_FROM_NAME'), - '', - ].join('\n') - writeFileSync(join(dir, '.env.local'), devEnv) + const { dev: devLines, test: testLines } = worktreeEnvFileContents({ + branch, + issueLabel, + generatedBy, + env, + payloadSecret, + copiedLines: [ + ...copy('BLOB_READ_WRITE_TOKEN'), + ...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'), + ...copy('VAPID_PUBLIC_KEY'), + ...copy('VAPID_PRIVATE_KEY'), + ...copy('VAPID_SUBJECT'), + ...copy('RESEND_API_KEY'), + ...copy('CAMPAIGN_EMAIL_FROM'), + ...copy('CAMPAIGN_EMAIL_FROM_NAME'), + ], + }) + writeFileSync(join(dir, '.env.local'), devLines.join('\n')) console.log(`[worktree] .env.local escrito (porta ${env.devPort}).`) - - const testEnv = [ - GENERATED_ENV_MARKER, - `# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`, - '# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.', - `DATABASE_URL=${testUrl}`, - `NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`, - '', - ].join('\n') - writeFileSync(join(dir, '.env.test.local'), testEnv) + writeFileSync(join(dir, '.env.test.local'), testLines.join('\n')) console.log('[worktree] .env.test.local escrito.') if (skipMigrate) { diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index bd035120..5bc2a488 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -10,6 +10,7 @@ import { isGeneratedDatabaseName, numericSlotOfCode, testDatabaseForSlot, + worktreeEnvFileContents, worktreeEnvironment, } from '../../scripts/lib/worktree-env.mjs' import { @@ -205,3 +206,56 @@ describe('worktreeEnvironment (per-worktree ports and databases)', () => { expect(GENERATED_ENV_MARKER).toContain('generated by pnpm worktree next') }) }) + +describe('worktreeEnvFileContents (dev/test env parity)', () => { + const env = worktreeEnvironment({ branch: 'C15-fullcalendar', code: 'C15' }) + const lines = worktreeEnvFileContents({ + branch: 'C15-fullcalendar', + issueLabel: ' · issue #390', + generatedBy: 'gerado por pnpm worktree next', + env, + payloadSecret: 'secret', + copiedLines: ['BLOB_READ_WRITE_TOKEN=blob', 'VAPID_PRIVATE_KEY=key'], + }) + const dev = lines.dev.join('\n') + const test = lines.test.join('\n') + const url = `http://localhost:${devPortForSlot(env.slot)}` + + it('the test env carries PLAYWRIGHT_BASE_URL on the slot port (e2e never falls back to 3000)', () => { + expect(test).toContain(`PLAYWRIGHT_BASE_URL=${url}`) + }) + + it('keeps NEXT_PUBLIC_SITE_URL and PLAYWRIGHT_BASE_URL in parity across both files', () => { + for (const key of ['NEXT_PUBLIC_SITE_URL', 'PLAYWRIGHT_BASE_URL']) { + const devValue = dev.split('\n').find((line) => line.startsWith(`${key}=`)) + const testValue = test.split('\n').find((line) => line.startsWith(`${key}=`)) + expect(devValue).toBe(`${key}=${url}`) + expect(testValue).toBe(devValue) + } + }) + + it('points each file at its own database and keeps dev-only fields in the dev file', () => { + expect(dev).toContain( + `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/${devDatabaseForSlot(env.slot)}`, + ) + expect(test).toContain( + `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/${testDatabaseForSlot(env.slot)}`, + ) + expect(dev).toContain(`PORT=${devPortForSlot(env.slot)}`) + expect(dev).toContain('PAYLOAD_SECRET=secret') + expect(dev).toContain('BLOB_READ_WRITE_TOKEN=blob') + expect(dev).toContain('VAPID_PRIVATE_KEY=key') + expect(test).not.toContain('PAYLOAD_SECRET') + expect(test).not.toContain('PORT=') + expect(test).not.toContain('BLOB_READ_WRITE_TOKEN') + expect(test).not.toContain('VAPID_PRIVATE_KEY') + }) + + it('both files carry the generated marker and the same header', () => { + for (const content of [dev, test]) { + expect(content).toContain(GENERATED_ENV_MARKER) + expect(content).toContain('branch C15-fullcalendar') + expect(content).toContain(`· slot ${env.slot}`) + } + }) +})