diff --git a/CHANGELOG.md b/CHANGELOG.md index ea477c19..dfb99f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [Unreleased] (Consistem) +- Enhancements + - Testes unitários: resumo consolidado no fim da execução, com as falhas e as classes que não executaram, também gravado no canal de saída "Consistem: Resumo dos Testes". + - Testes unitários: classe que não executa por impedimento do ambiente passa a ser reportada como erro, ficando visível na árvore mesmo com o pacote colapsado. + - Novo comando "Consistem: Mostrar o Que Falhou nos Testes" (botão na barra de título do Test Explorer), que colapsa a árvore, ordena por situação e abre o resumo. + ## [3.8.3] 11-Sep-2026 (Consistem) - Enhancements - Synchronize the Consistem fork with upstream through `v3.8.6-beta.8`. diff --git a/package.json b/package.json index 06cd57a9..0e292633 100644 --- a/package.json +++ b/package.json @@ -402,6 +402,11 @@ "command": "vscode-objectscript.openISCDocument", "when": "view == workbench.explorer.fileView && vscode-objectscript.connectActive && workspaceFolderCount != 0", "group": "navigation" + }, + { + "command": "vscode-objectscript.ccs.mostrarResumoTestes", + "when": "view == workbench.view.testing", + "group": "navigation" } ], "view/item/context": [ @@ -1108,6 +1113,12 @@ "category": "Consistem", "icon": "$(database)" }, + { + "command": "vscode-objectscript.ccs.mostrarResumoTestes", + "title": "Mostrar o Que Falhou nos Testes", + "category": "Consistem", + "icon": "$(checklist)" + }, { "category": "ObjectScript", "command": "vscode-objectscript.jumpToTagAndOffset", diff --git a/src/ccs/features/unitTest/resumoExecucao.ts b/src/ccs/features/unitTest/resumoExecucao.ts new file mode 100644 index 00000000..fea48431 --- /dev/null +++ b/src/ccs/features/unitTest/resumoExecucao.ts @@ -0,0 +1,234 @@ +import * as vscode from "vscode"; + +/** + * Resumo consolidado de uma execução de testes unitários. + * + * A saída do executor é longa demais para ser lida linha a linha: numa execução de pacote + * inteiro o que deu errado fica perdido no meio de centenas de linhas de asserts que + * passaram. Este resumo acumula só o que interessa e é impresso SEMPRE no fim da execução, + * e também num canal de saída próprio, que só tem resumos. + */ + +const ANSI_RESET = "\u001b[0m"; +const ANSI_RED = "\u001b[31m"; +const ANSI_GREEN = "\u001b[32m"; +const ANSI_YELLOW = "\u001b[33m"; +const ANSI_BOLD = "\u001b[1m"; + +const SEPARADOR = "─".repeat(78); + +type Cor = "vermelho" | "verde" | "amarelo" | "negrito" | undefined; + +interface Linha { + texto: string; + cor?: Cor; +} + +/** Uma classe que não chegou a executar, com o motivo */ +interface Impedimento { + classe: string; + motivo: string; + /** `true` quando o usuário escolheu não executar (recusou gerar a base, por exemplo) */ + porEscolha: boolean; +} + +/** Um método que falhou, com a primeira mensagem de falha */ +interface Falha { + classe: string; + metodo: string; + detalhe: string; +} + +/** Um erro que impediu a classe de produzir resultado */ +interface ErroClasse { + classe: string; + erro: string; +} + +export class ResumoExecucao { + private readonly falhas: Falha[] = []; + private readonly impedimentos: Impedimento[] = []; + private readonly errosClasse: ErroClasse[] = []; + private metodosPassaram = 0; + private metodosIgnorados = 0; + + public registrarMetodoPassou(): void { + this.metodosPassaram++; + } + + public registrarMetodoIgnorado(): void { + this.metodosIgnorados++; + } + + public registrarFalha(classe: string, metodo: string, detalhe: string): void { + this.falhas.push({ classe, metodo, detalhe }); + } + + /** Classe que não executou por um impedimento do ambiente (base ausente, classe inválida…) */ + public registrarNaoExecutou(classe: string, motivo: string, porEscolha = false): void { + this.impedimentos.push({ classe, motivo, porEscolha }); + } + + public registrarErroClasse(classe: string, erro: string): void { + this.errosClasse.push({ classe, erro }); + } + + /** Não há nada para resumir — execução abortada antes de qualquer resultado */ + public get vazio(): boolean { + return ( + !this.falhas.length && + !this.impedimentos.length && + !this.errosClasse.length && + !this.metodosPassaram && + !this.metodosIgnorados + ); + } + + /** Houve problema que a árvore de testes não mostra sozinha (classe que nem rodou) */ + public get temImpedimento(): boolean { + return this.impedimentos.some((i) => !i.porEscolha) || this.errosClasse.length > 0; + } + + public get qtdeNaoExecutaram(): number { + return this.impedimentos.length + this.errosClasse.length; + } + + public get qtdeFalhas(): number { + return this.falhas.length; + } + + private linhas(): Linha[] { + const linhas: Linha[] = []; + const total = this.metodosPassaram + this.falhas.length + this.metodosIgnorados; + + linhas.push({ texto: SEPARADOR }); + linhas.push({ texto: "RESUMO DA EXECUÇÃO", cor: "negrito" }); + + // Contagens como substantivo, não como verbo: "1 falharam" fica torto no singular + const contagens = [`${this.metodosPassaram} com sucesso`]; + if (this.falhas.length) contagens.push(`${this.falhas.length} com falha`); + if (this.metodosIgnorados) contagens.push(`${this.metodosIgnorados} ignorado(s)`); + linhas.push({ + texto: ` ${total} método(s) de teste: ${contagens.join(" · ")}`, + cor: this.falhas.length ? "vermelho" : "verde", + }); + + if (this.qtdeNaoExecutaram) { + linhas.push({ texto: ` ${this.qtdeNaoExecutaram} classe(s) NÃO executada(s)`, cor: "amarelo" }); + } + + if (this.falhas.length) { + linhas.push({ texto: "" }); + linhas.push({ texto: `FALHAS (${this.falhas.length})`, cor: "vermelho" }); + for (const falha of this.falhas) { + linhas.push({ texto: ` ${falha.classe} :: ${falha.metodo}`, cor: "vermelho" }); + linhas.push({ texto: ` ${primeiraLinha(falha.detalhe)}` }); + } + } + + if (this.errosClasse.length) { + linhas.push({ texto: "" }); + linhas.push({ texto: `ERROS DE EXECUÇÃO (${this.errosClasse.length})`, cor: "vermelho" }); + for (const erro of this.errosClasse) { + linhas.push({ texto: ` ${erro.classe}`, cor: "vermelho" }); + linhas.push({ texto: ` ${primeiraLinha(erro.erro)}` }); + } + } + + if (this.impedimentos.length) { + linhas.push({ texto: "" }); + linhas.push({ texto: `NÃO EXECUTADAS (${this.impedimentos.length})`, cor: "amarelo" }); + for (const impedimento of this.impedimentos) { + linhas.push({ texto: ` ${impedimento.classe}`, cor: "amarelo" }); + linhas.push({ texto: ` ${primeiraLinha(impedimento.motivo)}` }); + } + } + + if (!this.falhas.length && !this.qtdeNaoExecutaram) { + linhas.push({ texto: " Nenhuma falha e nenhuma classe pendente.", cor: "verde" }); + } + + linhas.push({ texto: SEPARADOR }); + return linhas; + } + + /** Bloco colorido para o terminal "Test Results" (exige CRLF) */ + public paraTerminal(): string { + return this.linhas() + .map((l) => `${corAnsi(l.cor)}${l.texto}${l.cor ? ANSI_RESET : ""}\r\n`) + .join(""); + } + + /** Mesmo conteúdo sem ANSI, para o canal de saída */ + public paraTexto(cabecalho: string): string { + return [cabecalho, ...this.linhas().map((l) => l.texto)].join("\n"); + } +} + +function corAnsi(cor: Cor): string { + switch (cor) { + case "vermelho": + return ANSI_RED; + case "verde": + return ANSI_GREEN; + case "amarelo": + return ANSI_YELLOW; + case "negrito": + return ANSI_BOLD; + default: + return ""; + } +} + +/** Mensagens de assert podem ter várias linhas; no resumo só cabe a primeira */ +function primeiraLinha(texto: string): string { + const limpo = texto.replace(/\r/g, "").split("\n")[0].trim(); + if (!limpo) return "(sem detalhe)"; + return limpo.length > 160 ? `${limpo.slice(0, 157)}…` : limpo; +} + +let canal: vscode.OutputChannel | undefined; +let temResumo = false; + +/** Canal que guarda SÓ os resumos, para consulta depois que a saída da execução já rolou */ +function canalResumo(): vscode.OutputChannel { + if (!canal) canal = vscode.window.createOutputChannel("Consistem: Resumo dos Testes"); + return canal; +} + +/** Publica o resumo no canal de saída. Chamado ao fim de cada execução. */ +export function publicarResumo(resumo: ResumoExecucao, escopo: string): void { + const quando = new Date().toLocaleString("pt-BR"); + const saida = canalResumo(); + saida.appendLine(""); + saida.appendLine(resumo.paraTexto(`${quando} · ${escopo}`)); + temResumo = true; +} + +/** + * Deixa à vista o que deu errado na última execução: colapsa a árvore, ordena por situação + * (erro e falha primeiro) e abre o canal com o resumo. + */ +export async function mostrarResumoTestes(): Promise { + // As ações de colapsar/ordenar são `ViewAction`: só rodam com a view de testes aberta + const executar = (comando: string) => + Promise.resolve(vscode.commands.executeCommand(comando)).then(undefined, () => undefined); + await executar("workbench.view.testing"); + await executar("testing.collapseAll"); + await executar("testing.sortByStatus"); + + if (!temResumo) { + vscode.window.showInformationMessage( + "Nenhuma execução de testes registrada nesta sessão. A árvore foi colapsada e ordenada por situação.", + "Dismiss" + ); + return; + } + canalResumo().show(true); +} + +export function descartarCanalResumo(): void { + canal?.dispose(); + canal = undefined; + temResumo = false; +} diff --git a/src/ccs/index.ts b/src/ccs/index.ts index d4c4b3bc..28d91b95 100644 --- a/src/ccs/index.ts +++ b/src/ccs/index.ts @@ -35,3 +35,4 @@ export { analizarVersaoItem } from "./commands/analizarVersaoItem"; export { atualizarConfiguracoes } from "./commands/atualizarConfiguracoes"; export { activateSiblingFolders, reactivateNamespaceConnections } from "./connectionActivator"; export type { ReactivationResult } from "./connectionActivator"; +export { mostrarResumoTestes, descartarCanalResumo } from "./features/unitTest/resumoExecucao"; diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts index b0f360df..a6c4a7a4 100644 --- a/src/commands/unitTest.ts +++ b/src/commands/unitTest.ts @@ -19,6 +19,7 @@ import { DocumentContentProvider } from "../providers/DocumentContentProvider"; import { SourceControlApi } from "../ccs"; import { ROUTES } from "../ccs/sourcecontrol/routes"; import { createAbortSignal } from "../ccs/core/http"; +import { ResumoExecucao, publicarResumo, mostrarResumoTestes } from "../ccs/features/unitTest/resumoExecucao"; enum TestStatus { Failed = 0, @@ -1818,22 +1819,45 @@ async function aguardarOperacaoServidor( ); } -/** Marca todas as classes de um grupo como ignoradas, com o motivo */ +/** + * Marca todas as classes de um grupo como não executadas, com o motivo. + * + * Quando o motivo é um IMPEDIMENTO do ambiente (base que não existe, ambiente que não subiu), + * a classe é marcada como `errored`, não como `skipped`: a Test Explorer calcula o estado do + * pacote pelo pior estado dos filhos, e `skipped` perde para `passed` -- ou seja, um pacote + * verde escondia a classe que nem rodou. `errored` sobe a árvore e fica visível colapsado. + * Quando o usuário é que recusou executar, `skipped` continua correto: ele já sabe. + */ function marcarGrupoIgnorado( testRun: vscode.TestRun, classes: Map, grupo: GrupoExecucao, - motivo: string + motivo: string, + resumo: ResumoExecucao, + porEscolha = false ): void { for (const classe of grupo.classes) { + resumo.registrarNaoExecutou(classe, motivo, porEscolha); const clsItem = classes.get(classe); if (!clsItem) continue; testRun.appendOutput(`${ANSI_YELLOW}${classe}: ${motivo}${ANSI_RESET}\r\n`); - testRun.skipped(clsItem); + if (porEscolha) { + testRun.skipped(clsItem); + } else { + testRun.errored(clsItem, new vscode.TestMessage(motivo)); + } clsItem.children.forEach((m) => testRun.skipped(m)); } } +/** A mensagem que melhor explica a falha de um método, para a linha do resumo */ +function detalheDaFalha(metodo: ResultadoMetodo): string { + if (metodo.erro) return metodo.erroAcao ? `${metodo.erroAcao}: ${metodo.erro}` : metodo.erro; + const assert = metodo.asserts.find((a) => a.status === TestStatus.Failed); + if (assert) return assert.tipo ? `${assert.tipo} - ${assert.mensagem}` : assert.mensagem; + return "Teste falhou."; +} + /** Reporta o resultado de uma classe inteira na Test Explorer */ async function reportarClasse( testRun: vscode.TestRun, @@ -1843,16 +1867,28 @@ async function reportarClasse( workspaceFolder: vscode.WorkspaceFolder | undefined, documentSymbols: Map, filesText: Map, - metodosParaDecorar: { item: vscode.TestItem; status: TestStatus; durationText?: string }[] + metodosParaDecorar: { item: vscode.TestItem; status: TestStatus; durationText?: string }[], + resumo: ResumoExecucao ): Promise { for (const diagnostico of resultado.diagnosticos ?? []) { testRun.appendOutput(`${ANSI_RED}${diagnostico.codigo}: ${diagnostico.mensagem}${ANSI_RESET}\r\n`); + resumo.registrarErroClasse(resultado.execucao.classe, `${diagnostico.codigo}: ${diagnostico.mensagem}`); } const base = resultado.execucao.namespaceExec ? ` [${resultado.execucao.namespaceExec}]` : ""; testRun.appendOutput(`${ANSI_BOLD}${resultado.execucao.classe}${ANSI_RESET}${base}\r\n`); for (const metodo of resultado.metodos) { + // O resumo é alimentado antes do `continue`: um método sem TestItem correspondente no + // fonte local não pode sumir da contagem, senão o resumo mente sobre o que rodou. + if (metodo.status === TestStatus.Failed) { + resumo.registrarFalha(resultado.execucao.classe, metodo.metodo, detalheDaFalha(metodo)); + } else if (metodo.status === TestStatus.Passed) { + resumo.registrarMetodoPassou(); + } else { + resumo.registrarMetodoIgnorado(); + } + const methodItem = findMethodItemByLegacyName(clsItem, metodo.metodo); if (!methodItem) continue; @@ -1922,6 +1958,9 @@ async function reportarClasse( const texto = falhos.length ? `Existem métodos de teste com falha:\n${falhos.map((m) => `- ${m}`).join("\n")}` : execucao.erro || "Existem métodos de teste com falha."; + // Classe reprovada sem nenhum método reprovado: o impedimento é da classe (recusa do + // executor, erro de compilação…) e não aparece em nenhuma linha de falha do resumo. + if (!falhos.length) resumo.registrarErroClasse(execucao.classe, texto); testRun.failed(clsItem, new vscode.TestMessage(new vscode.MarkdownString(texto)), execucao.duracaoMs); } else if (execucao.status === TestStatus.Passed) { testRun.passed(clsItem, execucao.duracaoMs); @@ -1979,6 +2018,8 @@ async function executeConsistemRunner( metodosSolicitados.set(teste.class, teste.methods); } + const resumo = new ResumoExecucao(); + try { for (const classItem of new Set(clsItemsRun)) { testRun.started(classItem); @@ -2004,6 +2045,7 @@ async function executeConsistemRunner( } for (const naoExec of plano.naoExecutaveis ?? []) { + resumo.registrarNaoExecutou(naoExec.classe, naoExec.mensagem); const clsItem = classes.get(naoExec.classe); if (!clsItem) continue; testRun.appendOutput(`${ANSI_YELLOW}${naoExec.classe}: ${naoExec.mensagem}${ANSI_RESET}\r\n`); @@ -2018,14 +2060,14 @@ async function executeConsistemRunner( if (grupo.situacao === "baseAmbigua") { const escolhido = await perguntarNamespace(grupo); if (!escolhido) { - marcarGrupoIgnorado(testRun, classes, grupo, "Namespace não escolhido."); + marcarGrupoIgnorado(testRun, classes, grupo, "Namespace não escolhido.", resumo, true); continue; } grupo.namespace = escolhido; grupo.situacao = "pronta"; } else if (grupo.situacao === "baseAusente" && grupo.podeGerar) { if (!(await perguntarGerarBase(grupo))) { - marcarGrupoIgnorado(testRun, classes, grupo, "Base não gerada."); + marcarGrupoIgnorado(testRun, classes, grupo, "Base não gerada.", resumo, true); continue; } const idBase = grupo.base?.id ?? ""; @@ -2039,7 +2081,7 @@ async function executeConsistemRunner( signal ); if (!namespaceGerado) { - marcarGrupoIgnorado(testRun, classes, grupo, "A base não foi gerada."); + marcarGrupoIgnorado(testRun, classes, grupo, "A base não foi gerada.", resumo); continue; } grupo.namespace = namespaceGerado; @@ -2058,7 +2100,7 @@ async function executeConsistemRunner( signal ); if (!namespacePreparado) { - marcarGrupoIgnorado(testRun, classes, grupo, "O ambiente de testes não foi montado."); + marcarGrupoIgnorado(testRun, classes, grupo, "O ambiente de testes não foi montado.", resumo); continue; } grupo.namespace = namespacePreparado; @@ -2068,7 +2110,8 @@ async function executeConsistemRunner( testRun, classes, grupo, - `A base ${grupo.base?.id ?? ""} não está montada e não pode ser gerada nesta instalação.` + `A base ${grupo.base?.id ?? ""} não está montada e não pode ser gerada nesta instalação.`, + resumo ); continue; } @@ -2116,7 +2159,8 @@ async function executeConsistemRunner( workspaceFolder, documentSymbols, filesText, - metodosParaDecorar + metodosParaDecorar, + resumo ); continue; } @@ -2151,11 +2195,13 @@ async function executeConsistemRunner( workspaceFolder, documentSymbols, filesText, - metodosParaDecorar + metodosParaDecorar, + resumo ); } catch (error) { if (token.isCancellationRequested) break; handleError(error, `Error running tests for ${classe}.`); + resumo.registrarErroClasse(classe, String(error)); testRun.errored(clsItem, new vscode.TestMessage(String(error))); // Desistir de acompanhar nao para o JOB no servidor: sem descartar, o resultado @@ -2173,12 +2219,46 @@ async function executeConsistemRunner( applyTestResultDecorations(metodosParaDecorar); } finally { descartarSignal(); + // O resumo é a ÚLTIMA coisa escrita: numa execução de pacote inteiro a saída tem + // centenas de linhas, e quem rodou não vai varrer uma a uma atrás do que quebrou. + if (!resumo.vazio) { + testRun.appendOutput(resumo.paraTerminal()); + publicarResumo(resumo, escopoDaExecucao(clsItemsRun, testesSolicitados)); + } testRun.end(); } + if (!token.isCancellationRequested) avisarImpedimentos(resumo); + return true; } +/** Descreve o que foi executado, para identificar o resumo no canal de saída */ +function escopoDaExecucao(clsItemsRun: vscode.TestItem[], testesSolicitados: { class: string }[]): string { + const qtde = new Set(testesSolicitados.map((t) => t.class)).size; + if (qtde === 1) return testesSolicitados[0].class; + return `${qtde} classe(s) de teste${clsItemsRun.length ? "" : " (nenhuma resolvida)"}`; +} + +/** + * Classe que não rodou não fica visível na árvore com o pacote colapsado, porque a Test + * Explorer resolve o estado do pai pelo pior filho e `skipped` perde para `passed`. Um aviso + * com atalho para o resumo é o que garante que o impedimento não passe batido. + */ +function avisarImpedimentos(resumo: ResumoExecucao): void { + if (!resumo.temImpedimento) return; + const falhas = resumo.qtdeFalhas ? ` ${resumo.qtdeFalhas} método(s) com falha.` : ""; + vscode.window + .showWarningMessage( + `${resumo.qtdeNaoExecutaram} classe(s) de teste NÃO executada(s).${falhas}`, + "Ver resumo", + "Dismiss" + ) + .then((escolha) => { + if (escolha === "Ver resumo") mostrarResumoTestes(); + }); +} + async function runHandler( request: vscode.TestRunRequest, token: vscode.CancellationToken, diff --git a/src/extension.ts b/src/extension.ts index 7402b7ca..3d3f5df8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -173,6 +173,8 @@ import { atualizarConfiguracoes, activateSiblingFolders, reactivateNamespaceConnections, + mostrarResumoTestes, + descartarCanalResumo, } from "./ccs"; import { Authorization, ResolvedAuthorization } from "@intersystems-community/intersystems-servermanager"; @@ -1530,6 +1532,11 @@ export async function activate(context: vscode.ExtensionContext): Promise { + sendCommandTelemetryEvent("ccs.mostrarResumoTestes"); + await mostrarResumoTestes(); + }), + new vscode.Disposable(descartarCanalResumo), vscode.commands.registerCommand("vscode-objectscript.ccs.activateNamespaceConnections", async () => { sendCommandTelemetryEvent("ccs.activateNamespaceConnections"); await vscode.window.withProgress(