From fea6c64514d101c3eb4f997be632eac127046bed Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 26 Aug 2026 20:12:02 -0300 Subject: [PATCH] =?UTF-8?q?fix(admin):=20restaura=20S3ClientUploadHandler?= =?UTF-8?q?=20no=20importMap=20=E2=80=94=20admin=20em=20branco=20em=20prod?= =?UTF-8?q?=20(4=C2=AA=20recorr=C3=AAncia=20da=20classe=20OPS69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O importMap commitado perdeu a entrada do handler S3 no 1c865bd8 (regenerado sem as envs S3_*): com S3_* em prod, getFromImportMap retorna undefined e a raiz do Payload não monta — tela branca, zero erros de console, invisível no loop local (storage local em dev/test). Fix da causa: regeneração com S3_* dummy (+2 linhas). Prevenção no mesmo PR — o guard planejado no OPS69 (depends #77/#87) que nunca existiu: pin unit, guard CI+deploy que regenera com envs dummy e falha em divergência, wrapper do generate:importmap que injeta as envs, e contrato compartilhado da chave. Post-mortem + changelog incluídos. --- .github/workflows/ci-pr.yml | 2 + .github/workflows/deploy.yml | 2 + AGENTS.md | 2 +- .../2026-08-26-admin-branco-importmap-s3.md | 1 + .../2026-08-26-admin-branco-importmap-s3.md | 81 ++++++++++++ package.json | 2 +- scripts/check-importmap-s3.mjs | 120 ++++++++++++++++++ scripts/generate-importmap.mjs | 35 +++++ scripts/lib/importMapContract.mjs | 20 +++ src/app/(payload)/admin/importMap.js | 2 + .../importMapS3UploadHandler.unit.spec.ts | 29 +++++ 11 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/2026-08-26-admin-branco-importmap-s3.md create mode 100644 docs/postmortems/2026-08-26-admin-branco-importmap-s3.md create mode 100644 scripts/check-importmap-s3.mjs create mode 100644 scripts/generate-importmap.mjs create mode 100644 scripts/lib/importMapContract.mjs create mode 100644 tests/unit/importMapS3UploadHandler.unit.spec.ts diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index f2bdb787..40b53afc 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -132,6 +132,8 @@ jobs: GITHUB_BASE_REF: ${{ github.base_ref }} PR_BODY: ${{ github.event.pull_request.body }} run: node scripts/check-plans-only-pr-closes.mjs + - name: ImportMap keeps the S3 upload handler + run: node scripts/check-importmap-s3.mjs - name: Check test file locations run: node scripts/check-test-locations.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6450ddc5..aa7f07a6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,6 +73,8 @@ jobs: - name: Check test file locations run: node scripts/check-test-locations.mjs + - name: ImportMap keeps the S3 upload handler + run: node scripts/check-importmap-s3.mjs - name: Lint run: pnpm lint - name: Format check diff --git a/AGENTS.md b/AGENTS.md index 0159c516..b3f2eee6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ These are product/architecture constraints — not a ban on editing existing cod ### Code Validation - To validate typescript correctness after modifying code run `tsc --noEmit` -- Generate import maps after creating or modifying components. +- Generate import maps after creating or modifying components. **Always set the 4 `S3_*` envs (dummy values ok) when running `pnpm generate:importmap`** — without them the generator orphans `@payloadcms/storage-s3/client#S3ClientUploadHandler` and the admin goes blank in production (class OPS69/OPS72/OPS73; `scripts/check-importmap-s3.mjs` + the `importMapS3UploadHandler` unit pin guard this in CI). - Any change to a collection/global/field schema requires a migration: `pnpm migrate:create `, then `pnpm migrate` locally. Never rely on `push` (it is `false`). See the `payload-migrations` skill. ## Project Structure diff --git a/docs/changelog/2026-08-26-admin-branco-importmap-s3.md b/docs/changelog/2026-08-26-admin-branco-importmap-s3.md new file mode 100644 index 00000000..61f546dd --- /dev/null +++ b/docs/changelog/2026-08-26-admin-branco-importmap-s3.md @@ -0,0 +1 @@ +**Admin em branco — importMap órfão do handler S3, 4ª recorrência (2026-08-26):** o admin do Payload em produção (jorgesolla1313.com.br/admin) abria em TELA BRANCA (login e dashboard) — zero erros de console no browser, SSR 200, assets 200; detecção por relato humano. Causa: o `src/app/(payload)/admin/importMap.js` commitado perdeu a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` no commit 1c865bd8 (2026-08-23, refactor "E1 simplify"), que regenerou o importMap sem as envs `S3_*`; com as envs setadas em prod (plugin s3Storage condicional em src/payload.config.ts, resolvedor src/utilities/mediaStorage.ts), `getFromImportMap` retorna undefined e a raiz do Payload não monta — o erro sai como console.error no log do SERVIDOR, invisível no browser; dev/test/CI sem `S3_*` usam storage local e o importMap órfão é invisível no loop local (e2e do admin passa), e o smoke do deploy só checa HTTP 200. Classe recorrente: OPS69/OPS72/OPS73 (4ª vez); o guard de CI planejado nas lições do OPS69 (depends #77/#87) nunca foi implementado. Fix causa: regeneração do importMap com `S3_*` dummy → diff de exatamente +2 linhas (import + entrada); sem migration/access/UI. Prevenção implementada no mesmo PR: pin unit `tests/unit/importMapS3UploadHandler.unit.spec.ts` (vermelho sem o fix, verde com); guard `scripts/check-importmap-s3.mjs` (regenera com envs dummy, exit 1 com drift + restore se divergir) ligado no ci-pr.yml (content guards) e no job verify do deploy.yml; wrapper `scripts/generate-importmap.mjs` no `pnpm generate:importmap` (injeta `S3_*` dummy quando ausentes — regeneração local nunca mais orfa o handler); contrato compartilhado `scripts/lib/importMapContract.mjs`; AGENTS.md atualizado. Verificação (prova do verificador 6/6): regressão e guard vermelho/verde provados, unit completa 284 arquivos/2682 testes verdes, e2e original com S3\_\* dummy (PORT 4093) monta login+dashboard com 0 `getFromImportMap` e 0 erros de console, tsc/lint exit 0, gate:fast verde. Prevenção documentada (não implementada): gerar o importMap no build (BuildKit secrets) em vez de commitá-lo, s3Storage incondicional no config, smoke de deploy headless que asserta o mount da raiz Payload, e fail-fast estruturado quando getFromImportMap retorna undefined. Deploy PENDENTE (manual, workflow_dispatch do deploy.yml) + confirmação do humano em prod. Post-mortem: `docs/postmortems/2026-08-26-admin-branco-importmap-s3.md`. diff --git a/docs/postmortems/2026-08-26-admin-branco-importmap-s3.md b/docs/postmortems/2026-08-26-admin-branco-importmap-s3.md new file mode 100644 index 00000000..ac8b693a --- /dev/null +++ b/docs/postmortems/2026-08-26-admin-branco-importmap-s3.md @@ -0,0 +1,81 @@ +# Post-mortem: admin do Payload em tela branca — importMap órfão do handler S3 + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ---------------- | +| Data do post-mortem | 2026-08-26 | +| Severidade | crítica | +| Ambiente | prod | +| Issue(s) | sem Issue | +| PR do fix | — (em andamento) | +| Detectado por | humano | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | 2026-08-23 | commit 1c865bd8 (refactor "E1 simplify — pin loading/retry/stale-quirk no spec e tipo local") regenerou o importMap sem as envs S3\_\*, removendo a entrada do handler | +| Detecção | 2026-08-26 | relato do usuário: admin em branco em produção | +| Correção mergeada | em andamento | PR ainda sem número | +| Deploy | ainda não feito | dispatch manual do deploy.yml (só humano) | +| Verificado em prod | não | aguarda deploy + confirmação do humano | + +## O bug + +O admin do Payload em produção (jorgesolla1313.com.br/admin) abria em TELA BRANCA — tanto no login quanto no dashboard. Zero erros de console no browser, SSR HTML completo (200) e todos os assets 200, então não havia nenhum sinal visível no cliente. **Sintoma — não a causa.** + +## Causa-raiz + +O `src/app/(payload)/admin/importMap.js` commitado perdeu a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — removida no commit 1c865bd8 (2026-08-23, refactor "E1 simplify — pin loading/retry/stale-quirk no spec e tipo local"), que regenerou o importMap SEM as envs S3\_\*. + +5-whys: + +1. **Por que a tela fica branca?** Porque a raiz do Payload não monta: `getFromImportMap` (node_modules/payload/dist/bin/generateImportMap/utilities/getFromImportMap.js) retorna undefined para o handler `S3ClientUploadHandler` que o config serializado do admin referencia. +2. **Por que o handler não é encontrado?** Porque o importMap commitado não tem mais a entrada — o mapa é um artefato estático, não gerado em runtime. +3. **Por que a entrada sumiu do arquivo?** Porque o commit 1c865bd8 regenerou o importMap com `pnpm generate:importmap` sem as envs `S3_*` no ambiente. +4. **Por que sem as envs a entrada some?** Porque o plugin s3Storage é condicional em src/payload.config.ts (resolvedor src/utilities/mediaStorage.ts): sem `S3_*` o storage é local (dev/test/CI) e o mapa gerado é consistente para essa config; com `S3_*` setadas (produção, media no Garage) o config serializado do admin referencia o handler — que o mapa sem envs não inclui. +5. **Por que ninguém viu antes do deploy?** Porque dev/test/CI não têm `S3_*` — o importMap órfão é invisível em todo o loop local (o e2e do admin passa), e o smoke test do deploy só checa HTTP 200 (tela branca passa). O erro real (`getFromImportMap: PayloadComponent not found in importMap`) vai para o log do SERVIDOR via console.error silencioso — o browser mostra zero erros. E o guard planejado nas lições do OPS69 (CI que regenera com envs dummy e falha se divergir, depends #77/#87) nunca foi implementado. + +Classe recorrente: OPS69 (2026-08-19), OPS72 (2026-08-19), OPS73 (2026-08-20) — esta é a 4ª recorrência. + +**Reprodução:** local, servindo o build com envs S3*\* dummy no runtime → /admin/login branco (hidden div de 17 bytes, form ausente) + log do servidor com `getFromImportMap: PayloadComponent not found in importMap`; sem S3*\* → login renderiza. A mesma assinatura de prod foi verificada por instrumentação (chunk pushes, r.m, flight data, headers, HTML brotli). + +## Correção + +Regeneração do importMap com envs S3\_\* dummy — o diff foi de exatamente +2 linhas (import + entrada do handler). Resolve a **causa** (o artefato volta a ser consistente com o config que produção serializa), não o sintoma. Sem migration, sem mudança de access, sem UI. + +## Verificação + +- Teste de regressão: `tests/unit/importMapS3UploadHandler.unit.spec.ts` — falha se a entrada sumir do arquivo; vermelho sem o fix, verde com +- Guard: `scripts/check-importmap-s3.mjs` — regenera com S3\_\* dummy e sai 1 (com drift e restore do arquivo) se o commitado divergir; exit 1 sem o fix, exit 0 com +- Suíte: unit completa 284 arquivos / 2682 testes verdes; `tsc --noEmit` e lint exit 0 +- E2E original: admin servido com S3\_\* dummy (PORT 4093) — login renderiza (título "Login - Payload", form Email/Senha), login admin@test.invalid funciona, dashboard "Painel de Controle - Payload" monta, log do servidor com 0 `getFromImportMap`, 0 erros de console +- CI: gate:fast verde (284 arquivos, 2682 testes) +- Prod: não verificado — aguarda deploy manual + confirmação do humano + +## Prevenção + +| Estratégia | Custo | Estado | +| ------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------ | +| Pin unit `importMapS3UploadHandler.unit.spec.ts` (falha se a entrada sumir) | barata | implementada agora (mesmo PR) | +| Guard `scripts/check-importmap-s3.mjs` no ci-pr.yml (content guards) e no job verify do deploy.yml | barata | implementada agora (mesmo PR) | +| Wrapper `scripts/generate-importmap.mjs` no `pnpm generate:importmap` (injeta S3\_\* dummy quando ausentes) | barata | implementada agora (mesmo PR) | +| Contrato compartilhado `scripts/lib/importMapContract.mjs` (chave + prefixo do handler, chaves das envs S3) | barata | implementada agora (mesmo PR) | +| AGENTS.md: instrução das envs S3\_\* no generate:importmap + referência aos guards | barata | implementada agora (mesmo PR) | +| Gerar o importMap no BUILD (prebuild com S3\_\* via BuildKit secrets) em vez de commitá-lo | cara | documentada — não implementada neste fluxo | +| Tornar o plugin s3Storage incondicional no config (adaptador local em dev) | cara | documentada — não implementada neste fluxo | +| Smoke real de deploy: abrir /admin em headless e assertar o mount da raiz Payload (não só HTTP 200) | cara | documentada — não implementada neste fluxo | +| Fail-fast/erro estruturado quando getFromImportMap retorna undefined (hoje é console.error silencioso no log do servidor) | cara | documentada — não implementada neste fluxo | + +**Estratégia implementada:** pin unit que falha se o handler sumir do importMap; guard de CI+deploy que regenera com S3\_\* dummy e falha em divergência (drift + restore do arquivo); wrapper do `generate:importmap` que injeta as envs dummy — a regeneração local nunca mais orfa o handler; contrato compartilhado da chave/prefixo e das envs; doc no AGENTS.md. + +**Estratégia documentada (cara):** gerar o importMap no build (elimina a classe: artefato gerado não drift; toca o pipeline do homeserver); tornar o s3Storage incondicional (estabiliza o importMap; muda storage local e caminho de media); smoke de deploy que abre /admin em headless e asserta o mount da raiz Payload; fail-fast estruturado quando getFromImportMap retorna undefined. + +## Lições + +- **Padrão "silencioso só em prod":** o bug não tem NENHUM sinal no loop local — dev/test/CI rodam sem S3\_\*, o importMap órfão é consistente para a config local, o e2e do admin passa, e o erro real sai como console.error no log do servidor enquanto o browser mostra zero erros. Quarta recorrência da mesma classe (OPS69/OPS72/OPS73). Diferenças de config entre prod e o loop local (envs condicionais) precisam de um espelho artificial (envs dummy) em pelo menos um ponto do pipeline. +- **Lição registrada sem enforcement não é lição:** o guard de CI (regenerar com envs dummy e falhar em divergência) já estava escrito nas lições do OPS69 com depends #77/#87 — e nunca foi implementado. O custo de 3 recorrências seguidas é exatamente o custo de anotar e não executar. Lições pós-incidente deveriam virar Issue/guard no mesmo fluxo, não item de backlog. +- **Smoke test que só checa HTTP 200 não é smoke de UI:** tela branca devolve 200 com SSR completo — o smoke do deploy validava transporte, não aplicação. Para apps que dependem de mount client-side (admin do Payload), o smoke precisa abrir a página em headless e assertar a montagem da raiz, ou o contrato precisa ser o log do servidor sem o erro conhecido. diff --git a/package.json b/package.json index ff71b37c..6da40862 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "changelog:build": "node scripts/build-changelog.mjs", "changelog:read": "node scripts/build-changelog.mjs --stdout", "changelog:check": "node scripts/build-changelog.mjs --check", - "generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap", + "generate:importmap": "node scripts/generate-importmap.mjs", "generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types", "migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate", "migrate:create": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:create", diff --git a/scripts/check-importmap-s3.mjs b/scripts/check-importmap-s3.mjs new file mode 100644 index 00000000..76910bc1 --- /dev/null +++ b/scripts/check-importmap-s3.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Guard: the committed importMap must include the S3ClientUploadHandler entry + * (OPS69/OPS72/OPS73 class — regenerating the importMap without the S3_* envs + * silently orphans it, and the admin goes blank in production). + * + * Regenerates the importMap with dummy S3_* envs (same trick as the OPS69 + * fix — the CLI iterates the config without a DB) and fails if the committed + * file drifts. The working-tree file is restored afterwards so a failure + * leaves the checkout untouched; the drift is printed in the error. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { S3_STORAGE_ENV_KEYS } from './lib/importMapContract.mjs' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const importMapPath = path.join(repoRoot, 'src/app/(payload)/admin/importMap.js') + +if (!existsSync(importMapPath)) { + console.error(`importMap not found at ${importMapPath}`) + process.exit(1) +} + +const committed = readFileSync(importMapPath, 'utf8') + +const GUARD_S3_DUMMY = { + S3_BUCKET: 'guard-bucket', + S3_ENDPOINT: 'http://127.0.0.1:3900', + S3_ACCESS_KEY_ID: 'guard-access-key', + S3_SECRET_ACCESS_KEY: 'guard-secret-key', +} + +const env = { + ...process.env, + // Same NODE_OPTIONS as the package.json `generate:importmap` script — + // the payload CLI loads server-only modules. + NODE_OPTIONS: '--no-deprecation --conditions=react-server', + // Dummy S3_* envs gate the s3Storage plugin in (see mediaStorage.ts) so the + // generation is prod-faithful; the generator never connects to the bucket. + ...Object.fromEntries(S3_STORAGE_ENV_KEYS.map((key) => [key, GUARD_S3_DUMMY[key]])), + // The payload config needs a secret and a database URL to build, even + // though the generator never connects (iterateConfig, no DB). + PAYLOAD_SECRET: process.env.PAYLOAD_SECRET || 'guard-secret', + DATABASE_URL: process.env.DATABASE_URL || 'postgresql://guard:guard@127.0.0.1:1/guard', +} + +try { + execFileSync('pnpm', ['exec', 'payload', 'generate:importmap'], { + cwd: repoRoot, + env, + stdio: 'pipe', + }) +} catch (error) { + restore(committed) + console.error('guard-importmap: payload generate:importmap failed') + console.error(error.stderr?.toString() || error.message) + process.exit(1) +} + +const generated = readFileSync(importMapPath, 'utf8') +if (generated !== committed) { + restore(committed) + console.error( + [ + 'guard-importmap: the committed importMap drifted from the generation', + 'with S3_* envs set. Regenerate it with the S3_* envs (dummy values are', + 'fine) and commit the result:', + '', + ' S3_BUCKET=x S3_ENDPOINT=http://127.0.0.1:3900 \\', + ' S3_ACCESS_KEY_ID=x S3_SECRET_ACCESS_KEY=x pnpm generate:importmap', + '', + 'Drift (committed -> generated):', + ].join('\n'), + ) + const drift = diffLines(committed, generated) + console.error(drift) + process.exit(1) +} + +// Minimal line diff (no external deps): removed lines prefixed with "-", +// added lines with "+". +function diffLines(before, after) { + const beforeLines = before.split('\n') + const afterLines = after.split('\n') + const out = [] + let i = 0 + let j = 0 + while (i < beforeLines.length && j < afterLines.length) { + if (beforeLines[i] === afterLines[j]) { + i += 1 + j += 1 + } else { + const nextInBefore = beforeLines.indexOf(afterLines[j], i + 1) + const nextInAfter = afterLines.indexOf(beforeLines[i], j + 1) + if (nextInAfter === -1) { + out.push(`- ${beforeLines[i]}`) + i += 1 + } else if (nextInBefore === -1) { + out.push(`+ ${afterLines[j]}`) + j += 1 + } else if (nextInAfter - j <= nextInBefore - i) { + out.push(`+ ${afterLines[j]}`) + j += 1 + } else { + out.push(`- ${beforeLines[i]}`) + i += 1 + } + } + } + while (i < beforeLines.length) out.push(`- ${beforeLines[i++]}`) + while (j < afterLines.length) out.push(`+ ${afterLines[j++]}`) + return out.join('\n') +} + +function restore(content) { + writeFileSync(importMapPath, content) +} diff --git a/scripts/generate-importmap.mjs b/scripts/generate-importmap.mjs new file mode 100644 index 00000000..95622039 --- /dev/null +++ b/scripts/generate-importmap.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Wrapper for `payload generate:importmap` that always injects dummy S3_* + * envs (when unset) so the generated importMap carries the + * S3ClientUploadHandler entry — regenerating without the S3_* envs silently + * orphans it and the admin goes blank in production (class + * OPS69/OPS72/OPS73). Dummy values are fine: the generator only iterates the + * config, it never connects to the bucket. + */ +import { spawnSync } from 'node:child_process' + +import { S3_STORAGE_ENV_KEYS } from './lib/importMapContract.mjs' + +const DUMMY = { + S3_BUCKET: 'dev-dummy-bucket', + S3_ENDPOINT: 'http://127.0.0.1:3900', + S3_ACCESS_KEY_ID: 'dev-dummy-access-key', + S3_SECRET_ACCESS_KEY: 'dev-dummy-secret-key', +} + +const env = { + ...process.env, + // Same NODE_OPTIONS as before — the payload CLI loads server-only modules. + NODE_OPTIONS: '--no-deprecation --conditions=react-server', + ...Object.fromEntries( + S3_STORAGE_ENV_KEYS.map((key) => [key, process.env[key]?.trim() || DUMMY[key]]), + ), +} + +const result = spawnSync('payload', ['generate:importmap'], { + stdio: 'inherit', + env, +}) + +process.exit(result.status ?? 1) diff --git a/scripts/lib/importMapContract.mjs b/scripts/lib/importMapContract.mjs new file mode 100644 index 00000000..af9e28e1 --- /dev/null +++ b/scripts/lib/importMapContract.mjs @@ -0,0 +1,20 @@ +/** + * Contract shared by the importMap unit pin and the CI guard. The admin goes + * blank in production when the importMap loses the S3 upload handler entry + * (class OPS69/OPS72/OPS73 — the importMap regenerated without the S3_* envs). + */ +export const S3_UPLOAD_HANDLER_KEY = '@payloadcms/storage-s3/client#S3ClientUploadHandler' + +export const S3_UPLOAD_HANDLER_IMPORT_PREFIX = + 'import { S3ClientUploadHandler as S3ClientUploadHandler_' + +/** + * Env keys that gate the s3Storage plugin (see src/utilities/mediaStorage.ts). + * Kept in sync by the `importMapContract` unit pin. + */ +export const S3_STORAGE_ENV_KEYS = [ + 'S3_BUCKET', + 'S3_ENDPOINT', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', +] diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index ed8c41ef..097d2c4e 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -36,6 +36,7 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel' import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker' +import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -78,6 +79,7 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, "./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da, "./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0, + "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/tests/unit/importMapS3UploadHandler.unit.spec.ts b/tests/unit/importMapS3UploadHandler.unit.spec.ts new file mode 100644 index 00000000..66fe0363 --- /dev/null +++ b/tests/unit/importMapS3UploadHandler.unit.spec.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + S3_UPLOAD_HANDLER_IMPORT_PREFIX, + S3_UPLOAD_HANDLER_KEY, +} from '../../scripts/lib/importMapContract.mjs' + +// The admin goes blank in production when this entry is missing: with the +// S3_* envs set, the media collection uses the s3Storage plugin and the admin +// config references this handler — getFromImportMap returns undefined, the +// Payload root never mounts, and the admin renders an empty page with zero +// console errors (class OPS69/OPS72/OPS73; orphaned again by 1c865bd8, +// 2026-08-23). Regenerating the importMap without the S3_* envs silently +// removes the entry — this pin fails the exact drift. + +const importMapPath = path.resolve(__dirname, '../../src/app/(payload)/admin/importMap.js') + +describe('importMap (admin)', () => { + it('keeps the S3ClientUploadHandler entry required by the prod S3 storage', () => { + const source = readFileSync(importMapPath, 'utf8') + + // Import + map entry, like the generator emits (+2 lines in the file). + expect(source).toContain(S3_UPLOAD_HANDLER_IMPORT_PREFIX) + expect(source).toContain(`${S3_UPLOAD_HANDLER_KEY}": S3ClientUploadHandler_`) + }) +})