From f33c7d762a95050100e139a91a7dccef4e42a8a8 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:24:14 -0400 Subject: [PATCH 1/3] feat: add Python single-file runner with console capture --- README.md | 24 +- .../communication/python_runner.lua | 250 +++++ lua/consolelog/core/autocmds.lua | 2 +- lua/consolelog/core/constants.lua | 15 + lua/consolelog/core/init.lua | 28 +- lua/consolelog/core/utils.lua | 28 + lua/consolelog/display/display.lua | 26 +- .../processing/message_processor_impl.lua | 4 +- .../consolelog_runner.cpython-314.pyc | Bin 0 -> 15859 bytes py/consolelog_runner.py | 417 ++++++++ tests/lua/display_spec.lua | 82 +- tests/lua/integration_spec.lua | 8 +- tests/lua/python_capture_spec.lua | 391 +++++++ tests/lua/python_runner_spec.lua | 982 ++++++++++++++++++ tests/lua/single_file_run_spec.lua | 433 +++++++- tests/run_lua_tests.sh | 2 +- 16 files changed, 2654 insertions(+), 38 deletions(-) create mode 100644 lua/consolelog/communication/python_runner.lua create mode 100644 py/__pycache__/consolelog_runner.cpython-314.pyc create mode 100644 py/consolelog_runner.py create mode 100644 tests/lua/python_capture_spec.lua create mode 100644 tests/lua/python_runner_spec.lua diff --git a/README.md b/README.md index 8c3a0fc..b2e3f9d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Neovim plugin that captures and displays console outputs as virtual text inlin - Real-time Console Capture - See console outputs instantly as virtual text next to your code - Browser Support - Automatic console capture for Next.js, React, Vue, and Vite projects -- Single-File Runner - Run standalone `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts` files with console capture via Node.js Inspector +- Single-File Runner - Run standalone `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`, and `.py` files with console capture via Node.js Inspector (JS/TS) or a zero-dependency Python bootstrap (Python 3.8+) - Smart Object Display - Inline previews for small objects, floating inspector for large ones - Zero Config - Works out of the box with intelligent project detection - Accurate Line Mapping - Outputs appear exactly where they're logged using source maps @@ -52,11 +52,14 @@ ConsoleLog automatically detects your project type and enables console capture: ### Project-Specific Behavior **Single-File Execution** (`:ConsoleLogRun` or `lr`): -- Supports: `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts` files -- TypeScript requires Node >= 22.6 (native from 23.6; `--experimental-strip-types` added automatically for 22.6–23.5). Node type stripping supports only erasable TypeScript syntax and does not apply `tsconfig` transforms. -- Runs via Node.js Inspector with console capture +- **JavaScript/TypeScript**: `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts` files + - TypeScript requires Node >= 22.6 (native from 23.6; `--experimental-strip-types` added automatically for 22.6–23.5). Node type stripping supports only erasable TypeScript syntax and does not apply `tsconfig` transforms. + - Runs via Node.js Inspector with console capture +- **Python**: `.py` files — zero-dependency stdlib bootstrap, Python 3.8+ + - Captures `print()`, `logging` records, raw `sys.stderr` writes, and uncaught exceptions + - Interpreter resolution: `runner.python_executable` config → `$VIRTUAL_ENV` → `.venv`/`venv` walking up from file → `python3` - Auto re-runs on save for buffers previously run with `:ConsoleLogRun` (configurable via `runner.rerun_on_save`) -- Perfect for quick scripts and standalone JavaScript/TypeScript files +- Perfect for quick scripts and standalone JavaScript/TypeScript/Python files **Browser Framework Projects** (automatic): - Supports: `.js`, `.jsx`, `.ts`, `.tsx` @@ -64,6 +67,14 @@ ConsoleLog automatically detects your project type and enables console capture: - Automatically injects WebSocket console capture - Just run `npm run dev` and start coding +**Python** (single-file execution): +- `print()` calls are captured with source location (file + line number) +- `logging` records at WARNING+ are captured by default; lower levels are captured if the script configures its own logging level or calls `logging.basicConfig(level=...)` +- Raw `sys.stderr` writes are buffered per-line and emitted as error events +- Uncaught exceptions (including `SyntaxError` and non-zero `SystemExit`) report the deepest relevant traceback frame +- Interpreter resolution order: `runner.python_executable` config key → `$VIRTUAL_ENV/bin/python` → `.venv/bin/python` or `venv/bin/python` walking up from the script's directory → system `python3` +- Zero external dependencies — the bootstrap is a single stdlib-only Python 3.8+ script (`py/consolelog_runner.py`) + ## Commands & Keybindings ### Core Commands @@ -135,6 +146,7 @@ The plugin works out of the box with sensible defaults. Here's the full configur }, runner = { rerun_on_save = true, -- Re-run single-file buffers on save after :ConsoleLogRun + python_executable = nil, -- Override Python interpreter (nil = auto-detect) }, keymaps = { enabled = true, -- Enable default keymaps @@ -175,7 +187,7 @@ The plugin works out of the box with sensible defaults. Here's the full configur "ConsoleLogDebug", "ConsoleLogDebugClear", }, - ft = { "javascript", "typescript", "javascriptreact", "typescriptreact" }, + ft = { "javascript", "typescript", "javascriptreact", "typescriptreact", "python" }, } ``` diff --git a/lua/consolelog/communication/python_runner.lua b/lua/consolelog/communication/python_runner.lua new file mode 100644 index 0000000..8c08907 --- /dev/null +++ b/lua/consolelog/communication/python_runner.lua @@ -0,0 +1,250 @@ +local M = {} +local constants = require("consolelog.core.constants") + +M.sessions = {} +M._intentionally_stopped_jobs = {} + +local _run_counter = 0 + +function M._generate_run_id() + _run_counter = _run_counter + 1 + return string.format("cl_%d_%d", os.time(), _run_counter) +end + +function M.get_plugin_root() + local current_file = debug.getinfo(1, "S").source:sub(2) + return vim.fn.fnamemodify(current_file, ":p:h:h:h:h") .. "/" +end + +function M.get_runner_script() + local script = M.get_plugin_root() .. "py/consolelog_runner.py" + if vim.fn.filereadable(script) ~= 1 then + return nil + end + return script +end + +function M.resolve_python_executable(filepath) + -- (1) Config override + local ok, cfg = pcall(function() return require("consolelog").config.runner.python_executable end) + if ok and cfg and vim.fn.executable(cfg) == 1 then + return cfg + end + + -- (2) VIRTUAL_ENV + local venv = os.getenv("VIRTUAL_ENV") + if venv then + local venv_exe = venv .. "/bin/python" + if vim.fn.executable(venv_exe) == 1 then + return venv_exe + end + end + + -- (3) Walk up from file directory looking for .venv/bin/python or venv/bin/python + local dir = vim.fn.fnamemodify(filepath, ":h") + for _ = 1, constants.FILES.MAX_PATH_DEPTH do + for _, venv_dir in ipairs({ ".venv", "venv" }) do + local candidate = dir .. "/" .. venv_dir .. "/bin/python" + if vim.fn.executable(candidate) == 1 then + return candidate + end + end + local parent = vim.fn.fnamemodify(dir, ":h") + if parent == dir then break end + dir = parent + end + + -- (4) Fallback to python3 + if vim.fn.executable("python3") == 1 then + return "python3" + end + + return nil, "No Python interpreter found (need python3 or a virtualenv)" +end + +function M.build_run_command(filepath) + local exe, err = M.resolve_python_executable(filepath) + if not exe then + return nil, err + end + + local script = M.get_runner_script() + if not script then + return nil, "Python runner script not found at " .. M.get_plugin_root() .. "py/consolelog_runner.py" + end + + return { exe, script, filepath } +end + +function M.parse_event(line) + local sentinel = "__CONSOLELOG_EVENT__" + local search_from = 1 + + while search_from <= #line do + local sentinel_pos = line:find(sentinel, search_from, true) + if not sentinel_pos then + return nil + end + + local payload = line:sub(sentinel_pos + #sentinel) + local ok, result = pcall(vim.json.decode, payload) + if ok and type(result) == "table" and result.event ~= nil then + return result + end + + -- Invalid event at this sentinel; try the next occurrence + search_from = sentinel_pos + #sentinel + end + + return nil +end + +function M.handle_event(session, event) + if session.cancelled then + return + end + + if event.event == "console" then + if vim.fn.fnamemodify(event.file, ":p") ~= vim.fn.fnamemodify(session.filepath, ":p") then + return + end + + local args = {} + for _, arg in ipairs(event.args or {}) do + if arg == vim.NIL then + table.insert(args, "None") + else + table.insert(args, arg) + end + end + + local message_processor = require("consolelog.processing.message_processor_impl") + local display = require("consolelog.display.display") + + vim.schedule(function() + if session.cancelled then + return + end + local output, raw_value = message_processor.format_args(args, event.method, true) + display.update_output(session.bufnr, event.line, output, event.method, raw_value) + end) + elseif event.event == "exception" then + if vim.fn.fnamemodify(event.file, ":p") ~= vim.fn.fnamemodify(session.filepath, ":p") then + return + end + + local text = (event.text or ""):match("[^\n]+") or event.text or "" + local display = require("consolelog.display.display") + + vim.schedule(function() + if session.cancelled then + return + end + display.update_output(session.bufnr, event.line, text, "error", event.text) + end) + end +end + +function M.start_debug_session(filepath, bufnr) + local cmd, err = M.build_run_command(filepath) + if not cmd then + vim.notify("ConsoleLog: " .. err, vim.log.levels.ERROR) + return nil + end + + local run_id = M._generate_run_id() + + local session = { + filepath = filepath, + bufnr = bufnr, + job_id = nil, + run_id = run_id, + _stdout_buffer = "", + } + + local session_id = nil + + session.job_id = vim.fn.jobstart(cmd, { + stdout_buffered = false, + stderr_buffered = false, + env = { CONSOLELOG_RUN_ID = run_id }, + on_stdout = function(_, data) + session._stdout_buffer = session._stdout_buffer .. table.concat(data, "\n") + while true do + local newline_pos = session._stdout_buffer:find("\n") + if not newline_pos then break end + local line = session._stdout_buffer:sub(1, newline_pos - 1) + session._stdout_buffer = session._stdout_buffer:sub(newline_pos + 1) + local event = M.parse_event(line) + if event and event.run_id == session.run_id then + M.handle_event(session, event) + end + end + end, + on_exit = function(job_id, exit_code) + local was_intentional = M._intentionally_stopped_jobs[job_id] + M._intentionally_stopped_jobs[job_id] = nil + + local sid = tostring(job_id) + M.sessions[sid] = nil + + if exit_code ~= 0 and not was_intentional then + vim.notify("Process exited with code " .. exit_code, vim.log.levels.ERROR) + end + end, + }) + + if session.job_id == nil or session.job_id <= 0 then + return nil + end + + session_id = tostring(session.job_id) + M.sessions[session_id] = session + require("consolelog.communication.inspector").single_file_buffers[bufnr] = filepath + + return session_id +end + +function M.get_session_for_buffer(bufnr) + for _, session in pairs(M.sessions) do + if session.bufnr == bufnr then + return session + end + end + return nil +end + +function M.cleanup_session(session) + session.cancelled = true + + if session.bufnr then + local display = require("consolelog.display.display") + display.clear_buffer(session.bufnr) + end + + if session.job_id then + M._intentionally_stopped_jobs[session.job_id] = true + vim.fn.jobstop(session.job_id) + session.job_id = nil + end + + for id, s in pairs(M.sessions) do + if s == session then + M.sessions[id] = nil + break + end + end +end + +function M.stop_all_sessions() + local inspector = require("consolelog.communication.inspector") + for _, session in pairs(M.sessions) do + M.cleanup_session(session) + if session.bufnr and inspector.single_file_buffers then + inspector.single_file_buffers[session.bufnr] = nil + end + end + M.sessions = {} +end + +return M \ No newline at end of file diff --git a/lua/consolelog/core/autocmds.lua b/lua/consolelog/core/autocmds.lua index 8feb88f..707d764 100644 --- a/lua/consolelog/core/autocmds.lua +++ b/lua/consolelog/core/autocmds.lua @@ -14,7 +14,7 @@ end -- Check if a buffer should be processed by consolelog local function should_process_buffer(bufnr) - return utils.is_javascript_buffer(bufnr) + return utils.is_supported_buffer(bufnr) end -- Export the function for use by other modules diff --git a/lua/consolelog/core/constants.lua b/lua/consolelog/core/constants.lua index fc24484..1bc90d6 100644 --- a/lua/consolelog/core/constants.lua +++ b/lua/consolelog/core/constants.lua @@ -45,6 +45,7 @@ M.DISPLAY = { M.FILE_PATTERNS = { JAVASCRIPT_SINGLE = { "%.js$", "%.mjs$", "%.cjs$", "%.ts$", "%.mts$", "%.cts$" }, TYPESCRIPT = { "%.ts$", "%.mts$", "%.cts$" }, + PYTHON = { "%.py$" }, FRAMEWORK_SUPPORTED = { "%.js$", "%.jsx$", "%.ts$", "%.tsx$" } } @@ -97,6 +98,11 @@ function M.is_single_file_runnable(filepath) return true end end + for _, pattern in ipairs(M.FILE_PATTERNS.PYTHON) do + if filepath:match(pattern) then + return true + end + end return false end @@ -118,5 +124,14 @@ function M.is_typescript_file(filepath) return false end +function M.is_python_file(filepath) + for _, pattern in ipairs(M.FILE_PATTERNS.PYTHON) do + if filepath:match(pattern) then + return true + end + end + return false +end + return M diff --git a/lua/consolelog/core/init.lua b/lua/consolelog/core/init.lua index cd34e40..790c9e4 100644 --- a/lua/consolelog/core/init.lua +++ b/lua/consolelog/core/init.lua @@ -75,6 +75,7 @@ M.config = { command = nil, use_inspector = true, rerun_on_save = true, + python_executable = nil, }, keymaps = { enabled = true, @@ -220,6 +221,8 @@ function M.disable() local inspector = require("consolelog.communication.inspector") inspector.stop_all_sessions() + require("consolelog.communication.python_runner").stop_all_sessions() + -- Invalidate any deferred rerun callbacks queued before this disable require("consolelog.core.autocmds").invalidate_reruns() @@ -271,7 +274,7 @@ function M.run_buffer(bufnr) local constants = require("consolelog.core.constants") if not constants.is_single_file_runnable(filepath) then - M.notify("ConsoleLogRun supports .js/.mjs/.cjs/.ts/.mts/.cts files.", vim.log.levels.ERROR) + M.notify("ConsoleLogRun supports .js/.mjs/.cjs/.ts/.mts/.cts/.py files.", vim.log.levels.ERROR) return end @@ -284,17 +287,28 @@ function M.run_buffer(bufnr) M.enable() end - local inspector = require("consolelog.communication.inspector") - + -- Dispatch to the appropriate runner based on file type + local runner + if constants.is_python_file(filepath) then + runner = require("consolelog.communication.python_runner") + else + runner = require("consolelog.communication.inspector") + end + -- Clear any stale outputs from previous runs (including completed sessions) require("consolelog.display.display").clear_buffer(bufnr) - local existing_session = inspector.get_session_for_buffer(bufnr) - if existing_session then - inspector.cleanup_session(existing_session) + -- Clean any existing session from BOTH runners (handles buffer renamed between JS and Python) + local inspector = require("consolelog.communication.inspector") + local py_runner = require("consolelog.communication.python_runner") + for _, r in ipairs({ inspector, py_runner }) do + local existing = r.get_session_for_buffer(bufnr) + if existing then + r.cleanup_session(existing) + end end - local session_id = inspector.start_debug_session(filepath, bufnr) + local session_id = runner.start_debug_session(filepath, bufnr) if session_id then M.notify("Running " .. vim.fn.fnamemodify(filepath, ":t") .. " with console capture", vim.log.levels.INFO) diff --git a/lua/consolelog/core/utils.lua b/lua/consolelog/core/utils.lua index bc9ad75..a8fff43 100644 --- a/lua/consolelog/core/utils.lua +++ b/lua/consolelog/core/utils.lua @@ -29,6 +29,34 @@ function M.is_javascript_buffer(bufnr) return M.is_javascript_file(file) end +function M.is_python_file(file) + if not file or file == "" then + return false + end + return file:match("%.py$") +end + +function M.is_python_buffer(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + + local filetype = vim.api.nvim_buf_get_option(bufnr, "filetype") + if filetype == "python" then + return true + end + + local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype") + if buftype ~= "" then + return false + end + + local file = vim.api.nvim_buf_get_name(bufnr) + return M.is_python_file(file) +end + +function M.is_supported_buffer(bufnr) + return M.is_javascript_buffer(bufnr) or M.is_python_buffer(bufnr) +end + function M.strip_ansi(text) if not text or type(text) ~= "string" then return text diff --git a/lua/consolelog/display/display.lua b/lua/consolelog/display/display.lua index 6faa242..71ac875 100644 --- a/lua/consolelog/display/display.lua +++ b/lua/consolelog/display/display.lua @@ -16,7 +16,7 @@ function M.show_outputs(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local utils = require("consolelog.core.utils") - if not utils.is_javascript_buffer(bufnr) then + if not utils.is_supported_buffer(bufnr) then return end @@ -198,20 +198,26 @@ end function M.update_output(bufnr, line, value, console_type, raw_value) local utils = require("consolelog.core.utils") - if not utils.is_javascript_buffer(bufnr) then + if not utils.is_supported_buffer(bufnr) then return end local consolelog = require("consolelog") if consolelog.project_root then - local buf_path = vim.api.nvim_buf_get_name(bufnr) - if buf_path and buf_path ~= "" then - if not buf_path:find(consolelog.project_root, 1, true) then - local debug_logger = require("consolelog.core.debug_logger") - debug_logger.log("DISPLAY", - string.format("Buffer %d (%s) not in project %s - ignoring", - bufnr, buf_path, consolelog.project_root)) - return + -- Exempt explicitly run single-file sessions from project-root containment: + -- these buffers were started via :ConsoleLogRun and should receive output + -- regardless of whether they live inside a JS project root. + local inspector = require("consolelog.communication.inspector") + if not inspector.single_file_buffers or not inspector.single_file_buffers[bufnr] then + local buf_path = vim.api.nvim_buf_get_name(bufnr) + if buf_path and buf_path ~= "" then + if not buf_path:find(consolelog.project_root, 1, true) then + local debug_logger = require("consolelog.core.debug_logger") + debug_logger.log("DISPLAY", + string.format("Buffer %d (%s) not in project %s - ignoring", + bufnr, buf_path, consolelog.project_root)) + return + end end end end diff --git a/lua/consolelog/processing/message_processor_impl.lua b/lua/consolelog/processing/message_processor_impl.lua index 193b3f1..90fc043 100644 --- a/lua/consolelog/processing/message_processor_impl.lua +++ b/lua/consolelog/processing/message_processor_impl.lua @@ -4,7 +4,7 @@ local line_matching = require("consolelog.processing.line_matching") local display = require("consolelog.display.display") local debug_logger = require("consolelog.core.debug_logger") -function M.format_args(args, method) +function M.format_args(args, method, preserve_literals) if not args or #args == 0 then return "[console." .. (method or "log") .. "]", nil end @@ -12,7 +12,7 @@ function M.format_args(args, method) debug_logger.log("NEW_PROCESSOR", string.format("Processing %d args: %s", #args, vim.inspect(args))) -- Handle ANSI format strings from browser console - if #args >= 1 and type(args[1]) == "string" then + if not preserve_literals and #args >= 1 and type(args[1]) == "string" then local first = args[1] if first:match("%%s") then first = first:gsub("\27%[[^m]*m", "") diff --git a/py/__pycache__/consolelog_runner.cpython-314.pyc b/py/__pycache__/consolelog_runner.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5451b55fa16892566455141a96bcf0aa8e936691 GIT binary patch literal 15859 zcmd6Oe{37qo#zZWBu5k_Q9mhLvNV!x*|cRUww&0q9sibpO0r5Tbh@jUp~aERgeI98 z%D>LZDs8Zlyy;3#f|c9`u+j@I_O-aWcg0=qwu|ew3tS5nyZXVJ%0$2h-QW)W&#`=H z+B@Lx^L@jap=2g$x(nPrNN?V}c|X7J`}_O2rL@G&A&rckeWAUBKlj&3ZRVK{x6H#+&sF7Nh)j{?i`yOPHPu`b7xehJU`h|hmtlPG4x8R0-4raxm*!vv@|Ne;2P=0y%W+Yh zUOo3*OA}`-q7i4;!v6i$BO;FUa5AAL<8pU$Sn9i)zK~2v%19z1E7Gq=-@@vCjdcpZUV zTcp9{#Z)96J0F(=QrE>;T9x9lguFE>$76VtqmqgdP!r#pQskl76*=lYdFFI4Mv^dF zawILONhu=r%E?Qyi&82+f`wpt2`Lp(R2glQkwIE&6f?06<<=x!z7UJcZVal*$|Wpb zN+gwwkvMjg99AM1B@CS!NeA4$T2(lF_;l}?)7@R&r;mlZLS4Q6;qV|pWF{9%I5}Z? zJD@(wg*bU5!FRxH6XFJP=fQCUg3)ilX_iEz)FBurhap&sV&r;^*|KsxgZ{Y3+Ha@! zb=-i*7_+a`C|S>@{O?|?-Y7L;#sQNegZ$3i`gi2^v}!fAM!$izMybR0*vj_&%dBi* zjWI`vGuHDZYpr~|M++x$164--fXQV#Y)&p(Mk{UP2F!l_=1e2DbvTO{x5vJYi}C*w zWB6^?o&>pFl9e=2IY(`mBJmMfN)vJu!f#b0L$ajE3}F*#kW4ZWxXWvoXA*(bRjo7( z%8Etev1@WTq6{kp;C1*{{~p;W_p!_KjhEkX&pi44jvv{7^vsWs%x*rItvxhfduXw& zYRt3bDxci&PF2>`Fz;%3V6zuBE>-%bcE7p%o#^cszWu_T>K_Z4hNl-Q_sx3tJ#unl z1zN9v&oR9&>)JT)+W2^n?^oTCzgPF;Xr|%tLgkTJ&k^+Ucqbiyv1eAO`Rq{{SHAY0 z-Lqw_k2s#qHSKt7&%CGMfgP{^>yi75KL1`_k1hSJ(0;g@`)T>E!#la3Ro5ThVgFeN zPx;RJBQ^HFtLBjpnzWA&M8TkKZu#fPNP-6Wpe@7&?IBxG2-$;<5Z~@d2?O}5r#@=E zDiz`;eI_@t8iSsN0h4D0od!MUi?*OE|IMH*P=Ps2Ux`^X3waVmY~lPxnuBqD&6UFn z&3RFV{D^A85IC*|TuLAZMyLuwL*w1OcU_t%tjLjgSm)`Q&b13Edmgm& zd(u!yU*Wv%dFSmYc;3!`=6Snt-XepuR%8l&r4Gwf>XB(eTE3EIr>r#Ky;o^O?iZBJ z)M7nl8z^H3MYv#{Qz<(qYs@Y9x*z{)FEah4>Q*08k%JwQ=PMqPLV|XMIH#om^uqFg z2aAgcqxl42igC2br6%qiZxBn0w+^V!6z@h$j_WJ3%0|gLCUX>9h?jpzBIv7+7UKJe zSXQM%Du6kg{p|K#m!TBCKr^C$0d=6y6ol5m#I6hxpA;-nx`+RoaFUDStVx81gbtuL z6mv1ec};cmDAs5wIKzN-J$I^@;~Kc2&{I`JPe+fJSXQCF#=6b^;0yKK*=>akg$`Q_ z?Z{M5tktLZQ$OOkknJ2#ZT!w_Z=a3CUzE}pNNJ1=zDNq-<%klMB8e#EE_BF*v?+34 zg|>R3jVY;#q+d!7u^vM)MNLaX&>Lm<dce&7=M@Vg>ZLc_exs597|vD+cjI@35~y~*|8#(xKXP#sx#7} zy~A=szLHY*X)WQwNIWhp;dpXT*Gu+NDX#7dn2o-UzAEwQ&whh-&erVzVcVUrO{!C` z+b1W8nGsWu`D|}P!H`}un z8|ErD%)D@~d7O0FGLdx#fMOJ?ba ztr7nzA;#UcX?CcYy$~VJ!>!vE%D3O` zzr(*Bm?^(s(Q>zc^yF;$c4z>T)zj`fPi2Jdp9qzc{j(9NoY zya3%>K0h+739-abQWLOFXyq`Vlgc2bGLr%P^iRCBGGmocYja2|kiMk(9zXK{eR!EVK)U0cAJ`m48gdYoxXW`=IHyU-#xuhwPUPn$x}OBlWp#pYwlQResZ?ysSh0g z_2vKk@_kRwXG>+ZWG!u2^mwzL`Z-VibSUdtPZraL->Y;U-#8dJxSjjk?e&K$>_08% zk>BNd{VruIzG?1oPR@aQd&1$DMk4W?q$?w?4uPt(>2O$ip4wN3VI4re42C1=v=Tc% zl9p9;Er%~A9#K`YYzO6dJRF8qT#{FbZbU&kyH-Ldvg|qvDk0csuLCT*8gYPtfUz^jOMiO2A`6MIz%b z9<>l*{Q)|at;f&gyuD`w+>igvSq$>rnA^iKrtmJFvFGFn zv;>g8$u2^6%aLVho5pN8%@GDi$POt3jgv_k7*Qd?%N=cIBYp8&OcrA{!&t^1p(Cx= z$F!uD!E{Ko{R8JHz`6>~Vd~gtd1I%*3TS6J`Dc%TvG=YpRY;gJHIVSyR007A7$Sz; zfGD-RkH)|T2zBq`DFPv%Qwy6`s)foE7}{@B2x}Q#pbQryUA8@uN_lLf=$)+Ej{NSa`q5kZSXC@BbIJn@0PDm#6H){R- zZMUEFLN|DF_a}!)QvFhAgEC!{o#cCr2=FoGJw>>RKV#agHBX7leZXD~tOp{Hwo4 zhJCv^(euXTiR7uOZkdnN}ti&ANx>GPZAo-<;0y{COkd=w}1X)}Z&oHOhM9$w! z$yij1!tol@Z7V;5Dg6otP<2xk$TuiUhUG=}s2mx*klPDG_@9;yV4>c7V5DrtqI++tPDnD9o? zqOT4Q&r)^MnDCeG&-ECI%RgbgzF3N{^cTU?bA)7OVFkO-g#lNke7_6H-a?Ez>hC|HF-YHr)#a_6DtWVHIg}xolGG9 z2|KESYRw+rXf@2#JQ%c=sp~`pEr;67bgruV{$#UB0 z8$zyeIij#;`jCiCDZpz^>8FgmzdF&Pny?9C^(6G|_Wryr z({^^eN4Iu-Gdteze77^((lOW4aqq}NOXqC!?hm>$o|B8UYsZefabmHqaqP%nc7IyL zd8&1UBEDv%972H?pca+_KLqYokINCDyut@$zvNi=g(Z-lST}%HqSHjj$yIYlvk%wXO!MZU+L_xtqdLX3B=oaGk@P$?w z;@ix=xO8I6PZU~LEl#HyoLSkTCF)Tx9q2YBn7lTlq4Zsc-XWsNx(zZi~g9 z8=W6kR(&jbCfwuhtXMlI)=rma#JZ2G*JP`=%vEn$sNOo}{vXBF4@AyeHF0&~>e$sq zZ&lXYGUsi{csDFn)=a+i)>C6W;1|aCrMfL2)Mx6Bg8I8rT~snrbfakUYu_sUR?%W< z#bk6Ub~BbCiBvP?zv(9*?acaxij8-gepRt$?C>HY*PS;zr~9V|C-;vXS*)$UU39Bx zre$XRbQ$9(U&f90yu0U4dZA^sA5L%;~!s2~S2@#9pEmh$--`cW!?!6sJDiv}nT0D$7LY++9=r|1Sh!~`W#Lhx8NJs1zG=-0unSRGm&UxuzK0@b( zK-ArDCSp?aRfx&dWCFTMu@8hSi_5-5_T=zl>_onpd83;gaK4$&nJss4^iMD57u=Ji#4}hx-DU(UkateE_*->7x;RY1 z8EObsx^@Lw=6(6!1OswyK#MD@nM_YzyLoM9$3oerS#c9vJ)}M39*m<9&*=4+=*s6H zUX&BNqxqrR<*0sG;c`e1D;gX%4-fF7qwcp=yu<&1L&lcN23T>CBL{I|*#k%d z;uu#?h2ULCmRsH+$Qhgb5>a80Pl2jZPRmT$3B@;CZ!yQsZEzF z5f6}+-R^Ax>4+?+WCWnoa16*4($t$vIrBV!oK3MIOn4P;gzcZ-4=yqV?mH|Pxjr7& z8M~NDD!7ulH5auuodc>Hj8G3~TPuhr;Ul2PcPud&ABo~B8yp3BE5Qmn0@9hQxXX5> zi`g5vCKQQA(h+GBdDt)@%!&lO7Urf9L62OD#NrX$7KM{R!s6N_IWQcc`6JSK@~1ri zyb_5a{+>7RreBZ#8~^jqlUqZLVIkPQ4j(L`7n)2bkR~rwQ)Eu4xh~^MKsbF~d77H+ zql~mrtvGLVCK=de7JsoO0w&=!?$ELOFkyC2PII!#^O{rNz2?@RVFa@<=6ZjooX)){%6ETnCQ_L$$DZHfB&>c^^GUM^Zn*6H)0Z zuA*wnchfi3c(ZY)W}$r3=*f>=CG)N|kV9i7nzZ1&__RR(M%?D0?+_5*? z(S^U+j;=QjO&l9PHgS6V^z?~rePFIWknyztd^fz7MWv*qRcC8j=W1GUd2d#1T`aDi zd}-|IS)o=JjkIecxljNz;+9)cin#NIqvatl6z4Pu|D0_X7*X^9V=9CSEETlWO|uEc zl-1HyvW>o+c_jwzOz~WZ?ZRzjvicQmN&KuzFm23xxG-(Ffl{NvA9}gRx>>t1*LC>F zAFykvcOKke+-|y`{$K1qDCF_xe76T1NDPaofffl3AP_~oqf%QgR zU#n5Fo_&6+Y?RKp4Y*)Ft2YGp>ZI0$+_(saUwOnua(7s*6Xr<`kT%DX^|jFNFcK!@ z2A(u}7`w2bmM|fNdlBUCWU0`vFQ9;NL1)ml(}JCKa!(mOt>>4REVw}{aRYme_PD3; z9|;>$_I`UG$)8mz=d?v}reY%l-E7*c2Qr7lT(mV!L4lX3cWvM(_Oii|$iGk;79 z#`|r(%6WW-3p;^_lB7(bEJc}$3?_3A9-4>6i;Xxm!Le;r*VL-Qml2ap;gVTi+*)fU z?}8O9FVyBJX>i*y(q}NI#j**{xF;(%&xy@5do$t_OX8Y2v1R(`yx3xXY@8DtXFT`C ztxO-HE0Z)!IE=fVA}*w|>!rQdY`Z1D?Kc<~)_1Ey*Zx?rhh5aaq|gN-%gojU`ARHp z>CKumrjlucAEU$+cFAcE+S8pS3bx~G0@v-*3I#bd@%%_Ej<6_xc*3sf=@)V=Koa=0 z;yj?}P`%6s>_&i6Ry0q3fS#G-no|$#Y7XoOzdd0aV^>ApXRfMg`C=CqtO@LfDAUpD z#vRj$aYr5My6$e+LX(snil}mzVUNK)_%78NIy*L~e4i?KS-Fj;GDF$BRKvz4Fz6Nm z$uZ@7$e60mlysKqt_V$_Ls0%3zPHkehJhS(qAvFv9TU68cYX8a$(J&&+9k0nE3Tar z*Ur}UWW-+cH6zw9iNzD*4RNykjp9#mz4Y1fXD1`y`qR;)OT{G>J-V-I^_K znlElLyUniKaW8c5_y>n(>-J~F17`io;c#!jTRCxM{K~ZLTi0JZ{MvC;Sf;{z74Q4r z^}XNtZsWb0g{EB#<(;D^7hUCVpP730=Cc{sI<~HHaiVOzY}%ax9ty74kH32S_0zAO zo;)~tEh98zxSI8^9e=HNsdB?Y<;KzOC87BB?pM3BLfxEDH{G2P{Es}`8Yx@5Wv+J1 ztWfOTF@XA9@4dBnLLyFV`WOguII)byH6ar4sVtv_h{ zw{71G%y~AB9iORLYTUi(t(^(s&V*Eoi)~{B17F>A=Ud5aO<=AjFk9VzN4e*}S2tVv z^w^Ol4{kl+zNcsVlBaCq)6xYuj{ zh40XoZkPR+E)QOQS#@w19)DlPBUk<%nj&z{*xUw%__6YzD5LuvI@e=H^WV_xPRib) ztQi@jL|EF2L^VIAj4_WID$xoQqB1=`@Bx*&DPyd`air_eGsiUHMDNkln(OSrVDE|E zW16EY7(5-+#KXZ8{U;6|>{j4wf-5k0ZAWJx|Aof*Ys%7;F+NNjgYgDN=8V#GDyL29 zjyzHxHF2-*&e*5?171Okoca(MZZY!wr#1&KESGY;_amPt@`uh!-v8UDx_RFJDM$IkbG8PosGC2+^S%eYHcs%2 VUCY?ld?<*2eiT=C1l^PTe*v@1-O>O6 literal 0 HcmV?d00001 diff --git a/py/consolelog_runner.py b/py/consolelog_runner.py new file mode 100644 index 0000000..2b11da2 --- /dev/null +++ b/py/consolelog_runner.py @@ -0,0 +1,417 @@ +"""ConsoleLog Python runner — captures print/logging/stderr/exception events. + +Stdlib only, Python 3.8+ compatible. Emits line-delimited sentinel-prefixed +JSON on stdout so a Neovim plugin can parse structured console events while +preserving normal program output. +""" + +import builtins +import json +import logging +import os +import runpy +import sys +import traceback + +SENTINEL = "__CONSOLELOG_EVENT__" +_RUN_ID = os.environ.get("CONSOLELOG_RUN_ID", "") + +# Capture the real stdout at startup — events go here, never to the +# (potentially replaced) sys.stdout. +_real_stdout = sys.stdout +_real_stderr = sys.stderr + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +def _serialize_arg(value): + """Convert a Python value to a JSON-safe representation.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + try: + return json.loads(json.dumps(value, default=repr)) + except (TypeError, ValueError): + return repr(value) + if isinstance(value, (list, tuple)): + try: + return json.loads(json.dumps(list(value), default=repr)) + except (TypeError, ValueError): + return repr(value) + try: + return repr(value) + except Exception: + return f"" + + +# --------------------------------------------------------------------------- +# Event emission +# --------------------------------------------------------------------------- + + +def emit_console(method, args, filename, lineno): + event = { + "event": "console", + "method": method, + "file": filename, + "line": lineno, + "args": args, + } + if _RUN_ID: + event["run_id"] = _RUN_ID + _real_stdout.write(SENTINEL + json.dumps(event, default=repr) + "\n") + _real_stdout.flush() + + +def emit_exception(text, filename, lineno): + event = { + "event": "exception", + "file": filename, + "line": lineno, + "text": text, + } + if _RUN_ID: + event["run_id"] = _RUN_ID + _real_stdout.write(SENTINEL + json.dumps(event, default=repr) + "\n") + _real_stdout.flush() + + +# --------------------------------------------------------------------------- +# Caller location helper +# --------------------------------------------------------------------------- + + +def _caller_location(skip_modules=()): + """Walk the stack outward and return (abspath, lineno) of the first frame + whose filename is not this runner and whose module is not in *skip_modules*. + """ + frame = sys._getframe(1) + this_file = os.path.abspath(__file__) + while frame is not None: + fname = os.path.abspath(frame.f_code.co_filename) + if fname != this_file: + mod = frame.f_globals.get("__name__", "") + if not any(mod.startswith(m) for m in skip_modules): + return fname, frame.f_lineno + frame = frame.f_back + # Fallback — shouldn't normally happen + return this_file, 1 + + +# --------------------------------------------------------------------------- +# print wrapper +# --------------------------------------------------------------------------- + +_original_print = builtins.print + + +def _patched_print(*args, **kwargs): + fname, lineno = _caller_location() + try: + serialized = [_serialize_arg(a) for a in args] + except Exception: + serialized = [] + for a in args: + try: + serialized.append( + repr(a) if not isinstance(a, (str, int, float, bool)) else a + ) + except Exception: + serialized.append(f"") + emit_console("log", serialized, fname, lineno) + # Forward to the real print so plain stdout is preserved + _original_print(*args, **kwargs) + + +# --------------------------------------------------------------------------- +# Logging handler +# --------------------------------------------------------------------------- + +_LEVEL_TO_METHOD = { + logging.DEBUG: "debug", + logging.INFO: "info", + logging.WARNING: "warn", + logging.ERROR: "error", + logging.CRITICAL: "error", +} + + +class _ConsoleLogHandler(logging.Handler): + def emit(self, record): + try: + method = _LEVEL_TO_METHOD.get(record.levelno, "log") + fname = os.path.abspath(record.pathname) + emit_console(method, [record.getMessage()], fname, record.lineno) + except Exception: + self.handleError(record) + + +# --------------------------------------------------------------------------- +# Logger.callHandlers patch — preserve default basicConfig behaviour +# --------------------------------------------------------------------------- + +# The module-level logging functions (logging.warning, etc.) check +# ``len(root.handlers) == 0`` before calling basicConfig(). Our handler +# on root makes this check fail, so basicConfig() never runs and the +# default StreamHandler (which produces ``WARNING:root:msg`` on stderr) +# is never installed. We patch callHandlers to detect the condition +# (root has only _ConsoleLogHandler instances) and call the *original* +# basicConfig before the iteration begins. + +_original_callHandlers = logging.Logger.callHandlers +_orig_basicConfig = None # set in main() before handler installation + + +def _patched_callHandlers(self, record): + # Only trigger the root-bootstrap logic for the root logger itself. + # Named loggers (including non-propagating ones) must not pre-install + # root's default handler, which would silence a later target + # logging.basicConfig(...) call. + if self is logging.root and _orig_basicConfig is not None: + root_only_ours = not any( + h for h in logging.root.handlers if not isinstance(h, _ConsoleLogHandler) + ) + if root_only_ours: + # Temporarily remove our handler so basicConfig() sees an empty list + ours = [ + h for h in logging.root.handlers if isinstance(h, _ConsoleLogHandler) + ] + for h in ours: + logging.root.removeHandler(h) + _orig_basicConfig() + for h in ours: + logging.root.addHandler(h) + _original_callHandlers(self, record) + + +# --------------------------------------------------------------------------- +# stderr proxy +# --------------------------------------------------------------------------- + + +class _StderrProxy: + """Wraps sys.stderr, buffering writes until a newline completes a line, + then emitting a console/error event. Writes originating from the logging + package are forwarded without emitting (to avoid duplication). + + Tracks the source location of each buffered fragment so that partial writes + followed by logging output or exception-triggered flushes retain the correct + caller attribution. + """ + + _STDERR_SKIP_MODULES = ("logging", "warnings", "traceback") + + def __init__(self): + self._buf = "" + self._source = None # (filename, lineno) of the buffer's origin + self._source_set = False # whether _source has been attributed + self._real = _real_stderr + + def _is_logging_frame(self): + frame = sys._getframe(1) + while frame is not None: + mod = frame.f_globals.get("__name__", "") + if mod.startswith("logging"): + return True + frame = frame.f_back + return False + + def _caller_location(self): + return _caller_location(skip_modules=self._STDERR_SKIP_MODULES) + + def _emit_buf_line(self): + """Emit the current buffer contents as an error event using the stored + source location, then clear the buffer.""" + if self._buf: + fname, lineno = self._source or self._caller_location() + emit_console("error", [self._buf.rstrip("\n")], fname, lineno) + self._buf = "" + self._source = None + self._source_set = False + + def write(self, s): + if not isinstance(s, str): + # Native stderr raises TypeError for non-string writes; do the same + # so caller code gets the expected exception behaviour. + self._real.write(s) # raises TypeError + return 0 # unreachable, but satisfies type checkers + # Always forward to real stderr immediately + self._real.write(s) + + is_logging = self._is_logging_frame() + + if is_logging: + # Don't buffer logging writes — the logging handler already emits + # events through our _ConsoleLogHandler. But first, flush any + # pending non-logging content so it isn't lost or concatenated. + if self._buf: + self._emit_buf_line() + return len(s) + + # Non-logging write: capture source location when buffer is empty + if not self._buf: + if not self._source_set: + self._source = self._caller_location() + self._source_set = True + + # Buffer for event emission + self._buf += s + while "\n" in self._buf: + idx = self._buf.index("\n") + 1 + line = self._buf[:idx] + self._buf = self._buf[idx:] + fname, lineno = self._source or self._caller_location() + emit_console("error", [line.rstrip("\n")], fname, lineno) + if self._buf: + # Remaining partial content retains the current source location + # so that flush() attributes it to the original caller. + pass + else: + self._source = None + self._source_set = False + return len(s) + + def flush(self): + # Emit any remaining partial line using the stored source location + if self._buf: + fname, lineno = self._source or self._caller_location() + remaining = self._buf + self._buf = "" + self._source = None + self._source_set = False + emit_console("error", [remaining], fname, lineno) + self._real.flush() + + def writelines(self, lines): + for line in lines: + self.write(line) + + def __getattr__(self, name): + return getattr(self._real, name) + + +def _resolve_exception_location(exc_type, exc_value, exc_tb, target): + """Resolve (filename, lineno) for an exception, preferring target file frames. + + Selection order: + 1. Deepest traceback frame in the target file (if any). + 2. SyntaxError metadata when a target-frame traceback exists but no frame + matched the target (compile-time errors have accurate lineno/filename + but the traceback originates in runpy). + 3. Deepest traceback frame overall (fallback for imported-module exceptions + whose traceback does not include the target file). + 4. SyntaxError metadata as last resort (bare ``raise SyntaxError(…)``). + """ + target_abs = os.path.abspath(target) + # Walk traceback: prefer the deepest target-frame, otherwise the deepest frame + if exc_tb is not None: + deepest_frame = None + target_frame = None + for frame, lineno in traceback.walk_tb(exc_tb): + frame_fname = os.path.abspath(frame.f_code.co_filename) + deepest_frame = (frame_fname, lineno) + if frame_fname == target_abs: + target_frame = (frame_fname, lineno) + if target_frame is not None: + return target_frame + # No target frame found — prefer SyntaxError metadata over a + # non-target deepest frame (e.g. runpy internals for compile errors). + if isinstance(exc_value, SyntaxError) and exc_value.lineno is not None: + fname = ( + os.path.abspath(exc_value.filename) + if exc_value.filename + else target_abs + ) + return fname, exc_value.lineno + if deepest_frame is not None: + return deepest_frame + # SyntaxError metadata — last resort when no traceback is available + if isinstance(exc_value, SyntaxError) and exc_value.lineno is not None: + fname = ( + os.path.abspath(exc_value.filename) if exc_value.filename else target_abs + ) + return fname, exc_value.lineno + return target_abs, 1 + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main(): + if len(sys.argv) < 2: + _real_stderr.write("Usage: python consolelog_runner.py \n") + _real_stderr.flush() + sys.exit(2) + + target = sys.argv[1] + if not os.path.isfile(target): + _real_stderr.write(f"Error: file not found: {target}\n") + _real_stderr.flush() + sys.exit(2) + + # Shift argv so the target script sees sys.argv[0] == its own path + sys.argv = sys.argv[1:] + + # Insert the target's directory at the front of sys.path + target_dir = os.path.dirname(os.path.abspath(target)) + if target_dir in sys.path: + sys.path.remove(target_dir) + sys.path.insert(0, target_dir) + + # Install hooks + builtins.print = _patched_print + root_handler = _ConsoleLogHandler() + logging.root.addHandler(root_handler) + + # Wrap basicConfig so it works with our pre-attached handler: + # temporarily remove our handler, let basicConfig configure normally, + # then reattach ours. + global _orig_basicConfig + _orig_basicConfig = logging.basicConfig + + def _wrapped_basicConfig(**kwargs): + logging.root.removeHandler(root_handler) + _orig_basicConfig(**kwargs) + logging.root.addHandler(root_handler) + + logging.basicConfig = _wrapped_basicConfig + + # Patch callHandlers so basicConfig() is triggered when root has only + # our handler (preserving Python's default stderr logging output). + logging.Logger.callHandlers = _patched_callHandlers + + sys.stderr = _StderrProxy() + + try: + runpy.run_path(target, run_name="__main__") + except SystemExit as e: + # SystemExit with code 0 or None is a clean exit — no exception event + if e.code not in (0, None): + exc_type, exc_value, exc_tb = sys.exc_info() + exc_file, lineno = _resolve_exception_location( + exc_type, exc_value, exc_tb, target + ) + text = f"{type(exc_value).__name__}: {exc_value}" + emit_exception(text, exc_file, lineno) + sys.stderr.flush() + sys.exit(e.code if e.code is not None else 1) + except BaseException: + exc_type, exc_value, exc_tb = sys.exc_info() + exc_file, lineno = _resolve_exception_location( + exc_type, exc_value, exc_tb, target + ) + text = f"{exc_type.__name__}: {exc_value}" + emit_exception(text, exc_file, lineno) + sys.stderr.flush() + sys.exit(1) + # Clean exit — flush any buffered stderr events so partial writes are emitted + sys.stderr.flush() + + +if __name__ == "__main__": + main() diff --git a/tests/lua/display_spec.lua b/tests/lua/display_spec.lua index 61f26ff..31468e9 100644 --- a/tests/lua/display_spec.lua +++ b/tests/lua/display_spec.lua @@ -251,6 +251,64 @@ describe("Display Module", function() assert.is_true(display.is_tracked_buffer(test_bufnr), "Should be tracked after update") end) + + it("should accept output for python buffers via update_output", function() + setup() + vim.bo[test_bufnr].filetype = "python" + consolelog_mock.outputs[test_bufnr] = {} + + display.update_output(test_bufnr, 1, "hello from python", "log") + + assert.is_true(display.is_tracked_buffer(test_bufnr), "Python buffer should be tracked") + end) + + it("should allow tracked single-file buffers outside project root", function() + setup() + -- Set a project root that does NOT contain the buffer path + consolelog_mock.project_root = "/home/user/my-js-project" + vim.api.nvim_buf_set_name(test_bufnr, "/tmp/standalone.py") + vim.bo[test_bufnr].filetype = "python" + consolelog_mock.outputs[test_bufnr] = {} + + -- Register the buffer as an explicitly run single-file session + local inspector_mock = { + single_file_buffers = { [test_bufnr] = "/tmp/standalone.py" }, + } + package.loaded["consolelog.communication.inspector"] = inspector_mock + + display.update_output(test_bufnr, 1, "output from standalone", "log") + + assert.is_true(display.is_tracked_buffer(test_bufnr), + "Tracked single-file buffer should receive output despite being outside project root") + + -- Cleanup + package.loaded["consolelog.communication.inspector"] = nil + consolelog_mock.project_root = nil + end) + + it("should discard non-tracked buffers outside project root", function() + setup() + consolelog_mock.project_root = "/home/user/my-js-project" + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, "/tmp/untracked.py") + vim.bo[bufnr2].filetype = "python" + consolelog_mock.outputs[bufnr2] = {} + + -- NOT registered in single_file_buffers — should be discarded + local inspector_mock = { + single_file_buffers = {}, + } + package.loaded["consolelog.communication.inspector"] = inspector_mock + + display.update_output(bufnr2, 1, "should be discarded", "log") + + assert.is_false(display.is_tracked_buffer(bufnr2), + "Non-tracked buffer outside project root should not receive output") + + -- Cleanup + package.loaded["consolelog.communication.inspector"] = nil + consolelog_mock.project_root = nil + end) end) describe("History management", function() @@ -433,7 +491,27 @@ describe("Display Module", function() assert.equals(#extmarks, 0, "Should not create extmarks for empty outputs") end) - it("should handle non-javascript buffers", function() + it("should handle non-supported buffers", function() + setup() + vim.bo[test_bufnr].filetype = "markdown" + + consolelog_mock.outputs[test_bufnr] = { + { line = 1, value = "test", console_type = "log", type = "string" } + } + + display.show_outputs(test_bufnr) + + local extmarks = vim.api.nvim_buf_get_extmarks( + test_bufnr, + consolelog_mock.namespace, + 0, + -1, + {} + ) + assert.equals(#extmarks, 0, "Should not show outputs for unsupported buffer") + end) + + it("should show outputs for python buffers", function() setup() vim.bo[test_bufnr].filetype = "python" @@ -450,7 +528,7 @@ describe("Display Module", function() -1, {} ) - assert.equals(#extmarks, 0, "Should not show outputs for non-JS buffer") + assert.equals(#extmarks, 1, "Should show outputs for python buffer") end) it("should prevent recursive calls with flag", function() diff --git a/tests/lua/integration_spec.lua b/tests/lua/integration_spec.lua index 6ca912e..c35a90a 100644 --- a/tests/lua/integration_spec.lua +++ b/tests/lua/integration_spec.lua @@ -15,8 +15,10 @@ describe("Integration Tests", function() -- Load the main module consolelog = require('consolelog.core.init') - -- Set project root to /tmp so test buffers are considered in-project - consolelog.project_root = "/tmp" + -- Set project root to /tmp so test buffers are considered in-project. + -- Resolve symlinks (e.g. /tmp -> /private/tmp on macOS) so the root + -- matches the buffer name that nvim_buf_set_name normalises. + consolelog.project_root = vim.fn.resolve("/tmp") -- Create test buffer with unique name test_counter = test_counter + 1 @@ -199,7 +201,7 @@ describe("Integration Tests", function() type = "console", method = "log", location = { - file = string.format("/tmp/test_%d.js", test_counter), + file = string.format("test_%d.js", test_counter), line = 1 }, args = {"test output"} diff --git a/tests/lua/python_capture_spec.lua b/tests/lua/python_capture_spec.lua new file mode 100644 index 0000000..2e6fda8 --- /dev/null +++ b/tests/lua/python_capture_spec.lua @@ -0,0 +1,391 @@ +local helper = require('tests.lua.test_helper') +local assert = helper.assert +local describe = helper.describe +local it = helper.it + +package.path = package.path .. ";./lua/?.lua" + +-- Skip if python3 is not available +if vim.fn.executable("python3") == 0 then + print("SKIP: python3 not available, skipping python_capture_spec") + return +end + +local SENTINEL = "__CONSOLELOG_EVENT__" +local SPEC_TARGET = "/tmp/consolelog_py_spec_target.py" + +local function write_target(content) + local f = io.open(SPEC_TARGET, "w") + f:write(content) + f:close() +end + +local function parse_events(output) + local events = {} + for line in output:gmatch("[^\n]+") do + -- Find sentinel anywhere in the line (not just at start) because + -- vim.fn.system merges stderr and stdout, so real stderr content + -- may precede the sentinel on the same line. + local sentinel_pos = line:find(SENTINEL, 1, true) + if sentinel_pos then + local json_str = line:sub(sentinel_pos + #SENTINEL) + local ok, event = pcall(vim.json.decode, json_str) + if ok and event then + table.insert(events, event) + end + end + end + return events +end + +local function find_event(events, matcher) + for _, event in ipairs(events) do + if matcher(event) then + return event + end + end + return nil +end + +describe("Python Capture Spec", function() + it("should capture print, logging, stderr, and exception events", function() + write_target([[ +import logging, sys +print("hello", 42) +print({"a": 1, "b": 2}) +logging.warning("warned") +sys.stderr.write("raw stderr\n") +x = 1 / 0 +]]) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- console/log event from print("hello", 42) on line 2 + local print_hello = find_event(events, function(e) + return e.event == "console" and e.method == "log" and e.line == 2 + end) + assert.not_nil(print_hello, "Should capture print('hello', 42) event") + assert.equals(print_hello.args[1], "hello", "First arg should be 'hello'") + assert.equals(print_hello.args[2], 42, "Second arg should be 42") + assert.is_true(print_hello.file:find("consolelog_py_spec_target%.py$") ~= nil, + "File should end with 'consolelog_py_spec_target.py'") + + -- console/log event from print({"a": 1, "b": 2}) on line 3 + local print_dict = find_event(events, function(e) + return e.event == "console" and e.method == "log" and e.line == 3 + end) + assert.not_nil(print_dict, "Should capture print(dict) event") + assert.is_true(type(print_dict.args[1]) == "table", "Dict arg should be a table") + assert.equals(print_dict.args[1].a, 1, "Dict key 'a' should be 1") + assert.equals(print_dict.args[1].b, 2, "Dict key 'b' should be 2") + + -- console/warn event from logging.warning on line 4 + local log_warn = find_event(events, function(e) + return e.event == "console" and e.method == "warn" and e.line == 4 + end) + assert.not_nil(log_warn, "Should capture logging.warning event") + assert.equals(log_warn.args[1], "warned", "Warning message should be 'warned'") + + -- console/error event from stderr.write on line 5 + local stderr_event = find_event(events, function(e) + return e.event == "console" and e.method == "error" and e.line == 5 + end) + assert.not_nil(stderr_event, "Should capture stderr.write event") + assert.equals(stderr_event.args[1], "raw stderr", "Stderr content should be 'raw stderr'") + + -- exception event from ZeroDivisionError on line 6 + local exc_event = find_event(events, function(e) + return e.event == "exception" and e.line == 6 + end) + assert.not_nil(exc_event, "Should capture ZeroDivisionError exception event") + assert.is_true(exc_event.text:find("ZeroDivisionError") ~= nil, + "Exception text should contain ZeroDivisionError") + end) + + it("should exit cleanly for programs without exceptions", function() + write_target('print("ok")\n') + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- No exception event + local exc = find_event(events, function(e) + return e.event == "exception" + end) + assert.is_nil(exc, "Should not emit exception event for clean exit") + + assert.equals(vim.v.shell_error, 0, "Shell error should be 0 for clean exit") + end) + + it("should preserve raw stdout output alongside events", function() + write_target('print("hello", 42)\n') + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + + -- The raw "hello 42" text should still be in the output + assert.is_true(out:find("hello 42") ~= nil, + "Raw stdout 'hello 42' should be preserved in output") + end) + + it("should preserve %s literals in print events", function() + write_target('print("rate: 100%s")\n') + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + local print_event = find_event(events, function(e) + return e.event == "console" and e.method == "log" + end) + assert.not_nil(print_event, "Should capture print event") + assert.equals(print_event.args[1], "rate: 100%s", + "Percent-s literal should be preserved in Python print output") + end) + + it("should not suppress buffered stderr when logging writes after", function() + write_target([[ +import sys, logging +sys.stderr.write("prefix") +logging.warning("logged") +]]) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- The "prefix" write has no newline, so it buffers. Then logging writes. + -- The buffered "prefix" must still be emitted as an error event. + local prefix_event = find_event(events, function(e) + return e.event == "console" and e.method == "error" and e.args[1] == "prefix" + end) + assert.not_nil(prefix_event, + "Should capture 'prefix' stderr event even when logging writes after it") + + -- The logging warn event should also be present + local warn_event = find_event(events, function(e) + return e.event == "console" and e.method == "warn" + end) + assert.not_nil(warn_event, "Should capture logging.warning event") + end) + + it("should attribute partial stderr to target line, not runner", function() + write_target([[ +import sys +sys.stderr.write("partial") +x = 1 / 0 +]]) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- "partial" is written on line 2 of the target — the error event must + -- carry that line number, not a line from the runner's exception handler. + local partial_event = find_event(events, function(e) + return e.event == "console" and e.method == "error" and e.args[1] == "partial" + end) + assert.not_nil(partial_event, "Should capture 'partial' stderr event") + assert.equals(partial_event.line, 2, + "Partial stderr should be attributed to target line 2, not runner") + + -- Exception should also be present + local exc_event = find_event(events, function(e) + return e.event == "exception" + end) + assert.not_nil(exc_event, "Should capture exception event") + end) + + it("should attribute both fragments of complete\\npartial stderr to target line", function() + write_target([[ +import sys +sys.stderr.write("complete\npartial") +x = 1 / 0 +]]) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- The "complete" fragment should be attributed to line 2 + local complete_event = find_event(events, function(e) + return e.event == "console" and e.method == "error" and e.args[1] == "complete" + end) + assert.not_nil(complete_event, "Should capture 'complete' stderr event") + assert.equals(complete_event.line, 2, + "Complete stderr should be attributed to target line 2") + + -- The "partial" fragment (flushed by exception handler) should also be + -- attributed to line 2 — the original write line — not the runner. + local partial_event = find_event(events, function(e) + return e.event == "console" and e.method == "error" and e.args[1] == "partial" + end) + assert.not_nil(partial_event, "Should capture 'partial' stderr event") + assert.equals(partial_event.line, 2, + "Partial stderr should be attributed to target line 2, not runner") + + -- Exception should be present + local exc_event = find_event(events, function(e) + return e.event == "exception" + end) + assert.not_nil(exc_event, "Should capture exception event") + end) + + it("should attribute imported-module exceptions to the deepest traceback frame", function() + -- Create an imported module that raises at a specific line + local mod_path = "/tmp/consolelog_py_spec_failing_mod.py" + local mf = io.open(mod_path, "w") + mf:write("x = 1\ny = 2\nz = 1 / 0\n") + mf:close() + + -- Target imports the module — exception originates in the module, not target + write_target(string.format("import %s\n", mod_path:match("([^/]+)%.py$"))) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + local exc = find_event(events, function(e) + return e.event == "exception" + end) + assert.not_nil(exc, "Should capture exception from imported module") + assert.is_true(exc.text:find("ZeroDivisionError") ~= nil, + "Exception text should contain ZeroDivisionError") + -- The traceback includes the target's import line, so the exception should + -- point to the target file (the import line), not the imported module. + assert.is_true(exc.file:find("consolelog_py_spec_target%.py") ~= nil, + "Exception file should reference the target file's import line") + assert.equals(exc.line, 1, "Exception line should be the import line in the target") + + os.remove(mod_path) + end) + + it("should attribute imported-module SyntaxError to the target import line", function() + -- Create a module with a syntax error + local mod_path = "/tmp/consolelog_py_spec_syntax_mod.py" + local mf = io.open(mod_path, "w") + mf:write("def foo(\n pass\n") + mf:close() + + local mod_name = mod_path:match("([^/]+)%.py$") + write_target("import " .. mod_name .. "\n") + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + local exc = find_event(events, function(e) + return e.event == "exception" + end) + assert.not_nil(exc, "Should capture SyntaxError from imported module") + assert.is_true(exc.text:find("SyntaxError") ~= nil, + "Exception text should contain SyntaxError") + -- The traceback includes the target's import line, so the exception should + -- point to the target file (the import line), not the imported module. + assert.is_true(exc.file:find("consolelog_py_spec_target%.py") ~= nil, + "Exception file should reference the target file's import line") + assert.equals(exc.line, 1, "Exception line should be the import line in the target") + + os.remove(mod_name) + os.remove(mod_path) + end) + +it("should not prevent basicConfig when named logger logs first", function() + -- A target that uses a named logger with propagate=False, then calls basicConfig. + -- The callHandlers patch must not pre-install root's default handler, + -- which would make basicConfig a no op. + -- Note: with propagate=False, the named logger's records never reach root, + -- so they won't produce console events. The key test is that basicConfig + -- still takes effect (its format appears on stderr for root logger output). + write_target([[ +import logging + +logger = logging.getLogger("myns") +logger.propagate = False +logger.warning("before config") + +logging.basicConfig(format="CUSTOM:%(message)s") +# Log through root logger to verify basicConfig took effect +logging.warning("after config") +]]) + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + -- The root logger warning after basicConfig should produce a console event + local root_warn = find_event(events, function(e) + return e.event == "console" and e.method == "warn" and e.args[1] == "after config" + end) + assert.not_nil(root_warn, "Should capture root logger warning event") + + -- The raw stderr should contain the custom format from basicConfig, + -- proving that basicConfig was NOT silenced by our handler bootstrap. + assert.is_true(out:find("CUSTOM:after config") ~= nil, + "basicConfig format should be applied — callHandlers must not pre-install root handler") + end) + + it("should attribute target-file compile-time SyntaxError to the target line", function() + -- The target itself has a syntax error (unmatched parenthesis on line 1) + write_target("def foo(\n pass\n") + + local out = vim.fn.system({ "python3", "py/consolelog_runner.py", SPEC_TARGET }) + local events = parse_events(out) + + local exc = find_event(events, function(e) + return e.event == "exception" + end) + assert.not_nil(exc, "Should capture compile-time SyntaxError") + assert.is_true(exc.text:find("SyntaxError") ~= nil, + "Exception text should contain SyntaxError") + assert.is_true(exc.file:find("consolelog_py_spec_target%.py") ~= nil, + "Exception file should reference the target file") + assert.equals(exc.line, 1, + "Exception line should be line 1 of the target (the def with unclosed paren)") + end) + + -- Cleanup + os.remove(SPEC_TARGET) +end) + +describe("format_args preserve_literals", function() + local message_processor + + local function setup() + -- Stub dependencies for real message_processor_impl + package.loaded["consolelog.processing.line_matching"] = { + match_by_file_and_command = function() return nil, nil, nil end, + reset = function() end, + get_state_info = function() return {} end, + } + package.loaded["consolelog.core.debug_logger"] = { + log = function() end, + } + package.loaded["consolelog"] = { + config = { websocket = { display_methods = { "log", "error", "warn" } } }, + } + package.loaded["consolelog.display.display"] = { + update_output = function() end, + } + -- Clear cached module to force fresh require + package.loaded["consolelog.processing.message_processor_impl"] = nil + message_processor = require("consolelog.processing.message_processor_impl") + end + + local function teardown() + package.loaded["consolelog.processing.line_matching"] = nil + package.loaded["consolelog.core.debug_logger"] = nil + package.loaded["consolelog"] = nil + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + end + + it("should strip %s from browser format strings by default", function() + setup() + local output = message_processor.format_args({ "rate: 100%s" }, "log") + assert.is_true(output:find("%%s") == nil, + "Default format_args should strip %s from browser format strings") + teardown() + end) + + it("should preserve %s literals when preserve_literals is true", function() + setup() + local output = message_processor.format_args({ "rate: 100%s" }, "log", true) + assert.is_true(output:find("100%%s") ~= nil, + "format_args with preserve_literals=true should keep %s in Python output") + teardown() + end) +end) \ No newline at end of file diff --git a/tests/lua/python_runner_spec.lua b/tests/lua/python_runner_spec.lua new file mode 100644 index 0000000..ba27b6b --- /dev/null +++ b/tests/lua/python_runner_spec.lua @@ -0,0 +1,982 @@ +local helper = require('tests.lua.test_helper') +local assert = helper.assert +local describe = helper.describe +local it = helper.it + +package.path = package.path .. ";./lua/?.lua" + +describe("Python Runner", function() + describe("parse_event", function() + local python_runner + + local function setup() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + end + + it("should return decoded table for valid event line", function() + setup() + local line = '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["hi"]}' + local result = python_runner.parse_event(line) + assert.not_nil(result) + assert.equals("console", result.event) + assert.equals("log", result.method) + assert.equals("/tmp/a.py", result.file) + assert.equals(2, result.line) + assert.equals("hi", result.args[1]) + teardown() + end) + + it("should return nil for plain text line", function() + setup() + local result = python_runner.parse_event("just some output") + assert.is_nil(result) + teardown() + end) + + it("should return nil for sentinel with malformed JSON", function() + setup() + local result = python_runner.parse_event('__CONSOLELOG_EVENT__{bad json') + assert.is_nil(result) + teardown() + end) + + it("should return nil for sentinel with JSON missing event field", function() + setup() + local result = python_runner.parse_event('__CONSOLELOG_EVENT__{"method":"log"}') + assert.is_nil(result) + teardown() + end) + + it("should return decoded table when raw text precedes sentinel", function() + setup() + local line = 'raw output__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["hi"]}' + local result = python_runner.parse_event(line) + assert.not_nil(result) + assert.equals("console", result.event) + assert.equals("log", result.method) + assert.equals(2, result.line) + teardown() + end) + end) + + describe("resolve_python_executable", function() + local python_runner + local saved_executable + local executable_results = {} + + local function setup() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + saved_executable = vim.fn.executable + vim.fn.executable = function(name) + return executable_results[name] or 0 + end + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + vim.fn.executable = saved_executable + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + executable_results = {} + end + + it("should return config override when set and executable", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = "/custom/python3" } }, + } + executable_results["/custom/python3"] = 1 + local exe = python_runner.resolve_python_executable("/tmp/x.py") + assert.equals("/custom/python3", exe) + teardown() + end) + + it("should return nil when config override not executable", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = "/nonexistent/python" } }, + } + executable_results["/nonexistent/python"] = 0 + -- Set up VIRTUAL_ENV so it can fall through + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = nil + executable_results["python3"] = 1 + local exe = python_runner.resolve_python_executable("/tmp/x.py") + assert.equals("python3", exe) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + + it("should use VIRTUAL_ENV when set and python is executable", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = "/home/user/.venv" + executable_results["/home/user/.venv/bin/python"] = 1 + local exe = python_runner.resolve_python_executable("/tmp/x.py") + assert.equals("/home/user/.venv/bin/python", exe) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + + it("should resolve ancestor .venv/bin/python over python3 fallback", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = nil + -- File is in /tmp/projects/src/a.py; ancestor .venv is at /tmp/projects/.venv + executable_results["/tmp/projects/.venv/bin/python"] = 1 + executable_results["python3"] = 1 + local exe = python_runner.resolve_python_executable("/tmp/projects/src/a.py") + assert.equals("/tmp/projects/.venv/bin/python", exe) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + + it("should fall back to python3", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = nil + executable_results["python3"] = 1 + local exe = python_runner.resolve_python_executable("/tmp/x.py") + assert.equals("python3", exe) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + + it("should return nil error when no interpreter found", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = nil + executable_results["python3"] = 0 + local exe, err = python_runner.resolve_python_executable("/tmp/x.py") + assert.is_nil(exe) + assert.not_nil(err) + assert.is_true(err:find("No Python interpreter") ~= nil) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + end) + + describe("build_run_command", function() + local python_runner + local saved_executable + local executable_results = {} + local saved_filereadable + local filereadable_results = {} + + local function setup() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + saved_executable = vim.fn.executable + vim.fn.executable = function(name) + return executable_results[name] or 0 + end + saved_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + return filereadable_results[path] or 0 + end + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + executable_results = {} + filereadable_results = {} + end + + it("should return command with runner script path", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + executable_results["python3"] = 1 + + -- Make the runner script path filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + filereadable_results[runner_path] = 1 + + local cmd, err = python_runner.build_run_command("/tmp/x.py") + assert.not_nil(cmd) + assert.is_nil(err) + assert.equals(3, #cmd) + assert.equals("python3", cmd[1]) + assert.is_true(cmd[2]:match("py/consolelog_runner%.py$") ~= nil) + assert.equals("/tmp/x.py", cmd[3]) + teardown() + end) + + it("should find the real runner script without stubbing filereadable", function() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + local real_filereadable = vim.fn.filereadable + local runner = require("consolelog.communication.python_runner") + local script = runner.get_runner_script() + vim.fn.filereadable = real_filereadable + assert.not_nil(script, "get_runner_script() should find the real py/consolelog_runner.py") + assert.is_true(script:match("py/consolelog_runner%.py$") ~= nil, "script path should end with py/consolelog_runner.py") + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + end) + + it("should return nil and error when no interpreter found", function() + setup() + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + local saved_env = os.getenv("VIRTUAL_ENV") + vim.env.VIRTUAL_ENV = nil + executable_results["python3"] = 0 + + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + filereadable_results[runner_path] = 1 + + local cmd, err = python_runner.build_run_command("/tmp/x.py") + assert.is_nil(cmd) + assert.not_nil(err) + vim.env.VIRTUAL_ENV = saved_env + teardown() + end) + end) + + describe("start_debug_session", function() + local python_runner + + local function setup() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + python_runner.sessions = {} + python_runner._intentionally_stopped_jobs = {} + end + + it("should register session when jobstart returns positive id", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local saved_jobstart = vim.fn.jobstart + local saved_notify = vim.notify + vim.notify = function() end + vim.fn.jobstart = function(cmd, opts) + return 123 + end + + -- Make build_run_command succeed + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local session_id = python_runner.start_debug_session("/tmp/x.py", 7) + + vim.fn.jobstart = saved_jobstart + vim.notify = saved_notify + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + + assert.not_nil(session_id) + assert.not_nil(python_runner.sessions[session_id]) + assert.equals("/tmp/x.py", python_runner.sessions[session_id].filepath) + assert.equals(7, python_runner.sessions[session_id].bufnr) + assert.equals("/tmp/x.py", inspector.single_file_buffers[7]) + teardown() + end) + + it("should return nil when jobstart returns -1", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local saved_jobstart = vim.fn.jobstart + local saved_notify = vim.notify + vim.notify = function() end + vim.fn.jobstart = function(cmd, opts) + return -1 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local session_id = python_runner.start_debug_session("/tmp/x.py", 7) + + vim.fn.jobstart = saved_jobstart + vim.notify = saved_notify + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + + assert.is_nil(session_id) + assert.is_nil(inspector.single_file_buffers[7]) + teardown() + end) + end) + + describe("handle_event", function() + local python_runner + local update_output_calls + + local function setup() + update_output_calls = {} + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function(bufnr, line, output, method, raw_value) + table.insert(update_output_calls, { + bufnr = bufnr, + line = line, + output = output, + method = method, + raw_value = raw_value, + }) + end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + package.loaded["consolelog.communication.python_runner"] = nil + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + package.loaded["consolelog.communication.python_runner"] = nil + update_output_calls = {} + end + + it("should call update_output for console event matching session filepath", function() + setup() + local session = { filepath = "/tmp/a.py", bufnr = 5 } + local event = { + event = "console", + method = "log", + file = "/tmp/a.py", + line = 3, + args = {"hello"}, + } + python_runner.handle_event(session, event) + helper.async.wait(50) + assert.equals(1, #update_output_calls) + assert.equals(5, update_output_calls[1].bufnr) + assert.equals(3, update_output_calls[1].line) + assert.equals("log", update_output_calls[1].method) + assert.equals("hello", update_output_calls[1].output, "rendered console text should match format_args output") + assert.equals("hello", update_output_calls[1].raw_value, "raw_value should match format_args raw_value") + teardown() + end) + + it("should not call update_output for event with different filepath", function() + setup() + local session = { filepath = "/tmp/a.py", bufnr = 5 } + local event = { + event = "console", + method = "log", + file = "/tmp/b.py", + line = 3, + args = {"hello"}, + } + python_runner.handle_event(session, event) + assert.equals(0, #update_output_calls) + teardown() + end) + + it("should call update_output with error method for exception event", function() + setup() + local session = { filepath = "/tmp/a.py", bufnr = 5 } + local event = { + event = "exception", + file = "/tmp/a.py", + line = 10, + text = "Traceback (most recent call last):\n File \"/tmp/a.py\", line 10\nNameError: name 'x' is not defined", + } + python_runner.handle_event(session, event) + helper.async.wait(50) + assert.equals(1, #update_output_calls) + assert.equals(10, update_output_calls[1].line) + assert.equals("error", update_output_calls[1].method) + assert.equals("Traceback (most recent call last):", update_output_calls[1].output, "output should be first line of exception text") + assert.equals("Traceback (most recent call last):\n File \"/tmp/a.py\", line 10\nNameError: name 'x' is not defined", update_output_calls[1].raw_value, "raw_value should be full exception text") + teardown() + end) + end) + + describe("intentional stop", function() + local python_runner + + local function setup() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + python_runner.sessions = {} + python_runner._intentionally_stopped_jobs = {} + end + + it("should not produce ERROR notify for intentionally stopped job", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_exit = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_exit = opts.on_exit + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + local notify_calls = {} + vim.notify = function(msg, level) + table.insert(notify_calls, { msg = msg, level = level }) + end + + local session_id = python_runner.start_debug_session("/tmp/x.py", 7) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + + assert.not_nil(session_id) + + local session = python_runner.sessions[session_id] + assert.not_nil(session) + + python_runner.cleanup_session(session) + + captured_on_exit(123, 143) + + local error_notifications = vim.tbl_filter(function(n) + return n.level == vim.log.levels.ERROR + end, notify_calls) + + assert.equals(0, #error_notifications, "no error notification should be emitted for intentionally stopped job") + + vim.notify = saved_notify + teardown() + end) + + it("should clear Python-owned buffers from inspector.single_file_buffers on stop_all_sessions", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + python_runner.start_debug_session("/tmp/x.py", 7) + -- Also add an unrelated inspector entry + inspector.single_file_buffers[99] = "/tmp/other.lua" + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.equals("/tmp/x.py", inspector.single_file_buffers[7]) + assert.equals("/tmp/other.lua", inspector.single_file_buffers[99]) + + python_runner.stop_all_sessions() + + assert.is_nil(inspector.single_file_buffers[7], "Python buffer should be cleared") + assert.equals("/tmp/other.lua", inspector.single_file_buffers[99], "Unrelated inspector entry should remain") + + teardown() + end) + end) + + describe("stdout reassembly", function() + local python_runner + local update_output_calls + + local function setup() + update_output_calls = {} + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function(bufnr, line, output, method, raw_value) + table.insert(update_output_calls, { + bufnr = bufnr, + line = line, + output = output, + method = method, + raw_value = raw_value, + }) + end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + package.loaded["consolelog.communication.python_runner"] = nil + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + package.loaded["consolelog.communication.python_runner"] = nil + update_output_calls = {} + end + + it("should dispatch event split across two on_stdout callbacks exactly once", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_stdout = nil + local captured_session = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_stdout = opts.on_stdout + -- Capture the run_id from the env option + local run_id = opts.env and opts.env.CONSOLELOG_RUN_ID or "test_run" + captured_session = { run_id = run_id } + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + local session_id = python_runner.start_debug_session("/tmp/a.py", 5) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.not_nil(session_id) + assert.not_nil(captured_on_stdout) + assert.not_nil(captured_session) + + -- Send one event split across two callbacks with correct run_id + local full_event = '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["hi"],"run_id":"' .. captured_session.run_id .. '"}\n' + local mid = math.floor(#full_event / 2) + local chunk1 = full_event:sub(1, mid) + local chunk2 = full_event:sub(mid + 1) + + captured_on_stdout(123, { chunk1 }) + captured_on_stdout(123, { chunk2 }) + + helper.async.wait(50) + + assert.equals(1, #update_output_calls, "split event should produce exactly one update_output call") + assert.equals(5, update_output_calls[1].bufnr) + assert.equals(2, update_output_calls[1].line) + assert.equals("log", update_output_calls[1].method) + + teardown() + end) + + it("should parse event when raw stdout precedes sentinel", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_stdout = nil + local captured_session = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_stdout = opts.on_stdout + local run_id = opts.env and opts.env.CONSOLELOG_RUN_ID or "test_run" + captured_session = { run_id = run_id } + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + local session_id = python_runner.start_debug_session("/tmp/a.py", 5) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.not_nil(session_id) + assert.not_nil(captured_on_stdout) + + -- Simulate: raw stdout ("first") written without newline, then an event + -- (e.g. print("first", end=""); print("second")) + local event_line = '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":3,"args":["second"],"run_id":"' .. captured_session.run_id .. '"}\n' + captured_on_stdout(123, { "first" .. event_line }) + + helper.async.wait(50) + + assert.equals(1, #update_output_calls, "event preceded by raw stdout should be parsed") + assert.equals(3, update_output_calls[1].line) + assert.equals("second", update_output_calls[1].output) + + teardown() + end) + end) + + describe("run_id gating", function() + local python_runner + local update_output_calls + + local function setup() + update_output_calls = {} + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function(bufnr, line, output, method, raw_value) + table.insert(update_output_calls, { + bufnr = bufnr, + line = line, + output = output, + method = method, + raw_value = raw_value, + }) + end, + } + package.loaded["consolelog.communication.inspector"] = { + sessions = {}, + single_file_buffers = {}, + _intentionally_stopped_jobs = {}, + } + package.loaded["consolelog.processing.message_processor_impl"] = { + format_args = function(args, method) + return table.concat(args, " "), args[1] + end, + } + package.loaded["consolelog"] = { + config = { runner = { python_executable = nil } }, + } + package.loaded["consolelog.communication.python_runner"] = nil + python_runner = require("consolelog.communication.python_runner") + end + + local function teardown() + package.loaded["consolelog.display.display"] = nil + package.loaded["consolelog.communication.inspector"] = nil + package.loaded["consolelog.processing.message_processor_impl"] = nil + package.loaded["consolelog"] = nil + package.loaded["consolelog.communication.python_runner"] = nil + update_output_calls = {} + end + + it("should ignore event without run_id", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_stdout = nil + local captured_session = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_stdout = opts.on_stdout + local run_id = opts.env and opts.env.CONSOLELOG_RUN_ID or "test_run" + captured_session = { run_id = run_id } + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + local session_id = python_runner.start_debug_session("/tmp/a.py", 5) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.not_nil(session_id) + assert.not_nil(captured_on_stdout) + + -- Send event WITHOUT run_id (forged target output) + captured_on_stdout(123, { + '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["forged"]}\n' + }) + + assert.equals(0, #update_output_calls, "event without run_id should be ignored") + + teardown() + end) + + it("should ignore event with wrong run_id", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_stdout = nil + local captured_session = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_stdout = opts.on_stdout + local run_id = opts.env and opts.env.CONSOLELOG_RUN_ID or "test_run" + captured_session = { run_id = run_id } + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + local session_id = python_runner.start_debug_session("/tmp/a.py", 5) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.not_nil(session_id) + assert.not_nil(captured_on_stdout) + + -- Send event WITH a DIFFERENT run_id + captured_on_stdout(123, { + '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["wrong"],"run_id":"cl_0000_999"}\n' + }) + + assert.equals(0, #update_output_calls, "event with wrong run_id should be ignored") + + teardown() + end) + + it("should process event with correct run_id", function() + setup() + local inspector = package.loaded["consolelog.communication.inspector"] + + local captured_on_stdout = nil + local captured_session = nil + local saved_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(cmd, opts) + captured_on_stdout = opts.on_stdout + local run_id = opts.env and opts.env.CONSOLELOG_RUN_ID or "test_run" + captured_session = { run_id = run_id } + return 123 + end + + local saved_executable = vim.fn.executable + vim.fn.executable = function(name) return name == "python3" and 1 or 0 end + local saved_filereadable = vim.fn.filereadable + local runner_path = python_runner.get_plugin_root() .. "py/consolelog_runner.py" + vim.fn.filereadable = function(path) + if path == runner_path then return 1 end + return 0 + end + + local saved_notify = vim.notify + vim.notify = function() end + + local session_id = python_runner.start_debug_session("/tmp/a.py", 5) + + vim.fn.jobstart = saved_jobstart + vim.fn.executable = saved_executable + vim.fn.filereadable = saved_filereadable + vim.notify = saved_notify + + assert.not_nil(session_id) + assert.not_nil(captured_on_stdout) + + -- Send event with correct run_id + captured_on_stdout(123, { + '__CONSOLELOG_EVENT__{"event":"console","method":"log","file":"/tmp/a.py","line":2,"args":["valid"],"run_id":"' .. captured_session.run_id .. '"}\n' + }) + + helper.async.wait(50) + + assert.equals(1, #update_output_calls, "event with correct run_id should be processed") + assert.equals("valid", update_output_calls[1].output) + + teardown() + end) + end) +end) \ No newline at end of file diff --git a/tests/lua/single_file_run_spec.lua b/tests/lua/single_file_run_spec.lua index 9c27166..99d87ce 100644 --- a/tests/lua/single_file_run_spec.lua +++ b/tests/lua/single_file_run_spec.lua @@ -167,6 +167,30 @@ describe("Single File Run", function() end) end) + describe("constants.is_single_file_runnable with Python", function() + it("should return true for .py files", function() + assert.is_true(constants.is_single_file_runnable("a.py")) + end) + + it("should return false for .pyc files", function() + assert.is_false(constants.is_single_file_runnable("a.pyc")) + end) + end) + + describe("constants.is_python_file", function() + it("should return true for .py files", function() + assert.is_true(constants.is_python_file("a.py")) + end) + + it("should return false for .js files", function() + assert.is_false(constants.is_python_file("a.js")) + end) + + it("should return false for .pyc files", function() + assert.is_false(constants.is_python_file("a.pyc")) + end) + end) + describe("constants.is_typescript_file", function() it("should return true for .ts files", function() assert.is_true(constants.is_typescript_file("a.ts")) @@ -605,6 +629,395 @@ describe("Single File Run", function() end) end) + describe("run_buffer Python dispatch", function() + local start_session_mock + local saved_inspector_module + local saved_python_runner_module + local py_start_session_mock + + local function setup_inspector_stub() + saved_inspector_module = package.loaded["consolelog.communication.inspector"] + start_session_mock = helper.mock.new("start_debug_session") + start_session_mock:returns("fake_session_id") + package.loaded["consolelog.communication.inspector"] = { + start_debug_session = start_session_mock, + get_session_for_buffer = function() return nil end, + cleanup_session = function() end, + single_file_buffers = {}, + is_single_file_buffer = function() return false end, + stop_all_sessions = function() end, + } + end + + local function teardown_inspector_stub() + package.loaded["consolelog.communication.inspector"] = saved_inspector_module + saved_inspector_module = nil + end + + local function setup_python_runner_stub() + saved_python_runner_module = package.loaded["consolelog.communication.python_runner"] + py_start_session_mock = helper.mock.new("start_debug_session") + py_start_session_mock:returns("py_session") + package.loaded["consolelog.communication.python_runner"] = { + start_debug_session = py_start_session_mock, + get_session_for_buffer = function() return nil end, + cleanup_session = function() end, + stop_all_sessions = function() end, + } + end + + local function teardown_python_runner_stub() + package.loaded["consolelog.communication.python_runner"] = saved_python_runner_module + saved_python_runner_module = nil + end + + local saved_display_module = nil + + local function setup_consolelog_module() + saved_display_module = package.loaded["consolelog.display.display"] + package.loaded["consolelog.display.display"] = { + clear_all = function() end, + clear_buffer = function() end, + show_outputs = function() end, + } + package.loaded["consolelog"] = { + config = { enabled = true, runner = { rerun_on_save = true } }, + enable = function() end, + notify = function() end, + } + end + + local function teardown_consolelog_module() + package.loaded["consolelog.display.display"] = saved_display_module + saved_display_module = nil + package.loaded["consolelog"] = nil + end + + local function cleanup_sessions() + inspector.sessions = {} + inspector.reconnect_attempts = {} + inspector.single_file_buffers = {} + inspector._intentionally_stopped_jobs = {} + end + + it("should call python_runner.start_debug_session for .py file and not inspector", function() + setup_inspector_stub() + setup_python_runner_stub() + setup_consolelog_module() + cleanup_sessions() + + local real_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path == "/tmp/consolelog_test.py" then return 1 end + return real_filereadable(path) + end + + local real_buf_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function() + return "/tmp/consolelog_test.py" + end + + local core = require("consolelog.core.init") + core.run_buffer(6) + + assert.equals(1, py_start_session_mock.call_count, "python_runner.start_debug_session should be called once") + assert.equals(0, start_session_mock.call_count, "inspector.start_debug_session should NOT be called") + + vim.api.nvim_buf_get_name = real_buf_get_name + vim.fn.filereadable = real_filereadable + teardown_inspector_stub() + teardown_python_runner_stub() + teardown_consolelog_module() + cleanup_sessions() + end) + end) + + describe("run_buffer cross-runner session cleanup", function() + local start_session_mock + local saved_inspector_module + local saved_python_runner_module + local py_start_session_mock + local inspector_cleanup_mock + local py_cleanup_mock + + local function setup_inspector_stub() + saved_inspector_module = package.loaded["consolelog.communication.inspector"] + start_session_mock = helper.mock.new("start_debug_session") + start_session_mock:returns("fake_session_id") + inspector_cleanup_mock = helper.mock.new("cleanup_session") + package.loaded["consolelog.communication.inspector"] = { + start_debug_session = start_session_mock, + get_session_for_buffer = function() return nil end, + cleanup_session = inspector_cleanup_mock, + single_file_buffers = {}, + is_single_file_buffer = function() return false end, + stop_all_sessions = function() end, + } + end + + local function teardown_inspector_stub() + package.loaded["consolelog.communication.inspector"] = saved_inspector_module + saved_inspector_module = nil + end + + local function setup_python_runner_stub() + saved_python_runner_module = package.loaded["consolelog.communication.python_runner"] + py_start_session_mock = helper.mock.new("start_debug_session") + py_start_session_mock:returns("py_session") + py_cleanup_mock = helper.mock.new("cleanup_session") + package.loaded["consolelog.communication.python_runner"] = { + start_debug_session = py_start_session_mock, + get_session_for_buffer = function() return nil end, + cleanup_session = py_cleanup_mock, + stop_all_sessions = function() end, + } + end + + local function teardown_python_runner_stub() + package.loaded["consolelog.communication.python_runner"] = saved_python_runner_module + saved_python_runner_module = nil + end + + local saved_display_module = nil + + local function setup_consolelog_module() + saved_display_module = package.loaded["consolelog.display.display"] + package.loaded["consolelog.display.display"] = { + clear_all = function() end, + clear_buffer = function() end, + show_outputs = function() end, + } + package.loaded["consolelog"] = { + config = { enabled = true, runner = { rerun_on_save = true } }, + enable = function() end, + notify = function() end, + } + end + + local function teardown_consolelog_module() + package.loaded["consolelog.display.display"] = saved_display_module + saved_display_module = nil + package.loaded["consolelog"] = nil + end + + local function cleanup_sessions() + inspector.sessions = {} + inspector.reconnect_attempts = {} + inspector.single_file_buffers = {} + inspector._intentionally_stopped_jobs = {} + end + + it("should clean inspector session when running a renamed .py buffer", function() + setup_inspector_stub() + setup_python_runner_stub() + setup_consolelog_module() + cleanup_sessions() + + -- Simulate an existing inspector session on buffer 6 + local existing_session = { filepath = "/tmp/old.js", bufnr = 6 } + local inspector_get_session = function(bufnr) + if bufnr == 6 then return existing_session end + return nil + end + package.loaded["consolelog.communication.inspector"].get_session_for_buffer = inspector_get_session + + local real_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path == "/tmp/consolelog_test.py" then return 1 end + return real_filereadable(path) + end + + local real_buf_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function() + return "/tmp/consolelog_test.py" + end + + local core = require("consolelog.core.init") + core.run_buffer(6) + + assert.equals(1, inspector_cleanup_mock.call_count, + "inspector session must be cleaned when buffer runs as .py") + assert.equals(1, py_start_session_mock.call_count, + "python_runner.start_debug_session should be called") + assert.equals(0, start_session_mock.call_count, + "inspector.start_debug_session should NOT be called") + + vim.api.nvim_buf_get_name = real_buf_get_name + vim.fn.filereadable = real_filereadable + teardown_inspector_stub() + teardown_python_runner_stub() + teardown_consolelog_module() + cleanup_sessions() + end) + + it("should clean python_runner session when running a renamed .js buffer", function() + setup_inspector_stub() + setup_python_runner_stub() + setup_consolelog_module() + cleanup_sessions() + + -- Simulate an existing python_runner session on buffer 6 + local existing_py_session = { filepath = "/tmp/old.py", bufnr = 6 } + local py_get_session = function(bufnr) + if bufnr == 6 then return existing_py_session end + return nil + end + package.loaded["consolelog.communication.python_runner"].get_session_for_buffer = py_get_session + + local real_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path == "/tmp/consolelog_test.js" then return 1 end + return real_filereadable(path) + end + + local real_buf_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function() + return "/tmp/consolelog_test.js" + end + + local core = require("consolelog.core.init") + core.run_buffer(6) + + assert.equals(1, py_cleanup_mock.call_count, + "python_runner session must be cleaned when buffer runs as .js") + assert.equals(1, start_session_mock.call_count, + "inspector.start_debug_session should be called") + assert.equals(0, py_start_session_mock.call_count, + "python_runner.start_debug_session should NOT be called") + + vim.api.nvim_buf_get_name = real_buf_get_name + vim.fn.filereadable = real_filereadable + teardown_inspector_stub() + teardown_python_runner_stub() + teardown_consolelog_module() + cleanup_sessions() + end) + end) + + describe("rerun-on-save for Python", function() + local run_buffer_mock + + local function setup_display_stub() + package.loaded["consolelog.display.display"] = { + clear_buffer = function() end, + update_output = function() end, + show_outputs = function() end, + } + end + + local function teardown_display_stub() + package.loaded["consolelog.display.display"] = nil + end + + local function cleanup_sessions() + inspector.sessions = {} + inspector.reconnect_attempts = {} + inspector.single_file_buffers = {} + inspector._intentionally_stopped_jobs = {} + end + + it("should rerun tracked .py buffer even when filetype is unset", function() + setup_display_stub() + cleanup_sessions() + + run_buffer_mock = helper.mock.new("run_buffer") + + -- Track clear_buffer calls to verify output clearing + local clear_buffer_mock = helper.mock.new("clear_buffer") + package.loaded["consolelog.display.display"] = { + clear_buffer = clear_buffer_mock, + update_output = function() end, + show_outputs = function() end, + } + + package.loaded["consolelog"] = { + config = { enabled = true, runner = { rerun_on_save = true } }, + enable = function() end, + notify = function() end, + outputs = { [3] = { { text = "old output", line = 1 } } }, + run_buffer = run_buffer_mock, + } + + -- Simulate unset filetype: is_javascript_buffer and is_supported_buffer both return false + package.loaded["consolelog.core.utils"] = { + is_javascript_buffer = function() return false end, + is_supported_buffer = function() return false end, + } + + package.loaded["consolelog.communication.inspector"] = inspector + + inspector.single_file_buffers[3] = "/tmp/tracked.py" + + local saved_buf_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function() return "/tmp/tracked.py" end + + local saved_buf_is_valid = vim.api.nvim_buf_is_valid + vim.api.nvim_buf_is_valid = function() return true end + + local saved_buf_is_loaded = vim.api.nvim_buf_is_loaded + vim.api.nvim_buf_is_loaded = function() return true end + + local saved_defer_fn = vim.defer_fn + local captured_deferred = nil + vim.defer_fn = function(fn, _delay) + captured_deferred = fn + end + + local autocmds = require("consolelog.core.autocmds") + + local captured_callbacks = {} + local saved_create_autocmd = vim.api.nvim_create_autocmd + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(captured_callbacks, { event = event, callback = opts.callback }) + return 1 + end + + local saved_create_augroup = vim.api.nvim_create_augroup + vim.api.nvim_create_augroup = function() return 1 end + + autocmds.setup() + + vim.api.nvim_create_autocmd = saved_create_autocmd + vim.api.nvim_create_augroup = saved_create_augroup + + local bufwritepost_cb = nil + for _, entry in ipairs(captured_callbacks) do + if entry.event == "BufWritePost" then + bufwritepost_cb = entry.callback + break + end + end + + assert.not_nil(bufwritepost_cb, "BufWritePost autocmd should have been registered") + + bufwritepost_cb({buf = 3}) + + -- Verify stale outputs are cleared before the deferred re-run + assert.equals(1, clear_buffer_mock.call_count, + "clear_buffer must be called for tracked Python buffer even when should_process_buffer is false") + assert.is_true(vim.tbl_isempty(package.loaded["consolelog"].outputs[3]), + "outputs for buffer 3 must be cleared on save") + + assert.not_nil(captured_deferred, "vim.defer_fn should have been called") + + captured_deferred() + + assert.equals(1, run_buffer_mock.call_count, + "tracked .py buffer must rerun even when should_process_buffer is false") + + vim.defer_fn = saved_defer_fn + vim.api.nvim_buf_is_loaded = saved_buf_is_loaded + vim.api.nvim_buf_is_valid = saved_buf_is_valid + vim.api.nvim_buf_get_name = saved_buf_get_name + teardown_display_stub() + package.loaded["consolelog"] = nil + package.loaded["consolelog.core.utils"] = nil + package.loaded["consolelog.core.autocmds"] = nil + package.loaded["consolelog.communication.inspector"] = nil + cleanup_sessions() + end) + end) + describe("single_file_buffers tracking", function() local function setup_display_stub() package.loaded["consolelog.display.display"] = { @@ -780,6 +1193,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -866,6 +1280,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -932,6 +1347,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -941,18 +1357,18 @@ describe("Single File Run", function() local saved_buf_get_name = vim.api.nvim_buf_get_name vim.api.nvim_buf_get_name = function() return "/tmp/tracked.js" end - local saved_defer_fn = vim.defer_fn - local captured_deferred = nil - vim.defer_fn = function(fn, _delay) - captured_deferred = fn - end - local saved_buf_is_valid = vim.api.nvim_buf_is_valid vim.api.nvim_buf_is_valid = function() return true end local saved_buf_is_loaded = vim.api.nvim_buf_is_loaded vim.api.nvim_buf_is_loaded = function() return true end + local saved_defer_fn = vim.defer_fn + local captured_deferred = nil + vim.defer_fn = function(fn, _delay) + captured_deferred = fn + end + local autocmds = require("consolelog.core.autocmds") local captured_callbacks = {} @@ -1019,6 +1435,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1114,6 +1531,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1174,6 +1592,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1267,6 +1686,7 @@ describe("Single File Run", function() -- Simulate unset filetype: is_javascript_buffer returns false package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return false end, + is_supported_buffer = function() return false end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1358,6 +1778,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, + is_supported_buffer = function() return true end, } package.loaded["consolelog.communication.inspector"] = inspector diff --git a/tests/run_lua_tests.sh b/tests/run_lua_tests.sh index 8cf3e24..fac1603 100755 --- a/tests/run_lua_tests.sh +++ b/tests/run_lua_tests.sh @@ -21,7 +21,7 @@ run_test() { echo -e "${CYAN}Running: ${test_name}${NC}" - PATH="/tmp/nvim-linux64/bin:$PATH" nvim --headless -c "lua package.path = './lua/?.lua;./lua/?/init.lua;./tests/lua/?.lua;' .. package.path" -c "lua local ok, err = pcall(dofile, '$test_file'); if not ok then print('FAILED: ' .. err) end" -c "qa!" 2>&1 | tee /tmp/test_output.txt + PATH="/tmp/nvim-linux64/bin:$PATH" nvim --headless --clean -c "lua package.path = './lua/?.lua;./lua/?/init.lua;./tests/lua/?.lua;' .. package.path" -c "lua local ok, err = pcall(dofile, '$test_file'); if not ok then print('FAILED: ' .. err) end" -c "qa!" 2>&1 | tee /tmp/test_output.txt local nvim_exit=${PIPESTATUS[0]} # Check for FAILED: lines emitted by test assertions From 3ef6a0545d2753527a6f6b06d75facc22cff7942 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:12:59 -0400 Subject: [PATCH 2/3] feat: guard against non-regular buffers and windows --- lua/consolelog/core/autocmds.lua | 21 ++-- lua/consolelog/core/init.lua | 24 ++++- lua/consolelog/core/utils.lua | 42 ++++++-- tests/lua/buffer_filtering_spec.lua | 153 ++++++++++++++++++++++++++++ tests/lua/display_spec.lua | 1 + tests/lua/integration_spec.lua | 19 ++++ tests/lua/single_file_run_spec.lua | 109 ++++++++++++++++++-- 7 files changed, 339 insertions(+), 30 deletions(-) create mode 100644 tests/lua/buffer_filtering_spec.lua diff --git a/lua/consolelog/core/autocmds.lua b/lua/consolelog/core/autocmds.lua index 707d764..c94eb67 100644 --- a/lua/consolelog/core/autocmds.lua +++ b/lua/consolelog/core/autocmds.lua @@ -13,8 +13,9 @@ function M.invalidate_reruns() end -- Check if a buffer should be processed by consolelog -local function should_process_buffer(bufnr) +local function should_process_buffer(bufnr, winid) return utils.is_supported_buffer(bufnr) + and utils.find_regular_buffer_window(bufnr, winid) ~= nil end -- Export the function for use by other modules @@ -27,13 +28,17 @@ function M.setup() group = group, callback = function() local bufnr = vim.api.nvim_get_current_buf() + local winid = vim.api.nvim_get_current_win() - if not should_process_buffer(bufnr) then + if not should_process_buffer(bufnr, winid) then return end local consolelog = require("consolelog") consolelog.active_buf = bufnr + if consolelog.config.auto_enable and not consolelog.config.enabled then + consolelog.enable() + end -- Show outputs for the newly active buffer if they exist if consolelog.config.enabled and consolelog.outputs[bufnr] and not vim.tbl_isempty(consolelog.outputs[bufnr]) then @@ -49,8 +54,9 @@ function M.setup() group = group, callback = function() local bufnr = vim.api.nvim_get_current_buf() + local winid = vim.api.nvim_get_current_win() - if not should_process_buffer(bufnr) then + if not should_process_buffer(bufnr, winid) then return end @@ -71,14 +77,16 @@ function M.setup() group = group, callback = function(args) local bufnr = args.buf + local winid = vim.api.nvim_get_current_win() local consolelog = require("consolelog") local inspector = require("consolelog.communication.inspector") local is_tracked = inspector.is_single_file_buffer(bufnr) + local is_regular_window = utils.find_regular_buffer_window(bufnr, winid) ~= nil -- Clear outputs on save for JS buffers and tracked single-file buffers. -- The is_tracked guard lets .mts/.cts files without a recognised -- filetype clear stale outputs before the deferred re-run. - if should_process_buffer(bufnr) or is_tracked then + if is_regular_window and (should_process_buffer(bufnr, winid) or is_tracked) then if consolelog.outputs[bufnr] then local debug_logger = require("consolelog.core.debug_logger") debug_logger.log("BUFWRITEPOST", string.format("Clearing outputs for buffer %d on save", bufnr)) @@ -89,7 +97,8 @@ function M.setup() -- Auto re-run on save for single-file buffers previously run via :ConsoleLogRun local gen = M._rerun_generation - if is_tracked + if is_regular_window + and is_tracked and consolelog.config.enabled and consolelog.config.runner.rerun_on_save and vim.api.nvim_buf_is_valid(bufnr) @@ -104,7 +113,7 @@ function M.setup() and inspector.is_single_file_buffer(bufnr) and vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then - consolelog.run_buffer(bufnr) + consolelog.run_buffer(bufnr, winid) end end, 50) end diff --git a/lua/consolelog/core/init.lua b/lua/consolelog/core/init.lua index 790c9e4..8226983 100644 --- a/lua/consolelog/core/init.lua +++ b/lua/consolelog/core/init.lua @@ -123,7 +123,13 @@ function M.setup(opts) if M.config.auto_enable then vim.defer_fn(function() - M.enable() + local bufnr = vim.api.nvim_get_current_buf() + local winid = vim.api.nvim_get_current_win() + local utils = require("consolelog.core.utils") + if utils.is_supported_buffer(bufnr) + and utils.find_regular_buffer_window(bufnr, winid) then + M.enable() + end end, 100) end end @@ -134,6 +140,12 @@ function M.enable() return end + local bufnr = vim.api.nvim_get_current_buf() + local winid = vim.api.nvim_get_current_win() + if not require("consolelog.core.utils").find_regular_buffer_window(bufnr, winid) then + return + end + M.clear() M.config.enabled = true @@ -269,7 +281,13 @@ function M.clear_cache() vim.notify("Build tool caches cleared", vim.log.levels.INFO) end -function M.run_buffer(bufnr) +function M.run_buffer(bufnr, winid) + local utils = require("consolelog.core.utils") + if not utils.find_regular_buffer_window(bufnr, winid) then + M.notify("ConsoleLogRun is only supported in regular buffer windows.", vim.log.levels.ERROR) + return + end + local filepath = vim.api.nvim_buf_get_name(bufnr) local constants = require("consolelog.core.constants") @@ -318,7 +336,7 @@ function M.run_buffer(bufnr) end function M.run() - M.run_buffer(vim.api.nvim_get_current_buf()) + M.run_buffer(vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win()) end function M.toggle_output_window() diff --git a/lua/consolelog/core/utils.lua b/lua/consolelog/core/utils.lua index a8fff43..0e1b0b6 100644 --- a/lua/consolelog/core/utils.lua +++ b/lua/consolelog/core/utils.lua @@ -15,16 +15,15 @@ end function M.is_javascript_buffer(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if not M.is_regular_buffer(bufnr) then + return false + end + local filetype = vim.api.nvim_buf_get_option(bufnr, "filetype") if filetype == "javascript" or filetype == "typescript" or filetype == "javascriptreact" or filetype == "typescriptreact" then return true end - local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype") - if buftype ~= "" then - return false - end - local file = vim.api.nvim_buf_get_name(bufnr) return M.is_javascript_file(file) end @@ -39,16 +38,15 @@ end function M.is_python_buffer(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if not M.is_regular_buffer(bufnr) then + return false + end + local filetype = vim.api.nvim_buf_get_option(bufnr, "filetype") if filetype == "python" then return true end - local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype") - if buftype ~= "" then - return false - end - local file = vim.api.nvim_buf_get_name(bufnr) return M.is_python_file(file) end @@ -57,6 +55,30 @@ function M.is_supported_buffer(bufnr) return M.is_javascript_buffer(bufnr) or M.is_python_buffer(bufnr) end +function M.is_regular_buffer(bufnr) + return vim.api.nvim_buf_is_valid(bufnr) + and vim.api.nvim_buf_get_option(bufnr, "buftype") == "" + and not vim.api.nvim_buf_get_name(bufnr):match("^[%w+.-]+://") +end + +function M.find_regular_buffer_window(bufnr, winid) + if not M.is_regular_buffer(bufnr) then + return nil + end + + local windows = winid and { winid } or vim.fn.win_findbuf(bufnr) + for _, candidate in ipairs(windows) do + if vim.api.nvim_win_is_valid(candidate) + and vim.api.nvim_win_get_buf(candidate) == bufnr + and vim.api.nvim_win_get_config(candidate).relative == "" + and not vim.api.nvim_win_get_option(candidate, "diff") then + return candidate + end + end + + return nil +end + function M.strip_ansi(text) if not text or type(text) ~= "string" then return text diff --git a/tests/lua/buffer_filtering_spec.lua b/tests/lua/buffer_filtering_spec.lua new file mode 100644 index 0000000..b3e2690 --- /dev/null +++ b/tests/lua/buffer_filtering_spec.lua @@ -0,0 +1,153 @@ +local helper = require('tests.lua.test_helper') +local assert = helper.assert +local describe = helper.describe +local it = helper.it + +package.path = package.path .. ";./lua/?.lua" +local utils = require('consolelog.core.utils') + +describe("Buffer Filtering", function() + describe("is_supported_buffer", function() + it("should reject buffers with non-empty buftype", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "filetype", "javascript") + + local result = utils.is_supported_buffer(bufnr) + assert.is_false(result, "Should reject nofile buffers even with javascript filetype") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("should reject quickfix buffers", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "quickfix") + vim.api.nvim_buf_set_option(bufnr, "filetype", "qf") + + local result = utils.is_supported_buffer(bufnr) + assert.is_false(result, "Should reject quickfix buffers") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("should reject help buffers", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "help") + vim.api.nvim_buf_set_option(bufnr, "filetype", "help") + + local result = utils.is_supported_buffer(bufnr) + assert.is_false(result, "Should reject help buffers") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("should accept regular buffers with javascript filetype", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "") + vim.api.nvim_buf_set_option(bufnr, "filetype", "javascript") + + local result = utils.is_supported_buffer(bufnr) + assert.is_true(result, "Should accept regular buffers with javascript filetype") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("should accept regular buffers with python filetype", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "") + vim.api.nvim_buf_set_option(bufnr, "filetype", "python") + + local result = utils.is_supported_buffer(bufnr) + assert.is_true(result, "Should accept regular buffers with python filetype") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("should reject URI-backed virtual buffers", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "") + vim.api.nvim_buf_set_option(bufnr, "filetype", "typescriptreact") + vim.api.nvim_buf_set_name(bufnr, "diffview:///repo/.git/:0:/app/example.tsx") + + local result = utils.is_supported_buffer(bufnr) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_false(result, "Should reject virtual URI buffers") + end) + end) + + describe("is_javascript_buffer", function() + it("should reject buffers with non-empty buftype", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "filetype", "javascript") + + local result = utils.is_javascript_buffer(bufnr) + assert.is_false(result, "Should reject nofile buffers even with javascript filetype") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) + + describe("is_python_buffer", function() + it("should reject buffers with non-empty buftype", function() + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "filetype", "python") + + local result = utils.is_python_buffer(bufnr) + assert.is_false(result, "Should reject nofile buffers even with python filetype") + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) + + describe("find_regular_buffer_window", function() + it("should reject diff windows containing regular buffers", function() + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "") + vim.api.nvim_win_set_buf(winid, bufnr) + vim.api.nvim_win_set_option(winid, "diff", true) + + local result = utils.find_regular_buffer_window(bufnr, winid) + + vim.api.nvim_win_set_option(winid, "diff", false) + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(result, "Should reject diff windows") + end) + + it("should accept non-diff windows containing regular buffers", function() + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + local bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "") + vim.api.nvim_win_set_buf(winid, bufnr) + + local result = utils.find_regular_buffer_window(bufnr, winid) + + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.equals(result, winid, "Should accept regular buffer windows") + end) + + it("should reject floating windows containing regular buffers", function() + local bufnr = vim.api.nvim_create_buf(false, false) + local winid = vim.api.nvim_open_win(bufnr, false, { + relative = "editor", + width = 10, + height = 2, + row = 0, + col = 0, + }) + + local result = utils.find_regular_buffer_window(bufnr, winid) + + vim.api.nvim_win_close(winid, true) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(result, "Should reject floating windows") + end) + end) +end) diff --git a/tests/lua/display_spec.lua b/tests/lua/display_spec.lua index 31468e9..f8d0372 100644 --- a/tests/lua/display_spec.lua +++ b/tests/lua/display_spec.lua @@ -15,6 +15,7 @@ describe("Display Module", function() local function setup() test_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'console.log("test1");', 'console.log("test2");', diff --git a/tests/lua/integration_spec.lua b/tests/lua/integration_spec.lua index c35a90a..74ffbd9 100644 --- a/tests/lua/integration_spec.lua +++ b/tests/lua/integration_spec.lua @@ -23,6 +23,7 @@ describe("Integration Tests", function() -- Create test buffer with unique name test_counter = test_counter + 1 test_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") vim.api.nvim_buf_set_name(test_bufnr, string.format("/tmp/test_%d.js", test_counter)) vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'console.log("test1");', @@ -57,6 +58,23 @@ describe("Integration Tests", function() end) describe("Enable/Disable functionality", function() + it("should not enable from a diff window", function() + setup() + + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) + vim.api.nvim_win_set_option(winid, "diff", true) + consolelog.config.enabled = false + + consolelog.enable() + local enabled = consolelog.config.enabled + + vim.api.nvim_win_set_option(winid, "diff", false) + vim.api.nvim_win_set_buf(winid, previous_bufnr) + assert.is_false(enabled, "Diff windows must not enable ConsoleLog") + end) + it("should enable and disable", function() setup() @@ -279,6 +297,7 @@ describe("Integration Tests", function() setup() local buf2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(buf2, "buftype", "") vim.api.nvim_buf_set_name(buf2, "/tmp/test2.js") vim.api.nvim_buf_set_lines(buf2, 0, -1, false, { 'console.log("buf2");' diff --git a/tests/lua/single_file_run_spec.lua b/tests/lua/single_file_run_spec.lua index 99d87ce..f3558b7 100644 --- a/tests/lua/single_file_run_spec.lua +++ b/tests/lua/single_file_run_spec.lua @@ -587,10 +587,15 @@ describe("Single File Run", function() setup_consolelog_module() cleanup_sessions() + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) local core = require("consolelog.core.init") vim.api.nvim_buf_get_name = function() return "/tmp/consolelog_test.txt" end - core.run_buffer(99) + core.run_buffer(test_bufnr, winid) assert.equals(0, start_session_mock.call_count) @@ -598,6 +603,8 @@ describe("Single File Run", function() teardown_inspector_stub() teardown_consolelog_module() cleanup_sessions() + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) end) it("should call start_debug_session for runnable .js file", function() @@ -605,6 +612,12 @@ describe("Single File Run", function() setup_consolelog_module() cleanup_sessions() + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) + local real_filereadable = vim.fn.filereadable vim.fn.filereadable = function(path) if path == "/tmp/consolelog_test.js" then return 1 end @@ -617,7 +630,7 @@ describe("Single File Run", function() end local core = require("consolelog.core.init") - core.run_buffer(5) + core.run_buffer(test_bufnr, winid) assert.equals(1, start_session_mock.call_count) @@ -626,6 +639,47 @@ describe("Single File Run", function() teardown_inspector_stub() teardown_consolelog_module() cleanup_sessions() + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) + end) + + it("should not run a regular buffer from a diff window", function() + setup_inspector_stub() + setup_consolelog_module() + cleanup_sessions() + + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + vim.api.nvim_win_set_buf(winid, test_bufnr) + vim.api.nvim_win_set_option(winid, "diff", true) + + local real_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path == "/tmp/consolelog_diff_test.js" then return 1 end + return real_filereadable(path) + end + + local real_buf_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function() + return "/tmp/consolelog_diff_test.js" + end + + local core = require("consolelog.core.init") + core.run_buffer(test_bufnr, winid) + local call_count = start_session_mock.call_count + + vim.api.nvim_buf_get_name = real_buf_get_name + vim.fn.filereadable = real_filereadable + vim.api.nvim_win_set_option(winid, "diff", false) + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) + teardown_inspector_stub() + teardown_consolelog_module() + cleanup_sessions() + + assert.equals(call_count, 0, "Diff windows must not start debug sessions") end) end) @@ -706,6 +760,12 @@ describe("Single File Run", function() setup_consolelog_module() cleanup_sessions() + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) + local real_filereadable = vim.fn.filereadable vim.fn.filereadable = function(path) if path == "/tmp/consolelog_test.py" then return 1 end @@ -718,7 +778,7 @@ describe("Single File Run", function() end local core = require("consolelog.core.init") - core.run_buffer(6) + core.run_buffer(test_bufnr, winid) assert.equals(1, py_start_session_mock.call_count, "python_runner.start_debug_session should be called once") assert.equals(0, start_session_mock.call_count, "inspector.start_debug_session should NOT be called") @@ -729,6 +789,8 @@ describe("Single File Run", function() teardown_python_runner_stub() teardown_consolelog_module() cleanup_sessions() + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) end) end) @@ -813,10 +875,16 @@ describe("Single File Run", function() setup_consolelog_module() cleanup_sessions() - -- Simulate an existing inspector session on buffer 6 - local existing_session = { filepath = "/tmp/old.js", bufnr = 6 } + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) + + -- Simulate an existing inspector session on test buffer + local existing_session = { filepath = "/tmp/old.js", bufnr = test_bufnr } local inspector_get_session = function(bufnr) - if bufnr == 6 then return existing_session end + if bufnr == test_bufnr then return existing_session end return nil end package.loaded["consolelog.communication.inspector"].get_session_for_buffer = inspector_get_session @@ -833,7 +901,7 @@ describe("Single File Run", function() end local core = require("consolelog.core.init") - core.run_buffer(6) + core.run_buffer(test_bufnr, winid) assert.equals(1, inspector_cleanup_mock.call_count, "inspector session must be cleaned when buffer runs as .py") @@ -848,6 +916,8 @@ describe("Single File Run", function() teardown_python_runner_stub() teardown_consolelog_module() cleanup_sessions() + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) end) it("should clean python_runner session when running a renamed .js buffer", function() @@ -856,10 +926,16 @@ describe("Single File Run", function() setup_consolelog_module() cleanup_sessions() - -- Simulate an existing python_runner session on buffer 6 - local existing_py_session = { filepath = "/tmp/old.py", bufnr = 6 } + local test_bufnr = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(test_bufnr, "buftype", "") + local winid = vim.api.nvim_get_current_win() + local previous_bufnr = vim.api.nvim_win_get_buf(winid) + vim.api.nvim_win_set_buf(winid, test_bufnr) + + -- Simulate an existing python_runner session on test buffer + local existing_py_session = { filepath = "/tmp/old.py", bufnr = test_bufnr } local py_get_session = function(bufnr) - if bufnr == 6 then return existing_py_session end + if bufnr == test_bufnr then return existing_py_session end return nil end package.loaded["consolelog.communication.python_runner"].get_session_for_buffer = py_get_session @@ -876,7 +952,7 @@ describe("Single File Run", function() end local core = require("consolelog.core.init") - core.run_buffer(6) + core.run_buffer(test_bufnr, winid) assert.equals(1, py_cleanup_mock.call_count, "python_runner session must be cleaned when buffer runs as .js") @@ -891,6 +967,8 @@ describe("Single File Run", function() teardown_python_runner_stub() teardown_consolelog_module() cleanup_sessions() + vim.api.nvim_win_set_buf(winid, previous_bufnr) + vim.api.nvim_buf_delete(test_bufnr, { force = true }) end) end) @@ -942,6 +1020,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return false end, is_supported_buffer = function() return false end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1194,6 +1273,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1281,6 +1361,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1348,6 +1429,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1436,6 +1518,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1532,6 +1615,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1593,6 +1677,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1687,6 +1772,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return false end, is_supported_buffer = function() return false end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector @@ -1779,6 +1865,7 @@ describe("Single File Run", function() package.loaded["consolelog.core.utils"] = { is_javascript_buffer = function() return true end, is_supported_buffer = function() return true end, + find_regular_buffer_window = function(_, winid) return winid end, } package.loaded["consolelog.communication.inspector"] = inspector From 2f76055c65acb1e291e3eb87dad1b65c394579d9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:17:15 -0400 Subject: [PATCH 3/3] feat: use bounded start/end markers for injection blocks and improve backup lifecycle --- .gitignore | 4 + js/nextjs-auto-injector.js | 18 +- lua/consolelog/core/constants.lua | 7 +- lua/consolelog/injection/injectors/nextjs.lua | 57 +++-- lua/consolelog/injection/injectors/react.lua | 39 ++- lua/consolelog/injection/injectors/vue.lua | 39 ++- .../consolelog_runner.cpython-314.pyc | Bin 15859 -> 0 bytes tests/lua/nextjs_injector_spec.lua | 234 ++++++++++++++---- tests/lua/react_injector_spec.lua | 125 +++++++++- tests/lua/test_helper.lua | 53 +++- tests/lua/vue_injector_spec.lua | 125 +++++++++- tests/run_lua_tests.sh | 2 +- 12 files changed, 575 insertions(+), 128 deletions(-) delete mode 100644 py/__pycache__/consolelog_runner.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 3a858b4..52523ea 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ dist/ build/ out/ +# Python +__pycache__/ +*.py[cod] + # IDE .vscode/ .idea/ diff --git a/js/nextjs-auto-injector.js b/js/nextjs-auto-injector.js index 8b50691..27da9ea 100644 --- a/js/nextjs-auto-injector.js +++ b/js/nextjs-auto-injector.js @@ -2,7 +2,8 @@ const fs = require('fs'); const path = require('path'); - const MARKER = '/* ConsoleLog.nvim auto-injection */'; + const START_MARKER = '// ConsoleLog.nvim auto-injection start'; + const END_MARKER = '// ConsoleLog.nvim auto-injection end'; const WS_PORT = __WS_PORT__; const PROJECT_ID = '__PROJECT_ID__'; @@ -16,7 +17,8 @@ try { if (!fs.existsSync(filepath)) return false; const content = fs.readFileSync(filepath, 'utf8'); - return !content.includes('window.__CONSOLELOG_WS_PORT'); + return !content.includes(START_MARKER) || + !content.includes(`window.__CONSOLELOG_WS_PORT = ${WS_PORT};`); } catch (err) { return false; } @@ -25,14 +27,13 @@ function patchFile(filepath) { try { const backup = filepath + '.bk'; - if (!fs.existsSync(backup)) { + let content = fs.readFileSync(filepath, 'utf8'); + if (!fs.existsSync(backup) || !content.includes('window.__CONSOLELOG_WS_PORT')) { fs.copyFileSync(filepath, backup); } - let content = fs.readFileSync(filepath, 'utf8'); - - if (content.includes(MARKER)) { - return true; + if (content.includes('window.__CONSOLELOG_WS_PORT') && fs.existsSync(backup)) { + content = fs.readFileSync(backup, 'utf8'); } const clientCodePath = path.join(__dirname, '../../../.consolelog-client-inject.js'); @@ -43,7 +44,7 @@ return false; } - const injection = `${MARKER} + const injection = `${START_MARKER} if (typeof window !== "undefined") { window.__CONSOLELOG_WS_PORT = ${WS_PORT}; window.__CONSOLELOG_PROJECT_ID = "${PROJECT_ID}"; @@ -51,6 +52,7 @@ if (typeof window !== "undefined") { window.__CONSOLELOG_DEBUG = true; } ${clientCode} +${END_MARKER} `; if (content.match(/^['"]use client['"]/m)) { diff --git a/lua/consolelog/core/constants.lua b/lua/consolelog/core/constants.lua index 1bc90d6..91ca46a 100644 --- a/lua/consolelog/core/constants.lua +++ b/lua/consolelog/core/constants.lua @@ -77,6 +77,12 @@ M.FILES = { BACKUP_SUFFIX = ".bk" } +M.INJECTION = { + START_MARKER = "// ConsoleLog.nvim auto-injection start", + END_MARKER = "// ConsoleLog.nvim auto-injection end", + BLOCK_PATTERN = "// ConsoleLog%.nvim auto%-injection start.-// ConsoleLog%.nvim auto%-injection end\n?" +} + M.LOG_LEVELS = { DEBUG = "DEBUG", INFO = "INFO", @@ -134,4 +140,3 @@ function M.is_python_file(filepath) end return M - diff --git a/lua/consolelog/injection/injectors/nextjs.lua b/lua/consolelog/injection/injectors/nextjs.lua index 7f8361d..b527929 100644 --- a/lua/consolelog/injection/injectors/nextjs.lua +++ b/lua/consolelog/injection/injectors/nextjs.lua @@ -96,21 +96,29 @@ local function patch_cpu_profile(project_root, plugin_dir, ws_port) end local content = table.concat(vim.fn.readfile(filepath), "\n") + if not content:match("ConsoleLog%.nvim auto%-injection") then + local success = vim.fn.writefile(vim.fn.readfile(filepath, "b"), backup_path, "b") + if success ~= 0 then + debug_logger.log("NEXTJS_PATCH", string.format("Failed to refresh backup: %s", backup_path)) + break + end + end if content:match("ConsoleLog%.nvim auto%-injection") then - debug_logger.log("NEXTJS_PATCH", - string.format("cpu-profile already patched: %s", filepath)) - patched_count = patched_count + 1 - break + content = table.concat(vim.fn.readfile(backup_path), "\n") end local injection = "\n" .. auto_injector_code content = content .. injection - vim.fn.writefile(vim.split(content, "\n"), filepath) - patched_count = patched_count + 1 - debug_logger.log("NEXTJS_PATCH", string.format("Patched cpu-profile: %s", filepath)) + local success = vim.fn.writefile(vim.split(content, "\n"), filepath) + if success == 0 then + patched_count = patched_count + 1 + debug_logger.log("NEXTJS_PATCH", string.format("Patched cpu-profile: %s", filepath)) + else + debug_logger.log("NEXTJS_PATCH", string.format("Failed to patch cpu-profile: %s", filepath)) + end break end end @@ -129,6 +137,12 @@ local function unpatch_cpu_profile(project_root) local backup_path = filepath .. constants.FILES.BACKUP_SUFFIX if vim.fn.filereadable(backup_path) == 1 then + local has_injection = vim.fn.filereadable(filepath) ~= 1 + or table.concat(vim.fn.readfile(filepath), "\n"):find("window.__CONSOLELOG_WS_PORT", 1, true) + if not has_injection then + vim.fn.delete(backup_path) + break + end local success = vim.fn.writefile(vim.fn.readfile(backup_path, "b"), filepath, "b") if success == 0 then unpatched_count = unpatched_count + 1 @@ -230,7 +244,7 @@ function M.patch(project_root, ws_port) debug_logger.log("NEXTJS_PATCH", "Source map resolver not found, skipping") end - local inject_script = string.format([[ + local inject_script = constants.INJECTION.START_MARKER .. "\n" .. string.format([[ if (typeof window !== 'undefined') { window.__CONSOLELOG_WS_PORT = %d; window.__CONSOLELOG_PROJECT_ID = '%s'; @@ -239,7 +253,7 @@ if (typeof window !== 'undefined') { %s %s } -]], ws_port, project_id, sourcemap_content, inject_content) +]], ws_port, project_id, sourcemap_content, inject_content) .. constants.INJECTION.END_MARKER for _, file in ipairs(NEXTJS_FILES) do local found_file = false @@ -264,12 +278,20 @@ if (typeof window !== 'undefined') { end local content = table.concat(vim.fn.readfile(filepath), "\n") + if not content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + local success = vim.fn.writefile(vim.fn.readfile(filepath, "b"), backup_path, "b") + if success ~= 0 then + debug_logger.log("NEXTJS_PATCH", string.format("Failed to refresh backup: %s", backup_path)) + break + end + end + if not content:find(constants.INJECTION.START_MARKER, 1, true) + and content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + content = table.concat(vim.fn.readfile(backup_path), "\n") + end - if content:match("ConsoleLog%.nvim auto%-injection") then - local start_marker = "// ConsoleLog%.nvim auto%-injection" - local end_marker = "\n}\n" - local pattern = start_marker .. ".-" .. end_marker - content = content:gsub(pattern, "", 1) + if content:find(constants.INJECTION.START_MARKER, 1, true) then + content = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) debug_logger.log("NEXTJS_PATCH", string.format("Removed old injection from %s", filepath)) end @@ -340,6 +362,13 @@ function M.unpatch(project_root) string.format("Failed to restore from backup: %s", filepath)) end break + elseif vim.fn.filereadable(filepath) == 1 then + local content = table.concat(vim.fn.readfile(filepath), "\n") + local restored, removed = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) + if removed > 0 and vim.fn.writefile(vim.split(restored, "\n"), filepath) == 0 then + unpatched_count = unpatched_count + 1 + end + break else debug_logger.log("NEXTJS_PATCH", string.format("No backup found for: %s", filepath)) end diff --git a/lua/consolelog/injection/injectors/react.lua b/lua/consolelog/injection/injectors/react.lua index fc3ab0e..9f83297 100644 --- a/lua/consolelog/injection/injectors/react.lua +++ b/lua/consolelog/injection/injectors/react.lua @@ -50,8 +50,8 @@ function M.is_patched(project_root) for _, root in ipairs(search_roots) do local filepath = root .. file if vim.fn.filereadable(filepath) == 1 then - local backup_path = filepath .. constants.FILES.BACKUP_SUFFIX - if vim.fn.filereadable(backup_path) == 1 then + local content = table.concat(vim.fn.readfile(filepath), "\n") + if content:find("window.__CONSOLELOG_WS_PORT", 1, true) then patched_files = patched_files + 1 debug_logger.log("REACT_PATCH", string.format("Found patched file: %s", filepath)) end @@ -102,7 +102,7 @@ function M.patch(project_root, ws_port) debug_logger.log("REACT_PATCH", "Source map resolver not found, skipping") end - local inject_script = string.format([[ + local inject_script = constants.INJECTION.START_MARKER .. "\n" .. string.format([[ if (typeof window !== 'undefined') { window.__CONSOLELOG_WS_PORT = %d; window.__CONSOLELOG_PROJECT_ID = '%s'; @@ -111,7 +111,7 @@ if (typeof window !== 'undefined') { %s %s } -]], ws_port, project_id, sourcemap_content, inject_content) +]], ws_port, project_id, sourcemap_content, inject_content) .. constants.INJECTION.END_MARKER for _, file in ipairs(REACT_FILES) do local found_file = false @@ -134,12 +134,20 @@ if (typeof window !== 'undefined') { end local content = table.concat(vim.fn.readfile(filepath), "\n") + if not content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + local success = vim.fn.writefile(vim.fn.readfile(filepath, "b"), backup_path, "b") + if success ~= 0 then + debug_logger.log("REACT_PATCH", string.format("Failed to refresh backup: %s", backup_path)) + break + end + end + if not content:find(constants.INJECTION.START_MARKER, 1, true) + and content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + content = table.concat(vim.fn.readfile(backup_path), "\n") + end - if content:match("ConsoleLog%.nvim auto%-injection") then - local start_marker = "// ConsoleLog%.nvim auto%-injection" - local end_marker = "\n}\n" - local pattern = start_marker .. ".-" .. end_marker - content = content:gsub(pattern, "", 1) + if content:find(constants.INJECTION.START_MARKER, 1, true) then + content = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) debug_logger.log("REACT_PATCH", string.format("Removed old injection from %s", filepath)) end @@ -189,6 +197,12 @@ function M.unpatch(project_root) local backup_path = filepath .. constants.FILES.BACKUP_SUFFIX if vim.fn.filereadable(backup_path) == 1 then + local has_injection = vim.fn.filereadable(filepath) ~= 1 + or table.concat(vim.fn.readfile(filepath), "\n"):find("window.__CONSOLELOG_WS_PORT", 1, true) + if not has_injection then + vim.fn.delete(backup_path) + break + end local success = vim.fn.writefile(vim.fn.readfile(backup_path, "b"), filepath, "b") if success == 0 then unpatched_count = unpatched_count + 1 @@ -200,6 +214,13 @@ function M.unpatch(project_root) debug_logger.log("REACT_PATCH", string.format("Failed to restore from backup: %s", filepath)) end break + elseif vim.fn.filereadable(filepath) == 1 then + local content = table.concat(vim.fn.readfile(filepath), "\n") + local restored, removed = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) + if removed > 0 and vim.fn.writefile(vim.split(restored, "\n"), filepath) == 0 then + unpatched_count = unpatched_count + 1 + end + break else debug_logger.log("REACT_PATCH", string.format("No backup found for: %s", filepath)) end diff --git a/lua/consolelog/injection/injectors/vue.lua b/lua/consolelog/injection/injectors/vue.lua index bde5570..c64a778 100644 --- a/lua/consolelog/injection/injectors/vue.lua +++ b/lua/consolelog/injection/injectors/vue.lua @@ -47,8 +47,8 @@ function M.is_patched(project_root) for _, root in ipairs(search_roots) do local filepath = root .. file if vim.fn.filereadable(filepath) == 1 then - local backup_path = filepath .. constants.FILES.BACKUP_SUFFIX - if vim.fn.filereadable(backup_path) == 1 then + local content = table.concat(vim.fn.readfile(filepath), "\n") + if content:find("window.__CONSOLELOG_WS_PORT", 1, true) then patched_files = patched_files + 1 debug_logger.log("VUE_PATCH", string.format("Found patched file: %s", filepath)) end @@ -99,7 +99,7 @@ function M.patch(project_root, ws_port) debug_logger.log("VUE_PATCH", "Source map resolver not found, skipping") end - local inject_script = string.format([[ + local inject_script = constants.INJECTION.START_MARKER .. "\n" .. string.format([[ if (typeof window !== 'undefined') { window.__CONSOLELOG_WS_PORT = %d; window.__CONSOLELOG_PROJECT_ID = '%s'; @@ -108,7 +108,7 @@ if (typeof window !== 'undefined') { %s %s } -]], ws_port, project_id, sourcemap_content, inject_content) +]], ws_port, project_id, sourcemap_content, inject_content) .. constants.INJECTION.END_MARKER for _, file in ipairs(VUE_FILES) do local found_file = false @@ -131,12 +131,20 @@ if (typeof window !== 'undefined') { end local content = table.concat(vim.fn.readfile(filepath), "\n") + if not content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + local success = vim.fn.writefile(vim.fn.readfile(filepath, "b"), backup_path, "b") + if success ~= 0 then + debug_logger.log("VUE_PATCH", string.format("Failed to refresh backup: %s", backup_path)) + break + end + end + if not content:find(constants.INJECTION.START_MARKER, 1, true) + and content:find("window.__CONSOLELOG_WS_PORT", 1, true) then + content = table.concat(vim.fn.readfile(backup_path), "\n") + end - if content:match("ConsoleLog%.nvim auto%-injection") then - local start_marker = "// ConsoleLog%.nvim auto%-injection" - local end_marker = "\n}\n" - local pattern = start_marker .. ".-" .. end_marker - content = content:gsub(pattern, "", 1) + if content:find(constants.INJECTION.START_MARKER, 1, true) then + content = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) debug_logger.log("VUE_PATCH", string.format("Removed old injection from %s", filepath)) end @@ -186,6 +194,12 @@ function M.unpatch(project_root) local backup_path = filepath .. constants.FILES.BACKUP_SUFFIX if vim.fn.filereadable(backup_path) == 1 then + local has_injection = vim.fn.filereadable(filepath) ~= 1 + or table.concat(vim.fn.readfile(filepath), "\n"):find("window.__CONSOLELOG_WS_PORT", 1, true) + if not has_injection then + vim.fn.delete(backup_path) + break + end local success = vim.fn.writefile(vim.fn.readfile(backup_path, "b"), filepath, "b") if success == 0 then unpatched_count = unpatched_count + 1 @@ -197,6 +211,13 @@ function M.unpatch(project_root) debug_logger.log("VUE_PATCH", string.format("Failed to restore from backup: %s", filepath)) end break + elseif vim.fn.filereadable(filepath) == 1 then + local content = table.concat(vim.fn.readfile(filepath), "\n") + local restored, removed = content:gsub(constants.INJECTION.BLOCK_PATTERN, "", 1) + if removed > 0 and vim.fn.writefile(vim.split(restored, "\n"), filepath) == 0 then + unpatched_count = unpatched_count + 1 + end + break else debug_logger.log("VUE_PATCH", string.format("No backup found for: %s", filepath)) end diff --git a/py/__pycache__/consolelog_runner.cpython-314.pyc b/py/__pycache__/consolelog_runner.cpython-314.pyc deleted file mode 100644 index 5451b55fa16892566455141a96bcf0aa8e936691..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15859 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 VUCY?ld?<*2eiT=C1l^PTe*v@1-O>O6 diff --git a/tests/lua/nextjs_injector_spec.lua b/tests/lua/nextjs_injector_spec.lua index 7acaf48..5c33bca 100644 --- a/tests/lua/nextjs_injector_spec.lua +++ b/tests/lua/nextjs_injector_spec.lua @@ -106,7 +106,7 @@ describe("NextJS Injector Tests", function() end) describe("Patch functionality", function() - it("should handle missing inject script gracefully", function() + it("should successfully patch app-index.js", function() setup() -- Create mock app-index.js file @@ -120,79 +120,47 @@ if (typeof window !== 'undefined') { local app_index_path = create_app_index_file(original_content) - -- In test environment, injector cannot locate plugin directory, - -- so patch returns false gracefully local ws_port = 9999 local patched = nextjs_injector.patch(project_root, ws_port) - assert.is_false(patched, "Should return false when inject script not found") + assert.is_true(patched, "Should successfully patch app-index.js") + + local patched_content = read_file_content(app_index_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 9999") ~= nil, + "Patched content should contain port 9999") cleanup() end) - it("should re-patch with updated port when inject script is found", function() + it("should re-patch with updated port", function() setup() - -- The injector resolves plugin_dir via debug.getinfo. In the fallback path - -- (when consolelog.nvim pattern doesn't match), fnamemodify(:p) returns a - -- path WITHOUT trailing slash, causing "lua" .. "js/" to become "luajs/". - -- Workaround: create a mock consolelog.nvim installation so the primary - -- pattern match succeeds (it includes the trailing slash in the capture). - local cwd = vim.fn.getcwd() - local mock_dir = temp_dir .. "/consolelog.nvim" - vim.fn.mkdir(mock_dir .. "/lua/consolelog/injection/injectors", "p") - vim.fn.mkdir(mock_dir .. "/js", "p") - vim.fn.system("cp " .. vim.fn.shellescape(cwd .. "/lua/consolelog/injection/injectors/nextjs.lua") .. " " .. vim.fn.shellescape(mock_dir .. "/lua/consolelog/injection/injectors/nextjs.lua")) - vim.fn.system("cp " .. vim.fn.shellescape(cwd .. "/js/inject-client.js") .. " " .. vim.fn.shellescape(mock_dir .. "/js/inject-client.js")) - vim.fn.system("cp " .. vim.fn.shellescape(cwd .. "/js/nextjs-auto-injector.js") .. " " .. vim.fn.shellescape(mock_dir .. "/js/nextjs-auto-injector.js")) - vim.fn.system("cp " .. vim.fn.shellescape(cwd .. "/js/sourcemap-resolver.js") .. " " .. vim.fn.shellescape(mock_dir .. "/js/sourcemap-resolver.js")) - - -- Temporarily load the injector from the mock installation - local orig_path = package.path - package.path = mock_dir .. "/lua/?.lua;" .. package.path - package.loaded["consolelog.injection.injectors.nextjs"] = nil - local mock_injector = require('consolelog.injection.injectors.nextjs') - package.path = orig_path - - -- Create pre-patched app-index.js with port 8888 - local pre_patched_content = [[ + local original_content = [[ 'use client' -// ConsoleLog.nvim auto-injection -if (typeof window !== 'undefined') { - window.__CONSOLELOG_WS_PORT = 8888; - window.__CONSOLELOG_PROJECT_ID = 'test-project'; - window.__CONSOLELOG_FRAMEWORK = 'Next.js'; - window.__CONSOLELOG_DEBUG = false; -} if (typeof window !== 'undefined') { console.log('Hello from Next.js'); } ]] - local app_index_path = create_app_index_file(pre_patched_content) + local app_index_path = create_app_index_file(original_content) + + nextjs_injector.patch(project_root, 8888) - -- Re-patch with a different port using the mock injector local new_ws_port = 9999 - local patched = mock_injector.patch(project_root, new_ws_port) + local patched = nextjs_injector.patch(project_root, new_ws_port) assert.is_true(patched, "Should successfully re-patch") - -- Verify the new port appears in patched content local patched_content = read_file_content(app_index_path) - assert.is_true(patched_content ~= nil, "Patched file should exist") assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 9999") ~= nil, "Re-patched content should contain new port 9999") assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, "Re-patched content should not contain old port 8888") - - -- Restore the original module - package.loaded["consolelog.injection.injectors.nextjs"] = nil - nextjs_injector = require('consolelog.injection.injectors.nextjs') cleanup() end) - it("should not create backup when patch fails", function() + it("should create backup when patching", function() setup() local original_content = [[ @@ -207,12 +175,93 @@ if (typeof window !== 'undefined') { local ws_port = 9999 nextjs_injector.patch(project_root, ws_port) - -- In test environment, patch fails so no backup is created local backup_content = read_file_content(app_index_path .. ".bk") - assert.is_nil(backup_content, "No backup should be created when patch fails") + assert.not_nil(backup_content, "Backup should be created when patch succeeds") + assert.equals(backup_content, original_content, "Backup should contain original content") + + cleanup() + end) + + it("should upgrade legacy injection without start/end markers", function() + setup() + + local original_content = [[ +'use client' +if (typeof window !== 'undefined') { + console.log('Hello from Next.js'); +} +]] + + local app_index_path = create_app_index_file(original_content) + + vim.fn.writefile(vim.split(original_content, "\n"), app_index_path .. ".bk") + + local legacy_content = [[ +'use client' +if (typeof window !== 'undefined') { + window.__CONSOLELOG_WS_PORT = 8888; + window.__CONSOLELOG_PROJECT_ID = 'test-project'; + window.__CONSOLELOG_FRAMEWORK = 'Next.js'; + window.__CONSOLELOG_DEBUG = false; +} +if (typeof window !== 'undefined') { + console.log('Hello from Next.js'); +} +]] + vim.fn.writefile(vim.split(legacy_content, "\n"), app_index_path) + + local patched = nextjs_injector.patch(project_root, 9999) + assert.is_true(patched, "Should successfully upgrade legacy injection") + + local patched_content = read_file_content(app_index_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 9999") ~= nil, + "Upgraded content should contain new port 9999") + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, + "Upgraded content should not contain legacy port 8888") + local _, start_count = patched_content:gsub("// ConsoleLog%.nvim auto%-injection start", "") + assert.equals(1, start_count, "Should contain exactly one start marker") cleanup() end) + + it("should update injected auto-injector code in cpu-profile.js when re-patching", function() + setup() + + vim.fn.mkdir(project_root .. "/node_modules/next/dist/server/lib", "p") + local cpu_profile_path = project_root .. "/node_modules/next/dist/server/lib/cpu-profile.js" + local cpu_content = "function enableProfiling() {\n return true;\n}\n" + vim.fn.writefile(vim.split(cpu_content, "\n"), cpu_profile_path) + + local original_content = [[ +'use client' +if (typeof window !== 'undefined') { + console.log('Hello from Next.js'); +} +]] + create_app_index_file(original_content) + + nextjs_injector.patch(project_root, 8888) + + local cpu_after_first = read_file_content(cpu_profile_path) + assert.is_true(cpu_after_first:find("WS_PORT = 8888") ~= nil, + "cpu-profile.js should contain port 8888 after first patch") + + local updated_cpu_content = "function updatedProfiling() {\n return true;\n}\n" + vim.fn.writefile(vim.split(updated_cpu_content, "\n"), cpu_profile_path) + nextjs_injector.patch(project_root, 9999) + + local cpu_after_second = read_file_content(cpu_profile_path) + assert.is_true(cpu_after_second:find("WS_PORT = 9999") ~= nil, + "cpu-profile.js should contain port 9999 after re-patch") + assert.is_true(cpu_after_second:find("WS_PORT = 8888") == nil, + "cpu-profile.js should not contain old port 8888 after re-patch") + + nextjs_injector.unpatch(project_root) + assert.equals(updated_cpu_content, read_file_content(cpu_profile_path), + "Unpatch should restore current dependency content") + + cleanup() + end) end) describe("Unpatch functionality", function() @@ -314,10 +363,37 @@ if (typeof window !== 'undefined') { cleanup() end) + + it("should remove bounded marker block when backup is missing", function() + setup() + + local marker_block = "// ConsoleLog.nvim auto-injection start\n" .. + "if (typeof window !== 'undefined') {\n" .. + " window.__CONSOLELOG_WS_PORT = 9999;\n" .. + " window.__CONSOLELOG_PROJECT_ID = 'test';\n" .. + " window.__CONSOLELOG_FRAMEWORK = 'Next.js';\n" .. + " window.__CONSOLELOG_DEBUG = false;\n" .. + "}\n" .. + "// ConsoleLog.nvim auto-injection end\n" + + local file_with_marker = "'use client'\n" .. marker_block .. + "if (typeof window !== 'undefined') {\n console.log('Hello from Next.js');\n}\n" + local app_index_path = create_app_index_file(file_with_marker) + + nextjs_injector.unpatch(project_root) + + local result = read_file_content(app_index_path) + assert.is_true(result:find("ConsoleLog%.nvim auto%-injection") == nil, + "Marker block should be removed when backup is missing") + assert.is_true(result:find("console.log") ~= nil, + "Surrounding original code should be preserved") + + cleanup() + end) end) describe("Complete patch/unpatch cycle", function() - it("should handle patch failure and unpatch gracefully", function() + it("should patch then unpatch and restore original", function() setup() -- Create both regular and esm app-index.js files @@ -331,19 +407,69 @@ if (typeof window !== 'undefined') { local app_index_path = create_app_index_file(original_content) local esm_app_index_path = create_esm_app_index_file(original_content) - -- In test environment, patch fails local ws_port = 9999 local patched = nextjs_injector.patch(project_root, ws_port) - assert.is_false(patched, "Should return false when inject script not found") + assert.is_true(patched, "Should successfully patch") - -- Unpatch should not error even with no backups nextjs_injector.unpatch(project_root) - -- Verify files are unchanged - local content = read_file_content(app_index_path) - assert.equals(content, original_content, "app-index.js should remain unchanged") + local restored_content = read_file_content(app_index_path) + assert.equals(restored_content, original_content, "app-index.js should be restored to original") + + cleanup() + end) + end) + + describe("is_patched", function() + it("should return true after successful patch", function() + setup() + + local original_content = [[ +'use client' +if (typeof window !== 'undefined') { + console.log('Hello from Next.js'); +} +]] + + create_app_index_file(original_content) + + nextjs_injector.patch(project_root, 9999) + + local is_patched, count = nextjs_injector.is_patched(project_root) + assert.is_true(is_patched, "Should report as patched after patching") + assert.equals(1, count, "Should report one patched file") cleanup() end) + + it("should return false for clean file with stale backup", function() + setup() + + local original_content = [[ +'use client' +if (typeof window !== 'undefined') { + console.log('Hello from Next.js'); +} +]] + + local app_index_path = create_app_index_file(original_content) + vim.fn.writefile(vim.split(original_content, "\n"), app_index_path .. ".bk") + + local is_patched, count = nextjs_injector.is_patched(project_root) + assert.is_false(is_patched, "Should not report as patched when file is clean") + assert.equals(0, count, "Should report zero patched files for clean file with stale backup") + + local clean_content = read_file_content(app_index_path) + nextjs_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(app_index_path), "Clean file should remain unchanged after unpatch") + assert.is_false(vim.fn.filereadable(app_index_path .. ".bk") == 1, "Stale backup should be deleted after unpatch") + + vim.fn.writefile({ "stale dependency content" }, app_index_path .. ".bk") + nextjs_injector.patch(project_root, 9999) + nextjs_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(app_index_path), "Refreshed backup should restore current dependency content") + + cleanup() + end) end) end) diff --git a/tests/lua/react_injector_spec.lua b/tests/lua/react_injector_spec.lua index 9fd9ba1..90d919d 100644 --- a/tests/lua/react_injector_spec.lua +++ b/tests/lua/react_injector_spec.lua @@ -120,15 +120,19 @@ describe("React Injector Tests", function() end) describe("React Patching", function() - it("should handle missing inject script gracefully", function() + it("should successfully patch React files", function() setup() create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') local index_path, client_path, webpack_path = create_react_files() - -- In test environment, injector cannot locate plugin directory, - -- so patch returns false gracefully local success = react_injector.patch(project_root, 19990) - assert.is_false(success, "Should return false when inject script not found") + assert.is_true(success, "Should successfully patch React files") + + local index_content = read_file_content(index_path) + assert.is_true(has_injection(index_content), "index.js should contain injection") + assert.is_true(index_content:find("window.__CONSOLELOG_WS_PORT = 19990") ~= nil, + "Patched content should contain port 19990") + assert.is_true(backup_exists(index_path), "index.js backup should exist") cleanup() end) @@ -144,14 +148,57 @@ describe("React Injector Tests", function() cleanup() end) - it("should handle re-patch gracefully when inject script not found", function() + it("should re-patch with updated port", function() setup() create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') local index_path, client_path, webpack_path = create_react_files() - -- In test environment, injector cannot locate plugin directory + react_injector.patch(project_root, 8888) + local success = react_injector.patch(project_root, 19991) - assert.is_false(success, "Should return false when inject script not found") + assert.is_true(success, "Should successfully re-patch") + + local patched_content = read_file_content(index_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 19991") ~= nil, + "Re-patched content should contain new port 19991") + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, + "Re-patched content should not contain old port 8888") + + cleanup() + end) + + it("should upgrade legacy injection without start/end markers", function() + setup() + create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') + local index_path, client_path, webpack_path = create_react_files() + + local original_index = read_file_content(index_path) + vim.fn.writefile(vim.split(original_index, "\n"), index_path .. ".bk") + + local legacy_content = [[ +'use strict'; +if (typeof window !== 'undefined') { + window.__CONSOLELOG_WS_PORT = 8888; + window.__CONSOLELOG_PROJECT_ID = 'test-project'; + window.__CONSOLELOG_FRAMEWORK = 'React'; + window.__CONSOLELOG_DEBUG = true; +} +function checkDCE() { + return true; +} +]] + vim.fn.writefile(vim.split(legacy_content, "\n"), index_path) + + local success = react_injector.patch(project_root, 19991) + assert.is_true(success, "Should successfully upgrade legacy injection") + + local patched_content = read_file_content(index_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 19991") ~= nil, + "Upgraded content should contain new port 19991") + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, + "Upgraded content should not contain legacy port 8888") + local _, start_count = patched_content:gsub("// ConsoleLog%.nvim auto%-injection start", "") + assert.equals(1, start_count, "Should contain exactly one start marker") cleanup() end) @@ -174,7 +221,7 @@ describe("React Injector Tests", function() vim.fn.writefile(vim.split(original_webpack, "\n"), webpack_path .. ".bk") -- Overwrite files with patched content - vim.fn.writefile(vim.split(original_index .. "\n// injected", "\n"), index_path) + vim.fn.writefile(vim.split(original_index .. "\nwindow.__CONSOLELOG_WS_PORT = 19990;", "\n"), index_path) -- Unpatch should restore from backup react_injector.unpatch(project_root) @@ -200,18 +247,48 @@ describe("React Injector Tests", function() cleanup() end) + + it("should remove bounded marker block when backup is missing", function() + setup() + create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') + local index_path, client_path, webpack_path = create_react_files() + + local marker_block = "// ConsoleLog.nvim auto-injection start\n" .. + "if (typeof window !== 'undefined') {\n" .. + " window.__CONSOLELOG_WS_PORT = 19990;\n" .. + " window.__CONSOLELOG_PROJECT_ID = 'test';\n" .. + " window.__CONSOLELOG_FRAMEWORK = 'React';\n" .. + " window.__CONSOLELOG_DEBUG = true;\n" .. + "}\n" .. + "// ConsoleLog.nvim auto-injection end\n" + + local file_with_marker = "'use strict';\n" .. marker_block .. + "function checkDCE() {\n return true;\n}\n" + vim.fn.writefile(vim.split(file_with_marker, "\n"), index_path) + + react_injector.unpatch(project_root) + + local result = read_file_content(index_path) + assert.is_true(result:find("ConsoleLog%.nvim auto%-injection") == nil, + "Marker block should be removed when backup is missing") + assert.is_true(result:find("function checkDCE") ~= nil, + "Surrounding original code should be preserved") + + cleanup() + end) end) describe("React is_patched", function() - it("should return false when patch was not applied", function() + it("should return true after successful patch", function() setup() create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') create_react_files() - -- patch() failed, so no backup files were created + react_injector.patch(project_root, 19990) + local is_patched, count = react_injector.is_patched(project_root) - assert.is_false(is_patched, "Should report as not patched when patch failed") - assert.equals(0, count, "Should report zero patched files") + assert.is_true(is_patched, "Should report as patched after patching") + assert.is_true(count > 0, "Should report at least one patched file") cleanup() end) @@ -227,5 +304,29 @@ describe("React Injector Tests", function() cleanup() end) + + it("should return false for clean file with stale backup", function() + setup() + create_package_json('{"dependencies": {"react": "^18.0.0", "react-dom": "^18.0.0"}}') + local index_path, client_path, webpack_path = create_react_files() + + vim.fn.writefile(vim.split(read_file_content(index_path), "\n"), index_path .. ".bk") + + local is_patched, count = react_injector.is_patched(project_root) + assert.is_false(is_patched, "Should not report as patched when file is clean") + assert.equals(0, count, "Should report zero patched files for clean file with stale backup") + + local clean_content = read_file_content(index_path) + react_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(index_path), "Clean file should remain unchanged after unpatch") + assert.is_false(backup_exists(index_path), "Stale backup should be deleted after unpatch") + + vim.fn.writefile({ "stale dependency content" }, index_path .. ".bk") + react_injector.patch(project_root, 19990) + react_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(index_path), "Refreshed backup should restore current dependency content") + + cleanup() + end) end) end) diff --git a/tests/lua/test_helper.lua b/tests/lua/test_helper.lua index 5fcf8d5..7731d86 100644 --- a/tests/lua/test_helper.lua +++ b/tests/lua/test_helper.lua @@ -1,5 +1,27 @@ local M = {} +local describe_stack = {} + +local function current_depth() + return #describe_stack +end + +local function push_describe_context() + local ctx = { failed = false } + table.insert(describe_stack, ctx) + return ctx +end + +local function pop_describe_context() + return table.remove(describe_stack) +end + +local function mark_child_failed() + if #describe_stack > 0 then + describe_stack[#describe_stack].failed = true + end +end + M.assert = { equals = function(actual, expected, message) if actual ~= expected then @@ -96,26 +118,41 @@ M.assert = { } M.describe = function(name, fn) - print("Testing: " .. name) + local depth = current_depth() + local indent = string.rep(" ", depth) + print(indent .. "Testing: " .. name) + local ctx = push_describe_context() local success, err = pcall(fn) - if success then - print(" ✓ " .. name .. " passed") + pop_describe_context() + + local result_indent = string.rep(" ", depth + 1) + if success and not ctx.failed then + print(result_indent .. "✓ " .. name .. " passed") return true else - print(" ✗ " .. name .. " failed: " .. err) - print("FAILED: " .. name .. ": " .. tostring(err)) + if not success then + print(result_indent .. "✗ " .. name .. " failed: " .. err) + print("FAILED: " .. name .. ": " .. tostring(err)) + else + print(result_indent .. "✗ " .. name .. " failed (child test(s) failed)") + print("FAILED: " .. name) + end + mark_child_failed() return false end end M.it = function(name, fn) + local depth = current_depth() + local indent = string.rep(" ", depth + 1) local success, err = pcall(fn) if success then - print(" ✓ " .. name) + print(indent .. "✓ " .. name) return true else - print(" ✗ " .. name .. ": " .. err) + print(indent .. "✗ " .. name .. ": " .. err) print("FAILED: " .. name .. ": " .. tostring(err)) + mark_child_failed() return false end end @@ -192,4 +229,4 @@ M.async = { end } -return M \ No newline at end of file +return M diff --git a/tests/lua/vue_injector_spec.lua b/tests/lua/vue_injector_spec.lua index 0d99cc2..0b87610 100644 --- a/tests/lua/vue_injector_spec.lua +++ b/tests/lua/vue_injector_spec.lua @@ -116,15 +116,19 @@ describe("Vue Injector Tests", function() end) describe("Vue Patching", function() - it("should handle missing inject script gracefully", function() + it("should successfully patch Vue files", function() setup() create_package_json('{"dependencies": {"vue": "^3.5.0"}}') local runtime_path, esm_path, runtime_dom_path, vite_plugin_path = create_vue_files() - -- In test environment, injector cannot locate plugin directory, - -- so patch returns false gracefully local success = vue_injector.patch(project_root, 19990) - assert.is_false(success, "Should return false when inject script not found") + assert.is_true(success, "Should successfully patch Vue files") + + local runtime_content = read_file_content(runtime_path) + assert.is_true(has_injection(runtime_content), "vue.runtime.esm-browser.js should contain injection") + assert.is_true(runtime_content:find("window.__CONSOLELOG_WS_PORT = 19990") ~= nil, + "Patched content should contain port 19990") + assert.is_true(backup_exists(runtime_path), "vue.runtime.esm-browser.js backup should exist") cleanup() end) @@ -140,14 +144,57 @@ describe("Vue Injector Tests", function() cleanup() end) - it("should handle re-patch gracefully when inject script not found", function() + it("should re-patch with updated port", function() setup() create_package_json('{"dependencies": {"vue": "^3.5.0"}}') local runtime_path, esm_path, runtime_dom_path, vite_plugin_path = create_vue_files() - -- In test environment, injector cannot locate plugin directory + vue_injector.patch(project_root, 8888) + local success = vue_injector.patch(project_root, 19991) - assert.is_false(success, "Should return false when inject script not found") + assert.is_true(success, "Should successfully re-patch") + + local patched_content = read_file_content(runtime_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 19991") ~= nil, + "Re-patched content should contain new port 19991") + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, + "Re-patched content should not contain old port 8888") + + cleanup() + end) + + it("should upgrade legacy injection without start/end markers", function() + setup() + create_package_json('{"dependencies": {"vue": "^3.5.0"}}') + local runtime_path, esm_path, runtime_dom_path, vite_plugin_path = create_vue_files() + + local original_runtime = read_file_content(runtime_path) + vim.fn.writefile(vim.split(original_runtime, "\n"), runtime_path .. ".bk") + + local legacy_content = [[ +'use strict'; +if (typeof window !== 'undefined') { + window.__CONSOLELOG_WS_PORT = 8888; + window.__CONSOLELOG_PROJECT_ID = 'test-project'; + window.__CONSOLELOG_FRAMEWORK = 'Vue'; + window.__CONSOLELOG_DEBUG = true; +} +function createApp() { + return {}; +} +]] + vim.fn.writefile(vim.split(legacy_content, "\n"), runtime_path) + + local success = vue_injector.patch(project_root, 19991) + assert.is_true(success, "Should successfully upgrade legacy injection") + + local patched_content = read_file_content(runtime_path) + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 19991") ~= nil, + "Upgraded content should contain new port 19991") + assert.is_true(patched_content:find("window.__CONSOLELOG_WS_PORT = 8888") == nil, + "Upgraded content should not contain legacy port 8888") + local _, start_count = patched_content:gsub("// ConsoleLog%.nvim auto%-injection start", "") + assert.equals(1, start_count, "Should contain exactly one start marker") cleanup() end) @@ -166,7 +213,7 @@ describe("Vue Injector Tests", function() vim.fn.writefile(vim.split(original_runtime, "\n"), runtime_path .. ".bk") -- Overwrite file with patched content - vim.fn.writefile(vim.split(original_runtime .. "\n// injected", "\n"), runtime_path) + vim.fn.writefile(vim.split(original_runtime .. "\nwindow.__CONSOLELOG_WS_PORT = 19990;", "\n"), runtime_path) -- Unpatch should restore from backup vue_injector.unpatch(project_root) @@ -192,18 +239,48 @@ describe("Vue Injector Tests", function() cleanup() end) + + it("should remove bounded marker block when backup is missing", function() + setup() + create_package_json('{"dependencies": {"vue": "^3.5.0"}}') + local runtime_path, esm_path, runtime_dom_path, vite_plugin_path = create_vue_files() + + local marker_block = "// ConsoleLog.nvim auto-injection start\n" .. + "if (typeof window !== 'undefined') {\n" .. + " window.__CONSOLELOG_WS_PORT = 19990;\n" .. + " window.__CONSOLELOG_PROJECT_ID = 'test';\n" .. + " window.__CONSOLELOG_FRAMEWORK = 'Vue';\n" .. + " window.__CONSOLELOG_DEBUG = true;\n" .. + "}\n" .. + "// ConsoleLog.nvim auto-injection end\n" + + local file_with_marker = "/**\n* Vue.js v3.5.0\n*/\n" .. marker_block .. + "'use strict';\nfunction createApp() {\n return {};\n}\n" + vim.fn.writefile(vim.split(file_with_marker, "\n"), runtime_path) + + vue_injector.unpatch(project_root) + + local result = read_file_content(runtime_path) + assert.is_true(result:find("ConsoleLog%.nvim auto%-injection") == nil, + "Marker block should be removed when backup is missing") + assert.is_true(result:find("function createApp") ~= nil, + "Surrounding original code should be preserved") + + cleanup() + end) end) describe("Vue is_patched", function() - it("should return false when patch was not applied", function() + it("should return true after successful patch", function() setup() create_package_json('{"dependencies": {"vue": "^3.5.0"}}') create_vue_files() - -- patch() failed, so no backup files were created + vue_injector.patch(project_root, 19990) + local is_patched, count = vue_injector.is_patched(project_root) - assert.is_false(is_patched, "Should report as not patched when patch failed") - assert.equals(0, count, "Should report zero patched files") + assert.is_true(is_patched, "Should report as patched after patching") + assert.is_true(count > 0, "Should report at least one patched file") cleanup() end) @@ -219,5 +296,29 @@ describe("Vue Injector Tests", function() cleanup() end) + + it("should return false for clean file with stale backup", function() + setup() + create_package_json('{"dependencies": {"vue": "^3.5.0"}}') + local runtime_path, esm_path, runtime_dom_path, vite_plugin_path = create_vue_files() + + vim.fn.writefile(vim.split(read_file_content(runtime_path), "\n"), runtime_path .. ".bk") + + local is_patched, count = vue_injector.is_patched(project_root) + assert.is_false(is_patched, "Should not report as patched when file is clean") + assert.equals(0, count, "Should report zero patched files for clean file with stale backup") + + local clean_content = read_file_content(runtime_path) + vue_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(runtime_path), "Clean file should remain unchanged after unpatch") + assert.is_false(backup_exists(runtime_path), "Stale backup should be deleted after unpatch") + + vim.fn.writefile({ "stale dependency content" }, runtime_path .. ".bk") + vue_injector.patch(project_root, 19990) + vue_injector.unpatch(project_root) + assert.equals(clean_content, read_file_content(runtime_path), "Refreshed backup should restore current dependency content") + + cleanup() + end) end) end) diff --git a/tests/run_lua_tests.sh b/tests/run_lua_tests.sh index fac1603..87bdca7 100755 --- a/tests/run_lua_tests.sh +++ b/tests/run_lua_tests.sh @@ -21,7 +21,7 @@ run_test() { echo -e "${CYAN}Running: ${test_name}${NC}" - PATH="/tmp/nvim-linux64/bin:$PATH" nvim --headless --clean -c "lua package.path = './lua/?.lua;./lua/?/init.lua;./tests/lua/?.lua;' .. package.path" -c "lua local ok, err = pcall(dofile, '$test_file'); if not ok then print('FAILED: ' .. err) end" -c "qa!" 2>&1 | tee /tmp/test_output.txt + nvim --headless --clean -c "lua package.path = './lua/?.lua;./lua/?/init.lua;./tests/lua/?.lua;' .. package.path" -c "lua local ok, err = pcall(dofile, '$test_file'); if not ok then print('FAILED: ' .. err) end" -c "qa!" 2>&1 | tee /tmp/test_output.txt local nvim_exit=${PIPESTATUS[0]} # Check for FAILED: lines emitted by test assertions