fix(atualizacao): a tela mostra TODAS as versoes entre a sua e a nova, com os avisos de cada uma

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 `<details>`.** 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 `<details>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAtZo5GWZn8ckdmsoUVKts
This commit is contained in:
Rafael Melgaço
2026-08-27 10:07:39 -03:00
co-authored by Claude Opus 5
parent 2886696231
commit eed657ddcf
10 changed files with 588 additions and 49 deletions
+11
View File
@@ -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.
+81 -4
View File
@@ -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 () => {
+27 -3
View File
@@ -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<Response> {
}
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<Response> {
// 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,
@@ -286,23 +286,57 @@ export function UpdatePanel() {
</p>
)}
{data.notes?.requires_attention && (
{/* Os avisos de TODAS as versões da faixa, reunidos e sempre à vista.
Nenhum deles entra em `<details>`: 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 ? (
<div className="mb-4 rounded-md border border-warning bg-warning-bg p-3 text-sm text-warning-fg">
<p className="mb-1 font-medium">⚠️ Requer atenção</p>
<p className="whitespace-pre-line">
{markdownParaTextoSimples(data.notes.requires_attention)}
</p>
{data.notes.requires_attention.map((aviso) => (
<div key={aviso.version} className="mt-2">
<p className="font-medium">Da versão {aviso.version}:</p>
<p className="whitespace-pre-line">{markdownParaTextoSimples(aviso.texto)}</p>
</div>
))}
</div>
) : null}
{data.notes?.complete === false && data.notes.sections.length > 0 && (
<p className="mb-4 text-sm text-muted-foreground">
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.
</p>
)}
{data.notes?.body && (
{data.notes?.sections.length ? (
<div className="mb-6">
<p className="mb-2 text-sm font-medium">O que muda</p>
<pre className="whitespace-pre-wrap font-sans text-sm text-muted-foreground">
{markdownParaTextoSimples(data.notes.body)}
</pre>
{data.notes.sections.map((secao, i) => (
<div key={secao.version} className="mb-3">
{/* 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 ? (
<pre className="whitespace-pre-wrap font-sans text-sm text-muted-foreground">
{markdownParaTextoSimples(secao.body)}
</pre>
) : (
<details>
<summary className="cursor-pointer text-sm font-medium">
Versão {secao.version}
</summary>
<pre className="mt-2 whitespace-pre-wrap font-sans text-sm text-muted-foreground">
{markdownParaTextoSimples(secao.body)}
</pre>
</details>
)}
</div>
))}
</div>
)}
) : null}
<BotaoAtualizar mutate={() => atualizar.mutate()} isPending={atualizar.isPending} erro={erro} />
</Layout>
@@ -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 <tag>", "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 <tag>",
"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": [
{
+8 -1
View File
@@ -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;
+11 -1
View File
@@ -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
+83 -1
View File
@@ -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");
});
});
+88 -1
View File
@@ -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 };
}
/**
+65
View File
@@ -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 `<details>` 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";