diff --git a/.github/workflows/principal.yml b/.github/workflows/principal.yml index 396e5bd..41399ec 100644 --- a/.github/workflows/principal.yml +++ b/.github/workflows/principal.yml @@ -1,36 +1,88 @@ -name: Testes Unitários +name: Integração Contínua on: pull_request: - branches: [ principal ] + branches: [principal] push: - branches: [ principal ] + branches: [principal] permissions: contents: read jobs: + checks: + name: Verificações + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + + - name: Instalar dependências + run: yarn install --frozen-lockfile + + - name: Typecheck + run: yarn typecheck + + - name: Lint + run: yarn lint + + - name: Build + run: yarn build + testes-unitarios: + name: Testes Unitários + needs: checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'yarn' - - - name: Yarn - Dependências - run: yarn install --frozen-lockfile - - - name: Testes unitários com cobertura - run: yarn testes-unitarios --coverage --coverageReporters=json-summary --coverageReporters=text - - - name: Salvar resultados de cobertura - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage/coverage-summary.json - retention-days: 1 + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + + - name: Instalar dependências + run: yarn install --frozen-lockfile + + - name: Testes unitários com cobertura + run: > + yarn testes-unitarios + --coverage + --coverageReporters=json-summary + --coverageReporters=text + + - name: Salvar resultados da cobertura + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/coverage-summary.json + retention-days: 1 + + e2e: + name: Testes E2E (End-to-End/Ponta a Ponta) + needs: checks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + + - name: Instalar dependências + run: yarn install --frozen-lockfile + + - name: Instalar navegadores do Playwright + run: npx playwright install --with-deps chromium + + - name: Executar testes E2E + run: xvfb-run yarn testar-web \ No newline at end of file diff --git a/package.json b/package.json index 2ee7739..48bfbce 100644 --- a/package.json +++ b/package.json @@ -66,8 +66,9 @@ }, "scripts": { "compilar": "tsc -p ./", - "lint": "yarn eslint depuracao --ext ts", + "lint": "yarn eslint fontes --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "yarn build-base && yarn compilar-web", "build-base": "esbuild ./fontes/extensao.ts --bundle --sources-content=true --tsconfig=./tsconfig.json --external:vscode --format=cjs --platform=node --outfile=dist/extensao.js", "build-continuo": "npm run -S build-base -- --sourcemap --watch", "compilar-web": "webpack --mode development", @@ -82,7 +83,7 @@ "testes-unitarios": "jest --coverage", "testes-unitarios:continuo": "jest --watch", "testes-unitarios:verboso": "jest --verbose", - "testar-web": "yarn compilar-web && vscode-test-web --browserType=chromium --extensionDevelopmentPath=. .", + "testar-web": "yarn compilar-web && vscode-test-web --browserType=chromium --extensionDevelopmentPath=. --extensionTestsPath=dist/testes/e2e/web/index.js testes/e2e/fixtures", "vscode:pre-empacotar-web": "yarn pre-empacotar" }, "activationEvents": [ diff --git a/testes/e2e/fixtures/contador.delegua b/testes/e2e/fixtures/contador.delegua new file mode 100644 index 0000000..78e193c --- /dev/null +++ b/testes/e2e/fixtures/contador.delegua @@ -0,0 +1,2 @@ +var contador = 1 +escreva(contador) diff --git a/testes/e2e/web/index.ts b/testes/e2e/web/index.ts new file mode 100644 index 0000000..5837433 --- /dev/null +++ b/testes/e2e/web/index.ts @@ -0,0 +1,98 @@ +import * as vscode from 'vscode'; + +function assegurar(condicao: unknown, mensagem: string): asserts condicao { + if (!condicao) { + throw new Error(mensagem); + } +} + +async function abrirFixture(): Promise { + const pastaWorkspace = vscode.workspace.workspaceFolders?.[0]; + assegurar(pastaWorkspace, 'O workspace de fixtures E2E não foi aberto.'); + + const uri = vscode.Uri.joinPath(pastaWorkspace.uri, 'contador.delegua'); + const documento = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(documento); + return documento; +} + +async function testarAtivacao(): Promise { + const extensao = vscode.extensions.getExtension('designliquido.designliquido-vscode'); + assegurar(extensao, 'A extensão Design Líquido não foi encontrada pelo Extension Host.'); + + await extensao.activate(); + assegurar(extensao.isActive, 'A extensão Design Líquido não foi ativada.'); +} + +async function testarCompletude(documento: vscode.TextDocument): Promise { + const itens = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + documento.uri, + new vscode.Position(1, 8) + ); + + assegurar(itens?.items.some(item => item.label === 'escreva'), + 'A completude da extensão não retornou a função nativa "escreva".'); +} + +async function testarHover(documento: vscode.TextDocument): Promise { + const hovers = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + documento.uri, + new vscode.Position(1, 2) + ); + + assegurar(hovers && hovers.length > 0, + 'O provedor de Hover não retornou nenhuma informação.'); + + const possuiConteudo = hovers.some(h => + h.contents.some(c => { + const texto = typeof c === 'string' ? c : c.value; + return texto.includes('escreva') || texto.includes('função'); + }) + ); + + assegurar(possuiConteudo, 'O Hover sobre a função "escreva" não retornou a documentação esperada.'); +} + +async function testarDefinicao(documento: vscode.TextDocument): Promise { + const definicoes = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + documento.uri, + new vscode.Position(1, 10) + ); + + assegurar(definicoes && definicoes.length > 0, + 'O provedor de definição não retornou nenhuma localização.'); + + const primeiraDefinicao = definicoes[0]; + assegurar(primeiraDefinicao.range.start.line === 0, + `A definição deveria apontar para a linha 0, mas apontou para a linha ${primeiraDefinicao.range.start.line}.`); +} + +async function testarRenomeacao(documento: vscode.TextDocument): Promise { + const edicoes = await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + documento.uri, + new vscode.Position(1, 10), + 'total' + ); + + assegurar(edicoes, 'O provedor de renomeação não retornou edições.'); + const edicoesDocumento = edicoes.get(documento.uri) ?? []; + assegurar(edicoesDocumento.length === 2, + `A renomeação deveria editar duas ocorrências, mas retornou ${edicoesDocumento.length}.`); + assegurar(edicoesDocumento.every(edicao => edicao.newText === 'total'), + 'A renomeação não aplicou o novo identificador a todas as ocorrências.'); +} + +export async function run(): Promise { + await testarAtivacao(); + const documento = await abrirFixture(); + + await testarCompletude(documento); + await testarHover(documento); + await testarDefinicao(documento); + await testarRenomeacao(documento); + console.log('Todos os testes E2E passaram com sucesso!'); +} diff --git a/testes/lsp/contrato-delegua-lsp.test.ts b/testes/lsp/contrato-delegua-lsp.test.ts new file mode 100644 index 0000000..4f58ac4 --- /dev/null +++ b/testes/lsp/contrato-delegua-lsp.test.ts @@ -0,0 +1,152 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import * as lsp from '@designliquido/delegua-lsp'; +import * as cacheAnalise from '@designliquido/delegua-lsp/analise/cache-analise'; +import * as cacheDefinicoes from '@designliquido/delegua-lsp/analise/cache-definicoes'; + +import { ambienteVscode } from '../../fontes/ambiente/ambiente-vscode'; +import { DeleguaProvedorCompletude } from '../../fontes/completude/delegua-provedor-completude'; +import { DeleguaProvedorRenomeacao } from '../../fontes/renomeacao/delegua-provedor-renomeacao'; + +jest.unmock('@designliquido/delegua-lsp'); +jest.unmock('@designliquido/delegua-lsp/analise/cache-analise'); +jest.unmock('@designliquido/delegua-lsp/analise/cache-definicoes'); + +const arquivos = new Map(); + +jest.mock('vscode', () => ({ + CompletionItem: class CompletionItem { + constructor(public label: string, public kind?: number) {} + documentation: unknown; + detail: string | undefined; + insertText: unknown; + sortText: string | undefined; + }, + MarkdownString: class MarkdownString { + constructor(public value: string) {} + }, + Uri: { + parse: (caminho: string) => ({ caminho }), + file: (caminho: string) => ({ caminho }), + }, + Range: class Range { + start: { line: number; character: number }; + end: { line: number; character: number }; + + constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { + this.start = { line: startLine, character: startCharacter }; + this.end = { line: endLine, character: endCharacter }; + } + }, + TextEdit: class TextEdit { + constructor(public range: unknown, public newText: string) {} + }, + WorkspaceEdit: class WorkspaceEdit { + edicoes = new Map(); + + set(uri: { caminho: string }, edicoes: unknown[]) { + this.edicoes.set(uri.caminho, edicoes); + } + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + fs: { + readFile: async (uri: { caminho: string }) => { + const conteudo = arquivos.get(uri.caminho); + if (conteudo === undefined) { + throw new Error(`Arquivo não encontrado: ${uri.caminho}`); + } + return new TextEncoder().encode(conteudo); + }, + readDirectory: async () => [], + }, + }, +}), { virtual: true }); + +function criarDocumento(texto: string) { + return { + uri: { toString: () => 'file:///workspace/teste.delegua' }, + fileName: '/workspace/teste.delegua', + getText: () => texto, + version: 1, + languageId: 'delegua', + offsetAt: (posicao: { line: number; character: number }) => texto + .split('\n') + .slice(0, posicao.line) + .reduce((total, linha) => total + linha.length + 1, 0) + posicao.character, + }; +} + +describe('contrato com @designliquido/delegua-lsp', () => { + beforeEach(() => { + arquivos.clear(); + jest.clearAllMocks(); + }); + + describe('verificação do contrato de exports (API pública do LSP)', () => { + it('expõe as capacidades principais usadas pela extensão', () => { + expect(lsp.proverItensCompletude).toEqual(expect.any(Function)); + expect(lsp.proverDefinicao).toEqual(expect.any(Function)); + expect(lsp.proverReferencias).toEqual(expect.any(Function)); + expect(lsp.prepareRename).toEqual(expect.any(Function)); + expect(lsp.provideRenameEdits).toEqual(expect.any(Function)); + }); + + it('expõe as funções do módulo de cache de análise', () => { + expect(cacheAnalise.expirarResultado).toEqual(expect.any(Function)); + expect(cacheAnalise.expirarResultados).toEqual(expect.any(Function)); + expect(cacheAnalise.expirarResultadosPorDependenciaArquivo).toEqual(expect.any(Function)); + expect(cacheAnalise.expirarTudo).toEqual(expect.any(Function)); + expect(cacheAnalise.obterResultado).toEqual(expect.any(Function)); + expect(cacheAnalise.definirResultado).toEqual(expect.any(Function)); + expect(cacheAnalise.obterDiagnosticos).toEqual(expect.any(Function)); + expect(cacheAnalise.obterResultadoValido).toEqual(expect.any(Function)); + }); + + it('expõe as funções do módulo de cache de definições', () => { + expect(cacheDefinicoes.expirarTodasDefinicoes).toEqual(expect.any(Function)); + expect(cacheDefinicoes.definirDefinicoes).toEqual(expect.any(Function)); + expect(cacheDefinicoes.obterDefinicoesPorContexto).toEqual(expect.any(Function)); + }); + }); + + describe('integração comportamental', () => { + it('fornece itens reais de completude por meio do provedor da extensão', () => { + const provedor = new DeleguaProvedorCompletude(); + const itens = provedor.provideCompletionItems(criarDocumento('esc'), { line: 0, character: 3 }, {} as any); + + expect(itens).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'escreva' }), + ])); + }); + + it('gera edições de renomeação reais e as converte para WorkspaceEdit', async () => { + const provedor = new DeleguaProvedorRenomeacao(); + const documento = criarDocumento('var contador = 1\nescreva(contador)'); + + const resultado = await provedor.provideRenameEdits( + documento, + { line: 1, character: 10 }, + 'total', + {} as any + ); + + expect(resultado?.edicoes.get('file:///workspace/teste.delegua')).toHaveLength(2); + expect(resultado?.edicoes.get('file:///workspace/teste.delegua')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ newText: 'total' }), + ]) + ); + }); + + it('disponibiliza ao LSP o sistema de arquivos da API do VS Code', async () => { + arquivos.set('/workspace/biblioteca.delegua', 'var resposta = 42'); + + await expect(ambienteVscode.sistemaArquivos.lerArquivoTexto('/workspace/biblioteca.delegua')) + .resolves.toBe('var resposta = 42'); + await expect(ambienteVscode.sistemaArquivos.lerArquivoTexto('/workspace/inexistente.delegua')) + .resolves.toBeUndefined(); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 0d35f19..fd5a6f2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,10 +8,13 @@ const webpack = require('webpack'); /**@type {import('webpack').Configuration}*/ const webConfig = { target: 'webworker', - entry: './fontes/extensao-web.ts', + entry: { + 'extensao-web': './fontes/extensao-web.ts', + 'testes/e2e/web/index': './testes/e2e/web/index.ts' + }, output: { path: path.resolve(__dirname, 'dist'), - filename: 'extensao-web.js', + filename: '[name].js', libraryTarget: 'commonjs2', devtoolModuleFilenameTemplate: '../[resource-path]' }, @@ -63,7 +66,8 @@ const webConfig = { options: { transpileOnly: true, compilerOptions: { - module: 'esnext' + module: 'esnext', + rootDir: '.', } } }