diff --git a/README.md b/README.md index f869068d..0f450fad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Consistem + Consistem

# Consistem ObjectScript extension for VS Code diff --git a/images/logo-consistem-horizontal.png b/images/logo-consistem-horizontal.png index 29b42c8b..1e98201e 100644 Binary files a/images/logo-consistem-horizontal.png and b/images/logo-consistem-horizontal.png differ diff --git a/images/logo.png b/images/logo.png index 82d5be74..146e8c9d 100644 Binary files a/images/logo.png and b/images/logo.png differ diff --git a/package.json b/package.json index 9c68ceee..06cd57a9 100644 --- a/package.json +++ b/package.json @@ -775,6 +775,11 @@ "command": "vscode-objectscript.ccs.atualizarConfiguracoes", "group": "7_config@1", "when": "vscode-objectscript.connectActive" + }, + { + "command": "vscode-objectscript.ccs.gerenciarBasesTeste", + "group": "8_bases@1", + "when": "vscode-objectscript.connectActive" } ] }, @@ -1097,6 +1102,12 @@ "category": "ObjectScript", "icon": "$(plug)" }, + { + "command": "vscode-objectscript.ccs.gerenciarBasesTeste", + "title": "Regerar Base de Testes", + "category": "Consistem", + "icon": "$(database)" + }, { "category": "ObjectScript", "command": "vscode-objectscript.jumpToTagAndOffset", @@ -1826,12 +1837,32 @@ "scope": "resource" }, "objectscript.unitTest.legacyRequestTimeout": { - "description": "Timeout in milliseconds applied to legacy unit test API calls. Set to 0 for no timeout.", + "description": "Timeout in milliseconds applied to each unit test class execution. Set to 0 for no timeout.", "type": "integer", - "default": 6000000, + "default": 600000, "minimum": 0, "scope": "resource" }, + "objectscript.unitTest.usarExecutorAntigo": { + "description": "Volta ao executor de testes anterior (sempre executa no namespace TESTEUNITARIO). Temporário, apenas para rollback durante a validação do executor novo.", + "type": "boolean", + "default": false, + "scope": "resource" + }, + "objectscript.unitTest.incluirAsserts": { + "description": "Quais asserts o executor de testes deve retornar. 'somenteFalhas' reduz muito o volume de dados em classes que passam.", + "type": "string", + "enum": [ + "somenteFalhas", + "todos" + ], + "enumDescriptions": [ + "Apenas os asserts que falharam, mais as mensagens de log", + "Todos os asserts, inclusive os que passaram" + ], + "default": "somenteFalhas", + "scope": "resource" + }, "objectscript.commentToken": { "description": "The line comment characters for ObjectScript in classes and MAC and INC routines.", "type": "string", diff --git a/src/ccs/sourcecontrol/client.ts b/src/ccs/sourcecontrol/client.ts index 265bc819..fbd6614d 100644 --- a/src/ccs/sourcecontrol/client.ts +++ b/src/ccs/sourcecontrol/client.ts @@ -85,4 +85,12 @@ export class SourceControlApi { ): Promise> { return this.client.post(route, data, config); } + + public get(route: string, config?: AxiosRequestConfig): Promise> { + return this.client.get(route, config); + } + + public delete(route: string, config?: AxiosRequestConfig): Promise> { + return this.client.delete(route, config); + } } diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts index 98af8deb..20be8297 100644 --- a/src/ccs/sourcecontrol/routes.ts +++ b/src/ccs/sourcecontrol/routes.ts @@ -13,6 +13,19 @@ export const ROUTES = { `/namespaces/${encodeURIComponent(namespace)}/converterArquivoCustomizado`, analizarVersaoItem: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/analizarVersaoItem`, atualizarConfig: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/atualizarConfig`, + resolveUnitTests: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/resolve`, + executarClasseTeste: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/executarClasse`, + situacaoExecucaoTeste: (namespace: string, idExecucao: string) => + `/namespaces/${encodeURIComponent(namespace)}/unitTests/execucao/${encodeURIComponent(idExecucao)}`, + paginaResultadoTeste: (namespace: string, idExecucao: string) => + `/namespaces/${encodeURIComponent(namespace)}/unitTests/resultado/${encodeURIComponent(idExecucao)}`, + gerarBaseTeste: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/gerarBase`, + listarBasesTeste: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/bases`, + regerarBaseTeste: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/bases/regerar`, + prepararAmbienteTeste: (namespace: string) => + `/namespaces/${encodeURIComponent(namespace)}/unitTests/prepararAmbiente`, + situacaoGeracaoBase: (namespace: string, idGeracao: string) => + `/namespaces/${encodeURIComponent(namespace)}/unitTests/gerarBase/${encodeURIComponent(idGeracao)}`, } as const; export type RouteKey = keyof typeof ROUTES; diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts index 570b8eed..4f9ec9a8 100644 --- a/src/commands/unitTest.ts +++ b/src/commands/unitTest.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode"; import * as Atelier from "../api/atelier"; import { clsLangId, extensionId, filesystemSchemas, lsExtensionId, sendUnitTestTelemetryEvent } from "../extension"; import { + currentWorkspaceFolder, getFileText, handleError, methodOffsetToLine, @@ -74,6 +75,143 @@ interface LegacyUnitTestResponse { console?: string[]; } +// --------------------------------------------------------------------------------------- +// API v2 do executor Consistem: uma classe por requisicao, resultado paginado. +// +// Substitui LegacyUnitTestResponse. Todos os campos vem prontos do backend, que le direto o +// ^UnitTest.Result do IRIS -- nao ha mais nada a derivar no cliente. +// --------------------------------------------------------------------------------------- + +/** Situacao de um grupo de classes no plano de execucao */ +type SituacaoGrupo = "pronta" | "baseAusente" | "baseAmbigua" | "baseNaoGeravel" | "ambienteAusente"; + +interface BaseDados { + id: string; + tipo: number; + tipoDescricao: string; + /** Versão do ERP para a qual a base foi publicada */ + versao?: string; + /** A base é de uma versão maior que a desta instalação. Aviso, não impedimento. */ + versaoIncompativel?: boolean; +} + +interface GrupoExecucao { + /** `null` quando as classes do grupo nao usam base de dados */ + base: BaseDados | null; + namespace: string; + namespacesCandidatos: string[]; + situacao: SituacaoGrupo; + podeGerar: boolean; + classes: string[]; +} + +interface ClasseNaoExecutavel { + classe: string; + motivo: string; + mensagem: string; +} + +interface ResolucaoResponse { + versao: number; + namespaceSolicitado: string; + grupos: GrupoExecucao[]; + naoExecutaveis: ClasseNaoExecutavel[]; +} + +interface LocalAssert { + documento: string; + texto: string; + label?: string; + offset?: number; + namespace?: string; +} + +interface ResultadoAssert { + sequencia: number; + status: TestStatus; + /** `log` sao os LogMessage do %UnitTest; `assert` e o resto */ + nivel: "assert" | "log"; + tipo: string; + mensagem: string; + /** + * `null` nos asserts customizados da Consistem (AssertEqualsTable, AssertEqualsGlobal e + * afins), que chamam LogAssert sem location. A mensagem TEM de aparecer mesmo assim. + */ + local: LocalAssert | null; +} + +interface ResultadoMetodo { + /** Rotulo sem o prefixo `Test` -- casa com o label do TestItem */ + metodo: string; + metodoCompleto: string; + status: TestStatus; + duracaoMs: number; + erroAcao: string; + erro: string; + assertsPassaram: number; + /** `true` quando os asserts deste metodo continuam na proxima pagina */ + parcial: boolean; + asserts: ResultadoAssert[]; +} + +interface Diagnostico { + nivel: string; + codigo: string; + mensagem: string; +} + +interface Paginacao { + idExecucao: string; + incluirAsserts: string; + fim: boolean; + cursor: string | null; +} + +interface ResultadoClasse { + versao: number; + execucao: { + classe: string; + suite?: string; + nomeClasse?: string; + namespaceExec: string; + idLog?: number; + status: TestStatus; + duracaoMs: number; + erroAcao?: string; + erro?: string; + totais?: { + metodos: number; + passou: number; + falhou: number; + ignorado: number; + asserts: number; + assertsFalharam: number; + }; + }; + metodos: ResultadoMetodo[]; + paginacao?: Paginacao; + diagnosticos?: Diagnostico[]; +} + +interface SituacaoExecucao { + versao: number; + idExecucao: string; + situacao: "executando" | "concluida" | "erro"; + codigo: string; + mensagem: string; + duracaoMs: number; +} + +interface SituacaoGeracao { + versao: number; + idGeracao: string; + idBase: string; + situacao: "executando" | "concluida" | "erro"; + namespace: string; + mensagem: string; + duracaoMs: number; +} + interface DerivedMethodSummary { status?: TestStatus; failures: { message: string; location?: TestAssertLocation }[]; @@ -105,6 +243,17 @@ const GLOB_PATTERN = /[*?]/; const DEFAULT_LEGACY_REQUEST_TIMEOUT = 10 * 60 * 1000; // 10 minutes +/** Timeout da consulta de situacao. O default do cliente (5s) e curto demais com a maquina + * ocupada rodando o teste. */ +const POLL_TIMEOUT = 30 * 1000; + +/** Falhas de transporte seguidas toleradas antes de desistir de acompanhar uma execucao */ +const POLL_MAX_FALHAS = 10; + +const POLL_INTERVALO_INICIAL = 500; + +const POLL_INTERVALO_MAXIMO = 5000; + const testResultDecorationType = vscode.window.createTextEditorDecorationType({ after: { margin: "0 0 0 3ch", @@ -264,7 +413,7 @@ function extractMethodDurations(classResult: TestResult): Map { } function applyTestResultDecorations( - methodResults: { item: vscode.TestItem; result: TestResult; durationText?: string }[] + methodResults: { item: vscode.TestItem; status: TestStatus; durationText?: string }[] ): void { if (!methodResults.length) { return; @@ -276,7 +425,7 @@ function applyTestResultDecorations( decorationsByEditor.set(editor, []); } - for (const { item, result, durationText } of methodResults) { + for (const { item, status, durationText } of methodResults) { const editor = vscode.window.visibleTextEditors.find( (e) => item.uri && e.document.uri.toString() === item.uri.toString() ); @@ -287,8 +436,7 @@ function applyTestResultDecorations( const line = item.range.start.line; const lineRange = editor.document.lineAt(line).range; - const statusText = - result.status === TestStatus.Passed ? "passed" : result.status === TestStatus.Failed ? "failed" : "skipped"; + const statusText = status === TestStatus.Passed ? "passed" : status === TestStatus.Failed ? "failed" : "skipped"; const parts: string[] = [`${item.label} ${statusText}`]; if (durationText) { @@ -415,12 +563,41 @@ function labelFromAssertion(assertion: LegacyAssertion): string | undefined { return assertion.location?.label ?? parseLegacyLabelFromText(assertion.locationText); } +/** + * URI dos itens de DIRETORIO, que sao criados sem `uri`. Ver `itemUri()`. + * + * Guarda o objeto `vscode.Uri` original em vez de reconstrui-lo a partir do `id`: no Windows, + * `Uri.toString()` minusculiza a letra do drive (`/C:/...` -> `/c:/...`), entao um `Uri.parse(id)` + * devolveria um path diferente do de `folder.uri` e quebraria as comparacoes de `uriIsAncestorOf`, + * que sao case-sensitive. + */ +const uriForDirectoryItem: WeakMap = new WeakMap(); + +/** + * URI que o `TestItem` representa. + * + * Itens de DIRETORIO (raiz do workspace e subpastas/pacotes) sao criados SEM `uri` de proposito. + * O VS Code, ao clicar no rotulo de um item da view Testing, executa `vscode.revealTest` quando o + * item ainda nao tem filhos resolvidos e tem `uri` + * (`!e.element.children.size && e.element.test.item.uri` em `testingExplorerView.ts`). + * Com `uri` de pasta, esse reveal cai no Explorer -- e so na PRIMEIRA vez, porque depois de expandida + * a pasta ja tem filhos e a condicao deixa de valer. Sem `uri`, o clique so expande/colapsa na Testing. + * + * Itens de classe (.cls) e de metodo mantem `uri` (+ `range`), que e o que permite abrir o fonte. + */ +function itemUri(item: vscode.TestItem): vscode.Uri { + // O parse do `id` e so uma rede de seguranca: todo item de diretorio entra em `uriForDirectoryItem` + // quando e criado, e o `id` e o `uri.toString()` do proprio item. + return item.uri ?? uriForDirectoryItem.get(item) ?? vscode.Uri.parse(item.id); +} + /** Find the root `TestItem` for `uri` */ function rootItemForItem(testController: vscode.TestController, uri: vscode.Uri): vscode.TestItem | undefined { let rootItem: vscode.TestItem | undefined; const uriString = uri.toString(); for (const [, i] of testController.items) { - if (uriIsAncestorOf(i.uri!, uri) || uriString == i.uri!.toString()) { + const rootUri = itemUri(i); + if (uriIsAncestorOf(rootUri, uri) || uriString == rootUri.toString()) { rootItem = i; break; } @@ -653,7 +830,9 @@ function createRootItemsForWorkspaceFolder( : undefined; const rootUri = folder.uri; - const rootItem = testController.createTestItem(rootUri.toString(), folder.name, rootUri); + // Created without a `uri` on purpose: it's a directory, not a file (see `itemUri()`) + const rootItem = testController.createTestItem(rootUri.toString(), folder.name); + uriForDirectoryItem.set(rootItem, rootUri); if (notIsfs(folder.uri)) { const roots = relativeTestRootsForUri(folder.uri); @@ -718,12 +897,15 @@ async function getTestItemForClass( const rootItem = rootItemForItem(testController, uri); if (rootItem && !rootItem.error) { // Walk the directory path until we reach a dead end or the TestItem for this class - let docPath = uri.path.slice(rootItem.uri!.path.length); + let docPath = uri.path.slice(itemUri(rootItem).path.length); docPath = docPath.startsWith("/") ? docPath.slice(1) : docPath; const docPathParts = docPath.split("/"); item = rootItem; for (const part of docPathParts) { - const currUri = item.uri!.with({ path: `${item.uri!.path}${!item.uri!.path.endsWith("/") ? "/" : ""}${part}` }); + const parentUri = itemUri(item); + const currUri = parentUri.with({ + path: `${parentUri.path}${!parentUri.path.endsWith("/") ? "/" : ""}${part}`, + }); let currItem = item.children.get(currUri.toString()); if (!currItem && create) { // We're allowed to create non-existent directory TestItems as we walk the path @@ -755,10 +937,11 @@ function replaceRootTestItems(testController: vscode.TestController): void { async function childrenForServerSideFolderItem( item: vscode.TestItem ): Promise>> { - const { project, system, generated, mapped } = isfsConfig(item.uri!); + const uri = itemUri(item); + const { project, system, generated, mapped } = isfsConfig(uri); let query: string; let parameters: string[]; - let folder = !item.uri!.path.endsWith("/") ? item.uri!.path + "/" : item.uri!.path; + let folder = !uri.path.endsWith("/") ? uri.path + "/" : uri.path; folder = folder.startsWith("/") ? folder.slice(1) : folder; if (folder == "/") { // Treat this the same as an empty folder @@ -766,7 +949,7 @@ async function childrenForServerSideFolderItem( } folder = folder.replace(/\//g, "."); const folderLen = String(folder.length + 1); // Need the + 1 because SUBSTR is 1 indexed - const api = new AtelierAPI(item.uri!); + const api = new AtelierAPI(uri); if (project) { query = "SELECT DISTINCT CASE " + @@ -790,7 +973,7 @@ async function childrenForServerSideFolderItem( folderLen, folderLen, folderLen, - fileSpecFromURI(item.uri!), + fileSpecFromURI(uri), "1", "1", system ? "1" : "0", @@ -808,12 +991,16 @@ async function childrenForServerSideFolderItem( /** Create a child `TestItem` of `item` with label `child`. */ function addChildItem(testController: vscode.TestController, item: vscode.TestItem, child: string): void { - const newUri = item.uri!.with({ - path: `${item.uri!.path}${!item.uri!.path.endsWith("/") ? "/" : ""}${child}`, + const parentUri = itemUri(item); + const newUri = parentUri.with({ + path: `${parentUri.path}${!parentUri.path.endsWith("/") ? "/" : ""}${child}`, }); if (!item.children.get(newUri.toString())) { // Only add the item if it doesn't already exist - const newItem = testController.createTestItem(newUri.toString(), child, newUri); + // Directory items are created without a `uri` on purpose (see `itemUri()`) + const isClass = child.toLowerCase().endsWith(".cls"); + const newItem = testController.createTestItem(newUri.toString(), child, isClass ? newUri : undefined); + if (!isClass) uriForDirectoryItem.set(newItem, newUri); newItem.canResolveChildren = true; item.children.add(newItem); } @@ -967,12 +1154,12 @@ async function executeLegacyRunner( action: string, showOutput: boolean ): Promise { - const generateBase = await promptGenerateLegacyBase(root.uri!); + const generateBase = await promptGenerateLegacyBase(itemUri(root)); if (generateBase === undefined) { return true; } - const unitTestConfiguration = vscode.workspace.getConfiguration("objectscript.unitTest", root.uri!); + const unitTestConfiguration = vscode.workspace.getConfiguration("objectscript.unitTest", itemUri(root)); const configuredLegacyTimeout = unitTestConfiguration.get("legacyRequestTimeout"); const legacyRequestTimeout = Number.isFinite(configuredLegacyTimeout) ? Math.max(0, Math.floor(configuredLegacyTimeout!)) @@ -1058,7 +1245,7 @@ async function executeLegacyRunner( const knownStatuses: WeakMap = new WeakMap(); const classes = classesForRoot.get(root) ?? new Map(); - const workspaceFolder = vscode.workspace.getWorkspaceFolder(root.uri!); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(itemUri(root)); const documentSymbols: Map = new Map(); const filesText: Map = new Map(); const requestedMethodsByClass: Map | undefined> = new Map(); @@ -1301,7 +1488,13 @@ async function executeLegacyRunner( } } } - applyTestResultDecorations(allMethodResultsForDecorations); + applyTestResultDecorations( + allMethodResultsForDecorations.map((d) => ({ + item: d.item, + status: d.result.status, + durationText: d.durationText, + })) + ); uniqueClassItems.forEach((classItem) => { if (!knownStatuses.has(classItem)) { knownStatuses.set(classItem, TestStatus.Skipped); @@ -1322,6 +1515,670 @@ async function executeLegacyRunner( } /** The `runHandler` function for the `TestRunProfile`s. */ +/** + * Resolve a posicao no fonte de um assert que trouxe `local`. + * + * Os asserts customizados da Consistem vem com `local: null` -- nesses casos nao ha posicao, + * mas a mensagem continua tendo de aparecer. + */ +async function resolverLocalAssert( + local: LocalAssert, + classes: Map, + workspaceFolder: vscode.WorkspaceFolder | undefined, + documentSymbols: Map, + filesText: Map +): Promise { + if (!workspaceFolder) return undefined; + + const documento = local.documento; + if (!documento) return undefined; + + let locationUri: vscode.Uri | null | undefined; + const semExtensao = documento.toLowerCase().endsWith(".cls") ? documento.slice(0, -4) : undefined; + + if (semExtensao && classes.has(semExtensao)) { + locationUri = classes.get(semExtensao)!.uri; + } else { + locationUri = DocumentContentProvider.getUri(documento, workspaceFolder.name, local.namespace); + } + if (!locationUri) return undefined; + + const chave = locationUri.toString(); + + if (!documentSymbols.has(chave)) { + const novos = await vscode.commands + .executeCommand("vscode.executeDocumentSymbolProvider", locationUri) + .then( + (r) => r[0]?.children, + () => undefined + ); + if (novos != undefined) documentSymbols.set(chave, novos); + } + const simbolos = documentSymbols.get(chave); + if (simbolos == undefined) return undefined; + + if (!filesText.has(chave)) { + const texto = await getFileText(locationUri).catch(() => undefined); + if (texto != undefined) filesText.set(chave, texto); + } + const texto = filesText.get(chave); + if (texto == undefined) return undefined; + + const linha = methodOffsetToLine(simbolos, texto, local.label ?? "", local.offset ?? 0); + if (linha == undefined) return undefined; + + return new vscode.Location(locationUri, new vscode.Range(linha - 1, 0, linha, 0)); +} + +/** Uma linha de output por assert, no mesmo espirito do que o terminal imprime */ +function formatarAssert(assert: ResultadoAssert): string { + if (assert.nivel === "log") { + return ` ${assert.mensagem}\r\n`; + } + const cor = assert.status === TestStatus.Passed ? ANSI_GREEN : ANSI_RED; + const situacao = assert.status === TestStatus.Passed ? "passed" : "failed"; + return ` ${assert.tipo} - ${assert.mensagem} >> ${cor}${situacao}${ANSI_RESET}\r\n`; +} + +/** + * Aguarda o fim de uma execucao iniciada no servidor. + * + * A execucao roda em JOB porque uma classe lenta estoura o timeout do gateway CSP: medimos + * 70s numa classe de producao contra o limite padrao de 60s. Aqui so se acompanha. + */ +async function aguardarExecucao( + sourceControlApi: SourceControlApi, + namespaceReq: string, + idExecucao: string, + token: vscode.CancellationToken, + signal: AbortSignal, + timeout: number +): Promise { + const inicio = Date.now(); + + const estado = { falhas: 0 }; + let intervalo = POLL_INTERVALO_INICIAL; + + for (;;) { + if (token.isCancellationRequested) return undefined; + + // Falha de transporte é transitória: desistir aqui abandonaria um JOB que continua + // rodando e segurando o lock da base, derrubando as classes seguintes em cascata. + const situacao = await consultarSituacao( + sourceControlApi, + ROUTES.situacaoExecucaoTeste(namespaceReq, idExecucao), + signal, + estado + ); + + if (token.isCancellationRequested) return undefined; + + if (situacao?.situacao === "concluida") return situacao; + if (situacao?.situacao === "erro") { + throw new Error(situacao.mensagem || "Falha na execução do teste."); + } + + if (timeout > 0 && Date.now() - inicio > timeout) { + throw new Error(`A execução excedeu ${Math.round(timeout / 1000)}s e foi abandonada pelo cliente.`); + } + + await new Promise((resolve) => setTimeout(resolve, intervalo)); + intervalo = Math.min(Math.round(intervalo * 1.5), POLL_INTERVALO_MAXIMO); + } +} + +/** Percorre todas as paginas de um resultado e devolve os metodos acumulados */ +async function coletarPaginas( + sourceControlApi: SourceControlApi, + namespace: string, + primeira: ResultadoClasse, + incluirAsserts: string, + signal: AbortSignal, + timeout: number +): Promise { + const acumulado: ResultadoClasse = { ...primeira, metodos: [...primeira.metodos] }; + + let paginacao = primeira.paginacao; + + while (paginacao && !paginacao.fim && paginacao.cursor) { + const resposta = await sourceControlApi.get( + ROUTES.paginaResultadoTeste(namespace, paginacao.idExecucao), + { params: { cursor: paginacao.cursor, incluirAsserts }, signal, timeout } + ); + + const pagina = resposta.data; + if (!pagina || !Array.isArray(pagina.metodos)) break; + + for (const metodo of pagina.metodos) { + // Um metodo partido entre paginas volta com o mesmo nome: junta os asserts + const anterior = acumulado.metodos.find((m) => m.metodoCompleto === metodo.metodoCompleto); + if (anterior) { + anterior.asserts.push(...metodo.asserts); + anterior.parcial = metodo.parcial; + } else { + acumulado.metodos.push(metodo); + } + } + + paginacao = pagina.paginacao; + } + + acumulado.paginacao = paginacao; + return acumulado; +} + +/** + * O backend responde erro como `{ error: "..." }` com HTTP 200. + * Sem isto, qualquer falha do servidor virava "payload inválido" e a causa real sumia. + */ +function erroDoBackend(data: unknown): string | undefined { + if (typeof data === "string") { + return data.trim() ? `Resposta inesperada do servidor: ${data.slice(0, 400)}` : undefined; + } + if (data && typeof data === "object") { + const erro = (data as { error?: unknown }).error; + if (typeof erro === "string" && erro.trim()) return erro; + } + return undefined; +} + +/** + * GET de consulta de situação, com timeout explícito e tolerância a falha transitória. + * + * O default do cliente é 5s, e a máquina fica saturada rodando o próprio teste (ou criando a + * database do TESTEUNITARIO). Desistir na primeira falha abandonaria um JOB que continua + * rodando no servidor -- foi assim que uma consulta lenta derrubou a operação inteira. + * + * Devolve `undefined` numa falha tolerada; só relança depois de POLL_MAX_FALHAS seguidas. + */ +async function consultarSituacao( + sourceControlApi: SourceControlApi, + rota: string, + signal: AbortSignal | undefined, + estado: { falhas: number } +): Promise { + try { + const resposta = await sourceControlApi.get(rota, { signal, timeout: POLL_TIMEOUT }); + estado.falhas = 0; + return resposta.data; + } catch (error) { + estado.falhas += 1; + if (estado.falhas > POLL_MAX_FALHAS) throw error; + return undefined; + } +} + +/** Pergunta ao usuario se quer gerar a base que falta para um grupo de classes */ +async function perguntarGerarBase(grupo: GrupoExecucao): Promise { + const idBase = grupo.base?.id ?? ""; + const incompativel = grupo.base?.versaoIncompativel === true; + const versao = grupo.base?.versao; + + // Versão incompatível não impede gerar -- o Gerenciador de Bases do terminal apenas pede + // confirmação, com "N" como default. Aqui o aviso vai no detalhe e "Pular" vem primeiro. + const opcaoGerar = { + label: `Gerar a base ${idBase}`, + gerar: true, + detail: incompativel + ? `⚠ Base publicada para a versão ${versao}, maior que a desta instalação. Gerar mesmo assim?` + : `Necessária por ${grupo.classes.length} classe(s) de teste`, + }; + const opcaoPular = { + label: "Pular estas classes", + gerar: false, + detail: "Os testes que dependem desta base ficarão ignorados", + }; + + const escolha = await vscode.window.showQuickPick( + incompativel ? [opcaoPular, opcaoGerar] : [opcaoGerar, opcaoPular], + { + title: "Execução de testes unitários", + placeHolder: incompativel + ? `A base ${idBase} (versão ${versao}) não está montada e é de versão incompatível.` + : `A base ${idBase} não está montada nesta instalação. Gerar agora?`, + ignoreFocusOut: true, + } + ); + return escolha?.gerar === true; +} + +/** Deixa o usuario escolher o namespace quando mais de um atende a mesma base */ +async function perguntarNamespace(grupo: GrupoExecucao): Promise { + return vscode.window.showQuickPick(grupo.namespacesCandidatos, { + title: "Execução de testes unitários", + placeHolder: `Mais de um namespace atende a base ${grupo.base?.id ?? ""}. Escolha onde executar.`, + ignoreFocusOut: true, + }); +} + +/** + * Dispara uma operacao longa no servidor e acompanha ate concluir. + * + * Serve tanto para gerar uma base versionada quanto para montar o namespace TESTEUNITARIO. + * As duas dropam/criam database e namespace: dentro da requisicao HTTP estouram o gateway. + */ +async function aguardarOperacaoServidor( + sourceControlApi: SourceControlApi, + namespaceReq: string, + titulo: string, + rota: string, + corpo: unknown, + token: vscode.CancellationToken, + signal?: AbortSignal +): Promise { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: titulo, + cancellable: false, + }, + async (progress) => { + progress.report({ message: "iniciando…" }); + + const inicio = await sourceControlApi.post(rota, corpo, { + signal, + timeout: POLL_TIMEOUT, + }); + + const erroInicio = erroDoBackend(inicio.data); + const idGeracao = inicio.data?.idGeracao; + if (erroInicio || !idGeracao) { + vscode.window.showErrorMessage(erroInicio ?? `Não foi possível iniciar: ${titulo}.`); + return undefined; + } + + // Minutos de trabalho. Por isso o backend roda em JOB e aqui so se acompanha. + const estado = { falhas: 0 }; + + for (;;) { + if (token.isCancellationRequested) return undefined; + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const situacao = await consultarSituacao( + sourceControlApi, + ROUTES.situacaoGeracaoBase(namespaceReq, idGeracao), + signal, + estado + ); + + if (situacao?.situacao === "concluida") { + progress.report({ message: "concluída" }); + return situacao.namespace; + } + if (situacao?.situacao === "erro") { + vscode.window.showErrorMessage(`${titulo}: ${situacao.mensagem}`); + return undefined; + } + + const segundos = Math.round((situacao?.duracaoMs ?? 0) / 1000); + progress.report({ message: `${segundos}s decorridos…` }); + } + } + ); +} + +/** Marca todas as classes de um grupo como ignoradas, com o motivo */ +function marcarGrupoIgnorado( + testRun: vscode.TestRun, + classes: Map, + grupo: GrupoExecucao, + motivo: string +): void { + for (const classe of grupo.classes) { + const clsItem = classes.get(classe); + if (!clsItem) continue; + testRun.appendOutput(`${ANSI_YELLOW}${classe}: ${motivo}${ANSI_RESET}\r\n`); + testRun.skipped(clsItem); + clsItem.children.forEach((m) => testRun.skipped(m)); + } +} + +/** Reporta o resultado de uma classe inteira na Test Explorer */ +async function reportarClasse( + testRun: vscode.TestRun, + clsItem: vscode.TestItem, + resultado: ResultadoClasse, + classes: Map, + workspaceFolder: vscode.WorkspaceFolder | undefined, + documentSymbols: Map, + filesText: Map, + metodosParaDecorar: { item: vscode.TestItem; status: TestStatus; durationText?: string }[] +): Promise { + for (const diagnostico of resultado.diagnosticos ?? []) { + testRun.appendOutput(`${ANSI_RED}${diagnostico.codigo}: ${diagnostico.mensagem}${ANSI_RESET}\r\n`); + } + + 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) { + const methodItem = findMethodItemByLegacyName(clsItem, metodo.metodo); + if (!methodItem) continue; + + const cor = + metodo.status === TestStatus.Passed ? ANSI_GREEN : metodo.status === TestStatus.Failed ? ANSI_RED : ANSI_YELLOW; + const situacao = + metodo.status === TestStatus.Passed ? "passed" : metodo.status === TestStatus.Failed ? "failed" : "skipped"; + + testRun.appendOutput( + ` ${ANSI_BOLD}${metodo.metodo}${ANSI_RESET} | ${cor}${situacao}${ANSI_RESET} | ${metodo.duracaoMs}ms\r\n` + ); + for (const assert of metodo.asserts) { + testRun.appendOutput(formatarAssert(assert)); + } + if (metodo.assertsPassaram > 0 && !metodo.asserts.some((a) => a.status === TestStatus.Passed)) { + testRun.appendOutput(` ${ANSI_GREEN}${metodo.assertsPassaram} assert(s) passaram${ANSI_RESET}\r\n`); + } + + if (metodo.status === TestStatus.Failed) { + const mensagens: vscode.TestMessage[] = []; + + // O erro que abortou o metodo vem antes dos asserts -- o runner antigo o descartava + if (metodo.erro) { + const rotulo = metodo.erroAcao ? `${metodo.erroAcao}: ${metodo.erro}` : metodo.erro; + mensagens.push(new vscode.TestMessage(new vscode.MarkdownString(markdownifyLine(rotulo)))); + } + + for (const assert of metodo.asserts) { + if (assert.status !== TestStatus.Failed) continue; + + const texto = assert.tipo ? `${assert.tipo} - ${assert.mensagem}` : assert.mensagem; + const mensagem = new vscode.TestMessage(new vscode.MarkdownString(markdownifyLine(texto))); + + // `local` e null nos asserts customizados da Consistem: a mensagem entra do mesmo + // jeito, so sem posicao no fonte. O runner antigo descartava essas falhas. + if (assert.local) { + mensagem.location = await resolverLocalAssert( + assert.local, + classes, + workspaceFolder, + documentSymbols, + filesText + ); + } + + mensagens.push(mensagem); + } + + if (!mensagens.length) { + mensagens.push(new vscode.TestMessage("Teste falhou.")); + } + + testRun.failed(methodItem, mensagens, metodo.duracaoMs); + } else if (metodo.status === TestStatus.Passed) { + testRun.passed(methodItem, metodo.duracaoMs); + } else { + testRun.skipped(methodItem); + } + + metodosParaDecorar.push({ item: methodItem, status: metodo.status, durationText: `${metodo.duracaoMs}ms` }); + } + + const execucao = resultado.execucao; + + if (execucao.status === TestStatus.Failed) { + const falhos = resultado.metodos.filter((m) => m.status === TestStatus.Failed).map((m) => m.metodo); + 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."; + testRun.failed(clsItem, new vscode.TestMessage(new vscode.MarkdownString(texto)), execucao.duracaoMs); + } else if (execucao.status === TestStatus.Passed) { + testRun.passed(clsItem, execucao.duracaoMs); + } else { + testRun.skipped(clsItem); + } +} + +/** + * Executor de testes unitarios da Consistem. + * + * Uma classe por requisicao, agrupadas por base de dados. O resultado de cada classe e + * reportado assim que chega, entao a Test Explorer vai preenchendo em vez de esperar o lote + * inteiro -- e o cancelamento interrompe entre classes. + */ +async function executeConsistemRunner( + api: AtelierAPI, + request: vscode.TestRunRequest, + testController: vscode.TestController, + root: vscode.TestItem, + clsItemsRun: vscode.TestItem[], + testesSolicitados: { class: string; methods?: string[] }[], + token: vscode.CancellationToken, + action: string +): Promise { + const unitTestConfiguration = vscode.workspace.getConfiguration("objectscript.unitTest", itemUri(root)); + const configuredTimeout = unitTestConfiguration.get("legacyRequestTimeout"); + const requestTimeout = Number.isFinite(configuredTimeout) + ? Math.max(0, Math.floor(configuredTimeout!)) + : DEFAULT_LEGACY_REQUEST_TIMEOUT; + const incluirAsserts = unitTestConfiguration.get("incluirAsserts") ?? "somenteFalhas"; + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = SourceControlApi.fromAtelierApi(api); + } catch (error) { + handleError(error, `Error preparing to ${action} tests.`); + return true; + } + + const testRun = testController.createTestRun(request, undefined, true); + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(testResultDecorationType, []); + } + + const { signal, dispose: descartarSignal } = createAbortSignal(token); + const classes = classesForRoot.get(root) ?? new Map(); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(itemUri(root)); + const documentSymbols = new Map(); + const filesText = new Map(); + const metodosParaDecorar: { item: vscode.TestItem; status: TestStatus; durationText?: string }[] = []; + + const metodosSolicitados = new Map(); + for (const teste of testesSolicitados) { + metodosSolicitados.set(teste.class, teste.methods); + } + + try { + for (const classItem of new Set(clsItemsRun)) { + testRun.started(classItem); + classItem.children.forEach((methodItem) => testRun.started(methodItem)); + } + + // 1. Plano: qual base cada classe exige e em qual namespace ela roda + const resolucao = await sourceControlApi.post( + ROUTES.resolveUnitTests(api.ns), + { classes: [...metodosSolicitados.keys()] }, + { signal, timeout: requestTimeout } + ); + + const plano = resolucao.data; + const erroPlano = erroDoBackend(plano); + if (erroPlano) { + handleError(new Error(erroPlano), `Error resolving tests to ${action}.`); + return true; + } + if (!plano || !Array.isArray(plano.grupos)) { + handleError(new Error("O executor de testes não devolveu um plano de execução válido.")); + return true; + } + + for (const naoExec of plano.naoExecutaveis ?? []) { + const clsItem = classes.get(naoExec.classe); + if (!clsItem) continue; + testRun.appendOutput(`${ANSI_YELLOW}${naoExec.classe}: ${naoExec.mensagem}${ANSI_RESET}\r\n`); + testRun.skipped(clsItem); + clsItem.children.forEach((m) => testRun.skipped(m)); + } + + // 2. Resolve o que esta ambiguo ou ausente, antes de executar qualquer coisa + for (const grupo of plano.grupos) { + if (token.isCancellationRequested) break; + + if (grupo.situacao === "baseAmbigua") { + const escolhido = await perguntarNamespace(grupo); + if (!escolhido) { + marcarGrupoIgnorado(testRun, classes, grupo, "Namespace não escolhido."); + 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."); + continue; + } + const idBase = grupo.base?.id ?? ""; + const namespaceGerado = await aguardarOperacaoServidor( + sourceControlApi, + api.ns, + `Gerando a base ${idBase}`, + ROUTES.gerarBaseTeste(api.ns), + { idBase }, + token, + signal + ); + if (!namespaceGerado) { + marcarGrupoIgnorado(testRun, classes, grupo, "A base não foi gerada."); + continue; + } + grupo.namespace = namespaceGerado; + grupo.situacao = "pronta"; + } else if (grupo.situacao === "ambienteAusente") { + // O TESTEUNITARIO e criado na primeira execucao sem base, dropando e recriando uma + // database e um namespace IRIS. Feito dentro da requisicao de execucao, isso devolve + // 504 no gateway -- por isso vira uma operacao acompanhada, igual a geração de base. + const namespacePreparado = await aguardarOperacaoServidor( + sourceControlApi, + api.ns, + "Montando o ambiente de testes sem base de dados", + ROUTES.prepararAmbienteTeste(api.ns), + {}, + token, + signal + ); + if (!namespacePreparado) { + marcarGrupoIgnorado(testRun, classes, grupo, "O ambiente de testes não foi montado."); + continue; + } + grupo.namespace = namespacePreparado; + grupo.situacao = "pronta"; + } else if (grupo.situacao !== "pronta") { + marcarGrupoIgnorado( + testRun, + classes, + grupo, + `A base ${grupo.base?.id ?? ""} não está montada e não pode ser gerada nesta instalação.` + ); + continue; + } + + // 3. Executa CLASSE A CLASSE, reportando conforme cada uma termina + for (const classe of grupo.classes) { + if (token.isCancellationRequested) break; + + const clsItem = classes.get(classe); + if (!clsItem) continue; + + let idExecucaoAtual: string | undefined; + + try { + // O POST apenas INICIA a execucao e volta na hora; o teste roda em JOB no servidor + const inicioExec = await sourceControlApi.post( + ROUTES.executarClasseTeste(api.ns), + { + namespace: grupo.namespace, + classe, + metodos: metodosSolicitados.get(classe) ?? [], + incluirAsserts, + }, + { signal, timeout: requestTimeout } + ); + + const erroInicio = erroDoBackend(inicioExec.data); + if (erroInicio) { + throw new Error(erroInicio); + } + + const inicioDados = inicioExec.data as Partial & Partial; + + // Impedimento detectado na validacao sincrona (base, catalogo, metodo): ja vem pronto + if (!inicioDados?.idExecucao) { + const recusa = inicioExec.data as ResultadoClasse; + if (!recusa || !Array.isArray(recusa.metodos)) { + throw new Error(`O executor não devolveu resultado para ${classe}.`); + } + await reportarClasse( + testRun, + clsItem, + recusa, + classes, + workspaceFolder, + documentSymbols, + filesText, + metodosParaDecorar + ); + continue; + } + + idExecucaoAtual = inicioDados.idExecucao; + + await aguardarExecucao(sourceControlApi, api.ns, inicioDados.idExecucao, token, signal, requestTimeout); + + if (token.isCancellationRequested) break; + + const primeira = await sourceControlApi.get( + ROUTES.paginaResultadoTeste(api.ns, inicioDados.idExecucao), + { params: { incluirAsserts }, signal, timeout: requestTimeout } + ); + + let resultado = primeira.data; + const erroClasse = erroDoBackend(resultado); + if (erroClasse) { + throw new Error(erroClasse); + } + if (!resultado || !Array.isArray(resultado.metodos)) { + throw new Error(`O executor não devolveu resultado para ${classe}.`); + } + + resultado = await coletarPaginas(sourceControlApi, api.ns, resultado, incluirAsserts, signal, requestTimeout); + + await reportarClasse( + testRun, + clsItem, + resultado, + classes, + workspaceFolder, + documentSymbols, + filesText, + metodosParaDecorar + ); + } catch (error) { + if (token.isCancellationRequested) break; + handleError(error, `Error running tests for ${classe}.`); + testRun.errored(clsItem, new vscode.TestMessage(String(error))); + + // Desistir de acompanhar nao para o JOB no servidor: sem descartar, o resultado + // fica orfao em ^mtempUTResultado ate o expurgo. + if (idExecucaoAtual) { + await sourceControlApi.delete(ROUTES.paginaResultadoTeste(api.ns, idExecucaoAtual)).then( + () => undefined, + () => undefined + ); + } + } + } + } + + applyTestResultDecorations(metodosParaDecorar); + } finally { + descartarSignal(); + testRun.end(); + } + + return true; +} + async function runHandler( request: vscode.TestRunRequest, token: vscode.CancellationToken, @@ -1341,7 +2198,7 @@ async function runHandler( // Determine the test root for this run let roots: (vscode.TestItem | undefined)[]; if (request.include?.length) { - roots = [...new Set(request.include.map((i) => rootItemForItem(testController, i.uri!)))]; + roots = [...new Set(request.include.map((i) => rootItemForItem(testController, itemUri(i))))]; } else { // Run was launched from controller's root level // Ignore any roots that have errors @@ -1354,7 +2211,7 @@ async function runHandler( roots.map((i) => { return { label: i!.label, - detail: displayableUri(i!.uri!), + detail: displayableUri(itemUri(i!)), item: i, }; }), @@ -1374,14 +2231,16 @@ async function runHandler( // Need a root to continue return; } - sendUnitTestTelemetryEvent(root.uri!, debug); + sendUnitTestTelemetryEvent(itemUri(root), debug); // Add the initial items to the queue to process const queue: vscode.TestItem[] = []; - const rootUriString = root.uri!.toString(); + const rootUri = itemUri(root); + const rootUriString = rootUri.toString(); if (request.include?.length) { request.include.forEach((i) => { - if (uriIsAncestorOf(root!.uri!, i.uri!) || i.uri!.toString() == rootUriString) { + const iUri = itemUri(i); + if (uriIsAncestorOf(rootUri, iUri) || iUri.toString() == rootUriString) { queue.push(i); } }); @@ -1390,11 +2249,11 @@ async function runHandler( } // Get the autoload configuration for the root - const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", root.uri); + const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", rootUri); const autoloadFolder: string = autoload.get("folder")!; const autoloadXml: boolean = autoload.get("xml")!; const autoloadUdl: boolean = autoload.get("udl")!; - const autoloadEnabled: boolean = autoloadFolder != "" && (autoloadXml || autoloadUdl) && notIsfs(root.uri!); + const autoloadEnabled: boolean = autoloadFolder != "" && (autoloadXml || autoloadUdl) && notIsfs(rootUri); const autoloadProcessed: string[] = []; // Process every test that was queued @@ -1409,8 +2268,13 @@ async function runHandler( if (autoloadEnabled) { // Process any autoload folders needed by this item - const basePath = root.uri!.path.endsWith("/") ? root.uri!.path.slice(0, -1) : root.uri!.path; - const directories = ["", ...test.uri!.path.slice(basePath.length + 1).split("/")]; + const basePath = rootUri.path.endsWith("/") ? rootUri.path.slice(0, -1) : rootUri.path; + const directories = [ + "", + ...itemUri(test) + .path.slice(basePath.length + 1) + .split("/"), + ]; if (directories[directories.length - 1].toLowerCase().endsWith(".cls")) { // Remove the class name directories.pop(); @@ -1423,7 +2287,7 @@ async function runHandler( // Look for XML or UDL files in the autoload folder const files = await vscode.workspace.findFiles( new vscode.RelativePattern( - test.uri!.with({ path: `${basePath}${testPath}/${autoloadFolder}` }), + itemUri(test).with({ path: `${basePath}${testPath}/${autoloadFolder}` }), `**/*.{${autoloadXml ? "xml,XML" : ""}${autoloadXml && autoloadUdl ? "," : ""}${ autoloadUdl ? "cls,CLS,mac,MAC,int,INT,inc,INC" : "" }}` @@ -1449,7 +2313,7 @@ async function runHandler( await testController.resolveHandler!(test); } - if (test.uri!.path.toLowerCase().endsWith(".cls")) { + if (itemUri(test).path.toLowerCase().endsWith(".cls")) { if (test.id.includes(methodIdSeparator)) { // This is a method item // Will only reach this code if this item is in request.include @@ -1540,25 +2404,32 @@ async function runHandler( return; } - const unitTestConfig = vscode.workspace.getConfiguration("objectscript.unitTest", root.uri); + const unitTestConfig = vscode.workspace.getConfiguration("objectscript.unitTest", itemUri(root)); const showOutputSetting = unitTestConfig.get("showOutput"); // Ignore console output at the user's request asyncRequest.console = showOutputSetting; - const api = new AtelierAPI(root.uri); + const api = new AtelierAPI(itemUri(root)); if (!debug) { - await executeLegacyRunner( - api, - request, - testController, - root, - clsItemsRun, - asyncRequest, - token, - action, - showOutputSetting !== false - ); + // Escape hatch enquanto o executor novo esta em validacao. Sera removido junto com + // executeLegacyRunner assim que o fluxo v2 estiver homologado. + if (unitTestConfig.get("usarExecutorAntigo") === true) { + await executeLegacyRunner( + api, + request, + testController, + root, + clsItemsRun, + asyncRequest, + token, + action, + showOutputSetting !== false + ); + return; + } + + await executeConsistemRunner(api, request, testController, root, clsItemsRun, asyncRequest.tests, token, action); return; } @@ -1604,7 +2475,7 @@ async function runHandler( let currentOutputItem: vscode.TestItem | undefined; // The workspace folder that we're running tests in - const workspaceFolder = vscode.workspace.getWorkspaceFolder(root.uri!); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(itemUri(root)); // A map of all documents that we've computed symbols for const documentSymbols: Map = new Map(); @@ -1839,7 +2710,7 @@ async function runHandler( } else if (debug && queueResp.result.content?.debugId && pollResp.result?.content?.debugReady) { // Make sure the activeTextEditor's document is in the same workspace folder as the test // root so the debugger connects to the correct server and runs in the correct namespace - const rootWsFolderIdx = vscode.workspace.getWorkspaceFolder(root.uri!)?.index; + const rootWsFolderIdx = vscode.workspace.getWorkspaceFolder(itemUri(root))?.index; if ( !vscode.window.activeTextEditor?.document.uri || vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)?.index != rootWsFolderIdx @@ -1931,32 +2802,33 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di const testController = vscode.tests.createTestController(extensionId, "ObjectScript"); testController.resolveHandler = async (item?: vscode.TestItem) => { if (!item) return; // Can't resolve "undefined" + const uri = itemUri(item); item.busy = true; try { - if (item.uri!.path.toLowerCase().endsWith(".cls")) { + if (uri.path.toLowerCase().endsWith(".cls")) { // Compute items for the Test* methods in this class await addTestItemsForClass(testController, item); } else { - if (notIsfs(item.uri!)) { + if (notIsfs(uri)) { // Read the local directory for non-autoload subdirectories and classes - const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", item.uri); + const autoload = vscode.workspace.getConfiguration("objectscript.unitTest.autoload", uri); const autoloadFolder: string = autoload.get("folder")!; const autoloadEnabled: boolean = autoloadFolder != "" && (autoload.get("xml") || autoload.get("udl"))!; - const workspaceFolder = vscode.workspace.getWorkspaceFolder(item.uri!); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); let workspaceRootItem: vscode.TestItem | undefined; if (workspaceFolder) { for (const [, root] of testController.items) { - if (root.uri!.toString() === workspaceFolder.uri.toString()) { + if (itemUri(root).toString() === workspaceFolder.uri.toString()) { workspaceRootItem = root; break; } } } - const entries = await vscode.workspace.fs.readDirectory(item.uri!); + const entries = await vscode.workspace.fs.readDirectory(uri); for (const element of entries) { - const childUri = item.uri!.with({ - path: `${item.uri!.path}${!item.uri!.path.endsWith("/") ? "/" : ""}${element[0]}`, + const childUri = uri.with({ + path: `${uri.path}${!uri.path.endsWith("/") ? "/" : ""}${element[0]}`, }); // Aplica o filtro dos roots configurados @@ -2083,7 +2955,7 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di // Update root items if needed e.removed.forEach((wf) => { testController.items.forEach((i) => { - if (uriIsAncestorOf(wf.uri, i.uri!)) { + if (uriIsAncestorOf(wf.uri, itemUri(i))) { // Remove this TestItem classesForRoot.delete(i); testController.items.delete(i.id); @@ -2102,10 +2974,11 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di // Determine the root items that need to be replaced, if any const replace: vscode.TestItem[] = []; testController.items.forEach((item) => { + const uri = itemUri(item); if ( - (notIsfs(item.uri!) && e.affectsConfiguration("objectscript.unitTest", item.uri)) || - e.affectsConfiguration("objectscript.conn", item.uri) || - e.affectsConfiguration("intersystems.servers", item.uri) + (notIsfs(uri) && e.affectsConfiguration("objectscript.unitTest", uri)) || + e.affectsConfiguration("objectscript.conn", uri) || + e.affectsConfiguration("intersystems.servers", uri) ) { replace.push(item); } @@ -2114,7 +2987,7 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di replace.forEach((item) => { classesForRoot.delete(item); testController.items.delete(item.id); - const folder = vscode.workspace.getWorkspaceFolder(item.uri!); + const folder = vscode.workspace.getWorkspaceFolder(itemUri(item)); if (folder) { const newItems = createRootItemsForWorkspaceFolder(testController, folder); newItems.forEach((i) => { @@ -2175,3 +3048,131 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di }), ]; } + +/** Uma base montada que atende alguma classe de teste unitário */ +interface BaseMontada { + namespace: string; + idBase: string; + tipo: number; + tipoDescricao: string; + revisao: string; + qtdClassesUT: number; + usuarioGerou: string; + /** `null` quando a base não registrou data de geração */ + geradaEm: string | null; +} + +interface ListaBasesResponse { + versao: number; + namespaceSolicitado: string; + bases: BaseMontada[]; +} + +/** Monta o rótulo de uma base no QuickPick */ +function descreverBase(base: BaseMontada): vscode.QuickPickItem & { base: BaseMontada } { + const quando = base.geradaEm ? `gerada em ${base.geradaEm}` : "data de geração desconhecida"; + const revisao = base.revisao ? ` · rev. ${base.revisao}` : ""; + const quem = base.usuarioGerou ? ` · por ${base.usuarioGerou}` : ""; + + return { + label: `${base.namespace} — ${base.idBase}`, + description: base.tipoDescricao, + detail: `${base.qtdClassesUT} classe(s) de teste · ${quando}${revisao}${quem}`, + base, + }; +} + +/** + * Lista as bases de teste montadas e permite regerar a escolhida. + * + * Regerar EXCLUI e recria o namespace a partir do checkout, exatamente como a opção + * "Regerar (B)ase" do Gerenciador de Bases. Por isso a confirmação é modal e o default + * é cancelar, espelhando o "(S/N): N" do terminal. + */ +export async function gerenciarBasesTeste(): Promise { + const folder = currentWorkspaceFolder(); + const uri = folder ? vscode.workspace.workspaceFolders?.find((f) => f.name === folder)?.uri : undefined; + + const api = new AtelierAPI(uri); + if (!api.active || !api.ns) { + vscode.window.showErrorMessage("Nenhuma conexão ativa com o servidor.", "Dismiss"); + return; + } + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = SourceControlApi.fromAtelierApi(api); + } catch (error) { + handleError(error, "Erro ao preparar a conexão com o servidor."); + return; + } + + let bases: BaseMontada[]; + try { + const resposta = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: "Consultando bases de teste…" }, + () => sourceControlApi.get(ROUTES.listarBasesTeste(api.ns), { timeout: POLL_TIMEOUT }) + ); + + const erro = erroDoBackend(resposta.data); + if (erro) { + handleError(new Error(erro), "Erro ao listar as bases de teste."); + return; + } + + bases = resposta.data?.bases ?? []; + } catch (error) { + handleError(error, "Erro ao listar as bases de teste."); + return; + } + + if (!bases.length) { + vscode.window.showInformationMessage( + "Nenhuma base de teste montada nesta instalação.", + { modal: false }, + "Dismiss" + ); + return; + } + + const escolha = await vscode.window.showQuickPick(bases.map(descreverBase), { + title: "Bases de teste montadas", + placeHolder: "Escolha a base para regerar", + matchOnDescription: true, + matchOnDetail: true, + ignoreFocusOut: true, + }); + if (!escolha) return; + + const alvo = escolha.base; + + // Modal com o namespace na frente: um clique errado no QuickPick não pode derrubar uma base + const confirmacao = await vscode.window.showWarningMessage( + `Regerar a base ${alvo.idBase}?`, + { + modal: true, + detail: + `O namespace ${alvo.namespace} será EXCLUÍDO e recriado a partir do checkout.\n\n` + + `Todos os dados atuais dessa base serão perdidos e a operação leva alguns minutos. ` + + `Não há como desfazer.`, + }, + "Regerar base" + ); + if (confirmacao !== "Regerar base") return; + + const namespaceRegerado = await aguardarOperacaoServidor( + sourceControlApi, + api.ns, + `Regerando a base ${alvo.idBase}`, + ROUTES.regerarBaseTeste(api.ns), + { namespace: alvo.namespace }, + new vscode.CancellationTokenSource().token + ); + + if (namespaceRegerado) { + vscode.window.showInformationMessage( + `Base ${alvo.idBase} regerada com sucesso no namespace ${namespaceRegerado}.`, + "Dismiss" + ); + } +} diff --git a/src/extension.ts b/src/extension.ts index 3ac8286a..7402b7ca 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -135,7 +135,7 @@ import { FileDecorationProvider } from "./providers/FileDecorationProvider"; import { RESTDebugPanel } from "./commands/restDebugPanel"; import { modifyWsFolder } from "./commands/addServerNamespaceToWorkspace"; import { WebSocketTerminalProfileProvider, launchWebSocketTerminal } from "./commands/webSocketTerminal"; -import { setUpTestController } from "./commands/unitTest"; +import { setUpTestController, gerenciarBasesTeste } from "./commands/unitTest"; import { pickDocument } from "./utils/documentPicker"; import { disposeDocumentIndex, @@ -1526,6 +1526,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { + sendCommandTelemetryEvent("ccs.gerenciarBasesTeste"); + await gerenciarBasesTeste(); + }), vscode.commands.registerCommand("vscode-objectscript.ccs.activateNamespaceConnections", async () => { sendCommandTelemetryEvent("ccs.activateNamespaceConnections"); await vscode.window.withProgress(