From 74db5fb1277c78c4c8184c60a94c2e0ba1f04591 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 8 Jan 2026 02:30:47 +0800 Subject: [PATCH 1/3] feat(version): add CLI version check on extension startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check daemon CLI version against server API when extension starts. If a newer version is available, show warning notification with update command: curl -sSL {webEndpoint}/i | bash - Add version.lua module with async curl HTTP request - Read apiEndpoint and webEndpoint from config file - Show warning only once per session - Auto-copy update command to clipboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lua/shelltime/config.lua | 11 +++ lua/shelltime/sender.lua | 12 ++- lua/shelltime/version.lua | 154 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 lua/shelltime/version.lua diff --git a/lua/shelltime/config.lua b/lua/shelltime/config.lua index 86e9deb..532154c 100644 --- a/lua/shelltime/config.lua +++ b/lua/shelltime/config.lua @@ -11,6 +11,8 @@ local defaults = { heartbeat_interval = 120000, -- 2 minutes in ms debounce_interval = 30000, -- 30 seconds in ms debug = false, + api_endpoint = nil, -- API endpoint for version check + web_endpoint = nil, -- Web endpoint for update command } -- Default config file path @@ -89,6 +91,15 @@ local function merge_config(file_config) config.debounce_interval = file_config.debounceInterval end + -- API and web endpoints for version check + if file_config.apiEndpoint then + config.api_endpoint = file_config.apiEndpoint + end + + if file_config.webEndpoint then + config.web_endpoint = file_config.webEndpoint + end + return config end diff --git a/lua/shelltime/sender.lua b/lua/shelltime/sender.lua index 054fa01..fb4074f 100644 --- a/lua/shelltime/sender.lua +++ b/lua/shelltime/sender.lua @@ -3,6 +3,7 @@ local config = require('shelltime.config') local socket = require('shelltime.socket') local heartbeat = require('shelltime.heartbeat') +local version = require('shelltime.version') local M = {} @@ -63,9 +64,18 @@ function M.start() end) end) - -- Check initial connection status + -- Check initial connection status and CLI version vim.schedule(function() is_connected = socket.is_connected_sync() + + -- Check CLI version in background (non-blocking) + if is_connected then + socket.get_status(function(status, err) + if status and status.version then + version.check_version(status.version) + end + end) + end end) end diff --git a/lua/shelltime/version.lua b/lua/shelltime/version.lua new file mode 100644 index 0000000..202732f --- /dev/null +++ b/lua/shelltime/version.lua @@ -0,0 +1,154 @@ +-- Version checker for shelltime + +local config = require('shelltime.config') + +local M = {} + +-- Track if warning has been shown this session +local has_shown_warning = false + +-- Version check API endpoint path +local VERSION_CHECK_ENDPOINT = '/api/v1/cli/version-check' + +--- URL encode a string +---@param str string String to encode +---@return string Encoded string +local function url_encode(str) + if str then + str = string.gsub(str, '\n', '\r\n') + str = string.gsub(str, '([^%w _%%%-%.~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + str = string.gsub(str, ' ', '+') + end + return str +end + +--- Parse JSON response (minimal parser for version check response) +---@param json_str string JSON string +---@return table|nil Parsed table or nil on error +local function parse_json(json_str) + -- Simple JSON parser for { "isLatest": bool, "latestVersion": "...", "version": "..." } + local is_latest = json_str:match('"isLatest"%s*:%s*(true)') + local latest_version = json_str:match('"latestVersion"%s*:%s*"([^"]+)"') + local version = json_str:match('"version"%s*:%s*"([^"]+)"') + + if latest_version and version then + return { + isLatest = is_latest ~= nil, + latestVersion = latest_version, + version = version, + } + end + return nil +end + +--- Check CLI version against server +---@param daemon_version string Current daemon version +---@param callback function|nil Optional callback(result, error) +function M.check_version(daemon_version, callback) + local api_endpoint = config.get('api_endpoint') + local web_endpoint = config.get('web_endpoint') + + if not api_endpoint or not web_endpoint then + if config.get('debug') then + vim.notify('[shelltime] No API/web endpoint configured, skipping version check', vim.log.levels.DEBUG) + end + if callback then + callback(nil, 'No endpoint configured') + end + return + end + + if has_shown_warning then + if config.get('debug') then + vim.notify('[shelltime] Version warning already shown this session', vim.log.levels.DEBUG) + end + if callback then + callback(nil, 'Already shown') + end + return + end + + local url = api_endpoint .. VERSION_CHECK_ENDPOINT .. '?version=' .. url_encode(daemon_version) + + if config.get('debug') then + vim.notify('[shelltime] Checking version at: ' .. url, vim.log.levels.DEBUG) + end + + -- Use curl asynchronously via vim.fn.jobstart + local stdout_data = {} + + vim.fn.jobstart({ 'curl', '-sSL', '-m', '5', '-H', 'Accept: application/json', url }, { + stdout_buffered = true, + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= '' then + table.insert(stdout_data, line) + end + end + end + end, + on_exit = function(_, exit_code) + vim.schedule(function() + if exit_code ~= 0 then + if config.get('debug') then + vim.notify('[shelltime] Version check failed with exit code: ' .. exit_code, vim.log.levels.DEBUG) + end + if callback then + callback(nil, 'curl failed') + end + return + end + + local response = table.concat(stdout_data, '') + local result = parse_json(response) + + if result then + if not result.isLatest then + has_shown_warning = true + M.show_update_warning(daemon_version, result.latestVersion, web_endpoint) + elseif config.get('debug') then + vim.notify('[shelltime] CLI version ' .. daemon_version .. ' is up to date', vim.log.levels.DEBUG) + end + + if callback then + callback(result, nil) + end + else + if config.get('debug') then + vim.notify('[shelltime] Failed to parse version check response', vim.log.levels.DEBUG) + end + if callback then + callback(nil, 'Parse error') + end + end + end) + end, + }) +end + +--- Show update warning notification +---@param current_version string Current version +---@param latest_version string Latest available version +---@param web_endpoint string Web endpoint for update command +function M.show_update_warning(current_version, latest_version, web_endpoint) + local update_command = 'curl -sSL ' .. web_endpoint .. '/i | bash' + local message = string.format( + '[shelltime] CLI update available: %s -> %s\n\nRun: %s', + current_version, + latest_version, + update_command + ) + + vim.notify(message, vim.log.levels.WARN) + + -- Also copy to clipboard if available + if vim.fn.has('clipboard') == 1 then + vim.fn.setreg('+', update_command) + vim.notify('[shelltime] Update command copied to clipboard', vim.log.levels.INFO) + end +end + +return M From ed461c794b2baaeebd14e4c189b1e2dac7aff338 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 8 Jan 2026 22:11:31 +0800 Subject: [PATCH 2/3] refactor(version): use vim.json.decode for JSON parsing Replace manual regex-based JSON parser with Neovim's built-in vim.json.decode for more robust handling of edge cases. Co-Authored-By: Claude Opus 4.5 --- lua/shelltime/version.lua | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/lua/shelltime/version.lua b/lua/shelltime/version.lua index 202732f..5851f61 100644 --- a/lua/shelltime/version.lua +++ b/lua/shelltime/version.lua @@ -24,23 +24,15 @@ local function url_encode(str) return str end ---- Parse JSON response (minimal parser for version check response) +--- Parse JSON response using Neovim's built-in JSON decoder ---@param json_str string JSON string ---@return table|nil Parsed table or nil on error local function parse_json(json_str) - -- Simple JSON parser for { "isLatest": bool, "latestVersion": "...", "version": "..." } - local is_latest = json_str:match('"isLatest"%s*:%s*(true)') - local latest_version = json_str:match('"latestVersion"%s*:%s*"([^"]+)"') - local version = json_str:match('"version"%s*:%s*"([^"]+)"') - - if latest_version and version then - return { - isLatest = is_latest ~= nil, - latestVersion = latest_version, - version = version, - } + local ok, result = pcall(vim.json.decode, json_str) + if not ok or type(result) ~= 'table' then + return nil end - return nil + return result end --- Check CLI version against server From fc67b4ac1b84bf788a6c3113b3788c48e20caf9e Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Fri, 9 Jan 2026 01:24:51 +0800 Subject: [PATCH 3/3] test(version): add comprehensive tests for version checker module Add tests for check_version and show_update_warning functions covering: - Configuration handling (missing endpoints, already shown warning) - API call construction (URL encoding, headers, timeout) - Success responses (version latest vs update available) - Error handling (curl failures, invalid JSON, empty responses) - Session state (warning shown once per session) - Clipboard functionality Also add _set_for_testing helper to config module for test isolation. Co-Authored-By: Claude Opus 4.5 --- lua/shelltime/config.lua | 14 + tests/helpers/reset.lua | 1 + tests/version_spec.lua | 584 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 tests/version_spec.lua diff --git a/lua/shelltime/config.lua b/lua/shelltime/config.lua index 532154c..7a2e2e3 100644 --- a/lua/shelltime/config.lua +++ b/lua/shelltime/config.lua @@ -22,6 +22,7 @@ local default_config_path = '~/.shelltime/config.yaml' local cached_config = nil local cached_mtime = nil local config_path = nil +local test_mode = false --- Expand tilde in path ---@param path string Path with possible tilde @@ -112,11 +113,17 @@ function M.setup(opts) -- Force reload cached_config = nil cached_mtime = nil + test_mode = false end --- Get merged configuration ---@return table Configuration function M.get_config() + -- In test mode, always return cached config + if test_mode and cached_config then + return cached_config + end + local path = expand_path(config_path or default_config_path) local mtime = get_mtime(path) @@ -153,4 +160,11 @@ function M.get_config_path() return config_path or default_config_path end +--- Set config values directly (for testing only) +---@param config_values table Config values to set +function M._set_for_testing(config_values) + cached_config = vim.tbl_deep_extend('force', {}, defaults, config_values) + test_mode = true +end + return M diff --git a/tests/helpers/reset.lua b/tests/helpers/reset.lua index caa4faf..639b36d 100644 --- a/tests/helpers/reset.lua +++ b/tests/helpers/reset.lua @@ -16,6 +16,7 @@ function M.reset_all() 'shelltime.heartbeat', 'shelltime.sender', 'shelltime.socket', + 'shelltime.version', 'shelltime.utils', 'shelltime.utils.system', 'shelltime.utils.git', diff --git a/tests/version_spec.lua b/tests/version_spec.lua new file mode 100644 index 0000000..9b88ea8 --- /dev/null +++ b/tests/version_spec.lua @@ -0,0 +1,584 @@ +-- Tests for shelltime/version.lua +describe('shelltime.version', function() + local version + local config + local stub = require('luassert.stub') + local stubs = {} + + -- Helper to create and track stubs + local function create_stub(obj, method) + local s = stub(obj, method) + table.insert(stubs, s) + return s + end + + before_each(function() + -- Reset modules + package.loaded['shelltime.version'] = nil + package.loaded['shelltime.config'] = nil + + config = require('shelltime.config') + config.setup({ config = '/nonexistent/config.yaml' }) + version = require('shelltime.version') + end) + + after_each(function() + -- Revert all stubs + for _, s in ipairs(stubs) do + if s and s.revert then + s:revert() + end + end + stubs = {} + end) + + describe('check_version', function() + describe('configuration handling', function() + it('should skip when no API endpoint configured', function() + local called = false + local error_msg = nil + + version.check_version('1.0.0', function(result, err) + called = true + error_msg = err + end) + + assert.is_true(called) + assert.is_nil(nil) + assert.equals('No endpoint configured', error_msg) + end) + + it('should not crash without callback when no endpoint', function() + assert.has_no_errors(function() + version.check_version('1.0.0') + end) + end) + end) + + describe('API call construction', function() + local jobstart_stub + local captured_cmd + local captured_opts + + before_each(function() + -- Configure endpoints + package.loaded['shelltime.config'] = nil + config = require('shelltime.config') + config._set_for_testing({ + api_endpoint = 'https://api.example.com', + web_endpoint = 'https://example.com', + }) + package.loaded['shelltime.version'] = nil + version = require('shelltime.version') + + jobstart_stub = create_stub(vim.fn, 'jobstart') + jobstart_stub.invokes(function(cmd, opts) + captured_cmd = cmd + captured_opts = opts + return 1 + end) + end) + + it('should call curl with correct command', function() + version.check_version('1.0.0', function() end) + + assert.is_table(captured_cmd) + assert.equals('curl', captured_cmd[1]) + end) + + it('should include timeout flag', function() + version.check_version('1.0.0', function() end) + + local has_timeout = false + for i, arg in ipairs(captured_cmd) do + if arg == '-m' and captured_cmd[i + 1] == '5' then + has_timeout = true + break + end + end + assert.is_true(has_timeout) + end) + + it('should include Accept header', function() + version.check_version('1.0.0', function() end) + + local has_accept_header = false + for i, arg in ipairs(captured_cmd) do + if arg == '-H' and captured_cmd[i + 1] == 'Accept: application/json' then + has_accept_header = true + break + end + end + assert.is_true(has_accept_header) + end) + + it('should include version parameter in URL', function() + version.check_version('1.2.3', function() end) + + local url = captured_cmd[#captured_cmd] + assert.matches('version=1%.2%.3', url) + end) + + it('should URL encode version with special characters', function() + version.check_version('1.0.0-beta+build', function() end) + + local url = captured_cmd[#captured_cmd] + -- + should be encoded as %2B + assert.matches('%%2B', url) + end) + + it('should use configured API endpoint', function() + version.check_version('1.0.0', function() end) + + local url = captured_cmd[#captured_cmd] + assert.matches('^https://api%.example%.com', url) + end) + + it('should include version check endpoint path', function() + version.check_version('1.0.0', function() end) + + local url = captured_cmd[#captured_cmd] + assert.matches('/api/v1/cli/version%-check', url) + end) + end) + + describe('success responses', function() + local jobstart_stub + local notify_stub + + before_each(function() + -- Configure endpoints + package.loaded['shelltime.config'] = nil + config = require('shelltime.config') + config._set_for_testing({ + api_endpoint = 'https://api.example.com', + web_endpoint = 'https://example.com', + }) + package.loaded['shelltime.version'] = nil + version = require('shelltime.version') + + notify_stub = create_stub(vim, 'notify') + end) + + after_each(function() + if jobstart_stub and jobstart_stub.revert then + jobstart_stub:revert() + end + end) + + it('should not show warning when version is latest', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '{"isLatest":true}' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + local result_data = nil + version.check_version('1.0.0', function(result, err) + called = true + result_data = result + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.is_table(result_data) + assert.is_true(result_data.isLatest) + -- Should not have called vim.notify with WARN level + for _, call in ipairs(notify_stub.calls or {}) do + if call.refs and call.refs[2] == vim.log.levels.WARN then + assert.fail('Should not show warning when version is latest') + end + end + end) + + it('should show warning when update available', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '{"isLatest":false,"latestVersion":"2.0.0"}' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + version.check_version('1.0.0', function(result, err) + called = true + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + -- Check that vim.notify was called with WARN level + local warn_called = false + for _, call in ipairs(notify_stub.calls or {}) do + if call.refs and call.refs[2] == vim.log.levels.WARN then + warn_called = true + local message = call.refs[1] + assert.matches('1%.0%.0', message) + assert.matches('2%.0%.0', message) + end + end + assert.is_true(warn_called) + end) + + it('should return result with latestVersion', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '{"isLatest":false,"latestVersion":"2.0.0"}' }) + opts.on_exit(1, 0) + return 1 + end) + + local result_data = nil + local called = false + version.check_version('1.0.0', function(result, err) + called = true + result_data = result + end) + + vim.wait(100, function() return called end) + + assert.is_table(result_data) + assert.is_false(result_data.isLatest) + assert.equals('2.0.0', result_data.latestVersion) + end) + + it('should handle multiline JSON response', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + -- Simulate response split across multiple lines + opts.on_stdout(1, { '{"isLatest":', 'true}' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + local result_data = nil + version.check_version('1.0.0', function(result, err) + called = true + result_data = result + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.is_table(result_data) + assert.is_true(result_data.isLatest) + end) + end) + + describe('error handling', function() + local jobstart_stub + + before_each(function() + -- Configure endpoints + package.loaded['shelltime.config'] = nil + config = require('shelltime.config') + config._set_for_testing({ + api_endpoint = 'https://api.example.com', + web_endpoint = 'https://example.com', + }) + package.loaded['shelltime.version'] = nil + version = require('shelltime.version') + end) + + it('should handle curl failure with non-zero exit code', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_exit(1, 7) -- curl exit code 7 = connection refused + return 1 + end) + + local called = false + local error_msg = nil + version.check_version('1.0.0', function(result, err) + called = true + error_msg = err + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.equals('curl failed', error_msg) + end) + + it('should handle invalid JSON response', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { 'not valid json' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + local error_msg = nil + version.check_version('1.0.0', function(result, err) + called = true + error_msg = err + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.equals('Parse error', error_msg) + end) + + it('should handle empty response', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + local error_msg = nil + version.check_version('1.0.0', function(result, err) + called = true + error_msg = err + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.equals('Parse error', error_msg) + end) + + it('should handle HTML error response', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '500 Internal Server Error' }) + opts.on_exit(1, 0) + return 1 + end) + + local called = false + local error_msg = nil + version.check_version('1.0.0', function(result, err) + called = true + error_msg = err + end) + + vim.wait(100, function() return called end) + + assert.is_true(called) + assert.equals('Parse error', error_msg) + end) + + it('should work without callback on error', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_exit(1, 1) + return 1 + end) + + assert.has_no_errors(function() + version.check_version('1.0.0') + end) + end) + end) + + describe('session state', function() + local jobstart_stub + local notify_stub + + before_each(function() + -- Configure endpoints + package.loaded['shelltime.config'] = nil + config = require('shelltime.config') + config._set_for_testing({ + api_endpoint = 'https://api.example.com', + web_endpoint = 'https://example.com', + }) + package.loaded['shelltime.version'] = nil + version = require('shelltime.version') + + notify_stub = create_stub(vim, 'notify') + end) + + it('should only show warning once per session', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '{"isLatest":false,"latestVersion":"2.0.0"}' }) + opts.on_exit(1, 0) + return 1 + end) + + -- First call + local first_called = false + version.check_version('1.0.0', function(result, err) + first_called = true + end) + vim.wait(100, function() return first_called end) + + -- Count warn notifications + local warn_count_after_first = 0 + for _, call in ipairs(notify_stub.calls or {}) do + if call.refs and call.refs[2] == vim.log.levels.WARN then + warn_count_after_first = warn_count_after_first + 1 + end + end + + -- Second call should skip + local second_called = false + local second_error = nil + version.check_version('1.0.0', function(result, err) + second_called = true + second_error = err + end) + + assert.is_true(second_called) + assert.equals('Already shown', second_error) + + -- Warn count should not increase + local warn_count_after_second = 0 + for _, call in ipairs(notify_stub.calls or {}) do + if call.refs and call.refs[2] == vim.log.levels.WARN then + warn_count_after_second = warn_count_after_second + 1 + end + end + + assert.equals(warn_count_after_first, warn_count_after_second) + end) + + it('should reset warning state on module reload', function() + jobstart_stub = stub(vim.fn, 'jobstart') + table.insert(stubs, jobstart_stub) + jobstart_stub.invokes(function(cmd, opts) + opts.on_stdout(1, { '{"isLatest":false,"latestVersion":"2.0.0"}' }) + opts.on_exit(1, 0) + return 1 + end) + + -- First call shows warning + local first_called = false + version.check_version('1.0.0', function(result, err) + first_called = true + end) + vim.wait(100, function() return first_called end) + + -- Reload module + package.loaded['shelltime.version'] = nil + version = require('shelltime.version') + + -- Should make API call again (not return 'Already shown') + local second_called = false + local second_error = nil + version.check_version('1.0.0', function(result, err) + second_called = true + second_error = err + end) + + vim.wait(100, function() return second_called end) + + -- Should not be 'Already shown' error since module was reloaded + assert.is_not.equals('Already shown', second_error) + end) + end) + end) + + describe('show_update_warning', function() + local notify_stub + local has_stub + local setreg_stub + + before_each(function() + -- Configure web endpoint + package.loaded['shelltime.config'] = nil + config = require('shelltime.config') + config._set_for_testing({ + web_endpoint = 'https://example.com', + }) + + notify_stub = create_stub(vim, 'notify') + end) + + it('should show warning notification', function() + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + assert.stub(notify_stub).was_called() + end) + + it('should include current and latest version in message', function() + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + local message = notify_stub.calls[1].refs[1] + assert.matches('1%.0%.0', message) + assert.matches('2%.0%.0', message) + end) + + it('should include update command in message', function() + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + local message = notify_stub.calls[1].refs[1] + assert.matches('curl %-sSL https://example%.com/i | bash', message) + end) + + it('should use WARN log level', function() + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + local level = notify_stub.calls[1].refs[2] + assert.equals(vim.log.levels.WARN, level) + end) + + it('should copy to clipboard when available', function() + has_stub = create_stub(vim.fn, 'has') + has_stub.returns(1) + setreg_stub = create_stub(vim.fn, 'setreg') + + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + assert.stub(setreg_stub).was_called() + local reg = setreg_stub.calls[1].refs[1] + local content = setreg_stub.calls[1].refs[2] + assert.equals('+', reg) + assert.matches('curl %-sSL https://example%.com/i | bash', content) + end) + + it('should not crash when clipboard unavailable', function() + has_stub = create_stub(vim.fn, 'has') + has_stub.returns(0) + + assert.has_no_errors(function() + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + end) + end) + + it('should notify about clipboard copy', function() + has_stub = create_stub(vim.fn, 'has') + has_stub.returns(1) + setreg_stub = create_stub(vim.fn, 'setreg') + + version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') + + -- Should have two notifications: warning and clipboard info + assert.equals(2, #notify_stub.calls) + local info_message = notify_stub.calls[2].refs[1] + local info_level = notify_stub.calls[2].refs[2] + assert.matches('clipboard', info_message) + assert.equals(vim.log.levels.INFO, info_level) + end) + end) + + describe('module exports', function() + it('should export check_version function', function() + assert.is_function(version.check_version) + end) + + it('should export show_update_warning function', function() + assert.is_function(version.show_update_warning) + end) + end) +end)