From 1ca9c06a6f431770333786d8fa87f1c5542cb917 Mon Sep 17 00:00:00 2001 From: s1n7ax Date: Fri, 12 Jun 2026 10:26:26 +0530 Subject: [PATCH 1/2] chore(test): replace unit-style specs with e2e integration tests Old specs only checked LSP attach/capabilities against an empty buffer. New integration_spec runs against a real Maven fixture project (copied to a temp dir so root markers resolve correctly) and exercises plugin features end-to-end: jdtls attach, extension bundling, project import, dap config, diagnostics, built-in runner, test runner, report viewer, profile UI and runtime switching. Closes #497 --- .github/workflows/test.yml | 2 + .luacheckrc | 1 + CLAUDE.md | 16 +- Makefile | 2 +- tests/constants/capabilities.lua | 81 ------ tests/fixtures/demo/pom.xml | 25 ++ .../demo/src/main/java/com/example/Main.java | 11 + .../src/test/java/com/example/MainTest.java | 18 ++ tests/specs/capabilities_spec.lua | 27 -- tests/specs/integration_spec.lua | 252 ++++++++++++++++++ tests/specs/jdtls_extensions_spec.lua | 34 --- tests/specs/lsp_spec.lua | 14 - tests/utils/project.lua | 165 ++++++++++++ tests/utils/test-config.lua | 10 +- 14 files changed, 491 insertions(+), 167 deletions(-) delete mode 100644 tests/constants/capabilities.lua create mode 100644 tests/fixtures/demo/pom.xml create mode 100644 tests/fixtures/demo/src/main/java/com/example/Main.java create mode 100644 tests/fixtures/demo/src/test/java/com/example/MainTest.java delete mode 100644 tests/specs/capabilities_spec.lua create mode 100644 tests/specs/integration_spec.lua delete mode 100644 tests/specs/jdtls_extensions_spec.lua delete mode 100644 tests/specs/lsp_spec.lua create mode 100644 tests/utils/project.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9875bb4..1a38ac6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,9 @@ on: jobs: test: runs-on: ${{ matrix.os }} + timeout-minutes: 40 strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] nvim-versions: ["stable", "nightly"] diff --git a/.luacheckrc b/.luacheckrc index 232e3ba..594677c 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -5,6 +5,7 @@ globals = { 'vim.bo', 'vim.opt', 'vim.lsp', + 'vim.ui', } read_globals = { 'vim', diff --git a/CLAUDE.md b/CLAUDE.md index 65aa678..962569d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,22 +76,22 @@ plugin/java.lua # User command registration ``` tests/ -├── assets/ # Test fixtures and assets (e.g., HelloWorld.java) -├── constants/ # Test constants (e.g., capabilities.lua) +├── fixtures/ # Test projects (e.g., demo/ - Maven project with JUnit tests) ├── utils/ # Test utilities and config files │ ├── lsp-utils.lua # LSP test helpers +│ ├── project.lua # Fixture project + wait/assert helpers │ ├── prepare-config.lua # Lazy.nvim test setup -│ └── test-config.lua # Manual test setup +│ └── test-config.lua # Minimal init for plenary child nvim └── specs/ # Test specifications - ├── lsp_spec.lua # All LSP-related tests - └── pkgm_spec.lua # All pkgm-related tests + └── integration_spec.lua # End-to-end plugin feature tests ``` **Test Guidelines:** -- Group related tests in single spec file (e.g., all pkgm tests in `pkgm_spec.lua`) +- Tests are end-to-end: real Neovim + real JDTLS against fixture Maven project +- Fixture projects are copied to a temp dir at runtime (root markers prefer `.git`, so they must run outside the repo) +- Spec `it` blocks run sequentially and share JDTLS session state; order matters - Extract reusable logic to `utils/` to keep test steps clean -- Store test data/fixtures in `assets/` -- Store constants (capabilities, expected values) in `constants/` +- Store test projects in `fixtures/` ## Code Patterns diff --git a/Makefile b/Makefile index d64073d..e2b7ba9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ TESTS_ROOT=tests TESTS_DIR?=${TESTS_ROOT}/specs PREPARE_CONFIG=${TESTS_ROOT}/utils/prepare-config.lua TEST_CONFIG=${TESTS_ROOT}/utils/test-config.lua -TEST_TIMEOUT?=60000 +TEST_TIMEOUT?=600000 .PHONY: test tests lint format all diff --git a/tests/constants/capabilities.lua b/tests/constants/capabilities.lua deleted file mode 100644 index fa4572b..0000000 --- a/tests/constants/capabilities.lua +++ /dev/null @@ -1,81 +0,0 @@ -local List = require('java-core.utils.list') - -local M = {} - -M.required_cmds = List:new({ - 'java.completion.onDidSelect', - 'java.decompile', - 'java.edit.handlePasteEvent', - 'java.edit.organizeImports', - 'java.edit.smartSemicolonDetection', - 'java.edit.stringFormatting', - 'java.getTroubleshootingInfo', - 'java.navigate.openTypeHierarchy', - 'java.navigate.resolveTypeHierarchy', - 'java.project.addToSourcePath', - 'java.project.changeImportedProjects', - 'java.project.createModuleInfo', - 'java.project.getAll', - 'java.project.getClasspaths', - 'java.project.getSettings', - 'java.project.import', - 'java.project.isTestFile', - 'java.project.listSourcePaths', - 'java.project.refreshDiagnostics', - 'java.project.removeFromSourcePath', - 'java.project.resolveSourceAttachment', - 'java.project.resolveStackTraceLocation', - 'java.project.resolveText', - 'java.project.resolveWorkspaceSymbol', - 'java.project.updateClassPaths', - 'java.project.updateJdk', - 'java.project.updateSettings', - 'java.project.updateSourceAttachment', - 'java.project.upgradeGradle', - 'java.protobuf.generateSources', - 'java.reloadBundles', - 'java.vm.getAllInstalls', - 'sts.java.addClasspathListener', - 'sts.java.code.completions', - 'sts.java.hierarchy.subtypes', - 'sts.java.hierarchy.supertypes', - 'sts.java.javadoc', - 'sts.java.javadocHoverLink', - 'sts.java.location', - 'sts.java.removeClasspathListener', - 'sts.java.search.packages', - 'sts.java.search.types', - 'sts.java.type', - 'sts.project.gav', - 'vscode.java.buildWorkspace', - 'vscode.java.checkProjectSettings', - 'vscode.java.fetchPlatformSettings', - 'vscode.java.fetchUsageData', - 'vscode.java.inferLaunchCommandLength', - 'vscode.java.isOnClasspath', - 'vscode.java.resolveBuildFiles', - 'vscode.java.resolveClassFilters', - 'vscode.java.resolveClasspath', - 'vscode.java.resolveElementAtSelection', - 'vscode.java.resolveInlineVariables', - 'vscode.java.resolveJavaExecutable', - 'vscode.java.resolveMainClass', - 'vscode.java.resolveMainMethod', - 'vscode.java.resolveSourceUri', - 'vscode.java.startDebugSession', - 'vscode.java.test.findDirectTestChildrenForClass', - 'vscode.java.test.findJavaProjects', - 'vscode.java.test.findTestLocation', - 'vscode.java.test.findTestPackagesAndTypes', - 'vscode.java.test.findTestTypesAndMethods', - 'vscode.java.test.generateTests', - 'vscode.java.test.get.testpath', - 'vscode.java.test.jacoco.getCoverageDetail', - 'vscode.java.test.junit.argument', - 'vscode.java.test.navigateToTestOrTarget', - 'vscode.java.test.resolvePath', - 'vscode.java.updateDebugSettings', - 'vscode.java.validateLaunchConfig', -}) - -return M diff --git a/tests/fixtures/demo/pom.xml b/tests/fixtures/demo/pom.xml new file mode 100644 index 0000000..14a7c1d --- /dev/null +++ b/tests/fixtures/demo/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + com.example + demo + 1.0.0 + jar + + + 17 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + diff --git a/tests/fixtures/demo/src/main/java/com/example/Main.java b/tests/fixtures/demo/src/main/java/com/example/Main.java new file mode 100644 index 0000000..5dca55c --- /dev/null +++ b/tests/fixtures/demo/src/main/java/com/example/Main.java @@ -0,0 +1,11 @@ +package com.example; + +public class Main { + public static void main(String[] args) { + System.out.println(greet()); + } + + public static String greet() { + return "Hello from nvim-java"; + } +} diff --git a/tests/fixtures/demo/src/test/java/com/example/MainTest.java b/tests/fixtures/demo/src/test/java/com/example/MainTest.java new file mode 100644 index 0000000..5001ea8 --- /dev/null +++ b/tests/fixtures/demo/src/test/java/com/example/MainTest.java @@ -0,0 +1,18 @@ +package com.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MainTest { + @Test + void greetReturnsGreeting() { + assertEquals("Hello from nvim-java", Main.greet()); + } + + @Test + void greetIsNotEmpty() { + assertTrue(Main.greet().length() > 0); + } +} diff --git a/tests/specs/capabilities_spec.lua b/tests/specs/capabilities_spec.lua deleted file mode 100644 index fc0550f..0000000 --- a/tests/specs/capabilities_spec.lua +++ /dev/null @@ -1,27 +0,0 @@ -local lsp_utils = dofile('tests/utils/lsp-utils.lua') -local capabilities = dofile('tests/constants/capabilities.lua') -local List = require('java-core.utils.list') -local assert = require('luassert') -local err = require('java-core.utils.errors') - -describe('LSP Capabilities', function() - it('should have all required commands', function() - vim.cmd.edit('HelloWorld.java') - - local client = lsp_utils.wait_for_lsp_attach('jdtls', 30000) - local commands = client.server_capabilities.executeCommandProvider.commands - local actual_cmds = List:new(commands) - - for _, required_cmd in ipairs(capabilities.required_cmds) do - assert.is_true(actual_cmds:contains(required_cmd), 'Missing required command: ' .. required_cmd) - end - - local extra_cmds = actual_cmds:filter(function(cmd) - return not capabilities.required_cmds:contains(cmd) - end) - - if #extra_cmds > 0 then - err.throw('Additional commands found that are not in required list:', extra_cmds) - end - end) -end) diff --git a/tests/specs/integration_spec.lua b/tests/specs/integration_spec.lua new file mode 100644 index 0000000..259425a --- /dev/null +++ b/tests/specs/integration_spec.lua @@ -0,0 +1,252 @@ +local lsp_utils = dofile('tests/utils/lsp-utils.lua') +local project = dofile('tests/utils/project.lua') +local assert = require('luassert') + +local is_win = vim.fn.has('win32') == 1 + +local TIMEOUT = { + attach = 60000, + import = 300000, + diagnostics = 60000, + run = 120000, + test = 180000, +} + +describe('nvim-java integration', function() + ---@type vim.lsp.Client + local jdtls + + ---@type string + local project_root + + it('attaches jdtls and spring-boot to a Java buffer', function() + project_root = project.open('demo') + + vim.cmd.edit('src/main/java/com/example/Main.java') + + jdtls = lsp_utils.wait_for_lsp_attach('jdtls', TIMEOUT.attach) + local spring = lsp_utils.wait_for_lsp_attach('spring-boot', TIMEOUT.attach) + + assert.is_not_nil(jdtls, 'JDTLS should attach to Java buffer') + assert.is_not_nil(spring, 'Spring Boot should attach to Java buffer') + end) + + it('bundles java-test, java-debug and spring-boot-tools extensions', function() + local bundles = jdtls.config.init_options.bundles + + assert.is_not_nil(bundles, 'Bundles should be configured') + assert.is_true(#bundles > 0, 'Bundles should not be empty') + + local has_java_test = false + local has_java_debug = false + local has_spring_boot = false + + for _, bundle in ipairs(bundles) do + if bundle:match('java%-test') and bundle:match('com%.microsoft%.java%.test%.plugin') then + has_java_test = true + end + if bundle:match('java%-debug') and bundle:match('com%.microsoft%.java%.debug%.plugin') then + has_java_debug = true + end + if bundle:match('spring%-boot%-tools') and bundle:match('jdt%-ls%-extension%.jar') then + has_spring_boot = true + end + end + + assert.is_true(has_java_test, 'java-test extension should be bundled') + assert.is_true(has_java_debug, 'java-debug extension should be bundled') + assert.is_true(has_spring_boot, 'spring-boot-tools extension should be bundled') + end) + + it('imports the maven project and resolves the main class', function() + local mains = project.wait_for_import(jdtls, TIMEOUT.import) + + assert.equals('com.example.Main', mains[1].mainClass) + end) + + it('registers dap configurations for the main class', function() + require('dap').configurations.java = {} + require('java-dap').config_dap() + + project.wait_for(function() + local configs = require('dap').configurations.java or {} + + for _, config in ipairs(configs) do + if config.mainClass == 'com.example.Main' then + return true + end + end + end, TIMEOUT.run, 'dap configuration for com.example.Main') + end) + + it('publishes and clears diagnostics on buffer changes', function() + local buf = vim.api.nvim_get_current_buf() + local original = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + vim.api.nvim_buf_set_lines(buf, 2, 3, false, { 'public class Main {', 'this is not valid java;' }) + + project.wait_for(function() + return #vim.diagnostic.get(buf) > 0 + end, TIMEOUT.diagnostics, 'diagnostics on broken buffer') + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, original) + + project.wait_for(function() + return #vim.diagnostic.get(buf) == 0 + end, TIMEOUT.diagnostics, 'diagnostics to clear on fixed buffer') + end) + + it('runs the main class with the built-in runner', function() + if is_win then + return + end + + vim.cmd('JavaRunnerRunMain') + + project.wait_for(function() + local run = require('java.api.runner').runner.curr_run + + if not run then + return false + end + + -- terminal buffer wraps output at window width, so join without + -- newlines before matching + local lines = vim.api.nvim_buf_get_lines(run.buffer, 0, -1, false) + return table.concat(lines, ''):match('Hello from nvim%-java') ~= nil + end, TIMEOUT.run, 'runner output of com.example.Main') + end) + + it('toggles the runner log window', function() + if is_win then + return + end + + -- the log window opens automatically when a run starts + local logger = require('java.api.runner').runner.logger + assert.is_true(logger:is_opened(), 'log window should open automatically on run') + + local win_count = #vim.api.nvim_list_wins() + + vim.cmd('JavaRunnerToggleLogs') + project.wait_for(function() + return #vim.api.nvim_list_wins() == win_count - 1 + end, TIMEOUT.attach, 'runner log window to close') + + vim.cmd('JavaRunnerToggleLogs') + project.wait_for(function() + return #vim.api.nvim_list_wins() == win_count + end, TIMEOUT.attach, 'runner log window to reopen') + end) + + it('stops the main class run', function() + if is_win then + return + end + + vim.cmd('JavaRunnerStopMain') + + project.wait_for(function() + return not require('java.api.runner').runner.curr_run.is_running + end, TIMEOUT.run, 'runner to stop') + end) + + it('runs the current test class', function() + vim.cmd.edit(project_root .. '/src/test/java/com/example/MainTest.java') + lsp_utils.wait_for_lsp_attach('jdtls', TIMEOUT.attach) + + vim.cmd('JavaTestRunCurrentClass') + + project.wait_for(function() + local leaves = project.last_report_leaves() + return leaves and project.all_passed(leaves, 2) + end, TIMEOUT.test, 'current test class to pass with 2 test results') + end) + + it('runs the current test method', function() + vim.fn.search('greetReturnsGreeting') + + vim.cmd('JavaTestRunCurrentMethod') + + project.wait_for(function() + local leaves = project.last_report_leaves() + return leaves and project.all_passed(leaves, 1) + end, TIMEOUT.test, 'current test method to pass with 1 test result') + end) + + it('runs all tests in the project', function() + vim.cmd('JavaTestRunAllTests') + + project.wait_for(function() + local leaves = project.last_report_leaves() + return leaves and project.all_passed(leaves, 2) + end, TIMEOUT.test, 'all tests to pass with 2 test results') + end) + + it('shows the last test report', function() + local win_count = #vim.api.nvim_list_wins() + local wins_before = vim.api.nvim_list_wins() + + vim.cmd('JavaTestViewLastReport') + + project.wait_for(function() + return #vim.api.nvim_list_wins() == win_count + 1 + end, TIMEOUT.attach, 'test report window to open') + + for _, win in ipairs(vim.api.nvim_list_wins()) do + if not vim.tbl_contains(wins_before, win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + end) + + it('opens the profile editor ui', function() + local win_count = #vim.api.nvim_list_wins() + local wins_before = vim.api.nvim_list_wins() + + vim.cmd('JavaProfile') + + project.wait_for(function() + return #vim.api.nvim_list_wins() > win_count + end, TIMEOUT.run, 'profile editor windows to open') + + for _, win in ipairs(vim.api.nvim_list_wins()) do + if not vim.tbl_contains(wins_before, win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + end) + + it('changes the default runtime', function() + local java_exec = vim.fn.exepath('java') + local java_home = vim.fs.dirname(vim.fs.dirname(java_exec)) + + -- paths must be distinct; change_runtime marks default by path equality + jdtls.config.settings = vim.tbl_deep_extend('force', jdtls.config.settings or {}, { + java = { + configuration = { + runtimes = { + { name = 'JavaSE-17', path = java_home .. '-other' }, + { name = 'JavaSE-21', path = java_home }, + }, + }, + }, + }) + + local original_select = vim.ui.select + ---@diagnostic disable-next-line: duplicate-set-field + vim.ui.select = function(items, _, on_choice) + on_choice(items[2], 2) + end + + vim.cmd('JavaSettingsChangeRuntime') + + local runtimes = jdtls.config.settings.java.configuration.runtimes + + project.wait_for(function() + return runtimes[2].default == true and runtimes[1].default == nil + end, TIMEOUT.attach, 'second runtime to become default') + + vim.ui.select = original_select + end) +end) diff --git a/tests/specs/jdtls_extensions_spec.lua b/tests/specs/jdtls_extensions_spec.lua deleted file mode 100644 index 6d8f41d..0000000 --- a/tests/specs/jdtls_extensions_spec.lua +++ /dev/null @@ -1,34 +0,0 @@ -local lsp_utils = dofile('tests/utils/lsp-utils.lua') -local assert = require('luassert') - -describe('JDTLS Extensions', function() - it('should bundle java-test, java-debug, and spring-boot-tools extensions', function() - vim.cmd.edit('HelloWorld.java') - - local client = lsp_utils.wait_for_lsp_attach('jdtls', 30000) - local bundles = client.config.init_options.bundles - - assert.is_not_nil(bundles, 'Bundles should be configured') - assert.is_true(#bundles > 0, 'Bundles should not be empty') - - local has_java_test = false - local has_java_debug = false - local has_spring_boot = false - - for _, bundle in ipairs(bundles) do - if bundle:match('java%-test') and bundle:match('com%.microsoft%.java%.test%.plugin') then - has_java_test = true - end - if bundle:match('java%-debug') and bundle:match('com%.microsoft%.java%.debug%.plugin') then - has_java_debug = true - end - if bundle:match('spring%-boot%-tools') and bundle:match('jdt%-ls%-extension%.jar') then - has_spring_boot = true - end - end - - assert.is_true(has_java_test, 'java-test extension (com.microsoft.java.test.plugin) should be bundled') - assert.is_true(has_java_debug, 'java-debug extension (com.microsoft.java.debug.plugin) should be bundled') - assert.is_true(has_spring_boot, 'spring-boot-tools extension (jdt-ls-extension.jar) should be bundled') - end) -end) diff --git a/tests/specs/lsp_spec.lua b/tests/specs/lsp_spec.lua deleted file mode 100644 index 8ef81b7..0000000 --- a/tests/specs/lsp_spec.lua +++ /dev/null @@ -1,14 +0,0 @@ -local lsp_utils = dofile('tests/utils/lsp-utils.lua') -local assert = require('luassert') - -describe('LSP Attach', function() - it('should attach when opening a Java buffer', function() - vim.cmd.edit('HelloWorld.java') - - local jdtls = lsp_utils.wait_for_lsp_attach('jdtls', 30000) - local spring = lsp_utils.wait_for_lsp_attach('spring-boot', 30000) - - assert.is_not_nil(jdtls, 'JDTLS should attach to Java buffer') - assert.is_not_nil(spring, 'Spring Boot should attach to Java buffer') - end) -end) diff --git a/tests/utils/project.lua b/tests/utils/project.lua new file mode 100644 index 0000000..ace2a30 --- /dev/null +++ b/tests/utils/project.lua @@ -0,0 +1,165 @@ +local M = {} + +local uv = vim.uv or vim.loop + +---Recursively copy a directory +---@param src string +---@param dest string +local function copy_dir(src, dest) + vim.fn.mkdir(dest, 'p') + + for name, type in vim.fs.dir(src) do + local src_path = src .. '/' .. name + local dest_path = dest .. '/' .. name + + if type == 'directory' then + copy_dir(src_path, dest_path) + else + assert(uv.fs_copyfile(src_path, dest_path), 'failed to copy ' .. src_path) + end + end +end + +---Copy a fixture project to a temp dir (outside the repo so root markers +---resolve to the fixture, not the plugin repo) and cd into it +---@param fixture string fixture dir name under tests/fixtures +---@return string project_root absolute path of the copied project +function M.open(fixture) + local src = vim.fn.fnamemodify('tests/fixtures/' .. fixture, ':p') + local dest = vim.fn.tempname() .. '-' .. fixture + + copy_dir(src, dest) + vim.cmd.cd(dest) + + return dest +end + +---Wait until cond() is truthy or fail the test +---@param cond fun(): any +---@param timeout number ms +---@param msg string +function M.wait_for(cond, timeout, msg) + local ok = vim.wait(timeout, function() + return cond() and true or false + end, 200) + + if not ok then + error(string.format('timed out after %dms waiting for: %s', timeout, msg)) + end +end + +---Run a workspace command on the client and wait for the response +---@param client vim.lsp.Client +---@param command string +---@param arguments? any[] +---@param timeout? number ms +---@return any # command result or nil on error/timeout +function M.execute_command(client, command, arguments, timeout) + local done = false + local cmd_result + + client:request('workspace/executeCommand', { + command = command, + arguments = arguments, + }, function(err, result) + done = true + if not err then + cmd_result = result + end + end) + + vim.wait(timeout or 10000, function() + return done + end, 100) + + return cmd_result +end + +---Wait until the project is imported by polling main class resolution +---@param client vim.lsp.Client +---@param timeout number ms +---@return table[] # resolved main classes +function M.wait_for_import(client, timeout) + local deadline = uv.now() + timeout + + while uv.now() < deadline do + local result = M.execute_command(client, 'vscode.java.resolveMainClass') + + if result and #result > 0 then + return result + end + + vim.wait(2000) + end + + error(string.format('project import did not complete within %dms', timeout)) +end + +---Collect leaf nodes from a test result tree +---@param results java-test.TestResults[] +---@return java-test.TestResults[] +function M.leaf_results(results) + local leaves = {} + + local function walk(nodes) + for _, node in ipairs(nodes) do + if node.children and #node.children > 0 then + walk(node.children) + else + table.insert(leaves, node) + end + end + end + + walk(results) + + return leaves +end + +---Get leaf results of the last test report, or nil if not available yet +---@return java-test.TestResults[] | nil +function M.last_report_leaves() + local last_report = require('java-test').last_report + + if not last_report then + return nil + end + + local ok, results = pcall(function() + return last_report:get_results() + end) + + if not ok or not results then + return nil + end + + return M.leaf_results(results) +end + +---Check all given leaves finished execution and passed +---Note: the result parser only sets status for Failed/Skipped tests; +---a passed test ends execution with no status +---@param leaves java-test.TestResults[] +---@param count number expected leaf count +---@return boolean +function M.all_passed(leaves, count) + local execution_status = require('java-test.results.execution-status') + + if #leaves ~= count then + return false + end + + for _, leaf in ipairs(leaves) do + if not leaf.result or leaf.result.execution ~= execution_status.Ended then + return false + end + + if leaf.result.status ~= nil then + return false + end + end + + return true +end + +return M diff --git a/tests/utils/test-config.lua b/tests/utils/test-config.lua index c2cca10..cc4aa84 100644 --- a/tests/utils/test-config.lua +++ b/tests/utils/test-config.lua @@ -9,13 +9,16 @@ vim.o.swapfile = false vim.o.backup = false vim.o.writebackup = false -local temp_path = './.test_plugins' +-- absolute paths so requires keep working after tests :cd elsewhere +local root = vim.fn.getcwd() +local temp_path = root .. '/.test_plugins' vim.opt.runtimepath:append(temp_path .. '/') +vim.opt.runtimepath:append(temp_path .. '/plenary.nvim') vim.opt.runtimepath:append(temp_path .. '/nui.nvim') vim.opt.runtimepath:append(temp_path .. '/spring-boot.nvim') vim.opt.runtimepath:append(temp_path .. '/nvim-dap') -vim.opt.runtimepath:append('.') +vim.opt.runtimepath:append(root) local is_nixos = vim.fn.filereadable('/etc/NIXOS') == 1 local is_ci = vim.env.CI ~= nil @@ -35,4 +38,7 @@ end require('java').setup(config) +-- plenary child nvim runs with --noplugin so user commands never register +vim.cmd('runtime! plugin/java.lua') + vim.lsp.enable('jdtls') From e6f5a2d08f6b0492ae57346430418d74a144dbb7 Mon Sep 17 00:00:00 2001 From: s1n7ax Date: Fri, 12 Jun 2026 14:09:45 +0530 Subject: [PATCH 2/2] fix(pkgm): downloads and extractions fail on Windows when 'shell' is pwsh vim.fn.system() with a command string is parsed by &shell; nvim nightly on Windows defaults &shell to pwsh, breaking cmd.exe-style quoting. Pass argv lists so no shell is involved. --- lua/pkgm/downloaders/curl.lua | 18 ++++++++++------ lua/pkgm/downloaders/powershell.lua | 17 ++++++++++----- lua/pkgm/downloaders/wget.lua | 18 ++++++++++------ lua/pkgm/extractors/powershell.lua | 17 ++++++++++----- lua/pkgm/extractors/tar.lua | 33 +++++++++++++---------------- lua/pkgm/extractors/unzip.lua | 3 ++- 6 files changed, 63 insertions(+), 43 deletions(-) diff --git a/lua/pkgm/downloaders/curl.lua b/lua/pkgm/downloaders/curl.lua index 2009ab8..3992e54 100644 --- a/lua/pkgm/downloaders/curl.lua +++ b/lua/pkgm/downloaders/curl.lua @@ -39,13 +39,17 @@ end ---@return string|nil # Error message if failed function Curl:download() log.debug('curl downloading:', self.url, 'to', self.dest) - local cmd = string.format( - 'curl --retry %d --connect-timeout %d -o %s %s', - self.retry_count, - self.timeout, - vim.fn.shellescape(self.dest), - vim.fn.shellescape(self.url) - ) + -- argv list so no shell quoting is involved + local cmd = { + 'curl', + '--retry', + tostring(self.retry_count), + '--connect-timeout', + tostring(self.timeout), + '-o', + self.dest, + self.url, + } log.debug('curl command:', cmd) local result = vim.fn.system(cmd) diff --git a/lua/pkgm/downloaders/powershell.lua b/lua/pkgm/downloaders/powershell.lua index f0a5434..aea0afa 100644 --- a/lua/pkgm/downloaders/powershell.lua +++ b/lua/pkgm/downloaders/powershell.lua @@ -49,12 +49,19 @@ function PowerShell:download() self.dest ) - local cmd = string.format( - -- luacheck: ignore - "%s -NoProfile -NonInteractive -Command \"$ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'Stop'; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; %s\"", + -- pass an argv list so no shell is involved; a single command string + -- breaks when &shell is pwsh (nvim nightly default on Windows) + local cmd = { pwsh, - pwsh_cmd - ) + '-NoProfile', + '-NonInteractive', + '-Command', + string.format( + -- luacheck: ignore + "$ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'Stop'; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; %s", + pwsh_cmd + ), + } log.debug('PowerShell command:', cmd) local result = vim.fn.system(cmd) diff --git a/lua/pkgm/downloaders/wget.lua b/lua/pkgm/downloaders/wget.lua index fff6629..411d27b 100644 --- a/lua/pkgm/downloaders/wget.lua +++ b/lua/pkgm/downloaders/wget.lua @@ -39,13 +39,17 @@ end ---@return string|nil # Error message if failed function Wget:download() log.debug('wget downloading:', self.url, 'to', self.dest) - local cmd = string.format( - 'wget -t %d -T %d -O %s %s', - self.retry_count, - self.timeout, - vim.fn.shellescape(self.dest), - vim.fn.shellescape(self.url) - ) + -- argv list so no shell quoting is involved + local cmd = { + 'wget', + '-t', + tostring(self.retry_count), + '-T', + tostring(self.timeout), + '-O', + self.dest, + self.url, + } log.debug('wget command:', cmd) local result = vim.fn.system(cmd) diff --git a/lua/pkgm/extractors/powershell.lua b/lua/pkgm/extractors/powershell.lua index 6aec31c..426b733 100644 --- a/lua/pkgm/extractors/powershell.lua +++ b/lua/pkgm/extractors/powershell.lua @@ -42,12 +42,19 @@ function PowerShellExtractor:extract() self.dest ) - local cmd = string.format( - --luacheck: ignore - "%s -NoProfile -NonInteractive -Command \"$ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'Stop'; %s\"", + -- pass an argv list so no shell is involved; a single command string + -- breaks when &shell is pwsh (nvim nightly default on Windows) + local cmd = { pwsh, - pwsh_cmd - ) + '-NoProfile', + '-NonInteractive', + '-Command', + string.format( + -- luacheck: ignore + "$ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'Stop'; %s", + pwsh_cmd + ), + } log.debug('PowerShell command:', cmd) local result = vim.fn.system(cmd) diff --git a/lua/pkgm/extractors/tar.lua b/lua/pkgm/extractors/tar.lua index 3d88036..76269e7 100644 --- a/lua/pkgm/extractors/tar.lua +++ b/lua/pkgm/extractors/tar.lua @@ -37,27 +37,24 @@ function Tar:extract() log.debug('tar extracting:', self.source, 'to', self.dest) log.debug('Using tar binary:', tar_cmd) - local cmd + local source = self.source + local dest = self.dest + + -- argv list so no shell quoting is involved (&shell may be pwsh on + -- Windows nvim nightly) + local cmd = { tar_cmd, '--no-same-owner' } + if system.get_os() == 'win' then -- Windows: convert backslashes to forward slashes (tar accepts them) - local source = self.source:gsub('\\', '/') - local dest = self.dest:gsub('\\', '/') - cmd = string.format( - '%s --no-same-owner %s -xf "%s" -C "%s"', - tar_cmd, - self:tar_supports_force_local(tar_cmd) and '--force-local' or '', - source, - dest - ) - else - -- Unix: use shellescape - cmd = string.format( - '%s --no-same-owner -xf %s -C %s', - tar_cmd, - vim.fn.shellescape(self.source), - vim.fn.shellescape(self.dest) - ) + source = source:gsub('\\', '/') + dest = dest:gsub('\\', '/') + + if self:tar_supports_force_local(tar_cmd) then + table.insert(cmd, '--force-local') + end end + + vim.list_extend(cmd, { '-xf', source, '-C', dest }) log.debug('tar command:', cmd) local result = vim.fn.system(cmd) diff --git a/lua/pkgm/extractors/unzip.lua b/lua/pkgm/extractors/unzip.lua index 5314703..1b867b8 100644 --- a/lua/pkgm/extractors/unzip.lua +++ b/lua/pkgm/extractors/unzip.lua @@ -21,7 +21,8 @@ end ---@return string|nil # Error message if failed function Unzip:extract() log.debug('unzip extracting:', self.source, 'to', self.dest) - local cmd = string.format('unzip -q -o %s -d %s', vim.fn.shellescape(self.source), vim.fn.shellescape(self.dest)) + -- argv list so no shell quoting is involved + local cmd = { 'unzip', '-q', '-o', self.source, '-d', self.dest } log.debug('unzip command:', cmd) local result = vim.fn.system(cmd)