From eed657ddcff91f964055560598d324623fb09f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Melga=C3=A7o?= Date: Thu, 27 Aug 2026 10:07:39 -0300 Subject: [PATCH] fix(atualizacao): a tela mostra TODAS as versoes entre a sua e a nova, com os avisos de cada uma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O dano nao e hipotetico e esta escrito no commit ac9472c5, por quem o descobriu por acaso: a 1.4.1 existia para consertar dois avisos errados da 1.4.0 — um deles mandava o operador APAGAR A CONEXAO que estava funcionando —, e como a tela mostrava so a secao da versao-alvo, quem pulasse da 1.4.0 para a 1.5.0 nunca os leria. O contorno da epoca foi carregar os avisos orfaos para a versao seguinte, a mao. Isto aposenta esse contorno. Medido no CHANGELOG real, quem esta na 1.4.0 indo para a 1.6.0: hoje -> secoes ["1.6.0"] avisos [] agora -> secoes ["1.6.0","1.5.0","1.4.1"] avisos ["1.5.0","1.4.1"] Dois avisos de acao manual que hoje simplesmente somem. **A selecao e POSICIONAL, nao semver.** O arquivo ja vem do mais novo para o mais antigo, e comparar numero exigiria um comparador que nao existe no projeto — e que tropecaria em `v1.1.1-jmpo.1`, tag de fork que este repo carrega. **Os quatro casos sao escritos um a um de proposito.** Um `slice(iAlvo, iInst)` ingenuo devolve lista VAZIA quando a instalada e mais nova que a alvo, e ainda assim diria `completa: true`: a tela ficaria sem corpo nenhum afirmando estar inteira. Ha caso de teste para cada ramo. **O limite inferior e `running`, nunca `current_version`.** Depois de um rollback, `current_version` nomeia a versao que QUEBROU (o `git checkout` deu certo; quem nao subiu foi o container) — a faixa sairia vazia justamente para quem mais precisa le-la. Coberto por caso proprio na rota. **O sinal de honestidade e estrutural, e e um so:** achei o cabecalho da versao instalada no texto recebido? O agente manda o CHANGELOG cortado em bytes, e um corpo truncado no meio da frase e indistinguivel de um corpo inteiro para quem le. Sem `complete`, a tela afirmaria completude que nao tem. **O agente passa a mandar MENOS e melhor, em vez de mais.** Subir o `head -c` mataria o heartbeat inteiro: o teto do Zod e 64000 sobre a string JA ESCAPADA, e o 422 morre calado, virando "agente offline" 24h depois. Entao o `awk` para de imprimir AO IMPRIMIR o cabecalho da versao instalada — e o cabecalho entra de proposito, e ele que prova a completude. Medido: quem esta na 1.4.1 indo para a 1.6.0 passa de 30.000 para 18.032 bytes, terminando exatamente em `## [1.4.1] — 2026-08-25`. E o app funciona com o agente VELHO, o que nao e detalhe: o `agent.sh` novo so chega na VPS no update seguinte. Medido sobre o corte cego de 30.000 bytes: instalada 1.4.1 -> 2 secoes completa=true; instalada 1.4.0 -> 3 secoes completa=true. **Nenhum aviso entra em `
`.** Esconder o que exige acao e o defeito que esta tela existe para consertar; so o CORPO das versoes intermediarias e recolhido. O e2e prova isso pela tela, com boundingBox (os dois avisos ANTES do botao) e abrindo o `
` com clique — texto dentro de um fechado nao e visivel para o Playwright, entao afirmar `toBeVisible()` sem abrir seria um teste que passa por motivo errado. Este commit traz o proprio fragmento em `.changes/`, e o gate pegou a primeira versao dele: um `**negrito**` que atravessava a quebra de linha, que chegaria a tela com os asteriscos a mostra. `pnpm release:conferir` -> 1.6.0 + minor = 1.7.0. Evidencia: `pnpm typecheck` zerado; `pnpm lint` 0 errors; `pnpm vitest run --exclude 'lib/ai/dispatcher/rate-limit.test.ts'` -> 498 arquivos, 5626 testes, verde. A exclusao e defeito de AMBIENTE pre-existente (medido: na main pura da 5 failed COM `.env.local` no disco e 5 passed SEM ele), nao desta branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DAtZo5GWZn8ckdmsoUVKts --- .changes/tela-mostra-a-faixa-inteira.md | 11 + app/api/v1/system/version/route.test.ts | 85 +++++++- app/api/v1/system/version/route.ts | 30 ++- .../atualizacao/_components/UpdatePanel.tsx | 52 ++++- ...atualizacao-self-service.architecture.json | 200 +++++++++++++++--- hooks/system/useSystemVersion.ts | 9 +- hostgator-setup-kit/agent.sh | 12 +- lib/system/changelog.test.ts | 84 +++++++- lib/system/changelog.ts | 89 +++++++- tests/e2e/system-update.spec.ts | 65 ++++++ 10 files changed, 588 insertions(+), 49 deletions(-) create mode 100644 .changes/tela-mostra-a-faixa-inteira.md diff --git a/.changes/tela-mostra-a-faixa-inteira.md b/.changes/tela-mostra-a-faixa-inteira.md new file mode 100644 index 000000000..56e741725 --- /dev/null +++ b/.changes/tela-mostra-a-faixa-inteira.md @@ -0,0 +1,11 @@ +--- +impacto: capacidade_nova +secao: corrigido +titulo: A tela de atualização mostra tudo o que mudou desde a sua versão +--- + +Antes ela mostrava só o texto da versão mais nova. Quem pulava versões — por +exemplo, quem estava na 1.4.0 e atualizava direto para a 1.6.0 — nunca via o que +tinha mudado no meio do caminho, e isso incluía os avisos de coisas que exigiam +a sua ação. Agora a tela lista todas as versões entre a sua e a nova, com os +avisos reunidos no topo e cada um dizendo de que versão veio. diff --git a/app/api/v1/system/version/route.test.ts b/app/api/v1/system/version/route.test.ts index de4851bcf..921322464 100644 --- a/app/api/v1/system/version/route.test.ts +++ b/app/api/v1/system/version/route.test.ts @@ -54,7 +54,11 @@ beforeEach(() => { current_version: "1.0.0", latest_version: "1.1.0", off_release: false, - changelog_raw: "## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nreconecte o número.\n\n### Adicionado\n\n- botão.\n", + // A seção da versão INSTALADA precisa estar aqui: sem ela, todo caso + // exercitaria o caminho "faixa incompleta" e o caminho feliz nasceria sem + // cobertura nenhuma. + changelog_raw: + "## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nreconecte o número.\n\n### Adicionado\n\n- botão.\n\n## [1.0.0] — 2026-08-01\n\n- primeira versão.\n", agent_last_seen_at: new Date().toISOString(), compare_failed: false, update_requested_at: null, @@ -158,13 +162,86 @@ describe("GET /api/v1/system/version", () => { expect(body.data.notes).toBeUndefined(); }); - it("entrega o estado completo e a seção do CHANGELOG para o dono", async () => { + it("entrega o estado completo e a faixa do CHANGELOG para o dono", async () => { vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never); const { GET } = await import("../version/route"); const body = await (await GET(get())).json(); expect(body.data.update_available).toBe(true); - expect(body.data.notes.body).toContain("botão"); - expect(body.data.notes.requires_attention).toContain("reconecte o número"); + expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual(["1.1.0"]); + expect(body.data.notes.sections[0].body).toContain("botão"); + expect(body.data.notes.requires_attention).toEqual([ + { version: "1.1.0", texto: expect.stringContaining("reconecte o número") }, + ]); + expect(body.data.notes.complete).toBe(true); + }); + + it("entrega TODAS as seções entre a instalada e a alvo, com o aviso do meio nomeado", async () => { + // O defeito que esta faixa conserta: quem pula versões via só a seção-alvo, + // e o aviso de ação manual da versão do meio desaparecia (commit ac9472c5). + versionRow.current_version = "1.0.0"; + versionRow.latest_version = "1.2.0"; + versionRow.changelog_raw = [ + "## [1.2.0] — 2026-08-03", + "", + "### Adicionado", + "", + "- coisa nova.", + "", + "## [1.1.0] — 2026-08-02", + "", + "**⚠️ Requer atenção**", + "", + "reconecte o número.", + "", + "## [1.0.0] — 2026-08-01", + "", + "- primeira versão.", + "", + ].join("\n"); + vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never); + const { GET } = await import("../version/route"); + const body = await (await GET(get())).json(); + expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual([ + "1.2.0", + "1.1.0", + ]); + expect(body.data.notes.requires_attention.map((a: { version: string }) => a.version)).toEqual([ + "1.1.0", + ]); + expect(body.data.notes.complete).toBe(true); + }); + + it("declara faixa INCOMPLETA quando o texto não alcança a versão que está no ar", async () => { + // O agente manda o CHANGELOG cortado em bytes. Um corpo truncado no meio da + // frase é indistinguível de um corpo inteiro — sem este sinal, a tela + // afirmaria completude que não tem. + versionRow.changelog_raw = "## [1.1.0] — 2026-08-02\n\n### Adicionado\n\n- botão.\n"; + vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never); + const { GET } = await import("../version/route"); + const body = await (await GET(get())).json(); + expect(body.data.notes.complete).toBe(false); + }); + + it("depois de um rollback a faixa parte da versão que VOLTOU AO AR, não da que quebrou", async () => { + // `current_version` nomeia a versão que quebrou (o `git checkout` deu + // certo; quem não subiu foi o container). Usar esse campo deixaria a faixa + // vazia justamente para quem mais precisa lê-la. + versionRow.current_version = "1.1.0"; + versionRow.latest_version = "1.1.0"; + runRow = { + id: "run-1", + status: "failed_rolled_back", + from_version: "1.0.0", + to_version: "1.1.0", + last_step: null, + log_tail: "", + dispatched_at: new Date().toISOString(), + } as never; + vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never); + const { GET } = await import("../version/route"); + const body = await (await GET(get())).json(); + expect(body.data.current_version).toBe("1.0.0"); + expect(body.data.notes.sections.map((s: { version: string }) => s.version)).toEqual(["1.1.0"]); }); it("entrega compare_failed para a tela poder dizer 'não sei' em vez de 'está em dia'", async () => { diff --git a/app/api/v1/system/version/route.ts b/app/api/v1/system/version/route.ts index e7ad2769f..650073edb 100644 --- a/app/api/v1/system/version/route.ts +++ b/app/api/v1/system/version/route.ts @@ -11,7 +11,7 @@ import { fail, ok } from "@/lib/api/wrappers"; import { loadAuthUser } from "@/lib/auth/server"; import { logger } from "@/lib/logger"; import { createAdminClient } from "@/lib/supabase/admin"; -import { extractChangelogSection } from "@/lib/system/changelog"; +import { extractChangelogRange } from "@/lib/system/changelog"; import { isRunStale, type RunStatus, type RunStep } from "@/lib/system/update-run"; export const dynamic = "force-dynamic"; @@ -80,7 +80,15 @@ export async function GET(_req: NextRequest): Promise { } const latest = version?.latest_version ?? ""; - const section = latest ? extractChangelogSection(version?.changelog_raw ?? "", latest) : null; + // A faixa INTEIRA entre o que está no ar e o que vai entrar, não só a seção + // da versão-alvo. Mostrar só a alvo perdia aviso: quem pulava da 1.4.0 para a + // 1.6.0 nunca lia a 1.4.1 nem a 1.5.0 — e a 1.4.1 existia para corrigir uma + // instrução invertida que mandava apagar a conexão que estava funcionando. + // + // O limite inferior é `running`, NUNCA `current`: depois de um rollback, + // `current` nomeia a versão que quebrou, e a faixa sairia vazia justamente + // para quem mais precisa lê-la. + const faixa = latest ? extractChangelogRange(version?.changelog_raw ?? "", latest, running) : null; return ok({ current_version: running, @@ -99,7 +107,23 @@ export async function GET(_req: NextRequest): Promise { // não tocada por nenhum heartbeat, coluna com o default da migration). has_known_release: version?.has_known_release ?? true, agent_online: !Number.isNaN(lastSeen) && now.getTime() - lastSeen < AGENT_OFFLINE_AFTER_MS, - notes: section ? { body: section.body, requires_attention: section.requiresAttention } : null, + notes: + faixa && faixa.secoes.length > 0 + ? { + // Consolidados no topo, cada um dizendo de que versão veio: numa + // faixa de várias versões, "reconecte o número" sem dizer de qual + // release não informa o operador — assusta. + requires_attention: faixa.secoes + .filter((s) => s.requiresAttention) + .map((s) => ({ version: s.version, texto: s.requiresAttention ?? "" })), + sections: faixa.secoes.map((s) => ({ version: s.version, body: s.body })), + // `false` = o texto recebido não alcança a versão que está no ar, e + // a última seção da lista pode estar cortada no meio da frase. A + // tela precisa DIZER isso — corpo truncado é indistinguível de + // corpo inteiro para quem lê. + complete: faixa.completa, + } + : null, run: run ? { id: run.id, diff --git a/app/app/settings/atualizacao/_components/UpdatePanel.tsx b/app/app/settings/atualizacao/_components/UpdatePanel.tsx index d3ec3cee7..8ae767394 100644 --- a/app/app/settings/atualizacao/_components/UpdatePanel.tsx +++ b/app/app/settings/atualizacao/_components/UpdatePanel.tsx @@ -286,23 +286,57 @@ export function UpdatePanel() {

)} - {data.notes?.requires_attention && ( + {/* Os avisos de TODAS as versões da faixa, reunidos e sempre à vista. + Nenhum deles entra em `
`: esconder o que exige ação manual é + exatamente o defeito que esta tela passou a consertar. Cada um diz de + que versão veio — num salto de várias, "reconecte o número" sem dizer + de qual release não informa o operador, assusta. */} + {data.notes?.requires_attention.length ? (

⚠️ Requer atenção

-

- {markdownParaTextoSimples(data.notes.requires_attention)} -

+ {data.notes.requires_attention.map((aviso) => ( +
+

Da versão {aviso.version}:

+

{markdownParaTextoSimples(aviso.texto)}

+
+ ))}
+ ) : null} + + {data.notes?.complete === false && data.notes.sections.length > 0 && ( +

+ Este histórico começa na versão {data.notes.sections.at(-1)?.version} e pode não alcançar + a que você tem instalada ({versao}) — a última parte pode estar cortada. O texto completo + está no arquivo CHANGELOG.md do projeto. +

)} - {data.notes?.body && ( + {data.notes?.sections.length ? (

O que muda

-
-            {markdownParaTextoSimples(data.notes.body)}
-          
+ {data.notes.sections.map((secao, i) => ( +
+ {/* A versão-alvo fica aberta; as do meio ficam recolhidas, para a + lista não virar um muro de texto num salto de várias versões. + Só o CORPO é recolhido — o aviso delas já está lá em cima. */} + {i === 0 ? ( +
+                  {markdownParaTextoSimples(secao.body)}
+                
+ ) : ( +
+ + Versão {secao.version} + +
+                    {markdownParaTextoSimples(secao.body)}
+                  
+
+ )} +
+ ))}
- )} + ) : null} atualizar.mutate()} isPending={atualizar.isPending} erro={erro} /> diff --git a/docs/architecture/atualizacao-self-service.architecture.json b/docs/architecture/atualizacao-self-service.architecture.json index 120d9e522..f5c333c1f 100644 --- a/docs/architecture/atualizacao-self-service.architecture.json +++ b/docs/architecture/atualizacao-self-service.architecture.json @@ -8,39 +8,181 @@ "quality_profile": "standard" }, "lanes": [ - { "id": "host", "label": "VPS (fora do app)" }, - { "id": "app", "label": "App — rota do agente e da UI" }, - { "id": "banco", "label": "Banco (instância, sem organization_id)" }, - { "id": "humano", "label": "O que o dono vê" } + { + "id": "host", + "label": "VPS (fora do app)" + }, + { + "id": "app", + "label": "App — rota do agente e da UI" + }, + { + "id": "banco", + "label": "Banco (instância, sem organization_id)" + }, + { + "id": "humano", + "label": "O que o dono vê" + } + ], + "mainPath": [ + "agentsh", + "agentroute", + "systemversion", + "footer", + "painel", + "updateroute", + "runs" ], - "mainPath": ["agentsh", "agentroute", "systemversion", "footer", "painel", "updateroute", "runs"], "nodes": [ - { "id": "agentsh", "lane": "host", "col": 0, "type": "service", "label": "agent.sh", "sublabel": "cron 5min · flock · esc() byte-safe (LC_ALL=C)" }, - { "id": "updatesh", "lane": "host", "col": 1, "type": "service", "label": "update.sh --to ", "sublabel": "backup → checkout da tag → baseline → imagem → healthcheck" }, - - { "id": "agentroute", "lane": "app", "col": 0, "type": "api", "label": "POST /api/v1/system/agent", "sublabel": "bearer INTERNAL_CRON_SECRET · heartbeat/run_progress/run_result" }, - { "id": "versionroute", "lane": "app", "col": 2, "type": "api", "label": "GET /api/v1/system/version", "sublabel": "só is_platform_admin vê o operacional" }, - { "id": "updateroute", "lane": "app", "col": 3, "type": "api", "label": "POST /api/v1/system/update", "sublabel": "cria o run — nunca executa nada" }, - - { "id": "systemversion", "lane": "banco", "col": 1, "type": "database", "label": "system_version", "sublabel": "singleton id=1 · current/latest/changelog_raw" }, - { "id": "runs", "lane": "banco", "col": 3, "type": "database", "label": "system_update_runs", "sublabel": "índice único: no máx. 1 'dispatched'" }, - - { "id": "footer", "lane": "humano", "col": 2, "type": "frontend", "label": "Rodapé da sidebar", "sublabel": "aviso só se is_owner E update_available" }, - { "id": "painel", "lane": "humano", "col": 3, "type": "frontend", "label": "/app/settings/atualizacao", "sublabel": "4 estados · atenção ANTES do botão" } + { + "id": "agentsh", + "lane": "host", + "col": 0, + "type": "service", + "label": "agent.sh", + "sublabel": "cron 5min · flock · esc() byte-safe (LC_ALL=C)" + }, + { + "id": "updatesh", + "lane": "host", + "col": 1, + "type": "service", + "label": "update.sh --to ", + "sublabel": "backup → checkout da tag → baseline → imagem → healthcheck" + }, + { + "id": "agentroute", + "lane": "app", + "col": 0, + "type": "api", + "label": "POST /api/v1/system/agent", + "sublabel": "bearer INTERNAL_CRON_SECRET · heartbeat/run_progress/run_result" + }, + { + "id": "versionroute", + "lane": "app", + "col": 2, + "type": "api", + "label": "GET /api/v1/system/version", + "sublabel": "só is_platform_admin vê o operacional · entrega a faixa instalada→alvo" + }, + { + "id": "updateroute", + "lane": "app", + "col": 3, + "type": "api", + "label": "POST /api/v1/system/update", + "sublabel": "cria o run — nunca executa nada" + }, + { + "id": "systemversion", + "lane": "banco", + "col": 1, + "type": "database", + "label": "system_version", + "sublabel": "singleton id=1 · current/latest/changelog_raw" + }, + { + "id": "runs", + "lane": "banco", + "col": 3, + "type": "database", + "label": "system_update_runs", + "sublabel": "índice único: no máx. 1 'dispatched'" + }, + { + "id": "footer", + "lane": "humano", + "col": 2, + "type": "frontend", + "label": "Rodapé da sidebar", + "sublabel": "aviso só se is_owner E update_available" + }, + { + "id": "painel", + "lane": "humano", + "col": 3, + "type": "frontend", + "label": "/app/settings/atualizacao", + "sublabel": "4 estados · avisos de TODAS as versões da faixa, antes do botão" + } ], "edges": [ - { "id": "e1", "from": "agentsh", "to": "agentroute", "label": "heartbeat: versão instalada + changelog cru" }, - { "id": "e2", "from": "agentroute", "to": "systemversion", "label": "grava current/latest/changelog_raw" }, - { "id": "e3", "from": "systemversion", "to": "versionroute", "label": "leitura para a tela" }, - { "id": "e4", "from": "versionroute", "to": "footer", "label": "update_available + is_owner" }, - { "id": "e5", "from": "footer", "to": "painel", "label": "clique no aviso 'Nova versão'" }, - { "id": "e6", "from": "painel", "to": "updateroute", "label": "clique em 'Atualizar agora'" }, - { "id": "e7", "from": "updateroute", "to": "runs", "label": "insert status='dispatched'" }, - { "id": "e8", "from": "agentroute", "to": "runs", "label": "heartbeat seguinte: update_requested=true" }, - { "id": "e9", "from": "agentsh", "to": "updatesh", "label": "dispara o script local — nunca um comando vindo do app", "route": "highlight" }, - { "id": "e10", "from": "updatesh", "to": "agentroute", "label": "report() em 3 pontos: backup/código/banco", "route": "highlight" }, - { "id": "e11", "from": "agentroute", "to": "runs", "label": "run_result fecha success/failed/failed_rolled_back" }, - { "id": "e12", "from": "runs", "to": "painel", "label": "polling 5s — progresso e desfecho" } + { + "id": "e1", + "from": "agentsh", + "to": "agentroute", + "label": "heartbeat: versão instalada + changelog cru" + }, + { + "id": "e2", + "from": "agentroute", + "to": "systemversion", + "label": "grava current/latest/changelog_raw" + }, + { + "id": "e3", + "from": "systemversion", + "to": "versionroute", + "label": "faixa de seções entre a instalada e a alvo" + }, + { + "id": "e4", + "from": "versionroute", + "to": "footer", + "label": "update_available + is_owner" + }, + { + "id": "e5", + "from": "footer", + "to": "painel", + "label": "clique no aviso 'Nova versão'" + }, + { + "id": "e6", + "from": "painel", + "to": "updateroute", + "label": "clique em 'Atualizar agora'" + }, + { + "id": "e7", + "from": "updateroute", + "to": "runs", + "label": "insert status='dispatched'" + }, + { + "id": "e8", + "from": "agentroute", + "to": "runs", + "label": "heartbeat seguinte: update_requested=true" + }, + { + "id": "e9", + "from": "agentsh", + "to": "updatesh", + "label": "dispara o script local — nunca um comando vindo do app", + "route": "highlight" + }, + { + "id": "e10", + "from": "updatesh", + "to": "agentroute", + "label": "report() em 3 pontos: backup/código/banco", + "route": "highlight" + }, + { + "id": "e11", + "from": "agentroute", + "to": "runs", + "label": "run_result fecha success/failed/failed_rolled_back" + }, + { + "id": "e12", + "from": "runs", + "to": "painel", + "label": "polling 5s — progresso e desfecho" + } ], "cards": [ { diff --git a/hooks/system/useSystemVersion.ts b/hooks/system/useSystemVersion.ts index 221e85fd2..afe56e337 100644 --- a/hooks/system/useSystemVersion.ts +++ b/hooks/system/useSystemVersion.ts @@ -14,7 +14,14 @@ export interface SystemVersion { /** O host já viu ao menos uma tag `v*` publicada neste repositório. */ has_known_release?: boolean; agent_online?: boolean; - notes?: { body: string; requires_attention: string | null } | null; + notes?: { + /** Um por versão da faixa que tem aviso, do mais novo ao mais antigo. */ + requires_attention: Array<{ version: string; texto: string }>; + /** Todas as seções entre a versão no ar e a alvo, da mais nova à mais antiga. */ + sections: Array<{ version: string; body: string }>; + /** `false`: o texto recebido pode não alcançar a versão instalada. */ + complete: boolean; + } | null; run?: { id: string; status: string; diff --git a/hostgator-setup-kit/agent.sh b/hostgator-setup-kit/agent.sh index 3022571df..459f44a11 100755 --- a/hostgator-setup-kit/agent.sh +++ b/hostgator-setup-kit/agent.sh @@ -157,7 +157,17 @@ if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "$CURRENT" ]; then # HEARTBEAT INTEIRO morreria com 422 — sem short-circuit, isso morre calado. # 30000 cru garante ≤60000 escapado mesmo no pior caso (100% do texto # escapando 2x), com folga sobre o teto de 64000. - CHANGELOG="$(git show "${LATEST_TAG}:CHANGELOG.md" 2>/dev/null | head -c 30000 || true)" + # O corte deixou de ser cego. O `awk` para de imprimir AO IMPRIMIR o cabeçalho + # da versão instalada — e o cabeçalho entra de propósito: é ele que prova ao + # app que a faixa está completa. Isso encolhe o payload no caso comum (uma ou + # duas versões de salto) em vez de subir o teto, que mataria o heartbeat + # inteiro com 422, calado. O `head -c 30000` continua depois, como teto para o + # salto grande. `index()` e não regex: o rótulo tem `[` e `]`, e escapar isso + # em awk é onde se erra. Instalação fora de release (CURRENT é um SHA) nunca + # casa, cai no arquivo inteiro cortado, e o app declara que não alcançou. + # MANTENHA numa linha física só: tests/unit/changelog-cabe-na-tela-da-vps.test.ts + # lê o teto daqui por regex de linha única e EXPLODE se ela for quebrada. + CHANGELOG="$(git show "${LATEST_TAG}:CHANGELOG.md" 2>/dev/null | awk -v cur="## [${CURRENT#v}]" 'index($0, cur) == 1 { print; exit } { print }' | head -c 30000 || true)" # `head -c` corta em byte fixo, e o CHANGELOG tem emoji/acento multi-byte # (UTF-8) — um corte no meio de um caractere quebraria o JSON de um jeito # difícil de rastrear. `iconv -c` descarta o byte incompleto do final sem diff --git a/lib/system/changelog.test.ts b/lib/system/changelog.test.ts index 068e556e9..4f23263ca 100644 --- a/lib/system/changelog.test.ts +++ b/lib/system/changelog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "fs"; import { join } from "path"; -import { extractChangelogSection, markdownParaTextoSimples } from "./changelog"; +import { extractChangelogRange, extractChangelogSection, markdownParaTextoSimples } from "./changelog"; const CHANGELOG = `# Changelog @@ -193,3 +193,85 @@ describe("markdownParaTextoSimples", () => { ); }); }); + +describe("extractChangelogRange — todas as seções entre a instalada e a alvo", () => { + const TRES = [ + "# Changelog", + "", + "## [Não lançado]", + "", + "## [1.2.0] — 2026-08-03", + "", + "### Adicionado", + "", + "- coisa nova", + "", + "## [1.1.0] — 2026-08-02", + "", + "### ⚠️ Requer atenção", + "", + "- reconecte o número depois de atualizar", + "", + "### Corrigido", + "", + "- conserto do meio", + "", + "## [1.0.0] — 2026-08-01", + "", + "- primeira", + "", + ].join("\n"); + + it("devolve da mais nova para a mais antiga, sem incluir a instalada", () => { + const f = extractChangelogRange(TRES, "1.2.0", "1.0.0"); + expect(f.secoes.map((s) => s.version)).toEqual(["1.2.0", "1.1.0"]); + expect(f.completa).toBe(true); + }); + + it("o aviso da versão do MEIO sobrevive — é o defeito que esta função existe para corrigir", () => { + // Quem pulava versões via só a seção-alvo, e o aviso de ação manual da + // versão intermediária desaparecia (commit ac9472c5). + const f = extractChangelogRange(TRES, "1.2.0", "1.0.0"); + const comAviso = f.secoes.filter((s) => s.requiresAttention); + expect(comAviso.map((s) => s.version)).toEqual(["1.1.0"]); + expect(comAviso[0]?.requiresAttention).toContain("reconecte o número"); + }); + + it("não afirma completude quando a instalada é um SHA (instalação fora de release)", () => { + const f = extractChangelogRange(TRES, "1.2.0", "abc1234"); + expect(f.completa).toBe(false); + expect(f.secoes.length).toBeGreaterThan(0); + }); + + it("não afirma completude quando o texto chegou cortado antes da instalada", () => { + const cortado = TRES.slice(0, TRES.indexOf("## [1.0.0]")); + expect(extractChangelogRange(cortado, "1.2.0", "1.0.0").completa).toBe(false); + }); + + it("em dia: nada entre as duas, e isso NÃO é incompletude", () => { + expect(extractChangelogRange(TRES, "1.2.0", "1.2.0")).toEqual({ secoes: [], completa: true }); + }); + + it("instalada mais nova que a alvo devolve a seção-alvo, nunca uma lista vazia dizendo-se completa", () => { + // Um `slice(iAlvo, iInst)` ingênuo devolveria [] com completa=true aqui: a + // tela ficaria sem corpo nenhum afirmando estar inteira. + const f = extractChangelogRange(TRES, "1.0.0", "1.2.0"); + expect(f.secoes.map((s) => s.version)).toEqual(["1.0.0"]); + expect(f.secoes[0]?.body).toContain("primeira"); + }); + + it("alvo que não existe no texto devolve vazio e incompleto", () => { + expect(extractChangelogRange(TRES, "9.9.9", "1.0.0")).toEqual({ secoes: [], completa: false }); + }); + + it("faixa de uma versão só é igual à seção única — as duas leituras não podem divergir", () => { + const pelaFaixa = extractChangelogRange(TRES, "1.2.0", "1.1.0").secoes[0]; + const pelaSecao = extractChangelogSection(TRES, "1.2.0"); + expect(pelaFaixa).toEqual(pelaSecao); + }); + + it("`[Não lançado]` nunca entra na faixa", () => { + const f = extractChangelogRange(TRES, "1.2.0", "1.0.0"); + expect(f.secoes.map((s) => s.version)).not.toContain("Não lançado"); + }); +}); diff --git a/lib/system/changelog.ts b/lib/system/changelog.ts index db7096090..f91b89cf3 100644 --- a/lib/system/changelog.ts +++ b/lib/system/changelog.ts @@ -45,6 +45,16 @@ export function extractChangelogSection(raw: string, version: string): Changelog if (start === -1) return null; + return montarSecao(lines, start, end, wanted); +} + +/** O miolo de `extractChangelogSection`, reusado pela faixa. */ +function montarSecao( + lines: readonly string[], + start: number, + end: number, + versao: string, +): ChangelogSection { const bodyLines = lines.slice(start, end); const attention = findAttentionRange(bodyLines); @@ -62,7 +72,84 @@ export function extractChangelogSection(raw: string, version: string): Changelog ? cleanBody(bodyLines.slice(attention.start + 1, attention.end).join("\n").trim()) || null : null; - return { version: wanted, body, requiresAttention }; + return { version: versao, body, requiresAttention }; +} + +export interface ChangelogRange { + /** Da mais NOVA para a mais antiga, como o arquivo. */ + secoes: ChangelogSection[]; + /** + * Achei o cabeçalho da versão instalada no texto recebido? + * + * `false` significa "este histórico pode não alcançar a sua versão" — e a tela + * precisa DIZER isso. O agente da VPS manda o CHANGELOG cortado em bytes, e um + * corpo truncado no meio da frase é indistinguível de um corpo inteiro: sem + * este sinal, a tela afirmaria completude que não tem. + */ + completa: boolean; +} + +/** + * Todas as seções entre a versão-alvo e a instalada. + * + * Existe porque mostrar só a seção-alvo perde aviso: quem pula da 1.4.0 para a + * 1.6.0 nunca lia a 1.4.1 nem a 1.5.0 — e a 1.4.1 existia justamente para + * corrigir uma instrução invertida que mandava o operador apagar a conexão que + * estava funcionando. O contorno da época foi carregar o aviso órfão para a + * versão seguinte, à mão (commit ac9472c5); isto o aposenta. + * + * A seleção é POSICIONAL, não semver: o arquivo já vem do mais novo para o mais + * antigo, e comparar número exigiria um comparador que não existe no projeto — + * que tropeçaria em `v1.1.1-jmpo.1`, tag de fork que este repo carrega. + */ +export function extractChangelogRange( + raw: string, + alvo: string, + instalada: string, +): ChangelogRange { + const vazio: ChangelogRange = { secoes: [], completa: false }; + if (!raw || !alvo) return vazio; + + const lines = raw.split("\n"); + const cabs: Array<{ rotulo: string; i: number }> = []; + lines.forEach((linha, i) => { + const m = VERSION_HEADING.exec(linha); + if (m) cabs.push({ rotulo: normalize(m[1] ?? ""), i }); + }); + + const iAlvo = cabs.findIndex((c) => c.rotulo === normalize(alvo)); + if (iAlvo === -1) return vazio; + + const iInst = cabs.findIndex((c) => c.rotulo === normalize(instalada)); + + // Os quatro casos são escritos um a um de propósito. Um `slice(iAlvo, iInst)` + // ingênuo devolve lista VAZIA quando `iInst < iAlvo`, e ainda assim diria + // `completa: true` — a tela ficaria sem corpo nenhum afirmando estar inteira. + let fim: number; + let completa: boolean; + if (iInst === -1) { + // Instalação fora de release (a versão é um SHA) ou texto cortado antes de + // alcançá-la. Mostra o que veio e admite que pode não alcançar. + fim = cabs.length; + completa = false; + } else if (iInst === iAlvo) { + return { secoes: [], completa: true }; // está em dia: nada entre as duas + } else if (iInst < iAlvo) { + // A instalada é MAIS NOVA que a alvo (rollback pendente, canal trocado). + // Só a seção-alvo faz sentido aqui. + fim = iAlvo + 1; + completa = true; + } else { + fim = iInst; + completa = true; + } + + const secoes = cabs.slice(iAlvo, fim).map((c, k, arr) => { + const proximo = arr[k + 1]?.i ?? cabs[iAlvo + arr.length]?.i ?? lines.length; + return montarSecao(lines, c.i + 1, proximo, c.rotulo); + }); + + return { secoes, completa }; } /** diff --git a/tests/e2e/system-update.spec.ts b/tests/e2e/system-update.spec.ts index 372708629..2e1d63d84 100644 --- a/tests/e2e/system-update.spec.ts +++ b/tests/e2e/system-update.spec.ts @@ -178,6 +178,71 @@ function resetEstado(): void { execFileSync("npx", ["tsx", "scripts/seed-e2e-system-update.ts"], { stdio: "inherit" }); } +test("quem pula versões vê os avisos de TODAS elas, não só o da mais nova", async ({ + page, + request, +}) => { + // O defeito que este caso guarda: a tela mostrava só a seção da versão-alvo. + // Quem estava na 1.0.0 e ia para a 1.2.0 nunca lia a 1.1.0 — e no caso real + // (commit ac9472c5) a versão do meio trazia uma instrução para o operador + // apagar a conexão que estava funcionando. + const changelog = [ + "## [1.2.0] — 2026-08-03", + "", + "**⚠️ Requer atenção**", + "", + "Rode o comando de migração antes.", + "", + "### Adicionado", + "", + "- Coisa da versão nova.", + "", + "## [1.1.0] — 2026-08-02", + "", + "**⚠️ Requer atenção**", + "", + "Reconecte o número depois.", + "", + "### Corrigido", + "", + "- Conserto da versão do meio.", + "", + "## [1.0.0] — 2026-08-01", + "", + "- Primeira versão.", + "", + ].join("\n"); + + await loginWithTotp(page, creds.users.dono!.email, creds.dono_totp!.secret); + await heartbeat(request, { latest_version: "1.2.0", current_version: "1.0.0", changelog }); + await page.goto("/app/settings/atualizacao"); + + // Os DOIS avisos visíveis, o da alvo e o da versão do meio, cada um nomeando + // de onde veio. + await expect(page.getByText(/Rode o comando de migração antes/)).toBeVisible(); + await expect(page.getByText(/Reconecte o número depois/)).toBeVisible(); + await expect(page.getByText(/Da versão 1\.1\.0/)).toBeVisible(); + + // E os dois ANTES do botão — medido por ferramenta, nunca a olho: aviso que + // aparece depois do clique não é aviso. + const y = async (rx: RegExp) => (await page.getByText(rx).boundingBox())!.y; + const botao = (await page.getByRole("button", { name: /atualizar agora/i }).boundingBox())!.y; + expect(await y(/Rode o comando de migração antes/)).toBeLessThan(botao); + expect(await y(/Reconecte o número depois/)).toBeLessThan(botao); + + // O corpo da versão-alvo fica aberto; o da intermediária, recolhido. Texto + // dentro de um `
` FECHADO não é visível para o Playwright, então o + // caso abre e prova pelo estado real do elemento — e nunca põe um AVISO ali + // dentro, que é o defeito que esta tela existe para consertar. + await expect(page.getByText(/Coisa da versão nova/)).toBeVisible(); + const recolhido = page.locator("details", { hasText: "Versão 1.1.0" }); + await recolhido.locator("summary").click(); + await expect(recolhido).toHaveJSProperty("open", true); + await expect(page.getByText(/Conserto da versão do meio/)).toBeVisible(); + + await page.screenshot({ path: ".superpowers/evidence/faixa-de-versoes.png" }); +}); + test("o dono vê a versão nova na sidebar e atualiza pela tela", async ({ page, request }) => { const changelog = "## [1.1.0] — 2026-08-02\n\n**⚠️ Requer atenção**\n\nReconecte o número depois.\n\n### Adicionado\n\n- Botão de atualizar pela tela.\n";