diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67ebaf46..e42b02ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,58 +41,62 @@ jobs: run: deno task check test: + needs: check + strategy: + fail-fast: false matrix: runner: - windows-latest - macos-latest - ubuntu-latest version: - - "1.38.x" + - "1.45.x" - "1.x" host_version: - - vim: "v9.0.2189" - nvim: "v0.9.4" + - vim: "v9.1.0448" + nvim: "v0.10.0" + runs-on: ${{ matrix.runner }} + steps: - run: git config --global core.autocrlf false if: runner.os == 'Windows' + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v1.1.4 with: deno-version: "${{ matrix.version }}" + - uses: rhysd/action-setup-vim@v1 id: vim with: version: "${{ matrix.host_version.vim }}" - - name: Check Vim - run: | - echo ${DENOPS_TEST_VIM} - ${DENOPS_TEST_VIM} --version - env: - DENOPS_TEST_VIM: ${{ steps.vim.outputs.executable }} + - uses: rhysd/action-setup-vim@v1 id: nvim with: neovim: true version: "${{ matrix.host_version.nvim }}" - - name: Check Neovim + + - name: Export executables run: | - echo ${DENOPS_TEST_NVIM} - ${DENOPS_TEST_NVIM} --version - env: - DENOPS_TEST_NVIM: ${{ steps.nvim.outputs.executable }} + echo "DENOPS_TEST_VIM_EXECUTABLE=${{ steps.vim.outputs.executable }}" >> "$GITHUB_ENV" + echo "DENOPS_TEST_NVIM_EXECUTABLE=${{ steps.nvim.outputs.executable }}" >> "$GITHUB_ENV" + - name: Perform pre-cache run: deno cache ./denops/@denops-private/mod.ts + - name: Test run: deno task test:coverage env: DENOPS_TEST_DENOPS_PATH: "./" - DENOPS_TEST_VIM_EXECUTABLE: ${{ steps.vim.outputs.executable }} - DENOPS_TEST_NVIM_EXECUTABLE: ${{ steps.nvim.outputs.executable }} - timeout-minutes: 5 + timeout-minutes: 10 + - run: | deno task coverage --lcov > coverage.lcov + - uses: codecov/codecov-action@v4 with: os: ${{ runner.os }} diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index c130c366..ba304678 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -18,7 +18,7 @@ jobs: git config user.name github-actions[bot] git config user.email github-actions[bot]@users.noreply.github.com - name: Update dependencies and commit changes - run: deno task -q upgrade:commit --summary ../title.txt --report ../body.md + run: deno task -q update:commit --summary ../title.txt --report ../body.md - name: Check result id: result uses: andstor/file-existence-action@v2 diff --git a/README.md b/README.md index e6bd7e5e..593d7c3a 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,15 @@ Denops
An ecosystem for Vim/Neovim enabling developers to write plugins in Deno. -[![Deno 1.38.5 or above](https://img.shields.io/badge/Deno-Support%201.38.5-yellowgreen.svg?logo=deno)](https://github.com/denoland/deno/tree/v1.38.5) -[![Vim 9.0.2189 or above](https://img.shields.io/badge/Vim-Support%209.0.2189-yellowgreen.svg?logo=vim)](https://github.com/vim/vim/tree/v9.0.2189) -[![Neovim 0.9.4 or above](https://img.shields.io/badge/Neovim-Support%200.9.4-yellowgreen.svg?logo=neovim&logoColor=white)](https://github.com/neovim/neovim/tree/v0.9.4) +[![Deno 1.45.0 or above](https://img.shields.io/badge/Deno-Support%201.45.0-yellowgreen.svg?logo=deno)](https://github.com/denoland/deno/tree/v1.45.0) +[![Vim 9.1.0448 or above](https://img.shields.io/badge/Vim-Support%209.1.0448-yellowgreen.svg?logo=vim)](https://github.com/vim/vim/tree/v9.1.0448) +[![Neovim 0.10.0 or above](https://img.shields.io/badge/Neovim-Support%200.10.0-yellowgreen.svg?logo=neovim&logoColor=white)](https://github.com/neovim/neovim/tree/v0.10.0) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![deno land](http://img.shields.io/badge/available%20on-deno.land/x/denops__core-lightgrey.svg?logo=deno)](https://deno.land/x/denops_core) [![test](https://github.com/vim-denops/denops.vim/actions/workflows/test.yml/badge.svg)](https://github.com/vim-denops/denops.vim/actions/workflows/test.yml) [![codecov](https://codecov.io/github/vim-denops/denops.vim/branch/main/graph/badge.svg?token=k50SaoYUp0)](https://codecov.io/github/vim-denops/denops.vim) [![vim help](https://img.shields.io/badge/vim-%3Ah%20denops-orange.svg)](doc/denops.txt) -[![deno doc](https://doc.deno.land/badge.svg)](https://doc.deno.land/https/deno.land/x/denops_core/mod.ts) [![Documentation](https://img.shields.io/badge/denops-Documentation-yellow.svg)](https://vim-denops.github.io/denops-documentation/) diff --git a/autoload/denops.vim b/autoload/denops.vim index 2518646e..9c0262f2 100644 --- a/autoload/denops.vim +++ b/autoload/denops.vim @@ -25,6 +25,14 @@ function! denops#request_async(name, method, params, success, failure) abort \) endfunction +function! denops#interrupt(...) abort + let l:args = a:0 ? [a:1] : [] + call denops#server#wait_async({ -> denops#_internal#server#chan#notify( + \ 'invoke', + \ ['interrupt', l:args], + \)}) +endfunction + " Configuration call denops#_internal#conf#define('denops#disabled', 0) call denops#_internal#conf#define('denops#deno', 'deno') diff --git a/autoload/denops/_internal/echo.vim b/autoload/denops/_internal/echo.vim index a5b6d56e..9c7f0c0f 100644 --- a/autoload/denops/_internal/echo.vim +++ b/autoload/denops/_internal/echo.vim @@ -56,5 +56,7 @@ function! s:echomsg_batch() abort let s:delayed_timer = 0 let s:delayed_messages = [] " Forcibly show the messages to the user - call feedkeys(printf("\%dmessages\", l:counter), 'n') + if l:counter > 1 && !g:denops#_test + call feedkeys(printf("\%dmessages\", l:counter), 'n') + endif endfunction diff --git a/autoload/denops/_internal/event.vim b/autoload/denops/_internal/event.vim new file mode 100644 index 00000000..694bac16 --- /dev/null +++ b/autoload/denops/_internal/event.vim @@ -0,0 +1,3 @@ +function denops#_internal#event#emit(name) abort + execute 'doautocmd User' a:name +endfunction diff --git a/autoload/denops/_internal/job.vim b/autoload/denops/_internal/job.vim index 7d1f32e6..f147eb9c 100644 --- a/autoload/denops/_internal/job.vim +++ b/autoload/denops/_internal/job.vim @@ -24,13 +24,17 @@ if has('nvim') \ 'on_stderr': funcref('s:on_recv', [a:options.on_stderr]), \ 'on_exit': funcref('s:on_exit', [a:options.on_exit]), \} - return jobstart(a:args, l:options) + try + return jobstart(a:args, l:options) + catch + " NOTE: Call `on_exit` when cmd (args[0]) is not executable. + call timer_start(0, { -> l:options.on_exit(-1, -1, 'exit') }) + endtry endfunction function! s:stop(job) abort try call jobstop(a:job) - call jobwait([a:job]) catch /^Vim\%((\a\+)\)\=:E900/ " NOTE: " Vim does not raise exception even the job has already closed so fail @@ -58,24 +62,27 @@ else \ 'err_cb': funcref('s:out_cb', [a:options.on_stderr, 'stderr']), \ 'exit_cb': funcref('s:exit_cb', [a:options.on_exit, 'exit']), \} - return job_start(a:args, l:options) + let l:job = job_start(a:args, l:options) + if l:job->job_status() ==# "fail" + " NOTE: + " On Windows call `on_exit` when cmd (args[0]) is not executable. + " On Unix a non-existing command results in "dead" instead of "fail", + " and `on_exit` is called by `job_start()`. + call timer_start(0, { -> l:options.exit_cb(-1, -1) }) + endif + return l:job endfunction function! s:stop(job) abort call job_stop(a:job) call timer_start(s:KILL_TIMEOUT_MS, { -> job_stop(a:job, 'kill') }) - " Wait until the job is actually closed - while job_status(a:job) ==# 'run' - sleep 10m - endwhile - redraw endfunction - function! s:out_cb(callback, event, ch, msg) abort - call a:callback(a:ch, a:msg, a:event) + function! s:out_cb(callback, event, job, msg) abort + call a:callback(a:job, a:msg, a:event) endfunction - function! s:exit_cb(callback, event, ch, status) abort - call a:callback(a:ch, a:status, a:event) + function! s:exit_cb(callback, event, job, status) abort + call a:callback(a:job, a:status, a:event) endfunction endif diff --git a/autoload/denops/_internal/plugin.vim b/autoload/denops/_internal/plugin.vim index 73d96ba5..a14a8f9a 100644 --- a/autoload/denops/_internal/plugin.vim +++ b/autoload/denops/_internal/plugin.vim @@ -1,12 +1,22 @@ const s:STATE_RESERVED = 'reserved' const s:STATE_LOADING = 'loading' const s:STATE_LOADED = 'loaded' +const s:STATE_UNLOADING = 'unloading' const s:STATE_FAILED = 'failed' +const s:VALID_NAME_PATTERN = '^[-_0-9a-zA-Z]\+$' + let s:plugins = {} +function! denops#_internal#plugin#is_valid_name(name) abort + return a:name =~# s:VALID_NAME_PATTERN +endfunction + function! denops#_internal#plugin#get(name) abort if !has_key(s:plugins, a:name) + if !denops#_internal#plugin#is_valid_name(a:name) + throw printf('[denops] Invalid plugin name: %s', a:name) + endif let s:plugins[a:name] = #{name: a:name, script: '', state: s:STATE_RESERVED, callbacks: []} endif return s:plugins[a:name] @@ -20,24 +30,41 @@ function! denops#_internal#plugin#load(name, script) abort const l:script = denops#_internal#path#norm(a:script) const l:args = [a:name, l:script] let l:plugin = denops#_internal#plugin#get(a:name) + if l:plugin.state !=# s:STATE_RESERVED && l:plugin.state !=# s:STATE_FAILED + call denops#_internal#echo#debug(printf('already loaded. skip: %s', l:args)) + return + endif let l:plugin.state = s:STATE_LOADING let l:plugin.script = l:script - let s:plugins[a:name] = l:plugin call denops#_internal#echo#debug(printf('load plugin: %s', l:args)) call denops#_internal#server#chan#notify('invoke', ['load', l:args]) endfunction +function! denops#_internal#plugin#unload(name) abort + const l:args = [a:name] + let l:plugin = denops#_internal#plugin#get(a:name) + if l:plugin.state ==# s:STATE_LOADED + let l:plugin.state = s:STATE_UNLOADING + endif + call denops#_internal#echo#debug(printf('unload plugin: %s', l:args)) + call denops#_internal#server#chan#notify('invoke', ['unload', l:args]) +endfunction + function! denops#_internal#plugin#reload(name) abort const l:args = [a:name] let l:plugin = denops#_internal#plugin#get(a:name) - let l:plugin.state = s:STATE_LOADING + if l:plugin.state ==# s:STATE_LOADED + let l:plugin.state = s:STATE_UNLOADING + endif call denops#_internal#echo#debug(printf('reload plugin: %s', l:args)) call denops#_internal#server#chan#notify('invoke', ['reload', l:args]) endfunction function! s:DenopsSystemPluginPre() abort const l:name = matchstr(expand(''), 'DenopsSystemPluginPre:\zs.*') - execute printf('doautocmd User DenopsPluginPre:%s', l:name) + let l:plugin = denops#_internal#plugin#get(l:name) + let l:plugin.state = s:STATE_LOADING + call denops#_internal#event#emit(printf('DenopsPluginPre:%s', l:name)) endfunction function! s:DenopsSystemPluginPost() abort @@ -49,7 +76,7 @@ function! s:DenopsSystemPluginPost() abort for l:Callback in l:callbacks call l:Callback() endfor - execute printf('doautocmd User DenopsPluginPost:%s', l:name) + call denops#_internal#event#emit(printf('DenopsPluginPost:%s', l:name)) endfunction function! s:DenopsSystemPluginFail() abort @@ -57,7 +84,29 @@ function! s:DenopsSystemPluginFail() abort let l:plugin = denops#_internal#plugin#get(l:name) let l:plugin.state = s:STATE_FAILED let l:plugin.callbacks = [] - execute printf('doautocmd User DenopsPluginFail:%s', l:name) + call denops#_internal#event#emit(printf('DenopsPluginFail:%s', l:name)) +endfunction + +function! s:DenopsSystemPluginUnloadPre() abort + const l:name = matchstr(expand(''), 'DenopsSystemPluginUnloadPre:\zs.*') + let l:plugin = denops#_internal#plugin#get(l:name) + let l:plugin.state = s:STATE_UNLOADING + call denops#_internal#event#emit(printf('DenopsPluginUnloadPre:%s', l:name)) +endfunction + +function! s:DenopsSystemPluginUnloadPost() abort + const l:name = matchstr(expand(''), 'DenopsSystemPluginUnloadPost:\zs.*') + let l:plugin = denops#_internal#plugin#get(l:name) + let l:plugin.state = s:STATE_RESERVED + call denops#_internal#event#emit(printf('DenopsPluginUnloadPost:%s', l:name)) +endfunction + +function! s:DenopsSystemPluginUnloadFail() abort + const l:name = matchstr(expand(''), 'DenopsSystemPluginUnloadFail:\zs.*') + let l:plugin = denops#_internal#plugin#get(l:name) + let l:plugin.state = s:STATE_RESERVED + let l:plugin.callbacks = [] + call denops#_internal#event#emit(printf('DenopsPluginUnloadFail:%s', l:name)) endfunction augroup denops_autoload_plugin_internal @@ -65,5 +114,8 @@ augroup denops_autoload_plugin_internal autocmd User DenopsSystemPluginPre:* ++nested call s:DenopsSystemPluginPre() autocmd User DenopsSystemPluginPost:* ++nested call s:DenopsSystemPluginPost() autocmd User DenopsSystemPluginFail:* ++nested call s:DenopsSystemPluginFail() + autocmd User DenopsSystemPluginUnloadPre:* ++nested call s:DenopsSystemPluginUnloadPre() + autocmd User DenopsSystemPluginUnloadPost:* ++nested call s:DenopsSystemPluginUnloadPost() + autocmd User DenopsSystemPluginUnloadFail:* ++nested call s:DenopsSystemPluginUnloadFail() autocmd User DenopsClosed let s:plugins = {} augroup END diff --git a/autoload/denops/_internal/rpc/nvim.vim b/autoload/denops/_internal/rpc/nvim.vim index 1b4cac8a..5b2d7a2d 100644 --- a/autoload/denops/_internal/rpc/nvim.vim +++ b/autoload/denops/_internal/rpc/nvim.vim @@ -24,7 +24,7 @@ endfunction function! denops#_internal#rpc#nvim#close(chan) abort call timer_stop(a:chan._healthcheck_timer) call chanclose(a:chan._id) - call a:chan._on_close(a:chan) + call timer_start(0, { -> a:chan._on_close(a:chan) }) endfunction function! denops#_internal#rpc#nvim#notify(chan, method, params) abort diff --git a/autoload/denops/_internal/rpc/vim.vim b/autoload/denops/_internal/rpc/vim.vim index 89bf8a02..a40a37bd 100644 --- a/autoload/denops/_internal/rpc/vim.vim +++ b/autoload/denops/_internal/rpc/vim.vim @@ -3,29 +3,34 @@ function! denops#_internal#rpc#vim#connect(addr, ...) abort \ 'on_close': { -> 0 }, \}, a:0 ? a:1 : {}, \) - let l:chan = ch_open(a:addr, { + let l:chan = { + \ '_on_close': l:options.on_close, + \} + let l:chan._handle = ch_open(a:addr, { \ 'mode': 'json', \ 'drop': 'auto', \ 'noblock': 1, \ 'timeout': g:denops#_internal#rpc#vim#timeout, - \ 'close_cb': l:options.on_close, + \ 'close_cb': { -> l:chan._on_close(l:chan) }, \}) - if ch_status(l:chan) !=# 'open' + if ch_status(l:chan._handle) !=# 'open' throw printf('Failed to connect `%s`', a:addr) endif return l:chan endfunction function! denops#_internal#rpc#vim#close(chan) abort - return ch_close(a:chan) + " NOTE: 'close_cb' specified on `ch_open` is not invoked when `ch_close` called. + call ch_close(a:chan._handle) + call timer_start(0, { -> a:chan._on_close(a:chan) }) endfunction function! denops#_internal#rpc#vim#notify(chan, method, params) abort - return ch_sendraw(a:chan, json_encode([0, [a:method] + a:params]) . "\n") + return ch_sendraw(a:chan._handle, json_encode([0, [a:method] + a:params]) . "\n") endfunction function! denops#_internal#rpc#vim#request(chan, method, params) abort - let [l:ok, l:err] = ch_evalexpr(a:chan, [a:method] + a:params) + let [l:ok, l:err] = ch_evalexpr(a:chan._handle, [a:method] + a:params) if l:err isnot# v:null throw l:err endif diff --git a/autoload/denops/_internal/server/chan.vim b/autoload/denops/_internal/server/chan.vim index 00c97921..3272b0e5 100644 --- a/autoload/denops/_internal/server/chan.vim +++ b/autoload/denops/_internal/server/chan.vim @@ -6,65 +6,80 @@ const s:rpcrequest = function(printf('denops#_internal#rpc#%s#request', s:HOST)) let s:chan = v:null let s:addr = v:null -let s:options = v:null let s:closed_on_purpose = 0 let s:exiting = 0 " Args: " addr: string " options: { -" retry_interval: number -" retry_threshold: number " reconnect_on_close: boolean " reconnect_delay: number " reconnect_interval: number " reconnect_threshold: number +" on_connect_failure: funcref " } " Return: -" boolean +" v:true - If the connection is successful immediately. +" 0 - Otherwise, if it fails or waits for reconnection. function! denops#_internal#server#chan#connect(addr, options) abort + call s:clear_reconnect_delayer() if s:chan isnot# v:null throw '[denops] Channel already exists' endif - let l:retry_threshold = a:options.retry_threshold - let l:retry_interval = a:options.retry_interval - let l:previous_exception = '' - for l:i in range(l:retry_threshold) - call denops#_internal#echo#debug(printf( - \ 'Connecting to channel `%s` [%d/%d]', - \ a:addr, - \ l:i + 1, - \ l:retry_threshold + 1, - \)) - try - call s:connect(a:addr, a:options) - return v:true - catch - call denops#_internal#echo#debug(printf( - \ 'Failed to connect channel `%s` [%d/%d]: %s', + try + call s:connect(a:addr, a:options) + return v:true + catch + if s:reconnect_guard(a:options) + call denops#_internal#echo#error(printf( + \ 'Failed to connect channel `%s`: %s', \ a:addr, - \ l:i + 1, - \ l:retry_threshold + 1, \ v:exception, \)) - let l:previous_exception = v:exception - endtry - execute printf('sleep %dm', l:retry_interval) - endfor - call denops#_internal#echo#error(printf( - \ 'Failed to connect channel `%s`: %s', - \ a:addr, - \ l:previous_exception, - \)) + if a:options->has_key('on_connect_failure') + call a:options.on_connect_failure(a:options) + endif + return + endif + call denops#_internal#echo#debug(printf( + \ 'Failed to connect channel `%s` [%d/%d]: %s', + \ a:addr, + \ s:reconnect_count, + \ a:options.reconnect_threshold, + \ v:exception, + \)) + let s:reconnect_delayer = timer_start( + \ a:options.reconnect_delay, + \ { -> denops#_internal#server#chan#connect(a:addr, a:options) }, + \) + endtry endfunction -function! denops#_internal#server#chan#close() abort +" Args: +" options: { +" timeout: number (default: 0) +" } +function! denops#_internal#server#chan#close(options) abort + if s:clear_reconnect_delayer() + return + endif if s:chan is# v:null throw '[denops] Channel does not exist yet' endif + let l:options = extend({ + \ 'timeout': 0, + \}, a:options) let s:closed_on_purpose = 1 - call s:rpcclose(s:chan) - let s:chan = v:null + if l:options.timeout ==# 0 + return s:force_close(l:options) + endif + if l:options.timeout > 0 + let s:force_close_delayer = timer_start( + \ l:options.timeout, + \ { -> s:force_close(l:options) }, + \) + endif + call denops#_internal#server#chan#notify('invoke', ['close', []]) endfunction function! denops#_internal#server#chan#is_connected() abort @@ -91,44 +106,96 @@ endfunction function! s:connect(addr, options) abort let s:closed_on_purpose = 0 + let s:addr = a:addr let s:chan = s:rpcconnect(a:addr, { \ 'on_close': { -> s:on_close(a:options) }, \}) - let s:addr = a:addr - let s:options = a:options call denops#_internal#echo#debug(printf('Channel connected (%s)', a:addr)) call s:rpcnotify(s:chan, 'void', []) endfunction +function! s:force_close(options) abort + let l:chan = s:chan + let s:chan = v:null + call s:clear_force_close_delayer() + call denops#_internal#echo#warn(printf( + \ 'Channel cannot close gracefully within %d millisec, force close (%s)', + \ a:options.timeout, + \ s:addr, + \)) + call s:rpcclose(l:chan) +endfunction + +function! s:clear_force_close_delayer() abort + if exists('s:force_close_delayer') + call timer_stop(s:force_close_delayer) + unlet s:force_close_delayer + endif +endfunction + function! s:on_close(options) abort let s:chan = v:null + call s:clear_force_close_delayer() call denops#_internal#echo#debug(printf('Channel closed (%s)', s:addr)) - doautocmd User DenopsClosed - if !a:options.reconnect_on_close || s:closed_on_purpose || s:exiting + call denops#_internal#event#emit('DenopsSystemClosed') + if s:chan isnot# v:null || !a:options.reconnect_on_close || s:closed_on_purpose || s:exiting return endif - " Reconnect + call s:schedule_reconnect(a:options) +endfunction + +function! s:schedule_reconnect(options) if s:reconnect_guard(a:options) + call denops#_internal#echo#warn(printf( + \ 'Channel closed %d times within %d millisec. Denops is disabled to avoid infinity reconnect loop.', + \ a:options.reconnect_threshold + 1, + \ a:options.reconnect_interval + \)) + let g:denops#disabled = 1 return endif call denops#_internal#echo#warn('Channel closed. Reconnecting...') - call timer_start( + let s:reconnect_delayer = timer_start( \ a:options.reconnect_delay, - \ { -> denops#_internal#server#chan#connect(s:addr, s:options) }, + \ { -> s:reconnect(a:options) }, \) endfunction +function! s:reconnect(options) abort + call denops#_internal#echo#debug(printf( + \ 'Reconnect channel `%s` [%d/%d]', + \ s:addr, + \ s:reconnect_count, + \ a:options.reconnect_threshold, + \)) + try + call s:connect(s:addr, a:options) + catch + call denops#_internal#echo#debug(printf( + \ 'Failed to reconnect channel `%s` [%d/%d]: %s', + \ s:addr, + \ s:reconnect_count, + \ a:options.reconnect_threshold, + \ v:exception, + \)) + call s:schedule_reconnect(a:options) + endtry +endfunction + +function! s:clear_reconnect_delayer() abort + if exists('s:reconnect_delayer') + call timer_stop(s:reconnect_delayer) + unlet s:reconnect_delayer + return v:true + endif +endfunction + function! s:reconnect_guard(options) abort let l:reconnect_threshold = a:options.reconnect_threshold let l:reconnect_interval = a:options.reconnect_interval let s:reconnect_count = get(s:, 'reconnect_count', 0) + 1 - if s:reconnect_count >= l:reconnect_threshold - call denops#_internal#echo#warn(printf( - \ 'Channel closed %d times within %d millisec. Denops is disabled to avoid infinity reconnect loop.', - \ l:reconnect_threshold, - \ l:reconnect_interval, - \)) - let g:denops#disabled = 1 + if s:reconnect_count > l:reconnect_threshold + let s:reconnect_count = 0 return 1 endif if exists('s:reset_reconnect_count_delayer') @@ -143,6 +210,6 @@ endfunction augroup denops_internal_server_chan_internal autocmd! autocmd VimLeave * let s:exiting = 1 - autocmd User DenopsReady : - autocmd User DenopsClosed : + autocmd User DenopsSystemReady : + autocmd User DenopsSystemClosed : augroup END diff --git a/autoload/denops/_internal/server/proc.vim b/autoload/denops/_internal/server/proc.vim index 6b6c868f..d3cb41cd 100644 --- a/autoload/denops/_internal/server/proc.vim +++ b/autoload/denops/_internal/server/proc.vim @@ -1,55 +1,36 @@ const s:SCRIPT = denops#_internal#path#script(['@denops-private', 'cli.ts']) let s:job = v:null -let s:options = v:null let s:stopped_on_purpose = 0 let s:exiting = 0 " Args: " options: { -" retry_interval: number -" retry_threshold: number " restart_on_exit: boolean " restart_delay: number " restart_interval: number " restart_threshold: number " } " Return: -" boolean +" v:true function! denops#_internal#server#proc#start(options) abort + call s:clear_restart_delayer() if s:job isnot# v:null throw '[denops] Server already exists' endif - let l:retry_interval = a:options.retry_interval - let l:retry_threshold = a:options.retry_threshold - let l:previous_exception = '' - for l:i in range(l:retry_threshold) - call denops#_internal#echo#debug(printf( - \ 'Spawn server [%d/%d]', - \ l:i + 1, - \ l:retry_threshold + 1, - \)) - try - call s:start(a:options) - return v:true - catch - call denops#_internal#echo#debug(printf( - \ 'Failed to spawn server [%d/%d]: %s', - \ l:i + 1, - \ l:retry_threshold + 1, - \ v:exception, - \)) - let l:previous_exception = v:exception - endtry - execute printf('sleep %dm', l:retry_interval) - endfor - call denops#_internal#echo#error(printf( - \ 'Failed to spawn server: %s', - \ l:previous_exception, + call denops#_internal#echo#debug(printf( + \ 'Spawn server [%d/%d]', + \ get(s:, 'restart_count', 0), + \ a:options.restart_threshold, \)) + call s:start(a:options) + return v:true endfunction function! denops#_internal#server#proc#stop() abort + if s:clear_restart_delayer() + return + endif if s:job is# v:null throw '[denops] Server does not exist yet' endif @@ -86,9 +67,8 @@ function! s:start(options) abort \ 'on_stderr': { _job, data, _event -> s:on_stderr(data) }, \ 'on_exit': { _job, status, _event -> s:on_exit(a:options, status) }, \}) - let s:options = a:options call denops#_internal#echo#debug(printf('Server started: %s', l:args)) - doautocmd User DenopsProcessStarted + call denops#_internal#event#emit('DenopsSystemProcessStarted') endfunction function! s:on_stdout(store, data) abort @@ -101,7 +81,7 @@ function! s:on_stdout(store, data) abort let a:store.prepared = 1 let l:addr = substitute(a:data, '\r\?\n$', '', 'g') call denops#_internal#echo#debug(printf('Server listen: %s', l:addr)) - execute printf('doautocmd User DenopsProcessListen:%s', l:addr) + call denops#_internal#event#emit(printf('DenopsSystemProcessListen:%s', l:addr)) endfunction function! s:on_stderr(data) abort @@ -115,8 +95,8 @@ endfunction function! s:on_exit(options, status) abort let s:job = v:null call denops#_internal#echo#debug(printf('Server stopped: %s', a:status)) - execute printf('doautocmd User DenopsProcessStopped:%s', a:status) - if !a:options.restart_on_exit || s:stopped_on_purpose || s:exiting + call denops#_internal#event#emit(printf('DenopsSystemProcessStopped:%s', a:status)) + if s:job isnot# v:null || !a:options.restart_on_exit || s:stopped_on_purpose || s:exiting return endif " Restart @@ -127,9 +107,9 @@ function! s:on_exit(options, status) abort \ 'Server stopped (%d). Restarting...', \ a:status, \)) - call timer_start( + let s:restart_delayer = timer_start( \ a:options.restart_delay, - \ { -> denops#_internal#server#proc#start(s:options) }, + \ { -> denops#_internal#server#proc#start(a:options) }, \) endfunction @@ -137,12 +117,13 @@ function! s:restart_guard(options) abort let l:restart_threshold = a:options.restart_threshold let l:restart_interval = a:options.restart_interval let s:restart_count = get(s:, 'restart_count', 0) + 1 - if s:restart_count >= l:restart_threshold + if s:restart_count > l:restart_threshold call denops#_internal#echo#warn(printf( \ 'Server stopped %d times within %d millisec. Denops is disabled to avoid infinity restart loop.', - \ l:restart_threshold, + \ s:restart_count, \ l:restart_interval, \)) + let s:restart_count = 0 let g:denops#disabled = 1 return 1 endif @@ -155,10 +136,25 @@ function! s:restart_guard(options) abort \) endfunction +function! s:clear_restart_delayer() abort + if exists('s:restart_delayer') + call timer_stop(s:restart_delayer) + unlet s:restart_delayer + return v:true + endif +endfunction + augroup denops_internal_server_proc_internal autocmd! autocmd VimLeave * let s:exiting = 1 - autocmd User DenopsProcessStarted : - autocmd User DenopsProcessListen:* : - autocmd User DenopsProcessStopped:* : + autocmd User DenopsSystemProcessStarted : + autocmd User DenopsSystemProcessListen:* : + autocmd User DenopsSystemProcessStopped:* : augroup END + +function! denops#_internal#server#proc#_get_job_for_test() abort + call denops#_internal#echo#warn( + \ '_get_job_for_test() should only be used for testing.' + \) + return s:job +endfunction diff --git a/autoload/denops/_internal/test.vim b/autoload/denops/_internal/test.vim index 76825d86..72cf0efa 100644 --- a/autoload/denops/_internal/test.vim +++ b/autoload/denops/_internal/test.vim @@ -8,10 +8,10 @@ if has('nvim') endfunction else function! denops#_internal#test#notify(method, params) abort - return denops#_internal#rpc#vim#notify(g:denops_test_channel, a:method, a:params) + return denops#_internal#rpc#vim#notify(#{ _handle: g:denops_test_channel }, a:method, a:params) endfunction function! denops#_internal#test#request(method, params) abort - return denops#_internal#rpc#vim#request(g:denops_test_channel, a:method, a:params) + return denops#_internal#rpc#vim#request(#{ _handle: g:denops_test_channel }, a:method, a:params) endfunction endif diff --git a/autoload/denops/plugin.vim b/autoload/denops/plugin.vim index 89f80e9a..a5ce303b 100644 --- a/autoload/denops/plugin.vim +++ b/autoload/denops/plugin.vim @@ -53,6 +53,10 @@ function! denops#plugin#load(name, script) abort call denops#_internal#plugin#load(a:name, a:script) endfunction +function! denops#plugin#unload(name) abort + call denops#_internal#plugin#unload(a:name) +endfunction + function! denops#plugin#reload(name) abort call denops#_internal#plugin#reload(a:name) endfunction @@ -62,7 +66,7 @@ function! denops#plugin#discover() abort let l:counter = 0 for l:script in globpath(&runtimepath, l:pattern, 1, 1, 1) let l:name = fnamemodify(l:script, ':h:t') - if l:name[:0] ==# '@' || !filereadable(l:script) + if !denops#_internal#plugin#is_valid_name(l:name) || !filereadable(l:script) continue endif call denops#plugin#load(l:name, l:script) @@ -72,11 +76,13 @@ function! denops#plugin#discover() abort endfunction function! denops#plugin#check_type(...) abort - let l:plugins = a:0 - \ ? [denops#_internal#plugin#get(a:1)] - \ : denops#_internal#plugin#list() - let l:args = [g:denops#deno, 'check'] - let l:args = extend(l:args, map(l:plugins, { _, v -> v.script })) + if a:0 + let l:scripts = [denops#_internal#plugin#get(a:1).script] + else + let l:scripts = denops#_internal#plugin#list() + \->copy()->map({ _, v -> v.script })->filter({ _, v -> v !=# '' }) + endif + let l:args = [g:denops#deno, 'check'] + l:scripts let l:job = denops#_internal#job#start(l:args, { \ 'env': { \ 'NO_COLOR': 1, @@ -90,33 +96,5 @@ function! denops#plugin#check_type(...) abort \ }) endfunction -" DEPRECATED -" Some plugins (e.g. dein.vim) use this function with options thus we cannot -" change the interface of this function. -" That's why we introduce 'load' function that replaces this function. -function! denops#plugin#register(name, ...) abort - call denops#_internal#echo#deprecate( - \ 'denops#plugin#register() is deprecated. Use denops#plugin#load() instead.', - \) - if a:0 is# 0 || type(a:1) is# v:t_dict - let l:script = s:find_script(a:name) - else - let l:script = a:1 - endif - return denops#plugin#load(a:name, l:script) -endfunction - -function! s:find_script(name) abort - const l:pattern = denops#_internal#path#join(['denops', a:name, 'main.ts']) - for l:script in globpath(&runtimepath, l:pattern, 1, 1, 1) - let l:name = fnamemodify(l:script, ':h:t') - if l:name[:0] ==# '@' || !filereadable(l:script) - continue - endif - return l:script - endfor - throw printf('Denops plugin "%s" does not exist in the runtimepath', a:name) -endfunction - call denops#_internal#conf#define('denops#plugin#wait_interval', 200) call denops#_internal#conf#define('denops#plugin#wait_timeout', 30000) diff --git a/autoload/denops/server.vim b/autoload/denops/server.vim index 6eccde43..269d2d9e 100644 --- a/autoload/denops/server.vim +++ b/autoload/denops/server.vim @@ -2,18 +2,44 @@ const s:STATUS_STOPPED = 'stopped' const s:STATUS_STARTING = 'starting' const s:STATUS_PREPARING = 'preparing' const s:STATUS_RUNNING = 'running' +const s:STATUS_CLOSING = 'closing' +const s:STATUS_CLOSED = 'closed' +" Common variables let s:is_ready = v:false let s:ready_callbacks = [] +" Local server variables +let s:stopping = v:false +let s:restart_once = v:false +let s:local_addr = "" + +" Shared server variables +let s:is_closed = v:false +let s:closing = v:false +let s:reconnect_once = v:false +let s:addr = "" + " Local server function! denops#server#start() abort - if g:denops#disabled || denops#_internal#server#proc#is_started() + if g:denops#disabled + return + endif + if s:stopping + let s:restart_once = v:true + return + endif + if denops#_internal#server#proc#is_started() + return + endif + if denops#_internal#server#chan#is_connected() + call denops#_internal#echo#warn(printf( + \ 'Not starting local server, already connected to (%s).', + \ s:addr, + \)) return endif return denops#_internal#server#proc#start({ - \ 'retry_interval': g:denops#server#retry_interval, - \ 'retry_threshold': g:denops#server#retry_threshold, \ 'restart_on_exit': v:true, \ 'restart_delay': g:denops#server#restart_delay, \ 'restart_interval': g:denops#server#restart_interval, @@ -22,10 +48,18 @@ function! denops#server#start() abort endfunction function! denops#server#stop() abort - if !denops#_internal#server#proc#is_started() + let s:restart_once = v:false + if s:stopping || !denops#_internal#server#proc#is_started() return endif - call denops#_internal#server#proc#stop() + let s:stopping = v:true + if s:is_connected_to_local_server() + if !s:closing + call s:disconnect() + endif + return + endif + call s:force_stop() endfunction function! denops#server#restart() abort @@ -33,33 +67,42 @@ function! denops#server#restart() abort call denops#server#start() endfunction +function! s:force_stop() abort + call denops#_internal#server#proc#stop() +endfunction + +function! s:is_connected_to_local_server() abort + return denops#_internal#server#chan#is_connected() && s:addr ==# s:local_addr +endfunction + " Shared server function! denops#server#connect() abort - if g:denops#disabled || denops#_internal#server#chan#is_connected() + if g:denops#disabled return endif - let l:addr = get(g:, 'denops_server_addr') - if empty(l:addr) - call denops#_internal#echo#error( - \ 'denops shared server address (g:denops_server_addr) is not given', - \) + if s:closing + let s:addr = s:get_server_addr() + if !empty(s:addr) + let s:reconnect_once = v:true + endif return endif - return denops#_internal#server#chan#connect(l:addr, { - \ 'retry_interval': g:denops#server#retry_interval, - \ 'retry_threshold': g:denops#server#retry_threshold, - \ 'reconnect_on_close': v:true, - \ 'reconnect_delay': g:denops#server#reconnect_delay, - \ 'reconnect_interval': g:denops#server#reconnect_interval, - \ 'reconnect_threshold': g:denops#server#reconnect_threshold, - \}) + if denops#_internal#server#chan#is_connected() + return + endif + let s:addr = s:get_server_addr() + if empty(s:addr) + return + endif + return s:connect(s:addr, { 'reconnect_on_close': v:true }) endfunction function! denops#server#close() abort - if !denops#_internal#server#chan#is_connected() + let s:reconnect_once = v:false + if s:closing || !denops#_internal#server#chan#is_connected() return endif - call denops#_internal#server#chan#close() + call s:disconnect() endfunction function! denops#server#reconnect() abort @@ -67,12 +110,42 @@ function! denops#server#reconnect() abort call denops#server#connect() endfunction +function! s:get_server_addr() abort + let l:addr = get(g:, 'denops_server_addr') + if empty(l:addr) + call denops#_internal#echo#error( + \ 'denops shared server address (g:denops_server_addr) is not given', + \) + endif + return l:addr +endfunction + " Common +function! denops#server#connect_or_start() abort + if g:denops#disabled || denops#server#status() !=# s:STATUS_STOPPED + return + endif + let s:addr = get(g:, 'denops_server_addr') + if !empty(s:addr) + " Connect to a shared server or fallback to a local server. + call s:connect(s:addr, { + \ 'reconnect_on_close': v:true, + \ 'on_connect_failure': { -> denops#server#start() }, + \}) + else + call denops#server#start() + endif +endfunction + function! denops#server#status() abort - if denops#_internal#server#chan#is_connected() + if s:closing + return s:STATUS_CLOSING + elseif denops#_internal#server#chan#is_connected() return s:is_ready ? s:STATUS_RUNNING : s:STATUS_PREPARING elseif denops#_internal#server#proc#is_started() - return s:STATUS_STARTING + return s:is_closed ? s:STATUS_CLOSED : s:STATUS_STARTING + elseif s:stopping + return s:STATUS_CLOSED endif return s:STATUS_STOPPED endfunction @@ -84,7 +157,7 @@ function! denops#server#wait(...) abort \ 'silent': 0, \}, a:0 ? a:1 : {}, \) - if denops#server#status() ==# 'stopped' + if denops#server#status() ==# s:STATUS_STOPPED if !l:options.silent call denops#_internal#echo#error( \ 'Failed to wait `DenopsReady` autocmd. Denops server itself is not started.', @@ -119,32 +192,97 @@ function! denops#server#wait_async(callback) abort call add(s:ready_callbacks, a:callback) endfunction -function! s:DenopsProcessListen(expr) abort - let l:addr = matchstr(a:expr, '\')) - autocmd User DenopsReady ++nested call s:DenopsReady() - autocmd User DenopsClosed let s:is_ready = v:false + autocmd User DenopsReady : + autocmd User DenopsClosed : + autocmd User DenopsProcessStarted : + autocmd User DenopsProcessStopped:* : + autocmd User DenopsSystemProcessStarted ++nested call s:DenopsSystemProcessStarted() + autocmd User DenopsSystemProcessListen:* ++nested call s:DenopsSystemProcessListen(expand('')) + autocmd User DenopsSystemReady ++nested call s:DenopsSystemReady() + autocmd User DenopsSystemClosed ++nested call s:DenopsSystemClosed() + autocmd User DenopsSystemProcessStopped:* ++nested call s:DenopsSystemProcessStopped(expand('')) augroup END call denops#_internal#conf#define('denops#server#deno', g:denops#deno) @@ -154,9 +292,6 @@ call denops#_internal#conf#define('denops#server#deno_args', [ \ '-A', \]) -call denops#_internal#conf#define('denops#server#retry_interval', 500) -call denops#_internal#conf#define('denops#server#retry_threshold', 3) - call denops#_internal#conf#define('denops#server#restart_delay', 100) call denops#_internal#conf#define('denops#server#restart_interval', 10000) call denops#_internal#conf#define('denops#server#restart_threshold', 3) @@ -165,5 +300,7 @@ call denops#_internal#conf#define('denops#server#reconnect_delay', 100) call denops#_internal#conf#define('denops#server#reconnect_interval', 1000) call denops#_internal#conf#define('denops#server#reconnect_threshold', 3) +call denops#_internal#conf#define('denops#server#close_timeout', 5000) + call denops#_internal#conf#define('denops#server#wait_interval', 200) call denops#_internal#conf#define('denops#server#wait_timeout', 30000) diff --git a/autoload/health/denops.vim b/autoload/health/denops.vim index b1913906..18f82e40 100644 --- a/autoload/health/denops.vim +++ b/autoload/health/denops.vim @@ -1,6 +1,6 @@ -const s:DENO_VERSION = '1.38.5' -const s:VIM_VERSION = '9.0.2189' -const s:NEOVIM_VERSION = '0.9.4' +const s:DENO_VERSION = '1.45.0' +const s:VIM_VERSION = '9.1.0448' +const s:NEOVIM_VERSION = '0.10.0' function! s:compare_version(v1, v2) abort let l:v1 = map(split(a:v1, '\.'), { _, v -> v + 0 }) diff --git a/deno.jsonc b/deno.jsonc index d5f1fc7a..6b0eeeb9 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,11 +1,19 @@ { - "lock": false, "tasks": { - "test": "deno test -A --parallel --shuffle --doc", + "check": "deno check **/*.ts", + "test": "LANG=C deno test -A --parallel --shuffle --doc", "test:coverage": "deno task test --coverage=.coverage", - "check": "deno check ./**/*.ts", - "coverage": "deno coverage .coverage --exclude=cli.ts --exclude=worker.ts --exclude=testdata/", - "upgrade": "deno run -q -A https://deno.land/x/molt@0.14.2/cli.ts ./**/*.ts", - "upgrade:commit": "deno task -q upgrade --commit --prefix :package: --pre-commit=fmt" + "coverage": "deno coverage --exclude=\"test[.]ts(#.*)?$\" .coverage", + "update": "deno run --allow-env --allow-read --allow-write --allow-run=git,deno --allow-net=jsr.io,registry.npmjs.org jsr:@molt/cli **/*.ts", + "update:write": "deno task -q update --write", + "update:commit": "deno task -q update --commit --prefix :package: --pre-commit=fmt,lint" + }, + "exclude": [ + ".coverage/" + ], + // NOTE: Import maps should only be used from test modules. + "imports": { + "/denops-private/": "./denops/@denops-private/", + "/denops-testutil/": "./tests/denops/testutil/" } } diff --git a/denops/@denops-private/cli.ts b/denops/@denops-private/cli.ts index 00832196..e0044e90 100644 --- a/denops/@denops-private/cli.ts +++ b/denops/@denops-private/cli.ts @@ -1,22 +1,16 @@ import { readableStreamFromWorker, writableStreamFromWorker, -} from "https://deno.land/x/workerio@v3.1.0/mod.ts"; -import { parseArgs } from "https://deno.land/std@0.217.0/cli/parse_args.ts"; +} from "jsr:@lambdalisue/workerio@4.0.1"; +import { deadline } from "jsr:@std/async@1.0.1/deadline"; +import { parseArgs } from "jsr:@std/cli@1.0.1/parse-args"; +import { asyncSignal } from "jsr:@milly/async-signal@^1.0.0"; -const script = new URL("./worker.ts", import.meta.url); +const WORKER_SCRIPT = import.meta.resolve("./worker.ts"); +const WORKER_CLOSE_TIMEOUT_MS = 5000; -async function handleConn( - conn: Deno.Conn, - { quiet }: { quiet?: boolean }, -): Promise { - const remoteAddr = conn.remoteAddr as Deno.NetAddr; - const name = `${remoteAddr.hostname}:${remoteAddr.port}`; - if (!quiet) { - console.info(`${name} is connected`); - } - - const worker = new Worker(script, { +async function processWorker(name: string, conn: Deno.Conn): Promise { + const worker = new Worker(WORKER_SCRIPT, { name, type: "module", }); @@ -25,17 +19,23 @@ async function handleConn( try { await Promise.race([ - reader.pipeTo(conn.writable), + reader.pipeTo(conn.writable, { preventCancel: true }), conn.readable.pipeTo(writer), ]); } finally { + try { + const closeWaiter = reader.pipeTo(new WritableStream()); + await deadline(closeWaiter, WORKER_CLOSE_TIMEOUT_MS); + } catch { + // `reader` already closed or deadline has passed, do nothing + } // Terminate worker to avoid leak worker.terminate(); } } -async function main(): Promise { - const { hostname, port, quiet, identity } = parseArgs(Deno.args, { +export async function main(args: string[]): Promise { + const { hostname, port, quiet, identity } = parseArgs(args, { string: ["hostname", "port"], boolean: ["quiet", "identity"], }); @@ -44,7 +44,7 @@ async function main(): Promise { hostname: hostname ?? "127.0.0.1", port: Number(port ?? "32123"), }); - const localAddr = listener.addr as Deno.NetAddr; + const localAddr = listener.addr; if (identity) { // WARNING: @@ -57,16 +57,50 @@ async function main(): Promise { ); } - for await (const conn of listener) { - handleConn(conn, { quiet }).catch((err) => + const handleConn = async (conn: Deno.Conn): Promise => { + const { remoteAddr } = conn; + const name = `${remoteAddr.hostname}:${remoteAddr.port}`; + if (!quiet) { + console.info(`${name} is connected`); + } + try { + await processWorker(name, conn); + } catch (err) { console.error( "Internal error occurred and Host/Denops connection is dropped", err, - ) - ); + ); + } finally { + try { + conn.close(); + } catch { + // Already closed, do nothing + } + if (!quiet) { + console.info(`${name} is closed`); + } + } + }; + + const connections = new Set>(); + + { + using sigintTrap = asyncSignal("SIGINT"); + sigintTrap.catch(() => { + listener.close(); + }); + + for await (const conn of listener) { + const handler = handleConn(conn) + .finally(() => connections.delete(handler)); + connections.add(handler); + } } + + // The listener is closed and waits for existing connections to terminate. + await Promise.allSettled([...connections]); } if (import.meta.main) { - await main(); + await main(Deno.args); } diff --git a/denops/@denops-private/cli_test.ts b/denops/@denops-private/cli_test.ts new file mode 100644 index 00000000..80840eda --- /dev/null +++ b/denops/@denops-private/cli_test.ts @@ -0,0 +1,611 @@ +// NOTE: Use sinon to stub the getter property. +// @deno-types="npm:@types/sinon@17.0.3" +import sinon from "npm:sinon@18.0.0"; + +import { + assertEquals, + assertMatch, + assertNotMatch, + assertStringIncludes, +} from "jsr:@std/assert@1.0.1"; +import { + assertSpyCallArgs, + assertSpyCalls, + resolvesNext, + returnsNext, + spy, + type Stub, + stub, +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { FakeTime } from "jsr:@std/testing@1.0.0-rc.5/time"; +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { + createFakeTcpConn, + createFakeTcpListener, + createFakeWorker, + pendingPromise, +} from "/denops-testutil/mock.ts"; +import { main } from "./cli.ts"; + +const stubDenoListen = ( + fn: ( + options: Deno.TcpListenOptions & { transpost?: "tcp" }, + ) => Deno.TcpListener, +) => { + return stub( + Deno, + "listen", + fn as unknown as typeof Deno["listen"], + ) as unknown as Stub< + typeof Deno, + [options: Deno.TcpListenOptions], + Deno.TcpListener + >; +}; + +Deno.test("main()", async (t) => { + using deno_addSignalListener = stub(Deno, "addSignalListener"); + + await t.step("listens", async (t) => { + await t.step("127.0.0.1:32123", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _console_info = stub(console, "info"); + + const p = main([]); + + assertSpyCalls(deno_listen, 1); + assertSpyCallArgs(deno_listen, 0, [{ + hostname: "127.0.0.1", + port: 32123, + }]); + + fakeTcpListener.close(); + await p; + }); + + await t.step("`--hostname`:32123", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _console_info = stub(console, "info"); + + const p = main(["--hostname", "foobar.example.net"]); + + assertSpyCalls(deno_listen, 1); + assertSpyCallArgs(deno_listen, 0, [{ + hostname: "foobar.example.net", + port: 32123, + }]); + + fakeTcpListener.close(); + await p; + }); + + await t.step("127.0.0.1:`--port`", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _console_info = stub(console, "info"); + + const p = main(["--port", "39393"]); + + assertSpyCalls(deno_listen, 1); + assertSpyCallArgs(deno_listen, 0, [{ + hostname: "127.0.0.1", + port: 39393, + }]); + + fakeTcpListener.close(); + await p; + }); + }); + + await t.step("outputs info logs", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using console_info = stub(console, "info"); + + const p = main([]); + + assertStringIncludes( + console_info.calls.flatMap((c) => c.args).join(" "), + "Listen denops clients on stub.example.net:99999", + ); + + fakeTcpListener.close(); + await p; + }); + + await t.step("if `--identity`", async (t) => { + await t.step("outputs the listen addr FIRST", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + const outputs: unknown[][] = []; + const appendOutput = (...args: unknown[]) => { + outputs.push(args); + }; + using _console_info = stub(console, "info", appendOutput); + using _console_log = stub(console, "log", appendOutput); + + const p = main(["--identity"]); + + assertEquals(outputs[0], ["stub.example.net:99999"]); + + fakeTcpListener.close(); + await p; + }); + + await t.step("and `--quiet` outputs the listen addr FIRST", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + const outputs: unknown[][] = []; + const appendOutput = (...args: unknown[]) => { + outputs.push(args); + }; + using _console_info = stub(console, "info", appendOutput); + using _console_log = stub(console, "log", appendOutput); + + const p = main(["--identity", "--quiet"]); + + assertEquals(outputs[0], ["stub.example.net:99999"]); + + fakeTcpListener.close(); + await p; + }); + }); + + await t.step("when the connection is accepted", async (t) => { + { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + + const fakeTcpConn = createFakeTcpConn(); + const connStreamCloseWaiter = Promise.withResolvers(); + sinon.stub(fakeTcpConn, "remoteAddr").get(() => ({ + hostname: "stub-remote.example.net", + port: 98765, + })); + sinon.stub(fakeTcpConn, "readable").get(() => + new ReadableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => con.close()); + }, + }) + ); + sinon.stub(fakeTcpConn, "writable").get(() => + new WritableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => + con.error("fake-tcpconn-writable-closed") + ); + }, + }) + ); + + const fakeWorker = createFakeWorker(); + using globalThis_Worker = stub( + globalThis, + "Worker", + returnsNext([fakeWorker]), + ); + using worker_terminate = stub(fakeWorker, "terminate"); + using worker_postMessage = stub(fakeWorker, "postMessage"); + + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _listener_accept = stub( + fakeTcpListener, + "accept", + resolvesNext([fakeTcpConn, pendingPromise()]), + ); + using console_info = stub(console, "info"); + using console_error = stub(console, "error"); + + const p = main([]); + + await t.step("creates a Worker", async () => { + assertSpyCalls(globalThis_Worker, 0); + + // Resolves microtasks. + await delay(0); + + assertSpyCalls(globalThis_Worker, 1); + assertMatch( + globalThis_Worker.calls[0].args[0] as string, + /.*\/denops\/@denops-private\/worker\.ts$/, + "Worker.specifier should be `*/denops/@denops-private/worker.ts`", + ); + assertEquals(globalThis_Worker.calls[0].args[1], { + name: "stub-remote.example.net:98765", + type: "module", + }, "Worker.name should be `remote-hostname:remote-port`"); + }); + + await t.step("outputs info logs", () => { + assertStringIncludes( + console_info.calls.flatMap((c) => c.args).join(" "), + "stub-remote.example.net:98765 is connected", + ); + }); + + await t.step("when the listener is closed", async (t) => { + fakeTcpListener.close(); + await delay(0); + + await t.step("does not calls Worker.terminate()", () => { + assertSpyCalls(worker_terminate, 0); + }); + + await t.step("pendings main() Promise", async () => { + assertEquals(await promiseState(p), "pending"); + }); + }); + + await t.step("when the connection is closed", async (t) => { + connStreamCloseWaiter.resolve(); + await delay(0); + + await t.step("does not calls Worker.terminate()", () => { + assertSpyCalls(worker_terminate, 0); + }); + + await t.step("pendings main() Promise", async () => { + assertEquals(await promiseState(p), "pending"); + }); + + await t.step( + "post a `null` message to tell the worker to close", + () => { + assertSpyCalls(worker_postMessage, 1); + assertSpyCallArgs(worker_postMessage, 0, [null]); + }, + ); + + await t.step("and the worker stream is closed", async (t) => { + fakeWorker.onmessage(new MessageEvent("message", { data: null })); + await delay(0); + + await t.step("calls Worker.terminate()", () => { + assertSpyCalls(worker_terminate, 1); + }); + + await t.step("resolves main() Promise", async () => { + assertEquals(await promiseState(p), "fulfilled"); + }); + }); + + await t.step("outputs error logs", () => { + assertStringIncludes( + console_error.calls.flatMap((c) => c.args).join(" "), + "Internal error occurred and Host/Denops connection is dropped fake-tcpconn-writable-closed", + ); + }); + }); + } + + { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + + const fakeTcpConn = createFakeTcpConn(); + const connStreamCloseWaiter = Promise.withResolvers(); + sinon.stub(fakeTcpConn, "remoteAddr").get(() => ({ + hostname: "stub-remote.example.net", + port: 98765, + })); + sinon.stub(fakeTcpConn, "readable").get(() => + new ReadableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => con.close()); + }, + }) + ); + sinon.stub(fakeTcpConn, "writable").get(() => + new WritableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => + con.error("fake-tcpconn-writable-closed") + ); + }, + }) + ); + + const fakeWorker = createFakeWorker(); + using globalThis_Worker = stub( + globalThis, + "Worker", + returnsNext([fakeWorker]), + ); + using worker_terminate = stub(fakeWorker, "terminate"); + using _worker_postMessage = stub(fakeWorker, "postMessage"); + + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _listener_accept = stub( + fakeTcpListener, + "accept", + resolvesNext([fakeTcpConn, pendingPromise()]), + ); + using _console_info = stub(console, "info"); + using console_error = stub(console, "error"); + + const p = main([]); + await delay(0); + + await t.step("when the connection is closed", async (t) => { + assertSpyCalls(globalThis_Worker, 1); + + await t.step("when the worker close times out", async (t) => { + using _time = new FakeTime(); + + connStreamCloseWaiter.resolve(); + const WORKER_CLOSE_TIMEOUT_MS = 5000; + await _time.tickAsync(WORKER_CLOSE_TIMEOUT_MS); + await _time.nextAsync(); + + await t.step("calls Worker.terminate()", () => { + assertSpyCalls(worker_terminate, 1); + }); + }); + + await t.step("outputs error logs", () => { + assertMatch( + console_error.calls.flatMap((c) => c.args).join(" "), + /Internal error occurred/, + ); + }); + + await t.step("pendings main() Promise", async () => { + assertEquals(await promiseState(p), "pending"); + }); + + await t.step("and the listner is closed", async (t) => { + fakeTcpListener.close(); + + await t.step("resolves main() Promise", async () => { + assertEquals(await promiseState(p), "fulfilled"); + }); + }); + }); + } + + { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + + const fakeTcpConn = createFakeTcpConn(); + const connStreamCloseWaiter = Promise.withResolvers(); + sinon.stub(fakeTcpConn, "remoteAddr").get(() => ({ + hostname: "stub-remote.example.net", + port: 98765, + })); + sinon.stub(fakeTcpConn, "readable").get(() => + new ReadableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => con.close()); + }, + }) + ); + sinon.stub(fakeTcpConn, "writable").get(() => + new WritableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => + con.error("fake-tcpconn-writable-closed") + ); + }, + }) + ); + + const fakeWorker = createFakeWorker(); + using globalThis_Worker = stub( + globalThis, + "Worker", + returnsNext([fakeWorker]), + ); + using worker_terminate = stub(fakeWorker, "terminate"); + using _worker_postMessage = stub(fakeWorker, "postMessage"); + + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _listener_accept = stub( + fakeTcpListener, + "accept", + resolvesNext([fakeTcpConn, pendingPromise()]), + ); + using _console_info = stub(console, "info"); + using console_error = stub(console, "error"); + + const p = main([]); + await delay(0); + + await t.step("when the worker stream is closed", async (t) => { + assertSpyCalls(globalThis_Worker, 1); + fakeTcpListener.close(); + + fakeWorker.onmessage(new MessageEvent("message", { data: null })); + await delay(0); + + await t.step("calls Worker.terminate()", () => { + assertSpyCalls(worker_terminate, 1); + }); + + await t.step("resolves main() Promise", async () => { + assertEquals(await promiseState(p), "fulfilled"); + }); + + await t.step("does not outputs error logs", () => { + assertNotMatch( + console_error.calls.flatMap((c) => c.args).join(" "), + /Internal error occurred/, + ); + }); + }); + } + }); + + await t.step("if `--quiet`", async (t) => { + await t.step("does not outputs info logs", async () => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using console_info = stub(console, "info"); + + const p = main(["--quiet"]); + + assertSpyCalls(console_info, 0); + + fakeTcpListener.close(); + await p; + }); + + await t.step("when the connection is accepted", async (t) => { + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + + const fakeTcpConn = createFakeTcpConn(); + const connStreamCloseWaiter = Promise.withResolvers(); + sinon.stub(fakeTcpConn, "remoteAddr").get(() => ({ + hostname: "stub-remote.example.net", + port: 98765, + })); + sinon.stub(fakeTcpConn, "readable").get(() => + new ReadableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => con.close()); + }, + }) + ); + sinon.stub(fakeTcpConn, "writable").get(() => + new WritableStream({ + start(con) { + connStreamCloseWaiter.promise.then(() => + con.error("fake-tcpconn-writable-closed") + ); + }, + }) + ); + + const fakeWorker = createFakeWorker(); + using _globalThis_Worker = stub( + globalThis, + "Worker", + returnsNext([fakeWorker]), + ); + using _worker_terminate = stub(fakeWorker, "terminate"); + using _worker_postMessage = stub(fakeWorker, "postMessage"); + + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _listener_accept = stub( + fakeTcpListener, + "accept", + resolvesNext([fakeTcpConn, pendingPromise()]), + ); + using console_info = stub(console, "info"); + using console_error = stub(console, "error"); + + const p = main(["--quiet"]); + await delay(0); + + await t.step("does not outputs info logs", () => { + assertNotMatch( + console_info.calls.flatMap((c) => c.args).join(" "), + /is connected/, + ); + }); + + fakeTcpListener.close(); + + await t.step("when the connection is closed", async (t) => { + connStreamCloseWaiter.resolve(); + await delay(0); + + await t.step("and the worker stream is closed", async (t) => { + fakeWorker.onmessage(new MessageEvent("message", { data: null })); + await delay(0); + + await t.step("outputs error logs", () => { + assertStringIncludes( + console_error.calls.flatMap((c) => c.args).join(" "), + "Internal error occurred and Host/Denops connection is dropped fake-tcpconn-writable-closed", + ); + }); + }); + }); + + await p; + }); + }); + + await t.step("listens SIGINT", async (t) => { + const prevSignalListenerCalls = deno_addSignalListener.calls.length; + const fakeTcpListener = createFakeTcpListener(); + sinon.stub(fakeTcpListener, "addr").get(() => ({ + hostname: "stub.example.net", + port: 99999, + })); + using listener_close = spy(fakeTcpListener, "close"); + using _deno_listen = stubDenoListen(returnsNext([fakeTcpListener])); + using _console_info = stub(console, "info"); + + const mainPromise = main([]); + await delay(0); + + assertSpyCalls(deno_addSignalListener, prevSignalListenerCalls + 1); + const [ + signal, + signalHandler, + ] = deno_addSignalListener.calls[prevSignalListenerCalls].args; + assertEquals(signal, "SIGINT"); + + await t.step("when SINGINT is trapped", async (t) => { + assertSpyCalls(listener_close, 0); + + signalHandler(); + await delay(0); + + await t.step("closes the listener", () => { + assertSpyCalls(listener_close, 1); + }); + + await t.step("resolves main() Promise", async () => { + assertEquals(await promiseState(mainPromise), "fulfilled"); + }); + }); + }); +}); diff --git a/denops/@denops-private/denops.ts b/denops/@denops-private/denops.ts index a659346d..996c9cae 100644 --- a/denops/@denops-private/denops.ts +++ b/denops/@denops-private/denops.ts @@ -1,11 +1,6 @@ -import type { - Context, - Denops, - Dispatcher, - Meta, -} from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { BatchError } from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { ensure, is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import type { Context, Denops, Dispatcher, Meta } from "jsr:@denops/core@7.0.0"; +import { BatchError } from "jsr:@denops/core@7.0.0"; +import { ensure, is } from "jsr:@core/unknownutil@3.18.1"; import type { Host as HostOrigin } from "./host.ts"; import type { Service as ServiceOrigin } from "./service.ts"; @@ -13,7 +8,10 @@ const isBatchReturn = is.TupleOf([is.Array, is.String] as const); export type Host = Pick; -export type Service = Pick; +export type Service = Pick< + ServiceOrigin, + "dispatch" | "waitLoaded" | "interrupted" +>; export class DenopsImpl implements Denops { readonly name: string; @@ -37,6 +35,10 @@ export class DenopsImpl implements Denops { this.#service = service; } + get interrupted(): AbortSignal { + return this.#service.interrupted; + } + redraw(force?: boolean): Promise { return this.#host.redraw(force); } diff --git a/denops/@denops-private/denops_test.ts b/denops/@denops-private/denops_test.ts index 0068a82a..4a25b701 100644 --- a/denops/@denops-private/denops_test.ts +++ b/denops/@denops-private/denops_test.ts @@ -1,12 +1,21 @@ -import type { Meta } from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { BatchError, type Meta } from "jsr:@denops/core@7.0.0"; import { - assertSpyCall, + assertEquals, + assertInstanceOf, + assertRejects, + assertStrictEquals, + unimplemented, +} from "jsr:@std/assert@1.0.1"; +import { + assertSpyCallArgs, + assertSpyCalls, + resolvesNext, stub, -} from "https://deno.land/std@0.217.0/testing/mock.ts"; -import { DenopsImpl, Host, Service } from "./denops.ts"; -import { promiseState } from "https://deno.land/x/async@v2.1.0/mod.ts"; -import { unimplemented } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { DenopsImpl, type Host, type Service } from "./denops.ts"; + +type BatchReturn = [results: unknown[], errmsg: string]; Deno.test("DenopsImpl", async (t) => { const meta: Meta = { @@ -23,114 +32,365 @@ Deno.test("DenopsImpl", async (t) => { const service: Service = { dispatch: () => unimplemented(), waitLoaded: () => unimplemented(), + interrupted: new AbortController().signal, }; const denops = new DenopsImpl("dummy", meta, host, service); - await t.step("redraw() calls host.redraw()", async () => { - const s = stub(host, "redraw"); - try { + await t.step("interrupted returns AbortSignal instance", () => { + assertInstanceOf(denops.interrupted, AbortSignal); + }); + + await t.step(".redraw()", async (t) => { + await t.step("calls host.redraw() without `force`", async () => { + using host_redraw = stub(host, "redraw", resolvesNext([undefined])); + await denops.redraw(); - assertSpyCall(s, 0, { args: [undefined] }); + + assertSpyCallArgs(host_redraw, 0, [undefined]); + }); + + await t.step("calls host.redraw() with `force=false`", async () => { + using host_redraw = stub(host, "redraw", resolvesNext([undefined])); await denops.redraw(false); - assertSpyCall(s, 1, { args: [false] }); + + assertSpyCallArgs(host_redraw, 0, [false]); + }); + + await t.step("calls host.redraw() with `force=true`", async () => { + using host_redraw = stub(host, "redraw", resolvesNext([undefined])); await denops.redraw(true); - assertSpyCall(s, 2, { args: [true] }); - } finally { - s.restore(); - } + + assertSpyCallArgs(host_redraw, 0, [true]); + }); }); - await t.step("call() calls host.call()", async () => { - const s = stub(host, "call"); - try { + await t.step(".call()", async (t) => { + await t.step("calls host.call() without `args`", async () => { + using host_call = stub(host, "call", resolvesNext([1])); + + await denops.call("bufnr"); + + assertSpyCallArgs(host_call, 0, ["bufnr"]); + }); + + await t.step("calls host.call() with one `args`", async () => { + using host_call = stub(host, "call", resolvesNext([4])); + await denops.call("abs", -4); - assertSpyCall(s, 0, { args: ["abs", -4] }); - await denops.call("abs", 10); - assertSpyCall(s, 1, { args: ["abs", 10] }); - } finally { - s.restore(); - } - }); + assertSpyCallArgs(host_call, 0, ["abs", -4]); + }); + + await t.step("calls host.call() with multiple `args`", async () => { + using host_call = stub(host, "call", resolvesNext([-1])); + + await denops.call("byteidx", "foobar", 42, false); + + assertSpyCallArgs(host_call, 0, ["byteidx", "foobar", 42, false]); + }); + + await t.step( + "calls host.call() with `args` omitting after `undefined`", + async () => { + using host_call = stub(host, "call", resolvesNext([3])); - await t.step("batch() calls host.batch()", async () => { - const s = stub( - host, - "batch", - () => Promise.resolve([[], ""] as [unknown[], string]), + await denops.call("charidx", "foobar", 3, undefined, false); + + assertSpyCallArgs(host_call, 0, ["charidx", "foobar", 3]); + }, ); - try { + + await t.step("resolves a result of host.call()", async () => { + using _host_call = stub(host, "call", resolvesNext([42])); + + const actual = await denops.call("bufnr"); + + assertEquals(actual, 42); + }); + + await t.step("if host.call() rejects with an error", async (t) => { + await t.step("rejects with an error", async () => { + const error = new Error("test error in host.call"); + using _host_call = stub(host, "call", resolvesNext([error])); + + const actual = await assertRejects( + () => denops.call("foo", true), + ); + assertStrictEquals(actual, error); + }); + }); + }); + + await t.step(".batch()", async (t) => { + await t.step("calls host.batch() with `calls`", async () => { + using host_batch = stub( + host, + "batch", + resolvesNext([[[4, 10, 9], ""]]), + ); + await denops.batch(["abs", -4], ["abs", 10], ["abs", -9]); - assertSpyCall(s, 0, { - args: [["abs", -4], ["abs", 10], ["abs", -9]], + + assertSpyCallArgs(host_batch, 0, [["abs", -4], ["abs", 10], ["abs", -9]]); + }); + + await t.step( + "calls host.batch() with `calls` omitting arguments after `undefined`", + async () => { + using host_batch = stub( + host, + "batch", + resolvesNext([[["", ""], ""]]), + ); + + await denops.batch( + ["findfile", "foo", undefined, 42], + ["getreg", undefined, 1, true], + ); + + assertSpyCallArgs(host_batch, 0, [["findfile", "foo"], ["getreg"]]); + }, + ); + + await t.step("resolves results of host.batch()", async () => { + using _host_batch = stub( + host, + "batch", + resolvesNext([[[4, 10, 9], ""]]), + ); + + const actual = await denops.batch(["abs", -4], ["abs", 10], ["abs", -9]); + + assertEquals(actual, [4, 10, 9]); + }); + + await t.step( + "if host.batch() resolves with an error message", + async (t) => { + await t.step("rejects with BatchError", async () => { + using _host_batch = stub( + host, + "batch", + resolvesNext([[[], "test error in host.batch"]]), + ); + + await assertRejects( + () => denops.batch(["foo", true]), + BatchError, + "test error in host.batch", + ); + }); + }, + ); + + await t.step("if host.batch() rejects with an error", async (t) => { + await t.step("rejects with an error", async () => { + const error = new Error("test error in host.batch"); + using _host_batch = stub( + host, + "batch", + resolvesNext([error]), + ); + + const actual = await assertRejects( + () => denops.batch(["foo", true]), + ); + assertStrictEquals(actual, error); }); - } finally { - s.restore(); - } + }); }); - await t.step("cmd() calls host.call()", async () => { - const s = stub(host, "call"); - try { + await t.step(".cmd()", async (t) => { + await t.step("calls host.call() without `ctx`", async () => { + using host_call = stub(host, "call", resolvesNext([undefined])); + await denops.cmd("echo 'foo'"); - assertSpyCall(s, 0, { - args: ["denops#api#cmd", "echo 'foo'", {}], + + assertSpyCallArgs(host_call, 0, ["denops#api#cmd", "echo 'foo'", {}]); + }); + + await t.step("calls host.call() with `ctx`", async () => { + using host_call = stub(host, "call", resolvesNext([undefined])); + + await denops.cmd("echo 'foo'", { bar: 1, qux: true }); + + assertSpyCallArgs(host_call, 0, [ + "denops#api#cmd", + "echo 'foo'", + { bar: 1, qux: true }, + ]); + }); + + await t.step("if host.call() rejects with an error", async (t) => { + await t.step("rejects with an error", async () => { + const error = new Error("test error in host.call"); + using _host_call = stub(host, "call", resolvesNext([error])); + + const actual = await assertRejects( + () => denops.cmd("echo 'foo'"), + ); + assertStrictEquals(actual, error); }); - } finally { - s.restore(); - } + }); }); - await t.step("eval() calls host.call()", async () => { - const s = stub(host, "call"); - try { + await t.step(".eval()", async (t) => { + await t.step("calls host.call() without `ctx`", async () => { + using host_call = stub(host, "call", resolvesNext([undefined])); + await denops.eval("v:version"); - assertSpyCall(s, 0, { - args: ["denops#api#eval", "v:version", {}], + + assertSpyCallArgs(host_call, 0, ["denops#api#eval", "v:version", {}]); + }); + + await t.step("calls host.call() with `ctx`", async () => { + using host_call = stub(host, "call", resolvesNext([undefined])); + + await denops.eval("v:version", { foo: 1, bar: true }); + + assertSpyCallArgs(host_call, 0, [ + "denops#api#eval", + "v:version", + { foo: 1, bar: true }, + ]); + }); + + await t.step("resolves a result of host.call()", async () => { + using _host_call = stub(host, "call", resolvesNext([901])); + + const actual = await denops.eval("v:version"); + + assertEquals(actual, 901); + }); + + await t.step("if host.call() rejects with an error", async (t) => { + await t.step("rejects with an error", async () => { + const error = new Error("test error in host.call"); + using _host_call = stub(host, "call", resolvesNext([error])); + + const actual = await assertRejects( + () => denops.eval("v:version"), + ); + assertStrictEquals(actual, error); }); - } finally { - s.restore(); - } + }); }); - await t.step("dispatch() calls service.dispatch()", async () => { - const s1 = stub(service, "waitLoaded", () => Promise.resolve()); - const s2 = stub(service, "dispatch", () => Promise.resolve()); - try { - await denops.dispatch("dummy", "fn", "args"); - assertSpyCall(s1, 0, { - args: ["dummy"], + await t.step(".dispatch()", async (t) => { + await t.step("if the plugin is already loaded", async (t) => { + await t.step("calls service.dispatch() without `args`", async () => { + using _service_waitLoaded = stub( + service, + "waitLoaded", + resolvesNext([undefined]), + ); + using service_dispatch = stub( + service, + "dispatch", + resolvesNext([undefined]), + ); + + await denops.dispatch("dummy", "fn"); + + assertSpyCallArgs(service_dispatch, 0, ["dummy", "fn", []]); }); - assertSpyCall(s2, 0, { - args: ["dummy", "fn", ["args"]], + + await t.step("calls service.dispatch() with `args`", async () => { + using _service_waitLoaded = stub( + service, + "waitLoaded", + resolvesNext([undefined]), + ); + using service_dispatch = stub( + service, + "dispatch", + resolvesNext([undefined]), + ); + + await denops.dispatch( + "dummy", + "fn", + "args0", + undefined, + null, + true, + 42, + ); + + assertSpyCallArgs(service_dispatch, 0, [ + "dummy", + "fn", + ["args0", undefined, null, true, 42], + ]); }); - } finally { - s1.restore(); - s2.restore(); - } - }); - await t.step( - "dispatch() internally waits 'service.waitLoaded()' before 'service.dispatch()'", - async () => { - const { promise, resolve } = Promise.withResolvers(); - const s1 = stub(service, "waitLoaded", () => promise); - const s2 = stub(service, "dispatch", () => Promise.resolve()); - try { - const p = denops.dispatch("dummy", "fn", "args"); - assertEquals(await promiseState(p), "pending"); - assertEquals(s1.calls.length, 1); - assertEquals(s2.calls.length, 0); - resolve(); - assertEquals(await promiseState(p), "fulfilled"); - assertEquals(s1.calls.length, 1); - assertEquals(s2.calls.length, 1); - } finally { - s1.restore(); - s2.restore(); - } - }, - ); + await t.step("resolves result of service.dispatch()", async () => { + using _service_waitLoaded = stub( + service, + "waitLoaded", + resolvesNext([undefined]), + ); + using _service_dispatch = stub( + service, + "dispatch", + resolvesNext([{ foo: "a", bar: 42, qux: true }]), + ); + + const actual = await denops.dispatch("dummy", "fn"); + + assertEquals(actual, { foo: "a", bar: 42, qux: true }); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + await t.step("waits service.waitLoaded() resolves", async () => { + const waiter = Promise.withResolvers(); + using service_waitLoaded = stub( + service, + "waitLoaded", + resolvesNext([waiter.promise]), + ); + using service_dispatch = stub( + service, + "dispatch", + resolvesNext([undefined]), + ); + + const dispatchPromise = denops.dispatch("dummy", "fn", "args"); + + assertEquals(await promiseState(dispatchPromise), "pending"); + assertSpyCalls(service_waitLoaded, 1); + assertSpyCalls(service_dispatch, 0); + waiter.resolve(); + assertEquals(await promiseState(dispatchPromise), "fulfilled"); + assertSpyCalls(service_waitLoaded, 1); + assertSpyCalls(service_dispatch, 1); + }); + }); + + await t.step("if the service is closed", async (t) => { + await t.step("rejects and service.dispatch() never calls", async () => { + using service_waitLoaded = stub( + service, + "waitLoaded", + resolvesNext([new Error("Service closed")]), + ); + using service_dispatch = stub( + service, + "dispatch", + resolvesNext([undefined]), + ); + + await assertRejects( + () => denops.dispatch("dummy", "fn"), + Error, + "Service closed", + ); + assertSpyCallArgs(service_waitLoaded, 0, ["dummy"]); + assertSpyCalls(service_dispatch, 0); + }); + }); + }); }); diff --git a/denops/@denops-private/error.ts b/denops/@denops-private/error.ts index 002a7081..4c698328 100644 --- a/denops/@denops-private/error.ts +++ b/denops/@denops-private/error.ts @@ -1,10 +1,10 @@ -import { is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import { is } from "jsr:@core/unknownutil@3.18.1"; import { fromErrorObject, isErrorObject, toErrorObject, tryOr, -} from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; +} from "jsr:@lambdalisue/errorutil@1.1.0"; export function errorSerializer(err: unknown): unknown { if (err instanceof Error) { diff --git a/denops/@denops-private/error_test.ts b/denops/@denops-private/error_test.ts index 86ff832b..7f87a543 100644 --- a/denops/@denops-private/error_test.ts +++ b/denops/@denops-private/error_test.ts @@ -1,9 +1,5 @@ -import { - assert, - assertEquals, - assertInstanceOf, -} from "https://deno.land/std@0.217.0/assert/mod.ts"; -import { is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import { assert, assertEquals, assertInstanceOf } from "jsr:@std/assert@1.0.1"; +import { is } from "jsr:@core/unknownutil@3.18.1"; import { errorDeserializer, errorSerializer } from "./error.ts"; Deno.test("errorSerializer", async (t) => { diff --git a/denops/@denops-private/host.ts b/denops/@denops-private/host.ts index cf5f32dc..f135dfdd 100644 --- a/denops/@denops-private/host.ts +++ b/denops/@denops-private/host.ts @@ -1,4 +1,4 @@ -import { ensure, is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import { ensure, is, type Predicate } from "jsr:@core/unknownutil@3.18.1"; /** * Host (Vim/Neovim) which is visible from Service @@ -22,9 +22,9 @@ export interface Host extends AsyncDisposable { ): Promise<[unknown[], string]>; /** - * Call host function and do nothing + * Call host function and does not check results */ - notify(fn: string, ...args: unknown[]): void; + notify(fn: string, ...args: unknown[]): Promise; /** * Initialize host @@ -45,11 +45,13 @@ export type HostConstructor = { }; // Minimum interface of Service that Host is relies on -type CallbackId = string; +export type CallbackId = string; export type Service = { bind(host: Host): void; load(name: string, script: string): Promise; + unload(name: string): Promise; reload(name: string): Promise; + interrupt(reason?: unknown): void; dispatch(name: string, fn: string, args: unknown[]): Promise; dispatchAsync( name: string, @@ -58,36 +60,49 @@ export type Service = { success: CallbackId, failure: CallbackId, ): Promise; + close(): Promise; }; +type ServiceForInvoke = Omit; + export function invoke( - service: Omit, + service: ServiceForInvoke, name: string, args: unknown[], ): Promise { switch (name) { case "load": - return service.load( - ...ensure(args, is.TupleOf([is.String, is.String] as const)), - ); + return service.load(...ensure(args, serviceMethodArgs.load)); + case "unload": + return service.unload(...ensure(args, serviceMethodArgs.unload)); case "reload": - return service.reload( - ...ensure(args, is.TupleOf([is.String] as const)), - ); + return service.reload(...ensure(args, serviceMethodArgs.reload)); + case "interrupt": + service.interrupt(...ensure(args, serviceMethodArgs.interrupt)); + return Promise.resolve(); case "dispatch": - return service.dispatch( - ...ensure(args, is.TupleOf([is.String, is.String, is.Array] as const)), - ); + return service.dispatch(...ensure(args, serviceMethodArgs.dispatch)); case "dispatchAsync": return service.dispatchAsync( - ...ensure( - args, - is.TupleOf( - [is.String, is.String, is.Array, is.String, is.String] as const, - ), - ), + ...ensure(args, serviceMethodArgs.dispatchAsync), ); + case "close": + return service.close(...ensure(args, serviceMethodArgs.close)); default: throw new Error(`Service does not have a method '${name}'`); } } + +const serviceMethodArgs = { + load: is.ParametersOf([is.String, is.String] as const), + unload: is.ParametersOf([is.String] as const), + reload: is.ParametersOf([is.String] as const), + interrupt: is.ParametersOf([is.OptionalOf(is.Unknown)] as const), + dispatch: is.ParametersOf([is.String, is.String, is.Array] as const), + dispatchAsync: is.ParametersOf( + [is.String, is.String, is.Array, is.String, is.String] as const, + ), + close: is.ParametersOf([] as const), +} as const satisfies { + [K in keyof ServiceForInvoke]: Predicate>; +}; diff --git a/denops/@denops-private/host/nvim.ts b/denops/@denops-private/host/nvim.ts index 88c69c69..82cf6363 100644 --- a/denops/@denops-private/host/nvim.ts +++ b/denops/@denops-private/host/nvim.ts @@ -1,11 +1,8 @@ -import { ensure, is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; -import { - Client, - Session, -} from "https://deno.land/x/messagepack_rpc@v2.0.3/mod.ts"; +import { ensure, is } from "jsr:@core/unknownutil@3.18.1"; +import { Client, Session } from "jsr:@lambdalisue/messagepack-rpc@2.4.0"; import { errorDeserializer, errorSerializer } from "../error.ts"; import { getVersionOr } from "../version.ts"; -import { Host, invoke, Service } from "../host.ts"; +import { type Host, invoke, type Service } from "../host.ts"; export class Neovim implements Host { #session: Session; @@ -89,8 +86,8 @@ export class Neovim implements Host { return [ret, ""]; } - notify(fn: string, ...args: unknown[]): void { - this.#client.notify("nvim_call_function", fn, args); + async notify(fn: string, ...args: unknown[]): Promise { + await this.#client.notify("nvim_call_function", fn, args); } async init(service: Service): Promise { diff --git a/denops/@denops-private/host/nvim_test.ts b/denops/@denops-private/host/nvim_test.ts index 88857c38..c5c5291c 100644 --- a/denops/@denops-private/host/nvim_test.ts +++ b/denops/@denops-private/host/nvim_test.ts @@ -2,165 +2,414 @@ import { assertEquals, assertMatch, assertRejects, -} from "https://deno.land/std@0.217.0/assert/mod.ts"; + assertStringIncludes, +} from "jsr:@std/assert@1.0.1"; import { - assertSpyCall, + assertSpyCallArgs, + assertSpyCalls, + resolvesNext, stub, -} from "https://deno.land/std@0.217.0/testing/mock.ts"; -import { promiseState } from "https://deno.land/x/async@v2.1.0/mod.ts"; -import { withNeovim } from "../testutil/with.ts"; -import { Service } from "../host.ts"; +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { unimplemented } from "jsr:@lambdalisue/errorutil@1.1.0"; +import { Client } from "jsr:@lambdalisue/messagepack-rpc@2.4.0"; +import { withNeovim } from "/denops-testutil/with.ts"; +import type { Service } from "../host.ts"; import { Neovim } from "./nvim.ts"; -import { unimplemented } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; + +const NOTIFY_DELAY = 100; Deno.test("Neovim", async (t) => { - let waitClosed: Promise | undefined; await withNeovim({ - fn: async (reader, writer) => { + fn: async ({ reader, writer }) => { const service: Service = { bind: () => unimplemented(), load: () => unimplemented(), + unload: () => unimplemented(), reload: () => unimplemented(), + interrupt: () => unimplemented(), dispatch: () => unimplemented(), dispatchAsync: () => unimplemented(), + close: () => unimplemented(), }; await using host = new Neovim(reader, writer); - await t.step( - "'invoke' message before init throws error", - async () => { - await assertRejects( - () => - host.call( - "denops#_internal#test#request", - "invoke", - ["reload", ["dummy"]], - ), - Error, - "Failed to call", - ); - }, - ); - await t.step("init() calls Service.bind()", async () => { - const s = stub(service, "bind"); - try { - await host.init(service); - assertSpyCall(s, 0, { args: [host] }); - } finally { - s.restore(); - } + await t.step("before .init() calls", async (t) => { + await t.step("when handle message", async (t) => { + await t.step("'invoke' rejects", async () => { + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "invoke", + ["reload", ["dummy"]], + ), + Error, + "No service is registered in the host", + ); + }); + }); }); - await t.step("redraw() does nothing", async () => { - await host.redraw(); + await t.step(".init()", async (t) => { + await t.step("calls service.bind()", async () => { + using service_bind = stub(service, "bind"); + + await host.init(service); + + assertSpyCallArgs(service_bind, 0, [host]); + }); }); - await t.step("call() returns a result of the function", async () => { - const result = await host.call("abs", -4); - assertEquals(result, 4); + await t.step(".redraw()", async (t) => { + await t.step("does nothing", async () => { + await host.redraw(); + }); }); - await t.step( - "call() throws an error when failed to call the function", - async () => { - await assertRejects( - () => host.call("@@@@@", -4), - Error, - "Failed to call '@@@@@' in Neovim: Vim:E117: Unknown function: @@@@@ (code: 0)", - ); - }, - ); - - await t.step("batch() returns results of the functions", async () => { - const [ret, err] = await host.batch( - ["abs", -4], - ["abs", 10], - ["abs", -9], - ); - assertEquals(ret, [4, 10, 9]); - assertEquals(err, ""); + await t.step(".call()", async (t) => { + await t.step("resolves a result of `fn`", async () => { + const result = await host.call("abs", -4); + + assertEquals(result, 4); + }); + + await t.step("if `fn` does not exist", async (t) => { + await t.step("rejects with an error", async () => { + await assertRejects( + () => host.call("@@@@@", -4), + Error, + "Failed to call '@@@@@' in Neovim: Vim:E117: Unknown function: @@@@@ (code: 0)", + ); + }); + }); + + await t.step("if Client error occurs", async (t) => { + await t.step("rejects with an error", async () => { + using _client_call = stub( + Client.prototype, + "call", + resolvesNext([new Error("Client call error")]), + ); + + await assertRejects( + () => host.call("abs", -4), + Error, + "Client call error", + ); + }); + }); }); - await t.step( - "batch() returns resutls with an error when failed to call the function", - async () => { + await t.step(".batch()", async (t) => { + await t.step("resolves results of `calls`", async () => { const [ret, err] = await host.batch( ["abs", -4], ["abs", 10], - ["@@@@@", -9], - ); - assertEquals(ret, [4, 10]); - assertMatch( - err, - /Failed to call '@@@@@' in Neovim: Vim:E117: Unknown function: @@@@@/, + ["abs", -9], ); - }, - ); - await t.step("notify() calls the function", () => { - host.notify("abs", -4); - host.notify("@@@@@", -4); // should not throw + assertEquals(ret, [4, 10, 9]); + assertEquals(err, ""); + }); + + await t.step("if some function does not exist", async (t) => { + await t.step("resolves resutls and an error", async () => { + const [ret, err] = await host.batch( + ["abs", -4], + ["abs", 10], + ["@@@@@", -9], + ["abs", 10], + ); + + assertEquals(ret, [4, 10]); + assertMatch( + err, + /^Failed to call '@@@@@' in Neovim: Vim:E117: Unknown function: @@@@@/, + ); + }); + + await t.step("does not call functions after failure", async () => { + await host.call("execute", [ + "let g:__test_host_batch_fn_calls = []", + "function! TestHostBatchFn(...) abort", + " call add(g:__test_host_batch_fn_calls, a:000)", + "endfunction", + ], ""); + + await host.batch( + ["TestHostBatchFn", -4], + ["TestHostBatchFn", 10], + ["@@@@@", 10], + ["TestHostBatchFn", -9], + ["TestHostBatchFn", -4], + ); + + const actual = await host.call( + "eval", + "g:__test_host_batch_fn_calls", + ); + assertEquals(actual, [[-4], [10]]); + }); + }); + + await t.step("if Client error occurs", async (t) => { + await t.step("rejects with an error", async () => { + using _client_call = stub( + Client.prototype, + "call", + resolvesNext([new Error("Client call error")]), + ); + + await assertRejects( + () => host.batch(["abs", -4]), + Error, + "Client call error", + ); + }); + }); }); - await t.step( - "'void' message does nothing", - async () => { - await host.call( - "denops#_internal#test#request", - "void", - [], + await t.step(".notify()", async (t) => { + await t.step("calls `fn`", async () => { + await host.call("execute", [ + "let g:__test_host_notify_fn_calls = []", + "function! TestHostNotifyFn(...) abort", + " call add(g:__test_host_notify_fn_calls, a:000)", + "endfunction", + ], ""); + + await host.notify( + "TestHostNotifyFn", + "foo", + 4, + undefined, + null, + false, + ); + + await delay(NOTIFY_DELAY); // maybe flaky + const actual = await host.call( + "eval", + "g:__test_host_notify_fn_calls", ); - }, - ); - - await t.step( - "'invoke' message calls Service method", - async () => { - const s = stub(service, "reload"); - try { + assertEquals(actual, [["foo", 4, 0, null, false]]); + }); + + await t.step("if `fn` does not exist", async (t) => { + using console_error = stub(console, "error"); + + await t.step("does not reject", async () => { + await host.notify("@@@@@", -4); + }); + + await t.step("outputs an error message", async () => { + await delay(NOTIFY_DELAY); // maybe flaky + assertSpyCalls(console_error, 1); + assertStringIncludes( + console_error.calls.flatMap((c) => c.args).join(" "), + "nvim_error_event(0) Vim:E117: Unknown function: @@@@@", + ); + }); + }); + }); + + await t.step("when handle request message", async (t) => { + await t.step("'void'", async (t) => { + await t.step("does nothing", async () => { + await host.call( + "denops#_internal#test#request", + "void", + [], + ); + }); + }); + + await t.step("'invoke'", async (t) => { + await t.step("calls Service method", async () => { + using service_reload = stub(service, "reload"); + await host.call( "denops#_internal#test#request", "invoke", ["reload", ["dummy"]], ); - assertSpyCall(s, 0, { args: ["dummy"] }); - } finally { - s.restore(); - } - }, - ); - - await t.step( - "'nvim_error_event' message shows error message", - async () => { - const s = stub(console, "error"); - try { + + assertSpyCallArgs(service_reload, 0, ["dummy"]); + }); + + await t.step("resolves a result of Service method", async () => { + using _service_dispatch = stub( + service, + "dispatch", + resolvesNext([{ foo: "dummy result" }]), + ); + + const actual = await host.call( + "denops#_internal#test#request", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ); + + assertEquals(actual, { foo: "dummy result" }); + }); + + await t.step("if Service method rejects", async (t) => { + await t.step("rejects with an error", async () => { + using _service_dispatch = stub( + service, + "dispatch", + () => Promise.reject("Error: stringified error message"), + ); + + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ), + Error, + "Error: stringified error message", + ); + }); + }); + }); + + await t.step("'nvim_error_event'", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + await host.call( "denops#_internal#test#request", "nvim_error_event", [0, "message"], ); - assertSpyCall(s, 0, { args: ["nvim_error_event(0)", "message"] }); - } finally { - s.restore(); - } - }, - ); - - await t.step( - "waitClosed() returns a promise that is pending when the session is not closed", - async () => { - waitClosed = host.waitClosed(); - assertEquals(await promiseState(waitClosed), "pending"); - }, - ); + + assertSpyCallArgs(console_error, 0, [ + "nvim_error_event(0)", + "message", + ]); + }); + }); + + await t.step("unknown message", async (t) => { + await t.step("rejects with an error", async () => { + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "unknown_message", + [0, "message"], + ), + Error, + "NoMethodFoundError: No MessagePack-RPC method 'unknown_message' exists", + ); + }); + }); + }); + + await t.step("when handle notify message", async (t) => { + await t.step("'void'", async (t) => { + await t.step("does nothing", async () => { + await host.call( + "denops#_internal#test#notify", + "void", + [], + ); + }); + }); + + await t.step("'invoke'", async (t) => { + await t.step("calls Service method", async () => { + using service_reload = stub(service, "reload"); + + await host.call( + "denops#_internal#test#notify", + "invoke", + ["reload", ["dummy"]], + ); + + assertSpyCallArgs(service_reload, 0, ["dummy"]); + }); + + await t.step("if Service method rejects", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + using _service_dispatch = stub( + service, + "dispatch", + () => Promise.reject("Error: stringified error message"), + ); + + await host.call( + "denops#_internal#test#notify", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ); + + assertSpyCalls(console_error, 1); + assertMatch( + console_error.calls[0].args.join(" "), + /^Failed to handle message [0-9]+,invoke,dispatch,dummy,fn,arg0/, + ); + }); + }); + }); + + await t.step("'nvim_error_event'", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + + await host.call( + "denops#_internal#test#notify", + "nvim_error_event", + [0, "message"], + ); + + assertSpyCallArgs(console_error, 0, [ + "nvim_error_event(0)", + "message", + ]); + }); + }); + + await t.step("unknown message", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + + await host.call( + "denops#_internal#test#notify", + "unknown_message", + [0, "message"], + ); + + assertSpyCalls(console_error, 1); + assertMatch( + console_error.calls[0].args.join(" "), + /^Failed to handle message [0-9]+,unknown_message,0,message/, + ); + }); + }); + }); + + // NOTE: This test closes the session of the host. + await t.step(".waitClosed()", async (t) => { + const waitClosedPromise = host.waitClosed(); + + await t.step("pendings before the session closes", async () => { + assertEquals(await promiseState(waitClosedPromise), "pending"); + }); + + // NOTE: Close the session of the host. + await host[Symbol.asyncDispose](); + + await t.step("fulfilled when the session closes", async () => { + assertEquals(await promiseState(waitClosedPromise), "fulfilled"); + }); + }); }, }); - await t.step( - "waitClosed promise is fulfilled when the session is closed", - async () => { - assertEquals(await promiseState(waitClosed!), "fulfilled"); - }, - ); }); diff --git a/denops/@denops-private/host/vim.ts b/denops/@denops-private/host/vim.ts index 35863211..2a61a27b 100644 --- a/denops/@denops-private/host/vim.ts +++ b/denops/@denops-private/host/vim.ts @@ -1,10 +1,10 @@ -import { ensure, is } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import { ensure, is } from "jsr:@core/unknownutil@3.18.1"; import { Client, - Message, + type Message, Session, -} from "https://deno.land/x/vim_channel_command@v3.0.0/mod.ts"; -import { Host, invoke, Service } from "../host.ts"; +} from "jsr:@denops/vim-channel-command@4.0.2"; +import { type Host, invoke, type Service } from "../host.ts"; export class Vim implements Host { #session: Session; @@ -81,8 +81,8 @@ export class Vim implements Host { return [ret, ""]; } - notify(fn: string, ...args: unknown[]): void { - this.#client.callNoReply(fn, ...args); + async notify(fn: string, ...args: unknown[]): Promise { + await this.#client.callNoReply(fn, ...args); } init(service: Service): Promise { diff --git a/denops/@denops-private/host/vim_test.ts b/denops/@denops-private/host/vim_test.ts index 43aaff41..17fc35cd 100644 --- a/denops/@denops-private/host/vim_test.ts +++ b/denops/@denops-private/host/vim_test.ts @@ -2,151 +2,392 @@ import { assertEquals, assertMatch, assertRejects, -} from "https://deno.land/std@0.217.0/assert/mod.ts"; +} from "jsr:@std/assert@1.0.1"; import { - assertSpyCall, + assertSpyCallArgs, + assertSpyCalls, + resolvesNext, stub, -} from "https://deno.land/std@0.217.0/testing/mock.ts"; -import { delay } from "https://deno.land/std@0.217.0/async/mod.ts"; -import { promiseState } from "https://deno.land/x/async@v2.1.0/mod.ts"; -import { withVim } from "../testutil/with.ts"; -import { Service } from "../host.ts"; +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { unimplemented } from "jsr:@lambdalisue/errorutil@1.1.0"; +import { Client, Session } from "jsr:@denops/vim-channel-command@4.0.2"; +import { withVim } from "/denops-testutil/with.ts"; +import type { Service } from "../host.ts"; import { Vim } from "./vim.ts"; -import { unimplemented } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; + +const NOTIFY_DELAY = 100; Deno.test("Vim", async (t) => { - let waitClosed: Promise | undefined; await withVim({ - fn: async (reader, writer) => { + fn: async ({ reader, writer }) => { const service: Service = { bind: () => unimplemented(), load: () => unimplemented(), + unload: () => unimplemented(), reload: () => unimplemented(), + interrupt: () => unimplemented(), dispatch: () => unimplemented(), dispatchAsync: () => unimplemented(), + close: () => unimplemented(), }; await using host = new Vim(reader, writer); - await t.step( - "'invoke' message before init throws error", - async () => { - await assertRejects( - () => - host.call( - "denops#_internal#test#request", - "invoke", - ["reload", ["dummy"]], - ), - Error, - "Failed to call", - ); - }, - ); - await t.step("init() calls Service.bind()", async () => { - const s = stub(service, "bind"); - try { - await host.init(service); - assertSpyCall(s, 0, { args: [host] }); - } finally { - s.restore(); - } + await t.step("before .init() calls", async (t) => { + await t.step("when handle message", async (t) => { + await t.step("'invoke' rejects", async () => { + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "invoke", + ["reload", ["dummy"]], + ), + Error, + // TODO: Fix it by stringifying the error object. + // "No service is registered in the host", + "Failed to call", + ); + }); + }); }); - await t.step("redraw() does nothing", async () => { - await host.redraw(); - // To avoid 'async operation to op_write_all was started before this test, ...' error. - await delay(10); - }); + await t.step(".init()", async (t) => { + await t.step("calls service.bind()", async () => { + using service_bind = stub(service, "bind"); - await t.step("call() returns a result of the function", async () => { - const result = await host.call("abs", -4); - assertEquals(result, 4); + await host.init(service); + + assertSpyCallArgs(service_bind, 0, [host]); + }); }); - await t.step( - "call() throws an error when failed to call the function", - async () => { - await assertRejects( - () => host.call("@@@@@", -4), - Error, - "Failed to call '@@@@@' in Vim: Vim(let):E117: Unknown function: @@@@@", + await t.step(".redraw()", async (t) => { + await t.step("sends a redraw command", async () => { + using session_send = stub( + Session.prototype, + "send", + resolvesNext([undefined]), + ); + + await host.redraw(); + + assertSpyCallArgs(session_send, 0, [["redraw", ""]]); + }); + + await t.step("sends a redraw command with `force`", async () => { + using session_send = stub( + Session.prototype, + "send", + resolvesNext([undefined]), ); - }, - ); - - await t.step("batch() returns results of the functions", async () => { - const [ret, err] = await host.batch( - ["abs", -4], - ["abs", 10], - ["abs", -9], - ); - assertEquals(ret, [4, 10, 9]); - assertEquals(err, ""); + + await host.redraw(true); + + assertSpyCallArgs(session_send, 0, [["redraw", "force"]]); + }); + }); + + await t.step(".call()", async (t) => { + await t.step("resolves a result of `fn`", async () => { + const result = await host.call("abs", -4); + + assertEquals(result, 4); + }); + + await t.step("if `fn` does not exist", async (t) => { + await t.step("rejects with an error", async () => { + await assertRejects( + () => host.call("@@@@@", -4), + Error, + "Failed to call '@@@@@' in Vim: Vim(let):E117: Unknown function: @@@@@", + ); + }); + }); + + await t.step("if Vim returns 'ERROR'", async (t) => { + await t.step("rejects with an error", async () => { + using _client_call = stub( + Client.prototype, + "call", + resolvesNext(["ERROR"]), + ); + + await assertRejects( + () => host.call("abs", -4), + Error, + 'Vim just returns "ERROR"', + ); + }); + }); }); - await t.step( - "batch() returns resutls with an error when failed to call the function", - async () => { + await t.step(".batch()", async (t) => { + await t.step("resolves results of `calls`", async () => { const [ret, err] = await host.batch( ["abs", -4], ["abs", 10], - ["@@@@@", -9], - ); - assertEquals(ret, [4, 10]); - assertMatch( - err, - /Failed to call '@@@@@' in Vim: Vim\(.*\):E117: Unknown function: @@@@@/, + ["abs", -9], ); - }, - ); - await t.step("notify() calls the function", () => { - host.notify("abs", -4); - host.notify("@@@@@", -4); // should not throw + assertEquals(ret, [4, 10, 9]); + assertEquals(err, ""); + }); + + await t.step("if some function does not exist", async (t) => { + await t.step("resolves resutls and an error", async () => { + const [ret, err] = await host.batch( + ["abs", -4], + ["abs", 10], + ["@@@@@", -9], + ["abs", 10], + ); + + assertEquals(ret, [4, 10]); + assertMatch( + err, + /^Failed to call '@@@@@' in Vim: Vim\(.*\):E117: Unknown function: @@@@@/, + ); + }); + + await t.step("does not call functions after failure", async () => { + await host.call("execute", [ + "let g:__test_host_batch_fn_calls = []", + "function! TestHostBatchFn(...) abort", + " call add(g:__test_host_batch_fn_calls, a:000)", + "endfunction", + ], ""); + + await host.batch( + ["TestHostBatchFn", -4], + ["TestHostBatchFn", 10], + ["@@@@@", 10], + ["TestHostBatchFn", -9], + ["TestHostBatchFn", -4], + ); + + const actual = await host.call( + "eval", + "g:__test_host_batch_fn_calls", + ); + assertEquals(actual, [[-4], [10]]); + }); + }); + + await t.step("if Vim returns 'ERROR'", async (t) => { + await t.step("rejects with an error", async () => { + using _client_call = stub( + Client.prototype, + "call", + resolvesNext(["ERROR"]), + ); + + await assertRejects( + () => host.batch(["abs", -4]), + Error, + 'Vim just returns "ERROR"', + ); + }); + }); }); - await t.step( - "'void' message does nothing", - async () => { - await host.call( - "denops#_internal#test#request", - "void", - [], + await t.step(".notify()", async (t) => { + await t.step("calls `fn`", async () => { + await host.call("execute", [ + "let g:__test_host_notify_fn_calls = []", + "function! TestHostNotifyFn(...) abort", + " call add(g:__test_host_notify_fn_calls, a:000)", + "endfunction", + ], ""); + + await host.notify( + "TestHostNotifyFn", + "foo", + 4, + undefined, + null, + false, ); - }, - ); - - await t.step( - "'invoke' message calls Service method", - async () => { - const s = stub(service, "reload"); - try { + + await delay(NOTIFY_DELAY); // maybe flaky + const actual = await host.call( + "eval", + "g:__test_host_notify_fn_calls", + ); + assertEquals(actual, [["foo", 4, null, null, false]]); + }); + + await t.step("if `fn` does not exist", async (t) => { + await t.step("does not reject", async () => { + await host.notify("@@@@@", -4); + }); + }); + }); + + await t.step("when handle request message", async (t) => { + await t.step("'void'", async (t) => { + await t.step("does nothing", async () => { + await host.call( + "denops#_internal#test#request", + "void", + [], + ); + }); + }); + + await t.step("'invoke'", async (t) => { + await t.step("calls Service method", async () => { + using service_reload = stub(service, "reload"); + await host.call( "denops#_internal#test#request", "invoke", ["reload", ["dummy"]], ); - assertSpyCall(s, 0, { args: ["dummy"] }); - } finally { - s.restore(); - } - }, - ); - - await t.step( - "waitClosed() returns a promise that is pending when the session is not closed", - async () => { - waitClosed = host.waitClosed(); - assertEquals(await promiseState(waitClosed), "pending"); - }, - ); + + assertSpyCallArgs(service_reload, 0, ["dummy"]); + }); + + await t.step("resolves a result of Service method", async () => { + using _service_dispatch = stub( + service, + "dispatch", + resolvesNext([{ foo: "dummy result" }]), + ); + + const actual = await host.call( + "denops#_internal#test#request", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ); + + assertEquals(actual, { foo: "dummy result" }); + }); + + await t.step("if Service method rejects", async (t) => { + await t.step("rejects with an error", async () => { + using _service_dispatch = stub( + service, + "dispatch", + () => Promise.reject("Error: stringified error message"), + ); + + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ), + Error, + "Error: stringified error message", + ); + }); + }); + }); + + await t.step("unknown message", async (t) => { + await t.step("rejects with an error", async () => { + await assertRejects( + () => + host.call( + "denops#_internal#test#request", + "unknown_message", + [0, "message"], + ), + Error, + // TODO: Fix it by stringifying the error object. + // 'Unexpected JSON channel message is received: ["unknown_message",0,"message"]', + "Failed to call", + ); + }); + }); + }); + + await t.step("when handle notify message", async (t) => { + await t.step("'void'", async (t) => { + await t.step("does nothing", async () => { + await host.call( + "denops#_internal#test#notify", + "void", + [], + ); + }); + }); + + await t.step("'invoke'", async (t) => { + await t.step("calls Service method", async () => { + using service_reload = stub(service, "reload"); + + await host.call( + "denops#_internal#test#notify", + "invoke", + ["reload", ["dummy"]], + ); + + assertSpyCallArgs(service_reload, 0, ["dummy"]); + }); + + await t.step("if Service method rejects", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + using _service_dispatch = stub( + service, + "dispatch", + () => Promise.reject("Error: stringified error message"), + ); + + await host.call( + "denops#_internal#test#notify", + "invoke", + ["dispatch", ["dummy", "fn", ["arg0"]]], + ); + + assertSpyCallArgs( + console_error, + 0, + ["Error: stringified error message"], + ); + }); + }); + }); + + await t.step("unknown message", async (t) => { + await t.step("outputs an error message", async () => { + using console_error = stub(console, "error"); + + await host.call( + "denops#_internal#test#notify", + "unknown_message", + [0, "message"], + ); + + assertSpyCalls(console_error, 1); + assertEquals( + console_error.calls[0].args.join(" "), + 'Error: Unexpected JSON channel message is received: ["unknown_message",0,"message"]', + ); + }); + }); + }); + + // NOTE: This test closes the session of the host. + await t.step(".waitClosed()", async (t) => { + const waitClosedPromise = host.waitClosed(); + + await t.step("pendings before the session closes", async () => { + assertEquals(await promiseState(waitClosedPromise), "pending"); + }); + + // NOTE: Close the session of the host. + await host[Symbol.asyncDispose](); + + await t.step("fulfilled when the session closes", async () => { + assertEquals(await promiseState(waitClosedPromise), "fulfilled"); + }); + }); }, }); - await t.step( - "waitClosed promise is fulfilled when the session is closed", - async () => { - assertEquals(await promiseState(waitClosed!), "fulfilled"); - }, - ); }); diff --git a/denops/@denops-private/host_test.ts b/denops/@denops-private/host_test.ts index 34c71231..2147e477 100644 --- a/denops/@denops-private/host_test.ts +++ b/denops/@denops-private/host_test.ts @@ -1,118 +1,141 @@ -import { assertThrows } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { assertThrows } from "jsr:@std/assert@1.0.1"; import { assertSpyCall, assertSpyCalls, stub, -} from "https://deno.land/std@0.217.0/testing/mock.ts"; -import { AssertError } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; -import { invoke, Service } from "./host.ts"; -import { unimplemented } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { AssertError } from "jsr:@core/unknownutil@3.18.1"; +import { unimplemented } from "jsr:@lambdalisue/errorutil@1.1.0"; +import { invoke, type Service } from "./host.ts"; Deno.test("invoke", async (t) => { const service: Omit = { load: () => unimplemented(), + unload: () => unimplemented(), reload: () => unimplemented(), + interrupt: () => unimplemented(), dispatch: () => unimplemented(), dispatchAsync: () => unimplemented(), + close: () => unimplemented(), }; await t.step("calls 'load'", async (t) => { await t.step("ok", async () => { - const s = stub(service, "load"); - try { - await invoke(service, "load", ["dummy", "dummy.ts"]); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { args: ["dummy", "dummy.ts"] }); - } finally { - s.restore(); - } + using s = stub(service, "load"); + await invoke(service, "load", ["dummy", "dummy.ts"]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: ["dummy", "dummy.ts"] }); }); await t.step("invalid args", () => { - const s = stub(service, "load"); - try { - assertThrows(() => invoke(service, "load", []), AssertError); - assertSpyCalls(s, 0); - } finally { - s.restore(); - } + using s = stub(service, "load"); + assertThrows(() => invoke(service, "load", []), AssertError); + assertSpyCalls(s, 0); + }); + }); + + await t.step("calls 'unload'", async (t) => { + await t.step("ok", async () => { + using s = stub(service, "unload"); + await invoke(service, "unload", ["dummy"]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: ["dummy"] }); + }); + + await t.step("invalid args", () => { + using s = stub(service, "unload"); + assertThrows(() => invoke(service, "unload", []), AssertError); + assertSpyCalls(s, 0); }); }); await t.step("calls 'reload'", async (t) => { await t.step("ok", async () => { - const s = stub(service, "reload"); - try { - await invoke(service, "reload", ["dummy"]); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { args: ["dummy"] }); - } finally { - s.restore(); - } + using s = stub(service, "reload"); + await invoke(service, "reload", ["dummy"]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: ["dummy"] }); }); await t.step("invalid args", () => { - const s = stub(service, "reload"); - try { - assertThrows(() => invoke(service, "reload", []), AssertError); - assertSpyCalls(s, 0); - } finally { - s.restore(); - } + using s = stub(service, "reload"); + assertThrows(() => invoke(service, "reload", []), AssertError); + assertSpyCalls(s, 0); + }); + }); + + await t.step("calls 'interrupt'", async (t) => { + await t.step("ok", async () => { + using s = stub(service, "interrupt"); + await invoke(service, "interrupt", []); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: [] }); + }); + + await t.step("ok (with reason)", async () => { + using s = stub(service, "interrupt"); + await invoke(service, "interrupt", ["reason"]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: ["reason"] }); + }); + + await t.step("invalid args", () => { + using s = stub(service, "interrupt"); + assertThrows(() => invoke(service, "interrupt", ["a", "b"]), AssertError); + assertSpyCalls(s, 0); }); }); await t.step("calls 'dispatch'", async (t) => { await t.step("ok", async () => { - const s = stub(service, "dispatch"); - try { - await invoke(service, "dispatch", ["dummy", "fn", ["args"]]); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { args: ["dummy", "fn", ["args"]] }); - } finally { - s.restore(); - } + using s = stub(service, "dispatch"); + await invoke(service, "dispatch", ["dummy", "fn", ["args"]]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: ["dummy", "fn", ["args"]] }); }); await t.step("invalid args", () => { - const s = stub(service, "dispatch"); - try { - assertThrows(() => invoke(service, "dispatch", []), AssertError); - assertSpyCalls(s, 0); - } finally { - s.restore(); - } + using s = stub(service, "dispatch"); + assertThrows(() => invoke(service, "dispatch", []), AssertError); + assertSpyCalls(s, 0); }); }); await t.step("calls 'dispatchAsync'", async (t) => { await t.step("ok", async () => { - const s = stub(service, "dispatchAsync"); - try { - await invoke(service, "dispatchAsync", [ - "dummy", - "fn", - ["args"], - "success", - "failure", - ]); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { - args: ["dummy", "fn", ["args"], "success", "failure"], - }); - } finally { - s.restore(); - } + using s = stub(service, "dispatchAsync"); + await invoke(service, "dispatchAsync", [ + "dummy", + "fn", + ["args"], + "success", + "failure", + ]); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { + args: ["dummy", "fn", ["args"], "success", "failure"], + }); + }); + + await t.step("invalid args", () => { + using s = stub(service, "dispatchAsync"); + assertThrows(() => invoke(service, "dispatchAsync", []), AssertError); + assertSpyCalls(s, 0); + }); + }); + + await t.step("calls 'close'", async (t) => { + await t.step("ok", async () => { + using s = stub(service, "close"); + await invoke(service, "close", []); + assertSpyCalls(s, 1); + assertSpyCall(s, 0, { args: [] }); }); await t.step("invalid args", () => { - const s = stub(service, "dispatchAsync"); - try { - assertThrows(() => invoke(service, "dispatchAsync", []), AssertError); - assertSpyCalls(s, 0); - } finally { - s.restore(); - } + using s = stub(service, "close"); + assertThrows(() => invoke(service, "close", ["foo"]), AssertError); + assertSpyCalls(s, 0); }); }); diff --git a/denops/@denops-private/mod.ts b/denops/@denops-private/mod.ts index 97c2f02e..47a24b3e 100644 --- a/denops/@denops-private/mod.ts +++ b/denops/@denops-private/mod.ts @@ -7,5 +7,5 @@ * * @module */ -export * from "./cli.ts"; -export * from "./worker.ts"; +import "./cli.ts"; +import "./worker.ts"; diff --git a/denops/@denops-private/service.ts b/denops/@denops-private/service.ts index b49e1427..74299f42 100644 --- a/denops/@denops-private/service.ts +++ b/denops/@denops-private/service.ts @@ -1,85 +1,106 @@ -import type { - Denops, - Meta, -} from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { toFileUrl } from "https://deno.land/std@0.217.0/path/mod.ts"; -import { toErrorObject } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; -import { DenopsImpl, Host } from "./denops.ts"; - -// We can use `PromiseWithResolvers` but Deno 1.38 doesn't have `PromiseWithResolvers` -type Waiter = { - promise: Promise; - resolve: () => void; -}; +import type { Denops, Entrypoint, Meta } from "jsr:@denops/core@7.0.0"; +import { toFileUrl } from "jsr:@std/path@1.0.2/to-file-url"; +import { toErrorObject } from "jsr:@lambdalisue/errorutil@1.1.0"; +import { DenopsImpl, type Host } from "./denops.ts"; +import type { CallbackId, Service as HostService } from "./host.ts"; /** * Service manage plugins and is visible from the host (Vim/Neovim) through `invoke()` function. */ -export class Service implements Disposable { - #plugins: Map = new Map(); - #waiters: Map = new Map(); +export class Service implements HostService, AsyncDisposable { + #interruptController = new AbortController(); + #plugins = new Map(); + #waiters = new Map>(); #meta: Meta; #host?: Host; + #closed = false; + #closedWaiter = Promise.withResolvers(); constructor(meta: Meta) { this.#meta = meta; } - #getWaiter(name: string): Waiter { - if (!this.#waiters.has(name)) { - this.#waiters.set(name, Promise.withResolvers()); + #getWaiter(name: string): PromiseWithResolvers { + let waiter = this.#waiters.get(name); + if (!waiter) { + waiter = Promise.withResolvers(); + waiter.promise.catch(() => {}); + this.#waiters.set(name, waiter); } - return this.#waiters.get(name)!; + return waiter; + } + + get interrupted(): AbortSignal { + return this.#interruptController.signal; } bind(host: Host): void { this.#host = host; } - async load( - name: string, - script: string, - suffix = "", - ): Promise { + async load(name: string, script: string): Promise { + if (this.#closed) { + throw new Error("Service closed"); + } if (!this.#host) { throw new Error("No host is bound to the service"); } - let plugin = this.#plugins.get(name); - if (plugin) { + if (this.#plugins.has(name)) { if (this.#meta.mode === "debug") { console.log(`A denops plugin '${name}' is already loaded. Skip`); } return; } const denops = new DenopsImpl(name, this.#meta, this.#host, this); - plugin = new Plugin(denops, name, script); + const plugin = new Plugin(denops, name, script); this.#plugins.set(name, plugin); - await plugin.load(suffix); - this.#getWaiter(name).resolve(); + try { + await plugin.waitLoaded(); + this.#getWaiter(name).resolve(); + } catch { + this.#plugins.delete(name); + } } - reload( - name: string, - ): Promise { + async #unload(name: string): Promise { const plugin = this.#plugins.get(name); if (!plugin) { if (this.#meta.mode === "debug") { console.log(`A denops plugin '${name}' is not loaded yet. Skip`); } - return Promise.resolve(); + return; } + this.#waiters.get(name)?.promise.finally(() => { + this.#waiters.delete(name); + }); + await plugin.unload(); this.#plugins.delete(name); - this.#waiters.delete(name); - // Import module with fragment so that reload works properly - // https://github.com/vim-denops/denops.vim/issues/227 - const suffix = `#${performance.now()}`; - return this.load(name, plugin.script, suffix); + return plugin; + } + + async unload(name: string): Promise { + await this.#unload(name); + } + + async reload(name: string): Promise { + const plugin = await this.#unload(name); + if (plugin) { + await this.load(name, plugin.script); + } } waitLoaded(name: string): Promise { + if (this.#closed) { + return Promise.reject(new Error("Service closed")); + } return this.#getWaiter(name).promise; } + interrupt(reason?: unknown): void { + this.#interruptController.abort(reason); + this.#interruptController = new AbortController(); + } + async #dispatch(name: string, fn: string, args: unknown[]): Promise { const plugin = this.#plugins.get(name); if (!plugin) { @@ -100,8 +121,8 @@ export class Service implements Disposable { name: string, fn: string, args: unknown[], - success: string, // Callback ID - failure: string, // Callback ID + success: CallbackId, + failure: CallbackId, ): Promise { if (!this.#host) { throw new Error("No host is bound to the service"); @@ -125,13 +146,42 @@ export class Service implements Disposable { } } - [Symbol.dispose](): void { - this.#plugins.clear(); + async close(): Promise { + if (!this.#closed) { + this.#closed = true; + const error = new Error("Service closed"); + for (const { reject } of this.#waiters.values()) { + reject(error); + } + this.#waiters.clear(); + await Promise.all( + [...this.#plugins.values()].map((plugin) => plugin.unload()), + ); + this.#plugins.clear(); + this.#host = undefined; + this.#closedWaiter.resolve(); + } + return this.waitClosed(); + } + + waitClosed(): Promise { + return this.#closedWaiter.promise; + } + + [Symbol.asyncDispose](): Promise { + return this.close(); } } +type PluginModule = { + main: Entrypoint; +}; + class Plugin { #denops: Denops; + #loadedWaiter: Promise; + #unloadedWaiter?: Promise; + #disposable: AsyncDisposable = voidAsyncDisposable; readonly name: string; readonly script: string; @@ -140,14 +190,19 @@ class Plugin { this.#denops = denops; this.name = name; this.script = resolveScriptUrl(script); + this.#loadedWaiter = this.#load(); } - async load(suffix = ""): Promise { + waitLoaded(): Promise { + return this.#loadedWaiter; + } + + async #load(): Promise { + const suffix = createScriptSuffix(this.script); + await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`); try { - await emit(this.#denops, `DenopsSystemPluginPre:${this.name}`); - const mod = await import(`${this.script}${suffix}`); - await mod.main(this.#denops); - await emit(this.#denops, `DenopsSystemPluginPost:${this.name}`); + const mod: PluginModule = await import(`${this.script}${suffix}`); + this.#disposable = await mod.main(this.#denops) ?? voidAsyncDisposable; } catch (e) { // Show a warning message when Deno module cache issue is detected // https://github.com/vim-denops/denops.vim/issues/358 @@ -164,7 +219,37 @@ class Plugin { } console.error(`Failed to load plugin '${this.name}': ${e}`); await emit(this.#denops, `DenopsSystemPluginFail:${this.name}`); + throw e; + } + await emit(this.#denops, `DenopsSystemPluginPost:${this.name}`); + } + + unload(): Promise { + if (!this.#unloadedWaiter) { + this.#unloadedWaiter = this.#unload(); + } + return this.#unloadedWaiter; + } + + async #unload(): Promise { + try { + // Wait for the load to complete to make the events atomically. + await this.#loadedWaiter; + } catch { + // Load failed, do nothing + return; + } + const disposable = this.#disposable; + this.#disposable = voidAsyncDisposable; + await emit(this.#denops, `DenopsSystemPluginUnloadPre:${this.name}`); + try { + await disposable[Symbol.asyncDispose](); + } catch (e) { + console.error(`Failed to unload plugin '${this.name}': ${e}`); + await emit(this.#denops, `DenopsSystemPluginUnloadFail:${this.name}`); + return; } + await emit(this.#denops, `DenopsSystemPluginUnloadPost:${this.name}`); } async call(fn: string, ...args: unknown[]): Promise { @@ -179,9 +264,24 @@ class Plugin { } } +const voidAsyncDisposable = { + [Symbol.asyncDispose]: () => Promise.resolve(), +} as const satisfies AsyncDisposable; + +const loadedScripts = new Set(); + +function createScriptSuffix(script: string): string { + // Import module with fragment so that reload works properly + // https://github.com/vim-denops/denops.vim/issues/227 + const suffix = loadedScripts.has(script) ? `#${performance.now()}` : ""; + loadedScripts.add(script); + return suffix; +} + +/** NOTE: `emit()` is never throws or rejects. */ async function emit(denops: Denops, name: string): Promise { try { - await denops.cmd(`doautocmd User ${name}`); + await denops.call("denops#_internal#event#emit", name); } catch (e) { console.error(`Failed to emit ${name}: ${e}`); } diff --git a/denops/@denops-private/service_test.ts b/denops/@denops-private/service_test.ts index 511a28d2..b6b2940f 100644 --- a/denops/@denops-private/service_test.ts +++ b/denops/@denops-private/service_test.ts @@ -1,30 +1,37 @@ import { assert, + assertArrayIncludes, assertEquals, + assertFalse, + assertInstanceOf, assertMatch, + assertNotStrictEquals, assertRejects, -} from "https://deno.land/std@0.217.0/assert/mod.ts"; + assertStrictEquals, + assertThrows, +} from "jsr:@std/assert@1.0.1"; import { assertSpyCall, assertSpyCalls, + resolvesNext, + spy, stub, -} from "https://deno.land/std@0.217.0/testing/mock.ts"; -import type { Meta } from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { promiseState } from "https://deno.land/x/async@v2.1.0/mod.ts"; -import { unimplemented } from "https://deno.land/x/errorutil@v0.1.1/mod.ts"; +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import type { Meta } from "jsr:@denops/core@7.0.0"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { unimplemented } from "jsr:@lambdalisue/errorutil@1.1.0"; import type { Host } from "./denops.ts"; import { Service } from "./service.ts"; +import { toFileUrl } from "jsr:@std/path@1.0.2/to-file-url"; -const scriptValid = - new URL("./testdata/dummy_valid_plugin.ts", import.meta.url).href; -const scriptInvalid = - new URL("./testdata/dummy_invalid_plugin.ts", import.meta.url).href; -const scriptInvalidConstraint = - new URL("./testdata/dummy_invalid_constraint_plugin.ts", import.meta.url) - .href; -const scriptInvalidConstraint2 = - new URL("./testdata/dummy_invalid_constraint_plugin2.ts", import.meta.url) - .href; +const NOOP = () => {}; + +const scriptValid = resolve("dummy_valid_plugin.ts"); +const scriptInvalid = resolve("dummy_invalid_plugin.ts"); +const scriptValidDispose = resolve("dummy_valid_dispose_plugin.ts"); +const scriptInvalidDispose = resolve("dummy_invalid_dispose_plugin.ts"); +const scriptInvalidConstraint = resolve("dummy_invalid_constraint_plugin.ts"); +const scriptInvalidConstraint2 = resolve("dummy_invalid_constraint_plugin2.ts"); Deno.test("Service", async (t) => { const meta: Meta = { @@ -38,336 +45,1282 @@ Deno.test("Service", async (t) => { call: () => unimplemented(), batch: () => unimplemented(), }; - const service = new Service(meta); - await t.step("load() rejects an error when no host is bound", async () => { - await assertRejects( - () => service.load("dummy", scriptValid), - Error, - "No host is bound to the service", + await t.step("new Service()", async (t) => { + await t.step("creates an instance", () => { + const actual = new Service(meta); + + assertInstanceOf(actual, Service); + }); + }); + + await t.step(".bind()", async (t) => { + await t.step("binds the host", () => { + const service = new Service(meta); + + service.bind(host); + }); + }); + + await t.step(".load()", async (t) => { + await t.step("if no host is bound", async (t) => { + const service = new Service(meta); + + await t.step("rejects", async () => { + await assertRejects( + () => service.load("dummy", scriptValid), + Error, + "No host is bound to the service", + ); + }); + }); + + await t.step("if the service is already closed", async (t) => { + const service = new Service(meta); + service.bind(host); + await service.close(); + using host_call = stub(host, "call"); + + await t.step("rejects", async () => { + await assertRejects( + () => service.load("dummy", scriptValid), + Error, + "Service closed", + ); + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); + + await t.step("if the plugin is valid", async (t) => { + const service = new Service(meta); + service.bind(host); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptValid); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("calls the plugin entrypoint", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#api#cmd", + "echo 'Hello, Denops!'", + {}, + ], + }); + }); + + await t.step("emits DenopsSystemPluginPost", () => { + assertSpyCall(host_call, 2, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + }); + }); + }); + + await t.step("if the plugin entrypoint throws", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_error = stub(console, "error"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptInvalid); + }); + + await t.step("outputs an error message", () => { + assertSpyCall(console_error, 0, { + args: [ + "Failed to load plugin 'dummy': Error: This is dummy error", + ], + }); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginFail", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginFail:dummy", + ], + }); + }); + }); + + await t.step("if the plugin constraints could not find", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_error = stub(console, "error"); + using console_warn = stub(console, "warn"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptInvalidConstraint); + }); + + await t.step("outputs an error message", () => { + assertMatch( + console_error.calls[0].args[0], + /^Failed to load plugin 'dummy': TypeError: Could not find constraint in the list of versions:/, + ); + }); + + await t.step("outputs warning messages", () => { + assertEquals( + console_warn.calls.flatMap((c) => c.args), + [ + "********************************************************************************", + "Deno module cache issue is detected.", + "Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.", + "See https://github.com/vim-denops/denops.vim/issues/358 for more detail.", + "********************************************************************************", + ], + ); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginFail", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginFail:dummy", + ], + }); + }); + }); + + await t.step( + "if the plugin constraint versions could not find", + async (t) => { + const service = new Service(meta); + service.bind(host); + using console_error = stub(console, "error"); + using console_warn = stub(console, "warn"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptInvalidConstraint2); + }); + + await t.step("outputs an error message", () => { + assertMatch( + console_error.calls[0].args[0], + /^Failed to load plugin 'dummy': TypeError: Could not find version of /, + ); + }); + + await t.step("outputs warning messages", () => { + assertEquals( + console_warn.calls.flatMap((c) => c.args), + [ + "********************************************************************************", + "Deno module cache issue is detected.", + "Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.", + "See https://github.com/vim-denops/denops.vim/issues/358 for more detail.", + "********************************************************************************", + ], + ); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginFail", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginFail:dummy", + ], + }); + }); + }, ); + + await t.step("if the plugin is already loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptValid); + }); + + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is already loaded. Skip", + ], + }); + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); + + await t.step("if the plugin is already unloaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + await service.unload("dummy"); + } + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.load("dummy", scriptValid); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("calls the plugin entrypoint", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#api#cmd", + "echo 'Hello, Denops!'", + {}, + ], + }); + }); + + await t.step("emits DenopsSystemPluginPost", () => { + assertSpyCall(host_call, 2, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + }); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); + + const prevLoadPromise = service.load("dummy", scriptValid); + const loadPromise = service.load("dummy", scriptInvalid); + + await t.step("resolves", async () => { + await loadPromise; + }); + + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is already loaded. Skip", + ], + }); + }); + + await t.step("previous `load()` resolves", async () => { + await prevLoadPromise; + }); + + await t.step("emits `load()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `load()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ + "denops#api#cmd", + "echo 'Hello, Denops!'", + {}, + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + ]); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); + + const prevUnloadPromise = service.unload("dummy"); + const loadPromise = service.load("dummy", scriptInvalid); + + await t.step("resolves", async () => { + await loadPromise; + }); + + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is already loaded. Skip", + ], + }); + }); + + await t.step("previous `unload()` resolves", async () => { + await prevUnloadPromise; + }); + + await t.step("emits `unload()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `unload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + ]); + }); + }); }); - await t.step( - "dispatchAsync() rejects when no host is bound", - async () => { - await assertRejects( - () => - service.dispatchAsync( - "dummy", - "test", - ["foo"], - "success", - "failure", - ), - Error, - "No host is bound to the service", - ); - }, - ); + await t.step(".unload()", async (t) => { + await t.step("if the plugin returns void", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("emits DenopsSystemPluginUnloadPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginUnloadPost", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + }); + }); + }); + + await t.step("if the plugin returns AsyncDisposable", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValidDispose); + } + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("emits DenopsSystemPluginUnloadPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + }); + }); + + await t.step("calls the plugin dispose method", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#api#cmd", + "echo 'Goodbye, Denops!'", + {}, + ], + }); + }); + + await t.step("emits DenopsSystemPluginUnloadPost", () => { + assertSpyCall(host_call, 2, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + }); + }); + }); + + await t.step("if the plugin dispose method throws", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptInvalidDispose); + } + using console_error = stub(console, "error"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("outputs an error message", () => { + assertSpyCall(console_error, 0, { + args: [ + "Failed to unload plugin 'dummy': Error: This is dummy error in async dispose", + ], + }); + }); + + await t.step("emits DenopsSystemPluginUnloadPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginUnloadFail", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadFail:dummy", + ], + }); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is not loaded yet. Skip", + ], + }); + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); + + await t.step("if the plugin is already unloaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + await service.unload("dummy"); + } + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is not loaded yet. Skip", + ], + }); + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + const service = new Service(meta); + service.bind(host); + using host_call = stub(host, "call"); + + const prevLoadPromise = service.load("dummy", scriptValid); + const unloadPromise = service.unload("dummy"); + + await t.step("resolves", async () => { + await unloadPromise; + }); + + await t.step("previous `load()` was resolved", async () => { + assertEquals(await promiseState(prevLoadPromise), "fulfilled"); + }); + + await t.step("emits `load()` and `unload()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `load()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ + "denops#api#cmd", + "echo 'Hello, Denops!'", + {}, + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + // This `unload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + ]); + }); + }); + + await t.step("if the plugin is loading and failur", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_error = stub(console, "error"); + using host_call = stub(host, "call"); + + const prevLoadPromise = service.load("dummy", scriptInvalid); + const unloadPromise = service.unload("dummy"); + + await t.step("resolves", async () => { + await unloadPromise; + }); + + await t.step("previous `load()` was resolved", async () => { + assertEquals(await promiseState(prevLoadPromise), "fulfilled"); + }); + + await t.step("outputs an error message", () => { + assertSpyCall(console_error, 0, { + args: [ + "Failed to load plugin 'dummy': Error: This is dummy error", + ], + }); + }); + + await t.step("emits `load()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `load()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginFail:dummy", + ], + ]); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub(host, "call"); + + const prevUnloadPromise = service.unload("dummy"); + const unloadPromise = service.unload("dummy"); + + await t.step("resolves", async () => { + await unloadPromise; + }); + + await t.step("previous `unload()` was resolved", async () => { + assertEquals(await promiseState(prevUnloadPromise), "fulfilled"); + }); + + await t.step("emits `unload()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `unload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + ]); + }); + }); + + await t.step("if `host.call()` rejects (channel closed)", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using console_error = stub(console, "error"); + using _host_call = stub( + host, + "call", + () => Promise.reject(new Error("channel closed")), + ); + + await t.step("resolves", async () => { + await service.unload("dummy"); + }); + + await t.step("outputs error messages", () => { + assertEquals(console_error.calls.map((c) => c.args), [ + [ + "Failed to emit DenopsSystemPluginUnloadPre:dummy: Error: channel closed", + ], + [ + "Failed to emit DenopsSystemPluginUnloadPost:dummy: Error: channel closed", + ], + ]); + }); + }); + }); + + await t.step(".reload()", async (t) => { + await t.step("if the plugin is already loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.reload("dummy"); + }); + + await t.step("emits DenopsSystemPluginUnloadPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginUnloadPost", () => { + assertSpyCall(host_call, 1, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + }); + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 2, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + }); + }); + + await t.step("calls the plugin entrypoint", () => { + assertSpyCall(host_call, 3, { + args: [ + "denops#api#cmd", + "echo 'Hello, Denops!'", + {}, + ], + }); + }); + + await t.step("emits DenopsSystemPluginPost", () => { + assertSpyCall(host_call, 4, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + }); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); - service.bind(host); + await t.step("resolves", async () => { + await service.reload("dummy"); + }); - const waitLoaded = service.waitLoaded("dummy"); + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { + args: [ + "A denops plugin 'dummy' is not loaded yet. Skip", + ], + }); + }); - await t.step( - "the result promise of waitLoaded() is 'pending' when the plugin is not loaded yet", - async () => { - assertEquals(await promiseState(waitLoaded), "pending"); - }, - ); + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); - await t.step("load() loads plugin and emits autocmd events", async () => { - const s = stub(host, "call"); - try { - await service.load("dummy", scriptValid); - assertSpyCalls(s, 3); - assertSpyCall(s, 0, { - args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginPre:dummy", - {}, - ], - }); - assertSpyCall(s, 1, { - args: [ - "denops#api#cmd", - "echo 'Hello, Denops!'", - {}, - ], - }); - assertSpyCall(s, 2, { - args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginPost:dummy", - {}, - ], - }); - } finally { - s.restore(); - } - }); + await t.step("if the plugin is already unloaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + await service.unload("dummy"); + } + using console_log = stub(console, "log"); + using host_call = stub(host, "call"); - await t.step( - "the result promise of waitLoaded() become 'fulfilled' when the plugin is loaded", - async () => { - assertEquals(await promiseState(waitLoaded), "fulfilled"); - }, - ); + await t.step("resolves", async () => { + await service.reload("dummy"); + }); - await t.step( - "load() loads plugin and emits autocmd events (failure)", - async () => { - const c = stub(console, "error"); - const s = stub(host, "call"); - try { - await service.load("dummyFail", scriptInvalid); - assertSpyCalls(c, 1); - assertSpyCall(c, 0, { + await t.step("outputs a log message", () => { + assertSpyCall(console_log, 0, { args: [ - "Failed to load plugin 'dummyFail': Error: This is dummy error", + "A denops plugin 'dummy' is not loaded yet. Skip", ], }); - assertSpyCalls(s, 2); - assertSpyCall(s, 0, { - args: [ + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + const service = new Service(meta); + service.bind(host); + using host_call = stub(host, "call"); + + const prevLoadPromise = service.load("dummy", scriptValid); + const reloadPromise = service.reload("dummy"); + + await t.step("resolves", async () => { + await reloadPromise; + }); + + await t.step("previous `load()` was resolved", async () => { + assertEquals(await promiseState(prevLoadPromise), "fulfilled"); + }); + + await t.step("emits `load()` and `reload()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `load()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ "denops#api#cmd", - "doautocmd User DenopsSystemPluginPre:dummyFail", + "echo 'Hello, Denops!'", {}, ], - }); - assertSpyCall(s, 1, { - args: [ + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + // This `reload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ "denops#api#cmd", - "doautocmd User DenopsSystemPluginFail:dummyFail", + "echo 'Hello, Denops!'", {}, ], - }); - } finally { - s.restore(); - c.restore(); + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + ]); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); } - }, - ); - - await t.step( - "load() loads plugin and emits autocmd events (could not find constraint)", - async () => { - const c = stub(console, "warn"); - const s = stub(host, "call"); - try { - await service.load("dummyFailConstraint", scriptInvalidConstraint); - const expects = [ - "********************************************************************************", - "Deno module cache issue is detected.", - "Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.", - "See https://github.com/vim-denops/denops.vim/issues/358 for more detail.", - "********************************************************************************", - ]; - assertSpyCalls(c, expects.length); - for (let i = 0; i < expects.length; i++) { - assertSpyCall(c, i, { - args: [expects[i]], - }); - } - assertSpyCalls(s, 2); - assertSpyCall(s, 0, { - args: [ + using host_call = stub(host, "call"); + + const prevUnloadPromise = service.unload("dummy"); + const reloadPromise = service.reload("dummy"); + + await t.step("resolves", async () => { + await reloadPromise; + }); + + await t.step("previous `unload()` was resolved", async () => { + assertEquals(await promiseState(prevUnloadPromise), "fulfilled"); + }); + + await t.step("emits `reload()` events", () => { + const events = host_call.calls.map((c) => c.args); + assertEquals(events, [ + // Previous `unload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + // This `reload()` events. + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", + ], + [ "denops#api#cmd", - "doautocmd User DenopsSystemPluginPre:dummyFailConstraint", + "echo 'Hello, Denops!'", {}, ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", + ], + ]); + }); + }); + + await t.step("if the plugin file is changed", async (t) => { + // Generate source script file. + await using tempFile = await useTempFile({ + // NOTE: Temporary script files should be ignored from coverage. + prefix: "test-denops-service-", + suffix: "_test.ts", + }); + const scriptRewrite = toFileUrl(tempFile.path).href; + const sourceOriginal = await Deno.readTextFile(new URL(scriptValid)); + await Deno.writeTextFile(new URL(scriptRewrite), sourceOriginal); + + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptRewrite); + } + using host_call = stub(host, "call"); + + // Change source script file. + const sourceRewrited = sourceOriginal.replaceAll( + "Hello, Denops!", + "Source Changed!", + ); + await Deno.writeTextFile(new URL(scriptRewrite), sourceRewrited); + + await t.step("resolves", async () => { + await service.reload("dummy"); + }); + + await t.step("emits DenopsSystemPluginUnloadPre", () => { + assertSpyCall(host_call, 0, { + args: [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], }); - assertSpyCall(s, 1, { + }); + + await t.step("emits DenopsSystemPluginUnloadPost", () => { + assertSpyCall(host_call, 1, { args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginFail:dummyFailConstraint", - {}, + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", ], }); - } finally { - s.restore(); - c.restore(); - } - }, - ); - - await t.step( - "load() loads plugin and emits autocmd events (could not find version)", - async () => { - const c = stub(console, "warn"); - const s = stub(host, "call"); - try { - await service.load("dummyFailConstraint2", scriptInvalidConstraint2); - const expects = [ - "********************************************************************************", - "Deno module cache issue is detected.", - "Execute 'call denops#cache#update(#{reload: v:true})' and restart Vim/Neovim.", - "See https://github.com/vim-denops/denops.vim/issues/358 for more detail.", - "********************************************************************************", - ]; - assertSpyCalls(c, expects.length); - for (let i = 0; i < expects.length; i++) { - assertSpyCall(c, i, { - args: [expects[i]], - }); - } - assertSpyCalls(s, 2); - assertSpyCall(s, 0, { + }); + + await t.step("emits DenopsSystemPluginPre", () => { + assertSpyCall(host_call, 2, { args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginPre:dummyFailConstraint2", - {}, + "denops#_internal#event#emit", + "DenopsSystemPluginPre:dummy", ], }); - assertSpyCall(s, 1, { + }); + + await t.step("calls the plugin entrypoint", () => { + assertSpyCall(host_call, 3, { args: [ "denops#api#cmd", - "doautocmd User DenopsSystemPluginFail:dummyFailConstraint2", + "echo 'Source Changed!'", {}, ], }); - } finally { - s.restore(); - c.restore(); - } - }, - ); - - await t.step( - "load() does nothing when the plugin is already loaded", - async () => { - const s1 = stub(host, "call"); - const s2 = stub(console, "log"); - try { - await service.load("dummy", scriptValid); - assertSpyCalls(s1, 0); - assertSpyCalls(s2, 1); - assertSpyCall(s2, 0, { + }); + + await t.step("emits DenopsSystemPluginPost", () => { + assertSpyCall(host_call, 4, { args: [ - "A denops plugin 'dummy' is already loaded. Skip", + "denops#_internal#event#emit", + "DenopsSystemPluginPost:dummy", ], }); - } finally { - s1.restore(); - s2.restore(); - } - }, - ); - - await t.step("reload() reloads plugin and emits autocmd events", async () => { - const s = stub(host, "call"); - try { - await service.reload("dummy"); - assertSpyCalls(s, 3); - assertSpyCall(s, 0, { - args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginPre:dummy", - {}, - ], - }); - assertSpyCall(s, 1, { - args: [ - "denops#api#cmd", - "echo 'Hello, Denops!'", - {}, - ], - }); - assertSpyCall(s, 2, { - args: [ - "denops#api#cmd", - "doautocmd User DenopsSystemPluginPost:dummy", - {}, - ], - }); - } finally { - s.restore(); - } + }); + }); + }); + + await t.step(".waitLoaded()", async (t) => { + await t.step("pendings if the plugin is not yet loaded", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + + const actual = service.waitLoaded("dummy"); + + assertEquals(await promiseState(actual), "pending"); + }); + + await t.step("pendings if the plugin is already unloaded", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + await service.unload("dummy"); + + const actual = service.waitLoaded("dummy"); + + assertEquals(await promiseState(actual), "pending"); + }); + + await t.step("resolves if the plugin is already loaded", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + + const actual = service.waitLoaded("dummy"); + + assertEquals(await promiseState(actual), "fulfilled"); + }); + + await t.step("resolves when the plugin is loaded", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + + const actual = service.waitLoaded("dummy"); + await service.load("dummy", scriptValid); + + assertEquals(await promiseState(actual), "fulfilled"); + }); + + await t.step( + "resolves if it is called between `load()` and `unload()`", + async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + + const loadPromise = service.load("dummy", scriptValid); + const actual = service.waitLoaded("dummy"); + const unloadPromise = service.unload("dummy"); + await Promise.all([loadPromise, unloadPromise]); + + assertEquals(await promiseState(actual), "fulfilled"); + }, + ); + + await t.step("rejects if the service is already closed", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + await service.close(); + + const actual = service.waitLoaded("dummy"); + actual.catch(NOOP); + + assertEquals(await promiseState(actual), "rejected"); + await assertRejects( + () => actual, + Error, + "Service closed", + ); + }); + + await t.step("rejects when the service is closed", async () => { + const service = new Service(meta); + service.bind(host); + using _host_call = stub(host, "call"); + + const actual = service.waitLoaded("dummy"); + await service.close(); + + assertEquals(await promiseState(actual), "rejected"); + await assertRejects( + () => actual, + Error, + "Service closed", + ); + }); + }); + + await t.step(".interrupt()", async (t) => { + await t.step("sends signal to `interrupted` attribute", () => { + const service = new Service(meta); + const signal = service.interrupted; + + service.interrupt(); + + assertThrows(() => signal.throwIfAborted()); + }); + + await t.step("sends signal to `interrupted` attribute with reason", () => { + const service = new Service(meta); + const signal = service.interrupted; + + service.interrupt("test"); + + assertThrows(() => signal.throwIfAborted(), "test"); + }); + }); + + await t.step(".interrupted property", async (t) => { + await t.step("does not aborted before .interrupt() is called", () => { + const service = new Service(meta); + + assertFalse(service.interrupted.aborted); + }); + + await t.step("does not aborted after .interrupt() is called", () => { + const service = new Service(meta); + service.interrupt(); + + assertFalse(service.interrupted.aborted); + }); + + await t.step("aborts when .interrupt() is called", () => { + const service = new Service(meta); + const signal = service.interrupted; + + service.interrupt(); + + assert(signal.aborted); + }); + + await t.step("returns same instance if .interrupt() is not called", () => { + const service = new Service(meta); + + const a = service.interrupted; + const b = service.interrupted; + + assertStrictEquals(a, b); + }); + + await t.step("returns new instance after .interrupt() is called", () => { + const service = new Service(meta); + + const a = service.interrupted; + service.interrupt(); + const b = service.interrupted; + + assertNotStrictEquals(a, b); + }); }); - await t.step( - "reload() does nothing when the plugin is not loaded yet", - async () => { - const s1 = stub(host, "call"); - const s2 = stub(console, "log"); - try { - await service.reload("pluginthatisnotloaded"); - assertSpyCalls(s1, 0); - assertSpyCalls(s2, 1); - assertSpyCall(s2, 0, { + await t.step(".dispatch()", async (t) => { + await t.step("if the plugin is already loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.dispatch("dummy", "test", ["foo"]); + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ - "A denops plugin 'pluginthatisnotloaded' is not loaded yet. Skip", + "denops#api#cmd", + `echo 'This is test call: ["foo"]'`, + {}, ], }); - } finally { - s1.restore(); - s2.restore(); - } - }, - ); - - await t.step("dispatch() call API of a plugin", async () => { - const s = stub(host, "call"); - try { - await service.dispatch("dummy", "test", ["foo"]); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { - args: [ - "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", - {}, - ], - }); - } finally { - s.restore(); - } - }); + }); + }); - await t.step( - "dispatch() rejects when the plugin is not loaded yet", - async () => { - const err = await assertRejects( - () => service.dispatch("pluginthatisnotloaded", "test", ["foo"]), - ); - assert(typeof err === "string"); - assertMatch(err, /No plugin 'pluginthatisnotloaded' is loaded/); - }, - ); + await t.step("if the plugin is not yet loaded", async (t) => { + const service = new Service(meta); + service.bind(host); + using host_call = stub(host, "call"); + + await t.step("rejects with string", async () => { + const err = await assertRejects( + () => service.dispatch("dummy", "test", ["foo"]), + ); + assert(typeof err === "string"); + assertMatch(err, /No plugin 'dummy' is loaded/); + }); + + await t.step("does not calls the host", () => { + assertSpyCalls(host_call, 0); + }); + }); - await t.step( - "dispatch() rejects when failed to call plugin API", - async () => { - const s = stub( + await t.step("if the plugin API call fails", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub( host, "call", - () => Promise.reject(new Error("invalid call")), + resolvesNext([ + // The plugin API call + new Error("invalid call"), + ]), ); - try { + + await t.step("rejects with string", async () => { const err = await assertRejects( () => service.dispatch("dummy", "test", ["foo"]), ); - assertSpyCalls(s, 1); - assertSpyCall(s, 0, { + assert(typeof err === "string"); + assertMatch(err, /invalid call/); + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", + `echo 'This is test call: ["foo"]'`, {}, ], }); - assert(typeof err === "string"); - assertMatch(err, /Failed to call 'test' API in 'dummy': invalid call/); - } finally { - s.restore(); + }); + }); + }); + + await t.step(".dispatchAsync()", async (t) => { + await t.step("if no host is bound", async (t) => { + const service = new Service(meta); + + await t.step("rejects", async () => { + await assertRejects( + () => + service.dispatchAsync( + "dummy", + "test", + ["foo"], + "success", + "failure", + ), + Error, + "No host is bound to the service", + ); + }); + }); + + await t.step("if the plugin API calls succeeded", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); } - }, - ); + using host_call = stub(host, "call"); - await t.step( - "dispatchAsync() call success callback when API call is succeeded", - async () => { - const s = stub(host, "call"); - try { + await t.step("resolves", async () => { await service.dispatchAsync( "dummy", "test", @@ -375,39 +1328,48 @@ Deno.test("Service", async (t) => { "success", "failure", ); - assertSpyCalls(s, 2); - assertSpyCall(s, 0, { + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", + `echo 'This is test call: ["foo"]'`, {}, ], }); - assertSpyCall(s, 1, { + }); + + await t.step("calls 'success' callback", () => { + assertSpyCall(host_call, 1, { args: [ "denops#callback#call", "success", undefined, ], }); - } finally { - s.restore(); - } - }, - ); + }); + }); - await t.step( - "dispatchAsync() call failure callback when API call is failed", - async () => { - const s = stub( + await t.step("if the plugin API calls failed", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using host_call = stub( host, "call", - (method) => - method === "denops#api#cmd" - ? Promise.reject(new Error("invalid call")) - : Promise.resolve(), + resolvesNext([ + // The plugin API call + new Error("invalid call"), + // 'success' callback call + undefined, + ]), ); - try { + + await t.step("resolves", async () => { await service.dispatchAsync( "dummy", "test", @@ -415,40 +1377,49 @@ Deno.test("Service", async (t) => { "success", "failure", ); - assertSpyCalls(s, 2); - assertSpyCall(s, 0, { + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", + `echo 'This is test call: ["foo"]'`, {}, ], }); - assertSpyCall(s, 1, { + }); + + await t.step("calls 'failure' callback", () => { + assertSpyCall(host_call, 1, { args: [ "denops#callback#call", "failure", - s.calls[1].args[2], + host_call.calls[1].args[2], ], }); - } finally { - s.restore(); - } - }, - ); + }); + }); - await t.step( - "dispatchAsync() call success callback when API call is succeeded (but fail)", - async () => { - const s1 = stub( + await t.step("if 'success' callback calls failed", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using console_error = stub(console, "error"); + using host_call = stub( host, "call", - (method) => - method !== "denops#api#cmd" - ? Promise.reject(new Error("invalid call")) - : Promise.resolve(), + resolvesNext([ + // The plugin API call + undefined, + // 'success' callback call + new Error("invalid call"), + ]), ); - const s2 = stub(console, "error"); - try { + + await t.step("resolves", async () => { await service.dispatchAsync( "dummy", "test", @@ -456,44 +1427,57 @@ Deno.test("Service", async (t) => { "success", "failure", ); - assertSpyCalls(s1, 2); - assertSpyCall(s1, 0, { + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", + `echo 'This is test call: ["foo"]'`, {}, ], }); - assertSpyCall(s1, 1, { + }); + + await t.step("calls 'success' callback", () => { + assertSpyCall(host_call, 1, { args: [ "denops#callback#call", "success", undefined, ], }); - assertSpyCalls(s2, 1); - assertSpyCall(s2, 0, { + }); + + await t.step("outputs an error message", () => { + assertSpyCall(console_error, 0, { args: [ "Failed to call success callback 'success': Error: invalid call", ], }); - } finally { - s1.restore(); - s2.restore(); - } - }, - ); + }); + }); - await t.step( - "dispatchAsync() call failure callback when API call is failed (but fail)", - async () => { - const s1 = stub( + await t.step("if 'failure' callback calls failed", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + } + using console_error = stub(console, "error"); + using host_call = stub( host, "call", - () => Promise.reject(new Error("invalid call")), + resolvesNext([ + // The plugin API call + new Error("invalid call"), + // 'failure' callback call + new Error("invalid call"), + ]), ); - const s2 = stub(console, "error"); - try { + + await t.step("resolves", async () => { await service.dispatchAsync( "dummy", "test", @@ -501,31 +1485,153 @@ Deno.test("Service", async (t) => { "success", "failure", ); - assertSpyCalls(s1, 2); - assertSpyCall(s1, 0, { + }); + + await t.step("calls the plugin API", () => { + assertSpyCall(host_call, 0, { args: [ "denops#api#cmd", - "echo 'This is test call: [\"foo\"]'", + `echo 'This is test call: ["foo"]'`, {}, ], }); - assertSpyCall(s1, 1, { + }); + + await t.step("calls 'failure' callback", () => { + assertSpyCall(host_call, 1, { args: [ "denops#callback#call", "failure", - s1.calls[1].args[2], + host_call.calls[1].args[2], ], }); - assertSpyCalls(s2, 1); - assertSpyCall(s2, 0, { + }); + + await t.step("outputs an error message", () => { + assertSpyCall(console_error, 0, { args: [ "Failed to call failure callback 'failure': Error: invalid call", ], }); - } finally { - s1.restore(); - s2.restore(); + }); + }); + }); + + await t.step(".close()", async (t) => { + await t.step("if the service is not yet closed", async (t) => { + const service = new Service(meta); + service.bind(host); + { + using _host_call = stub(host, "call"); + await service.load("dummy", scriptValid); + await service.load("dummyDispose", scriptValidDispose); } - }, - ); + using host_call = stub(host, "call"); + + await t.step("resolves", async () => { + await service.close(); + }); + + await t.step("unloads loaded plugins", () => { + assertArrayIncludes(host_call.calls.map((c) => c.args), [ + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPre:dummyDispose", + ], + [ + "denops#api#cmd", + "echo 'Goodbye, Denops!'", + {}, + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummy", + ], + [ + "denops#_internal#event#emit", + "DenopsSystemPluginUnloadPost:dummyDispose", + ], + ]); + }); + }); + + await t.step("if the service is already closed", async (t) => { + const service = new Service(meta); + using _host_call = stub(host, "call"); + service.bind(host); + await service.close(); + + await t.step("resolves", async () => { + await service.close(); + }); + }); + }); + + await t.step(".waitClosed()", async (t) => { + await t.step("pendings if the service is not yet closed", async () => { + using _host_call = stub(host, "call"); + const service = new Service(meta); + service.bind(host); + + const actual = service.waitClosed(); + + assertEquals(await promiseState(actual), "pending"); + }); + + await t.step("resolves if the service is already closed", async () => { + using _host_call = stub(host, "call"); + const service = new Service(meta); + service.bind(host); + service.close(); + + const actual = service.waitClosed(); + + assertEquals(await promiseState(actual), "fulfilled"); + }); + + await t.step("resolves when the service is closed", async () => { + using _host_call = stub(host, "call"); + const service = new Service(meta); + service.bind(host); + + const actual = service.waitClosed(); + service.close(); + + assertEquals(await promiseState(actual), "fulfilled"); + }); + }); + + await t.step("[@@asyncDispose]()", async (t) => { + const service = new Service(meta); + using _host_call = stub(host, "call"); + service.bind(host); + using service_close = spy(service, "close"); + + await t.step("resolves", async () => { + await service[Symbol.asyncDispose](); + }); + + await t.step("calls .close()", () => { + assertSpyCalls(service_close, 1); + }); + }); }); + +/** Resolve testdata script URL. */ +function resolve(path: string): string { + return new URL(`../../tests/denops/testdata/${path}`, import.meta.url).href; +} + +async function useTempFile(options?: Deno.MakeTempOptions) { + const path = await Deno.makeTempFile(options); + return { + path, + async [Symbol.asyncDispose]() { + await Deno.remove(path, { recursive: true }); + }, + }; +} diff --git a/denops/@denops-private/testutil/with.ts b/denops/@denops-private/testutil/with.ts deleted file mode 100644 index dc9c5e3e..00000000 --- a/denops/@denops-private/testutil/with.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ADDR_ENV_NAME } from "./cli.ts"; -import { getConfig } from "./conf.ts"; - -const script = new URL("./cli.ts", import.meta.url); - -export type Fn = ( - reader: ReadableStream, - writer: WritableStream, -) => Promise; - -export type WithOptions = { - fn: Fn; - prelude?: string[]; - postlude?: string[]; - env?: Record; -}; - -export function withVim( - options: WithOptions, -): Promise { - const conf = getConfig(); - const exec = Deno.execPath(); - const commands = [ - ...(options.prelude ?? []), - `set runtimepath^=${conf.denopsPath}`, - `let g:denops_test_channel = job_start(['${exec}', 'run', '--allow-all', '${script}'], {'mode': 'json', 'err_mode': 'nl'})`, - ...(options.postlude ?? []), - ]; - const cmd = conf.vimExecutable; - const args = [ - "-u", - "NONE", // Disable vimrc, plugins, defaults.vim - "-i", - "NONE", // Disable viminfo - "-n", // Disable swap file - "-N", // Disable compatible mode - "-X", // Disable xterm - "-e", // Start Vim in Ex mode - "-s", // Silent or batch mode - ...commands.flatMap((c) => ["-c", c]), - ]; - return withProcess(cmd, args, conf.verbose, options); -} - -export function withNeovim( - options: WithOptions, -): Promise { - const conf = getConfig(); - const exec = Deno.execPath(); - const commands = [ - ...(options.prelude ?? []), - `set runtimepath^=${conf.denopsPath}`, - `let g:denops_test_channel = jobstart(['${exec}', 'run', '--allow-all', '${script}'], {'rpc': v:true})`, - ...(options.postlude ?? []), - ]; - const cmd = conf.nvimExecutable; - const args = [ - "--clean", - "--embed", - "--headless", - "-n", - ...commands.flatMap((c) => ["-c", c]), - ]; - return withProcess(cmd, args, conf.verbose, options); -} - -async function withProcess( - cmd: string, - args: string[], - verbose: boolean, - { fn, env }: WithOptions, -): Promise { - const listener = Deno.listen({ - hostname: "127.0.0.1", - port: 0, // Automatically select free port - }); - if (verbose) { - args.unshift("--cmd", "redir >> /dev/stdout"); - } - const command = new Deno.Command(cmd, { - args, - stdin: "piped", - stdout: verbose ? "inherit" : "null", - stderr: verbose ? "inherit" : "null", - env: { - ...env, - [ADDR_ENV_NAME]: JSON.stringify(listener.addr), - }, - }); - const proc = command.spawn(); - const conn = await listener.accept(); - try { - return await fn(conn.readable, conn.writable); - } finally { - listener.close(); - proc.kill(); - await Promise.all([ - proc.stdin?.close(), - proc.output(), - ]); - } -} diff --git a/denops/@denops-private/util.ts b/denops/@denops-private/util.ts index a828549f..ecbaa6d1 100644 --- a/denops/@denops-private/util.ts +++ b/denops/@denops-private/util.ts @@ -1,5 +1,5 @@ -import type { Meta } from "https://deno.land/x/denops_core@v6.0.5/mod.ts"; -import { is, Predicate } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; +import type { Meta } from "jsr:@denops/core@7.0.0"; +import { is, type Predicate } from "jsr:@core/unknownutil@3.18.1"; export const isMeta: Predicate = is.ObjectOf({ mode: is.LiteralOneOf(["release", "debug", "test"] as const), diff --git a/denops/@denops-private/version.ts b/denops/@denops-private/version.ts index e5b727f3..046ff221 100644 --- a/denops/@denops-private/version.ts +++ b/denops/@denops-private/version.ts @@ -1,8 +1,7 @@ -import { - dirname, - fromFileUrl, -} from "https://deno.land/std@0.217.0/path/mod.ts"; -import { parse, SemVer } from "https://deno.land/std@0.217.0/semver/mod.ts"; +import { dirname } from "jsr:@std/path@1.0.2/dirname"; +import { fromFileUrl } from "jsr:@std/path@1.0.2/from-file-url"; +import type { SemVer } from "jsr:@std/semver@0.224.3/types"; +import { parse } from "jsr:@std/semver@0.224.3/parse"; const decoder = new TextDecoder(); diff --git a/denops/@denops-private/version_test.ts b/denops/@denops-private/version_test.ts new file mode 100644 index 00000000..3fef50bb --- /dev/null +++ b/denops/@denops-private/version_test.ts @@ -0,0 +1,90 @@ +import { assert, assertEquals } from "jsr:@std/assert@1.0.1"; +import { resolvesNext, stub } from "jsr:@std/testing@1.0.0-rc.5/mock"; +import type { SemVer } from "jsr:@std/semver@0.224.3/types"; +import { is, type Predicate } from "jsr:@core/unknownutil@3.18.1"; +import { getVersionOr } from "./version.ts"; + +Deno.test("getVersionOr()", async (t) => { + const HAS_TAG = (await isInsideWorkTree()) && (await isWorkTreeHasTags()); + + await t.step({ + name: "resolves a SemVer from git tags", + ignore: !HAS_TAG, + fn: async () => { + const actual = await getVersionOr({}); + assert(isSemVer(actual)); + }, + }); + + await t.step("if git command fails", async (t) => { + await t.step("resolves with `fallback`", async () => { + using _deno_command_output = stub( + Deno.Command.prototype, + "output", + resolvesNext([{ + success: false, + code: 1, + signal: null, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }]), + ); + const actual = await getVersionOr({ foo: "fallback value" }); + assertEquals(actual, { foo: "fallback value" }); + }); + }); + + await t.step("if git command outputs invalid value", async (t) => { + await t.step("resolves with `fallback`", async () => { + using _deno_command_output = stub( + Deno.Command.prototype, + "output", + resolvesNext([{ + success: true, + code: 0, + signal: null, + stdout: new TextEncoder().encode("invalid value"), + stderr: new Uint8Array(), + }]), + ); + const actual = await getVersionOr({ foo: "fallback value" }); + assertEquals(actual, { foo: "fallback value" }); + }); + }); +}); + +const isSemVer = is.ObjectOf({ + major: is.Number, + minor: is.Number, + patch: is.Number, + prerelease: is.ArrayOf(is.UnionOf([is.String, is.Number])), + build: is.ArrayOf(is.String), +}) satisfies Predicate; + +async function isInsideWorkTree(): Promise { + const cmd = new Deno.Command("git", { + args: ["rev-parse", "--is-inside-work-tree"], + stdout: "piped", + }); + try { + const { stdout } = await cmd.output(); + return /^true/.test(new TextDecoder().decode(stdout)); + } catch (e) { + console.error(e); + return false; + } +} + +async function isWorkTreeHasTags(): Promise { + const cmd = new Deno.Command("git", { + args: ["describe", "--tags", "--always"], + stdout: "piped", + }); + try { + const { stdout } = await cmd.output(); + return /-/.test(new TextDecoder().decode(stdout)); + } catch (e) { + console.error(e); + return false; + } +} diff --git a/denops/@denops-private/worker.ts b/denops/@denops-private/worker.ts index 3a7bae85..3da75287 100644 --- a/denops/@denops-private/worker.ts +++ b/denops/@denops-private/worker.ts @@ -1,15 +1,28 @@ +/// +/// + import { readableStreamFromWorker, writableStreamFromWorker, -} from "https://deno.land/x/workerio@v3.1.0/mod.ts"; -import { ensure } from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; -import { pop } from "https://deno.land/x/streamtools@v0.5.0/mod.ts"; -import type { HostConstructor } from "./host.ts"; +} from "jsr:@lambdalisue/workerio@4.0.1"; +import { ensure } from "jsr:@core/unknownutil@3.18.1"; +import { pop } from "jsr:@lambdalisue/streamtools@1.0.0"; +import { asyncSignal } from "jsr:@milly/async-signal@^1.0.0"; +import type { Meta } from "jsr:@denops/core@7.0.0"; +import type { Host, HostConstructor } from "./host.ts"; import { Vim } from "./host/vim.ts"; import { Neovim } from "./host/nvim.ts"; import { Service } from "./service.ts"; import { isMeta } from "./util.ts"; +const CONSOLE_PATCH_METHODS = [ + "log", + "info", + "debug", + "warn", + "error", +] as const satisfies (keyof typeof console)[]; + const marks = new TextEncoder().encode('[{tf"0123456789'); async function detectHost( @@ -34,66 +47,64 @@ function formatArgs(args: unknown[]): string[] { }); } -async function main(): Promise { - const worker = self as unknown as Worker; - const writer = writableStreamFromWorker(worker); - const [reader, detector] = readableStreamFromWorker(worker).tee(); +function patchConsole(host: Host, meta: Meta): void { + for (const name of CONSOLE_PATCH_METHODS) { + if (name === "debug" && meta.mode !== "debug") { + console[name] = () => {}; + continue; + } + const orig = console[name].bind(console); + const fn = `denops#_internal#echo#${name}`; + console[name] = (...args: unknown[]): void => { + host + .notify(fn, ...formatArgs(args)) + .catch(() => orig(...args)); + }; + } +} + +async function connectHost(): Promise { + const writer = writableStreamFromWorker(self); + const [reader, detector] = readableStreamFromWorker(self).tee(); // Detect host from payload const hostCtor = await detectHost(detector); await using host = new hostCtor(reader, writer); const meta = ensure(await host.call("denops#_internal#meta#get"), isMeta); - // Patch console - console.log = (...args: unknown[]) => { - host.notify( - "denops#_internal#echo#log", - ...formatArgs(args), - ); - }; - console.info = (...args: unknown[]) => { - host.notify( - "denops#_internal#echo#info", - ...formatArgs(args), - ); - }; - console.debug = meta.mode !== "debug" ? () => {} : (...args: unknown[]) => { - host.notify( - "denops#_internal#echo#debug", - ...formatArgs(args), - ); - }; - console.warn = (...args: unknown[]) => { - host.notify( - "denops#_internal#echo#warn", - ...formatArgs(args), - ); - }; - console.error = (...args: unknown[]) => { - host.notify( - "denops#_internal#echo#error", - ...formatArgs(args), - ); - }; + + patchConsole(host, meta); // Start service - using service = new Service(meta); + using sigintTrap = asyncSignal("SIGINT"); + await using service = new Service(meta); await host.init(service); - await host.call("execute", "doautocmd User DenopsReady", ""); - await host.waitClosed(); + await host.call("denops#_internal#event#emit", "DenopsSystemReady"); + await Promise.race([ + service.waitClosed(), + host.waitClosed(), + sigintTrap, + ]); } -if (import.meta.main) { +export async function main(): Promise { // Avoid denops server crash via UnhandledRejection globalThis.addEventListener("unhandledrejection", (event) => { event.preventDefault(); console.error(`Unhandled rejection:`, event.reason); }); - await main().catch((err) => { + try { + await connectHost(); + } catch (err) { console.error( `Internal error occurred in Worker`, err, ); - }); + } + self.close(); +} + +if (import.meta.main) { + await main(); } diff --git a/denops/@denops-private/worker_test.ts b/denops/@denops-private/worker_test.ts new file mode 100644 index 00000000..c0537f68 --- /dev/null +++ b/denops/@denops-private/worker_test.ts @@ -0,0 +1,566 @@ +// @deno-types="npm:@types/sinon@17.0.3" +import sinon from "npm:sinon@18.0.0"; +import { + assertEquals, + assertInstanceOf, + assertMatch, + assertObjectMatch, +} from "jsr:@std/assert@1.0.1"; +import { + assertSpyCalls, + resolvesNext, + spy, + stub, +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { DisposableStack } from "jsr:@nick/dispose@1.1.0/disposable-stack"; +import * as nvimCodec from "jsr:@lambdalisue/messagepack@^1.0.1"; +import { createFakeMeta } from "/denops-testutil/mock.ts"; +import { Neovim } from "./host/nvim.ts"; +import { Vim } from "./host/vim.ts"; +import { Service } from "./service.ts"; +import { main } from "./worker.ts"; + +const CONSOLE_PATCH_METHODS = [ + "log", + "info", + "debug", + "warn", + "error", +] as const satisfies (keyof typeof console)[]; + +function stubConsole() { + const target = globalThis.console; + const sandbox = sinon.createSandbox(); + const entries = CONSOLE_PATCH_METHODS.map((name) => { + type Fn = typeof target[typeof name]; + const fn = sandbox.fake((() => {}) as Fn); + const bindedFn = fn.bind(target); + const get = sandbox.fake(() => bindedFn); + const set = sandbox.fake<[fn: Fn], void>(); + sandbox.stub(target, name).get(get).set(set); + return [name, Object.assign(fn, { get, set })] as const; + }); + const methods = Object.fromEntries(entries) as Record< + typeof entries[number][0], + typeof entries[number][1] + >; + return Object.assign(methods, { + [Symbol.dispose]() { + sandbox.restore(); + }, + }); +} + +function stubMessage() { + const workerGlobal = globalThis as DedicatedWorkerGlobalScope; + const sandbox = sinon.createSandbox(); + sandbox.define(workerGlobal, "postMessage", () => {}); + const postMessage = sandbox.stub(workerGlobal, "postMessage"); + sandbox.define(workerGlobal, "onmessage", null); + const onmessage = sandbox.stub(workerGlobal, "onmessage"); + const fakeHostMessage = (data: unknown) => { + workerGlobal.onmessage?.(new MessageEvent("message", { data })); + }; + return { + postMessage, + onmessage, + /** Send fake message to worker global. */ + fakeHostMessage, + [Symbol.dispose]() { + sandbox.restore(); + }, + }; +} + +function spyAddEventListener() { + const stack = new DisposableStack(); + const stub = stack.adopt( + sinon.stub(globalThis, "addEventListener") + .callsFake((...args) => { + stub.wrappedMethod.call(globalThis, ...args); + stack.defer(() => { + globalThis.removeEventListener(...args); + }); + }), + (stub) => stub.restore(), + ); + return Object.assign(stub, { + [Symbol.dispose]() { + stack.dispose(); + }, + }); +} + +function stubDenoCommand() { + const sandbox = sinon.createSandbox(); + sandbox.stub(Deno.Command.prototype, "output").resolves({ + success: true, + stdout: _encoder.encode("v11.22.33-abcdef01"), + stderr: new Uint8Array(), + code: 0, + signal: null, + }); + return { + [Symbol.dispose]() { + sandbox.restore(); + }, + }; +} + +const _encoder = new TextEncoder(); +const _decoder = new TextDecoder(); +const vimCodec = { + encode: (value: unknown) => _encoder.encode(`${JSON.stringify(value)}\n`), + decode: (bytes: Uint8Array) => JSON.parse(_decoder.decode(bytes)), +}; + +const HOSTS = ["vim", "nvim"] as const; +const MODES = ["test", "debug"] as const; +const matrix = HOSTS.flatMap((host) => MODES.map((mode) => ({ host, mode }))); + +for (const { host, mode } of matrix) { + Deno.test(`(host: ${host}, mode: ${mode})`, async (t) => { + await t.step("main()", async (t) => { + using messageStub = stubMessage(); + using consoleStub = stubConsole(); + using _addEventListenerSpy = spyAddEventListener(); + using _denoCommandStub = stubDenoCommand(); + using deno_addSignalListener = stub(Deno, "addSignalListener"); + using host_asyncDispose = spy( + (host === "vim" ? Vim : Neovim).prototype, + Symbol.asyncDispose, + ); + using service_asyncDispose = spy( + Service.prototype, + Symbol.asyncDispose, + ); + using self_close = stub(globalThis, "close"); + const usePostMessageHistory = () => ({ + [Symbol.dispose]: () => messageStub.postMessage.resetHistory(), + }); + const fakeMeta = { ...createFakeMeta(), host, mode }; + + const mainPromise = main(); + + await t.step("catches unhandledrejection", async () => { + const error = new Error("error"); + + new Promise(() => { + throw error; + }); + + await delay(0); + assertEquals(consoleStub.error.firstCall.args, [ + "Unhandled rejection:", + error, + ]); + }); + + // Initial message from the host. + if (host === "vim") { + messageStub.fakeHostMessage(vimCodec.encode([0, ["void"]])); + } else { + messageStub.fakeHostMessage(nvimCodec.encode([2, "void", []])); + } + + await t.step("requests Meta data", async () => { + using _ = usePostMessageHistory(); + await delay(0); + assertEquals(messageStub.postMessage.callCount, 1); + if (host === "vim") { + assertEquals( + vimCodec.decode(messageStub.postMessage.getCall(0).args[0]), + [ + "call", + "denops#api#vim#call", + ["denops#_internal#meta#get", []], + -1, + ], + ); + messageStub.fakeHostMessage(vimCodec.encode([-1, [fakeMeta, ""]])); + } else { + assertEquals( + nvimCodec.decode(messageStub.postMessage.getCall(0).args[0]), + [ + 0, + 0, + "nvim_call_function", + ["denops#_internal#meta#get", []], + ], + ); + messageStub.fakeHostMessage(nvimCodec.encode([1, 0, null, fakeMeta])); + } + }); + + if (host === "nvim") { + await t.step("sets client info", async () => { + using _ = usePostMessageHistory(); + await delay(0); + assertEquals(messageStub.postMessage.callCount, 1); + const request = nvimCodec.decode( + messageStub.postMessage.getCall(0).args[0], + ) as unknown[]; + assertEquals(request.slice(0, 3), [0, 1, "nvim_set_client_info"]); + messageStub.fakeHostMessage(nvimCodec.encode([1, 1, null, 0])); + }); + } + + await t.step("emits `DenopsSystemReady`", async () => { + using _ = usePostMessageHistory(); + await delay(0); + assertEquals(messageStub.postMessage.callCount, 1); + if (host === "vim") { + assertEquals( + vimCodec.decode(messageStub.postMessage.getCall(0).args[0]), + [ + "call", + "denops#api#vim#call", + ["denops#_internal#event#emit", ["DenopsSystemReady"]], + -2, + ], + ); + messageStub.fakeHostMessage(vimCodec.encode([-2, ["", ""]])); + } else { + assertEquals( + nvimCodec.decode(messageStub.postMessage.getCall(0).args[0]), + [ + 0, + 2, + "nvim_call_function", + ["denops#_internal#event#emit", ["DenopsSystemReady"]], + ], + ); + messageStub.fakeHostMessage(nvimCodec.encode([1, 2, null, ""])); + } + }); + + await t.step("patches `console`", async (t) => { + for (const name of CONSOLE_PATCH_METHODS) { + await t.step(`.${name}()`, async (t) => { + await t.step("is patched", () => { + assertEquals(consoleStub[name].set.callCount, 1); + assertInstanceOf( + consoleStub[name].set.getCall(0).args[0], + Function, + ); + }); + + if (name === "debug" && fakeMeta.mode !== "debug") { + await t.step("does nothing", async () => { + using _ = usePostMessageHistory(); + const fn = consoleStub[name].set.getCall(0).args[0]; + + fn.apply(globalThis.console, ["foo", 123, false]); + + await delay(0); + assertEquals(messageStub.postMessage.callCount, 0); + }); + } else { + await t.step({ + name: `notifies \`denops#_internal#echo#${name}\``, + fn: async () => { + using _ = usePostMessageHistory(); + const fn = consoleStub[name].set.getCall(0).args[0]; + const errorWithStack = Object.assign( + new Error("fake-error-with-stack"), + { + stack: + "Error: fake-error-with-stack\n at foo (bar.ts:10:20)", + }, + ); + const errorWithoutStack = Object.assign( + new Error("fake-error-without-stack"), + { stack: undefined }, + ); + + fn.apply(globalThis.console, [ + "foo", + 123, + false, + errorWithStack, + errorWithoutStack, + ]); + + await delay(0); + assertEquals(messageStub.postMessage.callCount, 1); + if (host === "vim") { + assertEquals( + vimCodec.decode( + messageStub.postMessage.getCall(0).args[0], + ), + [ + "call", + `denops#_internal#echo#${name}`, + [ + "foo", + "123", + "false", + "Error: fake-error-with-stack\n at foo (bar.ts:10:20)", + "Error: fake-error-without-stack", + ], + ], + ); + } else { + assertEquals( + nvimCodec.decode( + messageStub.postMessage.getCall(0).args[0], + ), + [ + 2, + "nvim_call_function", + [ + `denops#_internal#echo#${name}`, + [ + "foo", + "123", + "false", + "Error: fake-error-with-stack\n at foo (bar.ts:10:20)", + "Error: fake-error-without-stack", + ], + ], + ], + ); + } + }, + }); + + await t.step(`calls native \`console.${name}\``, async () => { + const notifyError = new Error("fake-post-error"); + using _host_notify = stub( + (host === "vim" ? Vim : Neovim).prototype, + "notify", + resolvesNext([notifyError]), + ); + const nativeMethod = consoleStub[name]; + nativeMethod.resetHistory(); + const fn = consoleStub[name].set.getCall(0).args[0]; + const error = new Error("fake-error"); + + fn.apply(globalThis.console, ["foo", 123, false, error]); + + await delay(0); + assertEquals(nativeMethod.callCount, 1); + assertObjectMatch(nativeMethod.getCall(0), { + thisValue: globalThis.console, + args: [ + "foo", + 123, + false, + error, + ], + }); + }); + } + }); + } + }); + + await t.step("listens SIGINT", () => { + assertSpyCalls(deno_addSignalListener, 1); + const [signal, signalHandler] = deno_addSignalListener.calls[0].args; + assertEquals(signal, "SIGINT"); + assertInstanceOf(signalHandler, Function); + }); + + await t.step("before stream is closed", async (t) => { + await t.step("does not dispose service", () => { + assertSpyCalls(service_asyncDispose, 0); + }); + + await t.step("does not dispose host", () => { + assertSpyCalls(host_asyncDispose, 0); + }); + + await t.step("does not close worker", () => { + assertSpyCalls(self_close, 0); + }); + }); + + // NOTE: Send `null` to close workerio stream. + messageStub.fakeHostMessage(null); + await delay(0); + + await t.step("after stream is closed", async (t) => { + await t.step("disposes service", () => { + assertSpyCalls(service_asyncDispose, 1); + }); + + await t.step("disposes host", () => { + assertSpyCalls(host_asyncDispose, 1); + }); + + await t.step("closes worker", async () => { + assertSpyCalls(self_close, 1); + await mainPromise; + }); + }); + }); + + await t.step("main() if it raises an internal error", async (t) => { + using messageStub = stubMessage(); + using consoleStub = stubConsole(); + using _addEventListenerSpy = spyAddEventListener(); + using self_close = stub(globalThis, "close"); + + const error = new Error("fake-error"); + messageStub.onmessage.set(() => { + throw error; + }); + + await main(); + + await t.step("outputs an error log", () => { + assertEquals(consoleStub.error.callCount, 1); + assertEquals(consoleStub.error.getCall(0).args, [ + "Internal error occurred in Worker", + error, + ]); + }); + + await t.step("closes worker", () => { + assertSpyCalls(self_close, 1); + }); + }); + + await t.step("main() if SIGINT is trapped", async (t) => { + using messageStub = stubMessage(); + using consoleStub = stubConsole(); + using _addEventListenerSpy = spyAddEventListener(); + using _denoCommandStub = stubDenoCommand(); + using deno_addSignalListener = stub(Deno, "addSignalListener"); + using host_asyncDispose = spy( + (host === "vim" ? Vim : Neovim).prototype, + Symbol.asyncDispose, + ); + using service_asyncDispose = spy( + Service.prototype, + Symbol.asyncDispose, + ); + using self_close = stub(globalThis, "close"); + const fakeMeta = { ...createFakeMeta(), host, mode }; + + const mainPromise = main(); + + if (host === "vim") { + // Initial message from the host. + messageStub.fakeHostMessage(vimCodec.encode([0, ["void"]])); + await delay(0); + // requests Meta data + messageStub.fakeHostMessage(vimCodec.encode([-1, [fakeMeta, ""]])); + await delay(0); + // emits `DenopsSystemReady` + messageStub.fakeHostMessage(vimCodec.encode([-2, ["", ""]])); + await delay(0); + } else { + // Initial message from the host. + messageStub.fakeHostMessage(nvimCodec.encode([2, "void", []])); + await delay(0); + // requests Meta data + messageStub.fakeHostMessage(nvimCodec.encode([1, 0, null, fakeMeta])); + await delay(0); + // sets client info + messageStub.fakeHostMessage(nvimCodec.encode([1, 1, null, 0])); + await delay(0); + // emits `DenopsSystemReady` + messageStub.fakeHostMessage(nvimCodec.encode([1, 2, null, ""])); + await delay(0); + } + + const [_signal, signalHandler] = deno_addSignalListener.calls[0].args; + consoleStub.error.resetHistory(); + + signalHandler(); + await delay(0); + + await t.step("outputs an error log", () => { + assertEquals(consoleStub.error.callCount, 1); + assertMatch( + `${consoleStub.error.getCall(0).args[1]}`, + /^SignalError: SIGINT is trapped/, + ); + }); + + await t.step("disposes service", () => { + assertSpyCalls(service_asyncDispose, 1); + }); + + await t.step("disposes host", () => { + assertSpyCalls(host_asyncDispose, 1); + }); + + await t.step("closes worker", async () => { + assertSpyCalls(self_close, 1); + await mainPromise; + }); + }); + + await t.step("main() if service is closed", async (t) => { + using messageStub = stubMessage(); + using _consoleStub = stubConsole(); + using _addEventListenerSpy = spyAddEventListener(); + using _denoCommandStub = stubDenoCommand(); + using _deno_addSignalListener = stub(Deno, "addSignalListener"); + using host_asyncDispose = spy( + (host === "vim" ? Vim : Neovim).prototype, + Symbol.asyncDispose, + ); + using service_asyncDispose = spy( + Service.prototype, + Symbol.asyncDispose, + ); + const service_waitClosed_waiter = Promise.withResolvers(); + using service_waitClosed = stub( + Service.prototype, + "waitClosed", + () => service_waitClosed_waiter.promise, + ); + using self_close = stub(globalThis, "close"); + const fakeMeta = { ...createFakeMeta(), host, mode }; + + const mainPromise = main(); + + if (host === "vim") { + // Initial message from the host. + messageStub.fakeHostMessage(vimCodec.encode([0, ["void"]])); + await delay(0); + // requests Meta data + messageStub.fakeHostMessage(vimCodec.encode([-1, [fakeMeta, ""]])); + await delay(0); + // emits `DenopsSystemReady` + messageStub.fakeHostMessage(vimCodec.encode([-2, ["", ""]])); + await delay(0); + } else { + // Initial message from the host. + messageStub.fakeHostMessage(nvimCodec.encode([2, "void", []])); + await delay(0); + // requests Meta data + messageStub.fakeHostMessage(nvimCodec.encode([1, 0, null, fakeMeta])); + await delay(0); + // sets client info + messageStub.fakeHostMessage(nvimCodec.encode([1, 1, null, 0])); + await delay(0); + // emits `DenopsSystemReady` + messageStub.fakeHostMessage(nvimCodec.encode([1, 2, null, ""])); + await delay(0); + } + + assertSpyCalls(service_waitClosed, 1); + service_waitClosed_waiter.resolve(); + await delay(0); + + await t.step("disposes service", () => { + assertSpyCalls(service_asyncDispose, 1); + }); + + await t.step("disposes host", () => { + assertSpyCalls(host_asyncDispose, 1); + }); + + await t.step("closes worker", async () => { + assertSpyCalls(self_close, 1); + await mainPromise; + }); + }); + }); +} diff --git a/doc/denops.txt b/doc/denops.txt index 1f0e063d..c99717c9 100644 --- a/doc/denops.txt +++ b/doc/denops.txt @@ -22,12 +22,29 @@ Visit the Denops Document or Wiki for guidance on creating denops plugins. Denops Document: https://vim-denops.github.io/denops-documentation/ Wiki: https://github.com/vim-denops/denops.vim/wiki -API Reference: https://deno.land/x/denops_std/mod.ts +API Reference: https://jsr.io/@denops/std ============================================================================= USAGE *denops-usage* +----------------------------------------------------------------------------- +RECOMMENDED *denops-recommended* + +Add the following recommended settings to your |vimrc| or |init.vim|: +> + " Interrupt the process of plugins via + noremap call denops#interrupt() + inoremap call denops#interrupt() + cnoremap call denops#interrupt() + + " Restart Denops server + command! DenopsRestart call denops#server#restart() + + " Fix Deno module cache issue + command! DenopsFixCache call denops#cache#update(#{reload: v:true}) +< + ----------------------------------------------------------------------------- SHARED SERVER *denops-shared-server* @@ -115,8 +132,8 @@ VARIABLE *denops-variable* Default: ['-q', '--no-lock', '-A'] *g:denops#server#restart_delay* - Restart delay in milliseconds to avoid #136. - https://github.com/vim-denops/denops.vim/issues/136 + The delay in milliseconds before restarting the server. + This avoid #136. https://github.com/vim-denops/denops.vim/issues/136 Default: 100 *g:denops#server#restart_interval* @@ -126,18 +143,30 @@ VARIABLE *denops-variable* Default: 10000 *g:denops#server#restart_threshold* - The number of restart counts within - |g:denops#server#restart_interval|. + The number of restart counts on unexpected process terminattion within + |g:denops#server#restart_interval|. Default: 3 -*g:denops#server#reconnect_interval* - Interval in milliseconds before retrying connection to the server. +*g:denops#server#reconnect_delay* + The delay in milliseconds before reconnecting to the server. Default: 100 +*g:denops#server#reconnect_interval* + Interval in milliseconds to avoid infinite errors. Denops will reset + internal counter when the channel keeps connecting more than this + interval. + Default: 1000 + *g:denops#server#reconnect_threshold* - The number of reconnect counts on connection failure. + The number of reconnect counts on connection failure within + |g:denops#server#reconnect_interval|. Default: 3 +*g:denops#server#close_timeout* + Timeout in milliseconds to wait for the channel to close gracefully. + If the timeout expires, the channel will be forcibly closed. + Default: 5000 + *g:denops#server#wait_interval* Interval in milliseconds for |denops#server#wait()|. Default: 10 @@ -199,6 +228,26 @@ denops#request_async({name}, {method}, {params}, {success}, {failure}) \ { v -> s:success(v) }, \ { e -> s:failure(e) }, \) +< + *denops#interrupt()* +denops#interrupt([{reason}]) + Interrupts the process of plugins. It is assumed to be used to + interrupt the process of plugins when the user presses a key or + issues a command. + {reason} is an optional string to describe the reason for the + interruption. + Note that the interruption is not guaranteed to be immediate. + + Use the following mapping to interrupt the process of plugins: +> + " For normal/visual/select mode + noremap call denops#interrupt() + + " For insert mode + inoremap call denops#interrupt() + + " For command mode + cnoremap call denops#interrupt() < *denops#cache#update()* denops#cache#update([{options}]) @@ -213,7 +262,8 @@ denops#cache#update([{options}]) *denops#server#start()* denops#server#start() Starts a denops server process and connects to the channel. It does - nothing when the server is already started. + nothing when the server is already started or the channel is already + established. It is automatically called 1) on |VimEnter| autocmd when denops is in 'runtimepath' during Vim startup, 2) immediately when denops is @@ -251,7 +301,7 @@ denops#server#close() *denops#server#reconnect()* denops#server#reconnect() - Reconnects to a denops shared server. + Closes the channel and reconnects to a |denops-shared-server|. *denops#server#status()* denops#server#status() @@ -262,6 +312,8 @@ denops#server#status() "starting" Server is starting. "preparing" Server is preparing (initializing). "running" Server is running (ready). + "closing" Server is closing (disconnecting). + "closed" Server is closed (disconnected but server is running). Note that "starting" is never returned when a |denops-shared-server| is used. @@ -293,17 +345,28 @@ denops#server#wait_async({callback}) *denops#plugin#is_loaded()* denops#plugin#is_loaded({name}) - Returns 1 if a {name} plugin is already loaded. Otherwise, returns - 0. + Returns 1 if a {name} plugin is already loaded or failed to load. + Returns 0 otherwise, even if |denops#plugin#unload()| or + |denops#plugin#reload()| are called. It throws an error when {name} does + not match the |denops-plugin-name|. + + The *denops-plugin-name* only allows ASCII characters '0'-'9', 'a'-'z', + 'A'-'Z', '_' and '-', and may not be empty. *denops#plugin#wait()* denops#plugin#wait({name}[, {options}]) - Waits synchronously until a {name} plugin is loaded. It returns - immediately when the {name} plugin is already loaded. - It returns -1 if it times out, -2 if the server is not yet ready or - interrupted, or -3 if the plugin initialization failed. - Developers need to consider this return value to decide whether to - continue with the subsequent process. + Waits synchronously until a {name} plugin is loaded or failed to load. + It returns immediately when the {name} plugin is already loaded. It + throws an error when {name} does not match the |denops-plugin-name|. + + Developers need to consider the following return value to decide + whether to continue with the subsequent process: + + 0 The {name} plugin is loaded successfully. + -1 "timeout" expired. + -2 The server is not yet ready or interrupted. + -3 The {name} plugin failed to load. + The following attributes are available on {options}. "interval" Interval in milliseconds for |:sleep| in internal @@ -320,56 +383,63 @@ denops#plugin#wait_async({name}, {callback}) {callback}. It invokes the {callback} immediately when the {name} plugin is already loaded. If this function is called multiple times for the same {name} plugin, callbacks registered for the plugin are - called in order of registration. - - *denops#plugin#register()* -denops#plugin#register({name}[, {script}[, {options}]]) - DEPRECATED: Use |denops#plugin#load()| instead. - - Developers who would like to support previous denops versions should - fallback to this function like -> - try - call denops#plugin#load( - \ "denops-hello", - \ "/path/to/denops-hello/main.ts", - \) - catch /^Vim\%((\a\+)\)\=:E117:/ - " Fallback to `register` for backward compatibility - call denops#plugin#register( - \ "denops-hello", - \ "/path/to/denops-hello/main.ts", - \ {"mode": "skip"}, - \) - endtry -< - Note that the {options} are ignored and always treated as skip mode. + called in order of registration. It throws an error when {name} does + not match the |denops-plugin-name|. *denops#plugin#discover()* denops#plugin#discover() Discovers denops plugins from 'runtimepath' and loads them. - It gathers "main.ts" under "denops/*" directories under 'runtimepath' - if the middle directory does not start with "@". + It gathers "main.ts" in directories that match the |denops-plugin-name| + from "denops/*" under 'runtimepath'. This is automatically called on |User| |DenopsReady| autocmd invoked by |denops#server#start()|. *denops#plugin#load()* denops#plugin#load({name}, {script}) Loads a denops plugin. Use this function to load denops plugins that - are not discovered by |denops#plugin#discover()|. - It invokes |User| |DenopsPluginPre|:{plugin} just before denops - executes a "main" function of the plugin and |User| - |DenopsPluginPost|:{plugin} just after denops executes a "main" - function of the plugin. + are not discovered by |denops#plugin#discover()|. If the {name} plugin + is already loaded, it does nothing. It throws an error when {name} + does not match the |denops-plugin-name|. + + Loading a plugin involves the following event steps: + + - |User| |DenopsPluginPre|:{name} is fired. + - The plugin is loaded and the "main" function is executed. + - If it succeeds, |User| |DenopsPluginPost|:{name} is fired. + - If it fails, |User| |DenopsPluginFail|:{name} is fired. + + *denops#plugin#unload()* +denops#plugin#unload({name}) + Unloads a denops plugin. If the {name} plugin is currently loading, it + will be unloaded after it has been successfully loaded. If the {name} + plugin does not exist or fails to load, it does nothing. It throws an + error when {name} does not match the |denops-plugin-name|. + + Unloading a plugin involves the following event steps: + + - |User| |DenopsPluginUnloadPre|:{name} is fired. + - The plugin's dispose callback is executed, if it exists. + - If it succeeds, |User| |DenopsPluginUnloadPost|:{name} is fired. + - If it fails, |User| |DenopsPluginUnloadFail|:{name} is fired. + + The above events may not be fired if the connection is forcibly + closed or the denops server is forcibly terminated due to timeout + or other reasons. Plugins should also use the |DenopsClosed| event. *denops#plugin#reload()* -denops#plugin#reload({name}) - Reloads a denops plugin. +denops#plugin#reload({plugin}[, {options}]) + Reloads a denops plugin. If the {name} plugin does not exist or fails + to load, it does nothing. It throws an error when {name} does not + match the |denops-plugin-name|. + + It invokes |User| autocommand events. See |denops#plugin#load()| and + |denops#plugin#unload()| for details. *denops#plugin#check_type()* denops#plugin#check_type([{name}]) Runs Deno's type check feature for {name} or all registered plugins. - The result of the check will be displayed in |message-history|. + The result of the check will be displayed in |message-history|. It + throws an error when {name} does not match the |denops-plugin-name|. *denops#callback#register()* denops#callback#register({callback}[, {options}]) @@ -431,6 +501,24 @@ DenopsPluginPre:{plugin} *DenopsPluginPre* DenopsPluginPost:{plugin} *DenopsPluginPost* Fired after the "main" function of each plugin is called. {plugin} is the name of the target plugin. + Note that if the "main" function throws an error, it will not be fired. + +DenopsPluginFail:{plugin} *DenopsPluginFail* + Fired when the "main" function of each plugin throws an error. + {plugin} is the name of the target plugin. + +DenopsPluginUnloadPre:{plugin} *DenopsPluginUnloadPre* + Fired before each plugin is unloaded. + {plugin} is the name of the target plugin. + +DenopsPluginUnloadPost:{plugin} *DenopsPluginUnloadPost* + Fired after each plugin is unloaded. + {plugin} is the name of the target plugin. + Note that if an error is thrown when unloading, it will not be fired. + +DenopsPluginUnloadFail:{plugin} *DenopsPluginUnloadFail* + Fired if an error is thrown when unloading each plugin. + {plugin} is the name of the target plugin. DenopsProcessStarted *DenopsProcessStarted* Fires when the Denops local server process is started. diff --git a/plugin/denops.vim b/plugin/denops.vim index a7b58274..96e40ed0 100644 --- a/plugin/denops.vim +++ b/plugin/denops.vim @@ -3,9 +3,9 @@ if exists('g:loaded_denops') endif let g:loaded_denops = 1 -if !get(g:, 'denops_disable_version_check') && !has('nvim-0.9.4') && !has('patch-9.0.2189') +if !get(g:, 'denops_disable_version_check') && !has('nvim-0.10.0') && !has('patch-9.1.0448') echohl WarningMsg - echomsg '[denops] Denops requires Vim 9.0.2189 or Neovim 0.9.4. See ":h g:denops_disable_version_check" to disable this check.' + echomsg '[denops] Denops requires Vim 9.1.0448 or Neovim 0.10.0. See ":h g:denops_disable_version_check" to disable this check.' echohl None finish endif @@ -22,24 +22,17 @@ augroup denops_plugin_internal autocmd User DenopsPluginPre:* : autocmd User DenopsPluginPost:* : autocmd User DenopsPluginFail:* : + autocmd User DenopsPluginUnloadPre:* : + autocmd User DenopsPluginUnloadPost:* : + autocmd User DenopsPluginUnloadFail:* : autocmd User DenopsReady call denops#plugin#discover() augroup END -function! s:init() abort - if !empty(get(g:, 'denops_server_addr')) - if denops#server#connect() - return - endif - " Fallback to a local denops server - endif - call denops#server#start() -endfunction - if has('vim_starting') augroup denops_plugin_internal_startup autocmd! - autocmd VimEnter * call s:init() + autocmd VimEnter * call denops#server#connect_or_start() augroup END else - call s:init() + call denops#server#connect_or_start() endif diff --git a/plugin/denops/debug.vim b/plugin/denops/debug.vim index 6b09bb57..aa306b57 100644 --- a/plugin/denops/debug.vim +++ b/plugin/denops/debug.vim @@ -9,6 +9,8 @@ augroup denops_debug_plugin_internal \ call denops#_internal#echo#debug(expand(':t')) autocmd User DenopsStarted,DenopsListen:*,DenopsStopped:* \ call denops#_internal#echo#debug(expand(':t')) - autocmd User DenopsPluginPre:*,DenopsPluginPost:* + autocmd User DenopsPluginPre:*,DenopsPluginPost:*,DenopsPluginFail:* + \ call denops#_internal#echo#debug(expand(':t')) + autocmd User DenopsPluginUnloadPre:*,DenopsPluginUnloadPost:*,DenopsPluginUnloadFail:* \ call denops#_internal#echo#debug(expand(':t')) augroup END diff --git a/tests/denops/runtime/functions/plugin_test.ts b/tests/denops/runtime/functions/plugin_test.ts new file mode 100644 index 00000000..9df841a8 --- /dev/null +++ b/tests/denops/runtime/functions/plugin_test.ts @@ -0,0 +1,2223 @@ +import { + assertArrayIncludes, + assertEquals, + assertGreater, + assertLess, + assertMatch, + assertRejects, + assertStringIncludes, +} from "jsr:@std/assert@1.0.1"; +import { delay } from "jsr:@std/async@^0.224.0"; +import { join } from "jsr:@std/path@1.0.2/join"; +import { AsyncDisposableStack } from "jsr:@nick/dispose@1.1.0/async-disposable-stack"; +import { testHost } from "/denops-testutil/host.ts"; +import { wait } from "/denops-testutil/wait.ts"; + +const MESSAGE_DELAY = 200; // msc + +const scriptValid = resolve("dummy_valid_plugin.ts"); +const scriptInvalid = resolve("dummy_invalid_plugin.ts"); +const scriptValidDispose = resolve("dummy_valid_dispose_plugin.ts"); +const scriptInvalidDispose = resolve("dummy_invalid_dispose_plugin.ts"); +const scriptValidWait = resolve("dummy_valid_wait_plugin.ts"); +const scriptInvalidWait = resolve("dummy_invalid_wait_plugin.ts"); +const runtimepathPlugin = resolve("dummy_plugins"); + +testHost({ + mode: "all", + postlude: [ + "runtime plugin/denops.vim", + ], + fn: async ({ host, t, stderr }) => { + let outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await wait(() => host.call("eval", "denops#server#status() ==# 'running'")); + await host.call("execute", [ + "let g:__test_denops_events = []", + "autocmd User DenopsPlugin* call add(g:__test_denops_events, expand(''))", + ], ""); + + await t.step("denops#plugin#load()", async (t) => { + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#load", "dummy.invalid", scriptValid), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadNotLoaded', '${scriptValid}')`, + ], ""); + + await t.step("loads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadNotLoaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyLoadNotLoaded", + "DenopsPluginPost:dummyLoadNotLoaded", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin entrypoint throws", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadInvalid', '${scriptInvalid}')`, + ], ""); + + await t.step("fails loading a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginFail:dummyLoadInvalid") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyLoadInvalid", + "DenopsPluginFail:dummyLoadInvalid", + ]); + }); + + await t.step("outputs an error message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to load plugin 'dummyLoadInvalid': Error: This is dummy error/, + ); + }); + }); + + await t.step( + "if the plugin is the same script with a different name", + async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadOther', '${scriptValid}')`, + ], ""); + + await t.step("loads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadOther") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyLoadOther", + "DenopsPluginPost:dummyLoadOther", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }, + ); + + await t.step("if the plugin is loading", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadLoading', '${scriptValid}')`, + `call denops#plugin#load('dummyLoadLoading', '${scriptValid}')`, + ], ""); + + await t.step("loads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadLoading") + ); + }); + + await t.step("does not load a denops plugin twice", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginPost:")) + .length >= 2, + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyLoadLoading", + "DenopsPluginPost:dummyLoadLoading", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadLoaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadLoaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadLoaded', '${scriptValid}')`, + ], ""); + + await t.step("does not load a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadUnloading', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadUnloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyLoadUnloading')`, + `call denops#plugin#load('dummyLoadUnloading', '${scriptValid}')`, + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyLoadUnloading") + ); + }); + + await t.step("does not load a denops plugin", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadUnloading"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyLoadUnloading", + "DenopsPluginUnloadPost:dummyLoadUnloading", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + + await t.step("if the plugin is unloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadUnloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadUnloaded") + ); + // Unload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyLoadUnloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyLoadUnloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadUnloaded', '${scriptValid}')`, + ], ""); + + await t.step("loads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadUnloaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyLoadUnloaded", + "DenopsPluginPost:dummyLoadUnloaded", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadReloading', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadReloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyLoadReloading')`, + `call denops#plugin#load('dummyLoadReloading', '${scriptValid}')`, + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadReloading") + ); + }); + + await t.step("does not load a denops plugin twice", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginPost:")) + .length >= 2, + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyLoadReloading", + "DenopsPluginUnloadPost:dummyLoadReloading", + "DenopsPluginPre:dummyLoadReloading", + "DenopsPluginPost:dummyLoadReloading", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadReloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyLoadReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyLoadReloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyLoadReloaded', '${scriptValid}')`, + ], ""); + + await t.step("does not load a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + }); + + await t.step("denops#plugin#unload()", async (t) => { + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#unload", "dummy.invalid"), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#unload('notexistsplugin')", + ], ""); + + await t.step("does not unload a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin dispose method throws", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadInvalid', '${scriptInvalidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadInvalid") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#unload('dummyUnloadInvalid')", + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadFail:dummyUnloadInvalid") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyUnloadInvalid", + "DenopsPluginUnloadFail:dummyUnloadInvalid", + ]); + }); + + await t.step("outputs an error message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to unload plugin 'dummyUnloadInvalid': Error: This is dummy error in async dispose/, + ); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadLoading', '${scriptValidDispose}')`, + "call denops#plugin#unload('dummyUnloadLoading')", + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyUnloadLoading") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyUnloadLoading", + "DenopsPluginPost:dummyUnloadLoading", + "DenopsPluginUnloadPre:dummyUnloadLoading", + "DenopsPluginUnloadPost:dummyUnloadLoading", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadLoaded', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadLoaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#unload('dummyUnloadLoaded')", + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyUnloadLoaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyUnloadLoaded", + "DenopsPluginUnloadPost:dummyUnloadLoaded", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadUnloading', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadUnloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyUnloadUnloading')`, + "call denops#plugin#unload('dummyUnloadUnloading')", + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyUnloadUnloading") + ); + }); + + await t.step("does not unload a denops plugin twice", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginUnloadPost:")) + .length >= 2, + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyUnloadUnloading", + "DenopsPluginUnloadPost:dummyUnloadUnloading", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + + await t.step("if the plugin is unloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadUnloaded', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadUnloaded") + ); + // Unload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyUnloadUnloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyUnloadUnloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#unload('dummyUnloadUnloaded')", + ], ""); + + await t.step("does not unload a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadReloading', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadReloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyUnloadReloading')`, + "call denops#plugin#unload('dummyUnloadReloading')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadReloading") + ); + }); + + await t.step("does not unload a denops plugin twice", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginUnloadPost:")) + .length >= 2, + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyUnloadReloading", + "DenopsPluginUnloadPost:dummyUnloadReloading", + "DenopsPluginPre:dummyUnloadReloading", + "DenopsPluginPost:dummyUnloadReloading", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyUnloadReloaded', '${scriptValidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyUnloadReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyUnloadReloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#unload('dummyUnloadReloaded')", + ], ""); + + await t.step("unloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyUnloadReloaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyUnloadReloaded", + "DenopsPluginUnloadPost:dummyUnloadReloaded", + ]); + }); + + await t.step("calls the plugin dispose method", () => { + assertMatch(outputs.join(""), /Goodbye, Denops!/); + }); + }); + }); + + await t.step("denops#plugin#reload()", async (t) => { + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#reload", "dummy.invalid"), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#reload('notexistsplugin')", + ], ""); + + await t.step("does not reload a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin dispose method throws", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadInvalid', '${scriptInvalidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadInvalid") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#reload('dummyReloadInvalid')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadInvalid") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyReloadInvalid", + "DenopsPluginUnloadFail:dummyReloadInvalid", + "DenopsPluginPre:dummyReloadInvalid", + "DenopsPluginPost:dummyReloadInvalid", + ]); + }); + + await t.step("outputs an error message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to unload plugin 'dummyReloadInvalid': Error: This is dummy error in async dispose/, + ); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadLoading', '${scriptValid}')`, + "call denops#plugin#reload('dummyReloadLoading')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginPost:")) + .length >= 2 + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginPre:dummyReloadLoading", + "DenopsPluginPost:dummyReloadLoading", + "DenopsPluginUnloadPre:dummyReloadLoading", + "DenopsPluginUnloadPost:dummyReloadLoading", + "DenopsPluginPre:dummyReloadLoading", + "DenopsPluginPost:dummyReloadLoading", + ]); + }); + + await t.step("calls the plugin entrypoint twice", () => { + assertMatch(outputs.join(""), /Hello, Denops!.*Hello, Denops!/s); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadLoaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadLoaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#reload('dummyReloadLoaded')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadLoaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyReloadLoaded", + "DenopsPluginUnloadPost:dummyReloadLoaded", + "DenopsPluginPre:dummyReloadLoaded", + "DenopsPluginPost:dummyReloadLoaded", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadUnloading', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadUnloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyReloadUnloading')`, + "call denops#plugin#reload('dummyReloadUnload')", + ], ""); + + await t.step("does not reload a denops plugin", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadUnloading"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyReloadUnloading", + "DenopsPluginUnloadPost:dummyReloadUnloading", + ]); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin is unloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadUnloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadUnloaded") + ); + // Unload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyReloadUnloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyReloadUnloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#reload('dummyReloadUnload')", + ], ""); + + await t.step("does not reload a denops plugin", async () => { + const actual = wait( + () => host.call("eval", "len(g:__test_denops_events)"), + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("does not fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), []); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadReloading', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadReloading") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyReloadReloading')`, + "call denops#plugin#reload('dummyReloadReloading')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadReloading") + ); + }); + + await t.step("does not reload a denops plugin twice", async () => { + const actual = wait( + async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("DenopsPluginPost:")) + .length >= 2, + { timeout: 1000, interval: 100 }, + ); + await assertRejects(() => actual, Error, "Timeout"); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyReloadReloading", + "DenopsPluginUnloadPost:dummyReloadReloading", + "DenopsPluginPre:dummyReloadReloading", + "DenopsPluginPost:dummyReloadReloading", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyReloadReloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyReloadReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadReloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + "call denops#plugin#reload('dummyReloadReloaded')", + ], ""); + + await t.step("reloads a denops plugin", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyReloadReloaded") + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertEquals(await host.call("eval", "g:__test_denops_events"), [ + "DenopsPluginUnloadPre:dummyReloadReloaded", + "DenopsPluginUnloadPost:dummyReloadReloaded", + "DenopsPluginPre:dummyReloadReloaded", + "DenopsPluginPost:dummyReloadReloaded", + ]); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + }); + }); + + await t.step("denops#plugin#is_loaded()", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("execute", [ + "augroup __test_denops_is_loaded", + " autocmd!", + "augroup END", + ], ""); + }); + await host.call("execute", [ + "let g:__test_denops_is_loaded = {}", + "augroup __test_denops_is_loaded", + " autocmd!", + " autocmd User DenopsPlugin* let g:__test_denops_is_loaded[expand('')] = denops#plugin#is_loaded(expand('')->matchstr(':\\zs.*'))", + "augroup END", + ], ""); + + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#is_loaded", "dummy.invalid"), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + await t.step("returns 0", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "notexistsplugin", + ); + assertEquals(actual, 0); + }); + }); + + await t.step("if the plugin entrypoint throws", async (t) => { + // Load plugin and wait failure. + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_is_loaded = {}", + `call denops#plugin#load('dummyIsLoadedInvalid', '${scriptInvalid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginFail:dummyIsLoadedInvalid") + ); + + await t.step("returns 0 when DenopsPluginPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginPre:dummyIsLoadedInvalid']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 1 when DenopsPluginFail", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginFail:dummyIsLoadedInvalid']", + ); + assertEquals(actual, 1); + }); + + await t.step("returns 1 after DenopsPluginFail", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "dummyIsLoadedInvalid", + ); + assertEquals(actual, 1); + }); + + await delay(MESSAGE_DELAY); // Wait outputs of denops#plugin#load() + }); + + await t.step("if the plugin dispose method throws", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyIsLoadedInvalidDispose', '${scriptInvalidDispose}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyIsLoadedInvalidDispose") + ); + // Unload plugin and wait failure. + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_is_loaded = {}", + `call denops#plugin#unload('dummyIsLoadedInvalidDispose')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadFail:dummyIsLoadedInvalidDispose") + ); + + await t.step("returns 0 when DenopsPluginUnloadPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadPre:dummyIsLoadedInvalidDispose']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginUnloadFail", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadFail:dummyIsLoadedInvalidDispose']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 after DenopsPluginUnloadFail", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "dummyIsLoadedInvalidDispose", + ); + assertEquals(actual, 0); + }); + + await delay(MESSAGE_DELAY); // Wait outputs of denops#plugin#unload() + }); + + await t.step("if the plugin is loading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_is_loaded = {}", + `call denops#plugin#load('dummyIsLoadedLoading', '${scriptValid}')`, + "let g:__test_denops_plugin_is_loaded_after_load = denops#plugin#is_loaded('dummyIsLoadedLoading')", + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyIsLoadedLoading") + ); + + await t.step("returns 0 immediately after `load()`", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_plugin_is_loaded_after_load", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginPre:dummyIsLoadedLoading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 1 when DenopsPluginPost", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginPost:dummyIsLoadedLoading']", + ); + assertEquals(actual, 1); + }); + + await t.step("returns 1 after DenopsPluginPost", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "dummyIsLoadedLoading", + ); + assertEquals(actual, 1); + }); + }); + + await t.step("if the plugin is unloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyIsLoadedUnloading', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyIsLoadedUnloading") + ); + // Unload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_is_loaded = {}", + `call denops#plugin#unload('dummyIsLoadedUnloading')`, + "let g:__test_denops_plugin_is_loaded_after_unload = denops#plugin#is_loaded('dummyIsLoadedUnloading')", + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyIsLoadedUnloading") + ); + + await t.step("returns 0 immediately after `unload()`", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_plugin_is_loaded_after_unload", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginUnloadPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadPre:dummyIsLoadedUnloading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginUnloadPost", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadPost:dummyIsLoadedUnloading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 after DenopsPluginUnloadPost", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "dummyIsLoadedUnloading", + ); + assertEquals(actual, 0); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyIsLoadedReloading', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyIsLoadedReloading") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_is_loaded = {}", + `call denops#plugin#reload('dummyIsLoadedReloading')`, + "let g:__test_denops_plugin_is_loaded_after_reload = denops#plugin#is_loaded('dummyIsLoadedReloading')", + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyIsLoadedReloading") + ); + + await t.step("returns 0 immediately after `reaload()`", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_plugin_is_loaded_after_reload", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginUnloadPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadPre:dummyIsLoadedReloading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginUnloadPost", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginUnloadPost:dummyIsLoadedReloading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 0 when DenopsPluginPre", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginPre:dummyIsLoadedReloading']", + ); + assertEquals(actual, 0); + }); + + await t.step("returns 1 when DenopsPluginPost", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_is_loaded['DenopsPluginPost:dummyIsLoadedReloading']", + ); + assertEquals(actual, 1); + }); + + await t.step("returns 1 after DenopsPluginPost", async () => { + const actual = await host.call( + "denops#plugin#is_loaded", + "dummyIsLoadedReloading", + ); + assertEquals(actual, 1); + }); + }); + }); + + await t.step("denops#plugin#discover()", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `set runtimepath+=${await host.call("fnameescape", runtimepathPlugin)}`, + `call denops#plugin#discover()`, + ], ""); + + await t.step("loads denops plugins", async () => { + const loaded_events = [ + "DenopsPluginPost:", + "DenopsPluginFail:", + ]; + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => loaded_events.some((name) => ev.startsWith(name))) + .length >= 2 + ); + }); + + await t.step("fires DenopsPlugin* events", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + [ + "DenopsPluginPre:dummy_valid", + "DenopsPluginPost:dummy_valid", + "DenopsPluginPre:dummy_invalid", + "DenopsPluginFail:dummy_invalid", + ], + ); + }); + + await t.step("does not load invaid name plugins", async () => { + const valid_names = [ + ":dummy_valid", + ":dummy_invalid", + ] as const; + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => !valid_names.some((name) => ev.endsWith(name))); + assertEquals(actual, []); + }); + + await t.step("calls the plugin entrypoint", () => { + assertMatch(outputs.join(""), /Hello, Denops!/); + }); + + await t.step("outputs an error message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to load plugin 'dummy_invalid': Error: This is dummy error/, + ); + }); + }); + + await t.step("denops#plugin#check_type()", async (t) => { + await t.step("if no arguments is specified", async (t) => { + outputs = []; + await host.call("execute", [ + // NOTE: + // Call `denops#plugin#is_loaded()` and add an entry to the internal list. + // This will result in a plugin entry whose script is empty. + "call denops#plugin#is_loaded('notexistsplugin')", + ], ""); + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#check_type()`, + ], ""); + + await t.step("outputs an info message after delayed", async () => { + await wait(() => outputs.join("").includes("Type check")); + assertMatch(outputs.join(""), /Type check succeeded/); + }); + }); + + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#check_type", "dummy.invalid"), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is not yet loaded", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#check_type('notexistsplugin')`, + ], ""); + + await t.step("outputs an error message after delayed", async () => { + await wait(() => outputs.join("").includes("Type check")); + assertMatch(outputs.join(""), /Type check failed:/); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyCheckTypeLoaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyCheckTypeLoaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#check_type('dummyCheckTypeLoaded')`, + ], ""); + + await t.step("outputs an info message after delayed", async () => { + await wait(() => outputs.join("").includes("Type check")); + assertMatch(outputs.join(""), /Type check succeeded/); + }); + }); + + await t.step("if the plugin is unloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyCheckTypeUnloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyCheckTypeUnloaded") + ); + // Unload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#unload('dummyCheckTypeUnloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginUnloadPost:dummyCheckTypeUnloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#check_type('dummyCheckTypeUnloaded')`, + ], ""); + + await t.step("outputs an info message after delayed", async () => { + await wait(() => outputs.join("").includes("Type check")); + assertMatch(outputs.join(""), /Type check succeeded/); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyCheckTypeReloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyCheckTypeReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyCheckTypeReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyCheckTypeReloaded") + ); + + outputs = []; + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#check_type('dummyCheckTypeReloaded')`, + ], ""); + + await t.step("outputs an info message after delayed", async () => { + await wait(() => outputs.join("").includes("Type check")); + assertMatch(outputs.join(""), /Type check succeeded/); + }); + }); + }); + + await t.step("denops#plugin#wait_async()", async (t) => { + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => + host.call("execute", [ + "call denops#plugin#wait_async('dummy.invalid', { -> 0 })", + ], ""), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is load asynchronously", async (t) => { + // Load plugin asynchronously. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call timer_start(1000, { -> denops#plugin#load('dummyWaitAsyncLoadAsync', '${scriptValid}') })`, + ], ""); + + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncLoadAsync', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncLoadAsync') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("does not call `callback` immediately", async () => { + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("WaitAsyncCallbackCalled:")); + assertEquals(actual, []); + }); + + await t.step("calls `callback` when the plugin is loaded", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncLoadAsync") + ); + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["WaitAsyncCallbackCalled:dummyWaitAsyncLoadAsync"], + ); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitAsyncLoading', '${scriptValidWait}')`, + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncLoading', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncLoading') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("does not call `callback` immediately", async () => { + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("WaitAsyncCallbackCalled:")); + assertEquals(actual, []); + }); + + await t.step("calls `callback` when the plugin is loaded", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncLoading") + ); + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["WaitAsyncCallbackCalled:dummyWaitAsyncLoading"], + ); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + `call denops#plugin#load('dummyWaitAsyncLoaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncLoaded") + ); + + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncLoaded', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncLoaded') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("calls `callback` immediately", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["WaitAsyncCallbackCalled:dummyWaitAsyncLoaded"], + ); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitAsyncReloading', '${scriptValidWait}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncReloading") + ); + + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyWaitAsyncReloading')`, + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncReloading', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncReloading') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("does not call `callback` immediately", async () => { + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("WaitAsyncCallbackCalled:")); + assertEquals(actual, []); + }); + + await t.step("calls `callback` when the plugin is loaded", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncReloading") + ); + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["WaitAsyncCallbackCalled:dummyWaitAsyncReloading"], + ); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitAsyncReloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyWaitAsyncReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitAsyncReloaded") + ); + + await host.call("execute", [ + "let g:__test_denops_events = []", + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncReloaded', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncReloaded') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("calls `callback` immediately", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["WaitAsyncCallbackCalled:dummyWaitAsyncReloaded"], + ); + }); + }); + + await t.step("if the plugin is loading and fails", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitAsyncLoadingAndFails', '${scriptInvalidWait}')`, + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncLoadingAndFails', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncLoadingAndFails') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("does not call `callback`", async () => { + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginFail:dummyWaitAsyncLoadingAndFails") + ); + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("WaitAsyncCallbackCalled:")); + assertEquals(actual, []); + }); + + await delay(MESSAGE_DELAY); // Wait outputs of denops#plugin#load() + }); + + await t.step("if the plugin is failed to load", async (t) => { + // Load plugin and wait failure. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitFailed', '${scriptInvalid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginFail:dummyWaitFailed") + ); + + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "call denops#plugin#wait_async('dummyWaitAsyncFailed', { -> add(g:__test_denops_events, 'WaitAsyncCallbackCalled:dummyWaitAsyncFailed') })", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("does not call `callback`", async () => { + const actual = + (await host.call("eval", "g:__test_denops_events") as string[]) + .filter((ev) => ev.startsWith("WaitAsyncCallbackCalled:")); + assertEquals(actual, []); + }); + + await delay(MESSAGE_DELAY); // Wait outputs of denops#plugin#load() + }); + }); + + // NOTE: This test stops the denops server. + await t.step("denops#plugin#wait()", async (t) => { + await t.step("if the plugin name is invalid", async (t) => { + await t.step("throws an error", async () => { + // NOTE: '.' is not allowed in plugin name. + await assertRejects( + () => host.call("denops#plugin#wait", "dummy.invalid"), + Error, + "Invalid plugin name: dummy.invalid", + ); + }); + }); + + await t.step("if the plugin is loading", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitLoading', '${scriptValidWait}')`, + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitLoading', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("waits the plugin is loaded", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertGreater(elapsed_sec, 1.0); + assertLess(elapsed_sec, 5.0); + }); + + await t.step("returns 0", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, 0); + }); + + await t.step("the plugin is loaded after returns", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["DenopsPluginPost:dummyWaitLoading"], + ); + }); + }); + + await t.step("if the plugin is loaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitLoaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitLoaded") + ); + + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitLoaded', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("returns 0", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, 0); + }); + }); + + await t.step("if the plugin is reloading", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitReloading', '${scriptValidWait}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitReloading") + ); + + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyWaitReloading')`, + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitReloading', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("waits the plugin is loaded", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertGreater(elapsed_sec, 1.0); + assertLess(elapsed_sec, 5.0); + }); + + await t.step("returns 0", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, 0); + }); + + await t.step("the plugin is loaded after returns", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["DenopsPluginPost:dummyWaitReloading"], + ); + }); + }); + + await t.step("if the plugin is reloaded", async (t) => { + // Load plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitReloaded', '${scriptValid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitReloaded") + ); + // Reload plugin and wait. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#reload('dummyWaitReloaded')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginPost:dummyWaitReloaded") + ); + + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitReloaded', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("returns 0", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, 0); + }); + }); + + await t.step("if the plugin is loading and fails", async (t) => { + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitLoadingAndFails', '${scriptInvalidWait}')`, + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitLoadingAndFails', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("waits the plugin is failed", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertGreater(elapsed_sec, 1.0); + assertLess(elapsed_sec, 5.0); + }); + + await t.step("returns -3", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -3); + }); + + await t.step("the plugin is failed after returns", async () => { + assertArrayIncludes( + await host.call("eval", "g:__test_denops_events") as string[], + ["DenopsPluginFail:dummyWaitLoadingAndFails"], + ); + }); + }); + + await t.step("if the plugin is failed to load", async (t) => { + // Load plugin and wait failure. + await host.call("execute", [ + "let g:__test_denops_events = []", + `call denops#plugin#load('dummyWaitFailed', '${scriptInvalid}')`, + ], ""); + await wait(async () => + (await host.call("eval", "g:__test_denops_events") as string[]) + .includes("DenopsPluginFail:dummyWaitFailed") + ); + + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummyWaitFailed', {'timeout': 5000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("returns -3", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -3); + }); + + await delay(MESSAGE_DELAY); // Wait outputs of denops#plugin#load() + }); + + await t.step("if it times out", async (t) => { + await t.step("if no `silent` is specified", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('notexistsplugin', {'timeout': 1000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("waits `timeout` expired", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertGreater(elapsed_sec, 1.0); + assertLess(elapsed_sec, 5.0); + }); + + await t.step("returns -1", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -1); + }); + + await t.step("outputs an error message", async () => { + await delay(MESSAGE_DELAY); + assertStringIncludes( + outputs.join(""), + 'Failed to wait for "notexistsplugin" to start. It took more than 1000 milliseconds and timed out.', + ); + }); + }); + + await t.step("if `silent=1`", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('notexistsplugin', {'timeout': 1000, 'silent': 1})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("waits `timeout` expired", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertGreater(elapsed_sec, 1.0); + assertLess(elapsed_sec, 5.0); + }); + + await t.step("returns -1", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -1); + }); + + await t.step("does not output error messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + }); + + await t.step("if `denops#_internal#wait#timeout` expires", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:denops#_internal#wait#timeout = 500", + "call denops#plugin#wait('notexistsplugin', {'timeout': 1000})", + ], ""); + + await t.step("outputs an warning message", async () => { + await delay(MESSAGE_DELAY); + assertStringIncludes( + outputs.join(""), + "It tooks more than 500 ms. Use Ctrl-C to cancel.", + ); + }); + }); + + // NOTE: This test stops the denops server. + await t.step("if the denops server is stopped", async (t) => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + + await t.step("if no `silent` is specified", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummy', {'timeout': 1000})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("returns -2", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -2); + }); + + await t.step("outputs an error message", async () => { + await delay(MESSAGE_DELAY); + assertStringIncludes( + outputs.join(""), + 'Failed to wait for "dummy" to start. Denops server itself is not started.', + ); + }); + }); + + await t.step("if `silent=1`", async (t) => { + outputs = []; + await host.call("execute", [ + "let g:__test_denops_wait_start = reltime()", + "let g:__test_denops_wait_result = denops#plugin#wait('dummy', {'timeout': 1000, 'silent': 1})", + "let g:__test_denops_wait_elapsed = g:__test_denops_wait_start->reltime()->reltimefloat()", + ], ""); + + await t.step("returns immediately", async () => { + const elapsed_sec = await host.call( + "eval", + "g:__test_denops_wait_elapsed", + ) as number; + assertLess(elapsed_sec, 0.1); + }); + + await t.step("returns -2", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_wait_result", + ); + assertEquals(actual, -2); + }); + + await t.step("does not output error messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }); + }); + }); + }, +}); + +/** Resolve testdata script path. */ +function resolve(path: string): string { + return join(import.meta.dirname!, `../../testdata/${path}`); +} diff --git a/tests/denops/runtime/functions/server_test.ts b/tests/denops/runtime/functions/server_test.ts new file mode 100644 index 00000000..19efc79b --- /dev/null +++ b/tests/denops/runtime/functions/server_test.ts @@ -0,0 +1,1277 @@ +import { + assert, + assertEquals, + assertFalse, + assertMatch, + assertRejects, +} from "jsr:@std/assert@1.0.1"; +import { delay } from "jsr:@std/async@^0.224.0/delay"; +import { AsyncDisposableStack } from "jsr:@nick/dispose@1.1.0/async-disposable-stack"; +import { testHost } from "/denops-testutil/host.ts"; +import { useSharedServer } from "/denops-testutil/shared_server.ts"; +import { wait } from "/denops-testutil/wait.ts"; + +const MESSAGE_DELAY = 200; + +testHost({ + name: "denops#server#status()", + mode: "all", + postlude: [ + // NOTE: The `plugin/denops.vim` must be sourced to initialize the environment. + "runtime plugin/denops.vim", + // NOTE: Disable startup on VimEnter. + "autocmd! denops_plugin_internal_startup VimEnter", + ], + fn: async ({ host, t }) => { + await t.step( + "returns 'stopped' when no server running", + async () => { + const actual = await host.call("denops#server#status"); + assertEquals(actual, "stopped"); + }, + ); + + await host.call("execute", [ + "autocmd User DenopsReady let g:__test_denops_ready_fired = 1", + "autocmd User DenopsClosed let g:__test_denops_closed_fired = 1", + ], ""); + + // NOTE: The status may transition to `preparing`, so get it within execute. + // SEE: https://github.com/vim-denops/denops.vim/issues/354 + await host.call("execute", [ + "call denops#server#start()", + "let g:__test_denops_server_status_when_start_called = denops#server#status()", + ], ""); + + await t.step( + "returns 'starting' when denops#server#start() is called", + async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_start_called", + ); + assertEquals(actual, "starting"); + }, + ); + + await t.step( + "returns 'preparing' before DenopsReady is fired (flaky)", + async () => { + const actual = await wait(() => + host.call( + "eval", + "exists('g:__test_denops_ready_fired')" + + "? 'DenopsReady is fired (flaky result)'" + + ": denops#server#status() !=# 'starting'" + + " ? denops#server#status() : 0", + ) + ); + assertEquals(actual, "preparing"); + }, + ); + + await t.step( + "returns 'running' after DenopsReady is fired", + async () => { + await wait(() => host.call("exists", "g:__test_denops_ready_fired")); + const actual = await host.call("denops#server#status"); + assertEquals(actual, "running"); + }, + ); + + await t.step( + "returns 'closing' when denops#server#close() is called", + async () => { + await host.call("execute", [ + "call denops#server#close()", + "let g:__test_denops_server_status_when_close_called = denops#server#status()", + ], ""); + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_close_called", + ); + assertEquals(actual, "closing"); + }, + ); + + await t.step( + "returns 'closed' after DenopsClosed is fired", + async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + const actual = await host.call("denops#server#status"); + assertEquals(actual, "closed"); + }, + ); + + await t.step( + "returns 'stopped' after denops#server#stop() is called", + async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }, + ); + }, +}); + +testHost({ + name: "denops#server#start()", + mode: "all", + postlude: [ + // NOTE: The `plugin/denops.vim` must be sourced to initialize the environment. + "runtime plugin/denops.vim", + // NOTE: Disable startup on VimEnter. + "autocmd! denops_plugin_internal_startup VimEnter", + ], + fn: async ({ mode, host, t, stderr }) => { + let outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + + async function forceShutdownServer() { + const serverPid = await host.call( + "eval", + mode === "vim" + ? "job_info(denops#_internal#server#proc#_get_job_for_test()).process" + : "jobpid(denops#_internal#server#proc#_get_job_for_test())", + ) as number; + Deno.kill(serverPid, "SIGKILL"); + } + + await host.call("execute", [ + "autocmd User DenopsReady let g:__test_denops_ready_fired = 1", + "autocmd User DenopsClosed let g:__test_denops_closed_fired = 1", + "autocmd User DenopsProcessStarted let g:__test_denops_process_started_fired = 1", + "autocmd User DenopsProcessStopped:* let g:__test_denops_process_stopped_fired = expand('')", + ], ""); + + await t.step("if denops disabled", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("execute", [ + "let g:denops#disabled = 0", + ], ""); + }); + + await host.call("execute", [ + "let g:denops#disabled = 1", + "let g:__test_denops_server_start_result = denops#server#start()", + "let g:__test_denops_server_status_when_start_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_start_result"), + ); + }); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_start_called", + ); + assertEquals(actual, "stopped"); + }); + }); + + await t.step("if not yet started", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + "silent! unlet g:__test_denops_ready_fired", + "silent! unlet g:__test_denops_process_started_fired", + "let g:__test_denops_server_start_result = denops#server#start()", + "let g:__test_denops_server_status_when_start_called = denops#server#status()", + ], ""); + + await t.step("returns truthy", async () => { + assert(await host.call("eval", "g:__test_denops_server_start_result")); + }); + + await t.step("changes status to 'starting' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_start_called", + ); + assertEquals(actual, "starting"); + }); + + await t.step("fires DenopsProcessStarted", async () => { + await wait(() => + host.call("exists", "g:__test_denops_process_started_fired") + ); + }); + + await t.step("fires DenopsReady", async () => { + await wait(() => host.call("exists", "g:__test_denops_ready_fired")); + }); + + await t.step("changes status to 'running' asynchronously", async () => { + const actual = await host.call("denops#server#status"); + assertEquals(actual, "running"); + }); + }); + + await t.step("if already starting", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + // FIXME: Without this block, Neovim cannot stop the server. + if (await host.call("denops#server#status") !== "stopped") { + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + } + + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + // First call, changes state to 'starting'. + "call denops#server#start()", + // 2nd call, do nothing. + "let g:__test_denops_server_start_result_2nd = denops#server#start()", + "let g:__test_denops_server_status_2nd = denops#server#status()", + ], ""); + await host.call("execute", [ + // 3rd call with asynchronously, do nothing. + "let g:__test_denops_server_start_result_3rd = denops#server#start()", + "let g:__test_denops_server_status_3rd = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_start_result_2nd"), + ); + assertFalse( + await host.call("eval", "g:__test_denops_server_start_result_3rd"), + ); + }); + + await t.step("does not change status from 'running'", async () => { + const actual = await host.call( + "eval", + "[g:__test_denops_server_status_2nd, g:__test_denops_server_status_3rd]", + ); + assertEquals(actual, ["starting", "starting"]); + }); + }); + + await t.step("if already connected to shared-server", async (t) => { + outputs = []; + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "let g:__test_denops_server_start_result = denops#server#start()", + "let g:__test_denops_server_status_when_start_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_start_result"), + ); + }); + + await t.step("does not change status from 'running'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_start_called", + ); + assertEquals(actual, "running"); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Not starting local server, already connected to /, + ); + }); + }); + + await t.step("if `deno` command is not exists", async (t) => { + await using stack = new AsyncDisposableStack(); + const saved_deno_path = await host.call("eval", "g:denops#server#deno"); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + await host.call("execute", [ + `let g:denops#server#deno = '${saved_deno_path}'`, + `let g:denops#disabled = 0`, + ], ""); + }); + + await host.call("execute", [ + "silent! unlet g:__test_denops_process_stopped_fired", + "let g:denops#server#deno = '__test_no_exists_deno_executable_path'", + "let g:denops#server#restart_threshold = 0", + "let g:__test_denops_server_start_result = denops#server#start()", + "let g:__test_denops_server_status_when_start_called = denops#server#status()", + ], ""); + + await t.step("returns truthy", async () => { + assert(await host.call("eval", "g:__test_denops_server_start_result")); + }); + + await t.step("changes status to 'starting' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_start_called", + ); + assertEquals(actual, "starting"); + }); + + await t.step("fires DenopsProcessStopped:*", async () => { + const actual = await wait(() => + host.call("eval", "get(g:, '__test_denops_process_stopped_fired')") + ); + assertMatch(actual as string, /^DenopsProcessStopped:-?[0-9]+/); + }); + + await t.step("changes status to 'stopped' asynchronously", async () => { + const actual = await host.call("denops#server#status"); + assertEquals(actual, "stopped"); + }); + + await t.step("outputs warning message after delay", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Server stopped 1 times .* Denops is disabled/, + ); + }); + + await t.step("changes `g:denops#disabled` to truthy", async () => { + assert(await host.call("eval", "g:denops#disabled")); + }); + }); + + await t.step("if the server is stopped unexpectedly", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + "let g:denops#server#restart_delay = 1000", + "let g:denops#server#restart_interval = 1000", + "let g:denops#server#restart_threshold = 1", + "call denops#server#start()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + await host.call("execute", [ + "silent! unlet g:__test_denops_ready_fired", + "silent! unlet g:__test_denops_closed_fired", + "silent! unlet g:__test_denops_process_started_fired", + "silent! unlet g:__test_denops_process_stopped_fired", + ], ""); + outputs = []; + + await forceShutdownServer(); + + await t.step("fires DenopsClosed", async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("fires DenopsProcessStopped", async () => { + await wait(() => + host.call("exists", "g:__test_denops_process_stopped_fired") + ); + }); + + await t.step("changes status to 'stopped' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "stopped"); + }); + + await t.step("restart the server", async (t) => { + await t.step("fires DenopsProcessStarted", async () => { + await wait(() => + host.call("exists", "g:__test_denops_process_started_fired") + ); + }); + + await t.step("fires DenopsReady", async () => { + await wait(() => host.call("exists", "g:__test_denops_ready_fired")); + }); + + await t.step("changes status to 'running' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "running"); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Server stopped \(-?[0-9]+\)\. Restarting\.\.\./, + ); + }); + }); + }); + + await t.step("if restart count exceed", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + await host.call("execute", [ + `let g:denops#disabled = 0`, + ], ""); + }); + + await host.call("execute", [ + "silent! unlet g:__test_denops_process_started_fired", + "let g:denops#server#restart_delay = 10", + "let g:denops#server#restart_interval = 30000", + "let g:denops#server#restart_threshold = 3", + "call denops#server#start()", + ], ""); + outputs = []; + + for (let i = 0; i < 4; i++) { + await wait(() => + host.call("exists", "g:__test_denops_process_started_fired") + ); + await host.call("execute", [ + "unlet g:__test_denops_process_started_fired", + ], ""); + await forceShutdownServer(); + } + + await t.step("changes `g:denops#disabled` to truthy", async () => { + await wait(() => host.call("eval", "g:denops#disabled")); + }); + + await t.step("changes status to 'stopped'", async () => { + await wait(() => + host.call("eval", "denops#server#status() ==# 'stopped'") + ); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Server stopped 4 times within 30000 millisec/, + ); + }); + }); + }, +}); + +testHost({ + name: "denops#server#stop()", + mode: "all", + postlude: [ + // NOTE: The `plugin/denops.vim` must be sourced to initialize the environment. + "runtime plugin/denops.vim", + // NOTE: Disable startup on VimEnter. + "autocmd! denops_plugin_internal_startup VimEnter", + ], + fn: async ({ host, t }) => { + await host.call("execute", [ + "autocmd User DenopsClosed let g:__test_denops_closed_fired = 1", + "autocmd User DenopsProcessStopped:* let g:__test_denops_process_stopped_fired = expand('')", + ], ""); + + await t.step("if not yet started", async (t) => { + await host.call("execute", [ + "call denops#server#stop()", + "let g:__test_denops_server_status_when_stop_called = denops#server#status()", + ], ""); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_stop_called", + ); + assertEquals(actual, "stopped"); + }); + }); + + await t.step("if already connected to local-server", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + await host.call("denops#server#start"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "call denops#server#stop()", + "let g:__test_denops_server_status_when_stop_called = denops#server#status()", + "silent! unlet g:__test_denops_closed_fired", + "silent! unlet g:__test_denops_process_stopped_fired", + ], ""); + + await t.step("changes status to 'closing' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_stop_called", + ); + assertEquals(actual, "closing"); + }); + + await t.step("fires DenopsClosed", async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("fires DenopsProcessStopped:*", async () => { + const actual = await wait(() => + host.call("eval", "get(g:, '__test_denops_process_stopped_fired')") + ); + assertMatch(actual as string, /^DenopsProcessStopped:-?[0-9]+/); + }); + + await t.step("changes status to 'stopped' asynchronously", async () => { + const actual = await host.call("denops#server#status"); + assertEquals(actual, "stopped"); + }); + }); + + await t.step("if already connected to shared-server", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "silent! unlet g:__test_denops_closed_fired", + "silent! unlet g:__test_denops_process_stopped_fired", + "call denops#server#stop()", + "let g:__test_denops_server_status_when_stop_called = denops#server#status()", + ], ""); + + await t.step("does not change status from 'running'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_stop_called", + ); + assertEquals(actual, "running"); + }); + + await t.step("does not fire DenopsClosed", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_closed_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + + await t.step("does not fire DenopsProcessStopped:*", async () => { + // NOTE: The timeout test is performed in `does not fire DenopsClosed`. + assertFalse( + await host.call("exists", "g:__test_denops_process_stopped_fired"), + ); + }); + }); + }, +}); + +testHost({ + name: "denops#server#connect()", + mode: "all", + postlude: [ + // NOTE: The `plugin/denops.vim` must be sourced to initialize the environment. + "runtime plugin/denops.vim", + // NOTE: Disable startup on VimEnter. + "autocmd! denops_plugin_internal_startup VimEnter", + ], + fn: async ({ host, t, stderr }) => { + let outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + + await host.call("execute", [ + "autocmd User DenopsReady let g:__test_denops_ready_fired = 1", + "autocmd User DenopsClosed let g:__test_denops_closed_fired = 1", + ], ""); + + await t.step("if denops disabled", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("execute", [ + "let g:denops#disabled = 0", + ], ""); + }); + + await host.call("execute", [ + "let g:denops#disabled = 1", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "stopped"); + }); + }); + + await t.step("if not yet connected", async (t) => { + await t.step("if `g:denops_server_addr` is empty", async (t) => { + outputs = []; + + await host.call("execute", [ + `let g:denops_server_addr = ''`, + "silent! unlet g:__test_denops_ready_fired", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "stopped"); + }); + + await t.step("does not fire DenopsReady", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_ready_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + + await t.step( + "does not change `g:denops#disabled` from falsy", + async () => { + assertFalse(await host.call("eval", "g:denops#disabled")); + }, + ); + + await t.step("outputs error message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /denops shared server address \(g:denops_server_addr\) is not given/, + ); + }); + }); + + await t.step("if `g:denops_server_addr` is invalid", async (t) => { + outputs = []; + + // NOTE: Get a non-existent address. + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const not_exists_address = `127.0.0.1:${listener.addr.port}`; + listener.close(); + + await host.call("execute", [ + `let g:denops_server_addr = '${not_exists_address}'`, + "let g:denops#server#reconnect_delay = 10", + "let g:denops#server#reconnect_interval = 30000", + "let g:denops#server#reconnect_threshold = 3", + "silent! unlet g:__test_denops_ready_fired", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "stopped"); + }); + + await t.step("does not fire DenopsReady", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_ready_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + + await t.step( + "does not change `g:denops#disabled` from falsy", + async () => { + assertFalse(await host.call("eval", "g:denops#disabled")); + }, + ); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to connect channel `127\.0\.0\.1:[0-9]+`:/, + ); + }); + }); + + await t.step("if `g:denops_server_addr` is valid", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "silent! unlet g:__test_denops_ready_fired", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns truthy", async () => { + assert( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("change status to 'preparing' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "preparing"); + }); + + await t.step("fires DenopsReady", async () => { + await wait(() => host.call("exists", "g:__test_denops_ready_fired")); + }); + + await t.step("changes status to 'running' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "running"); + }); + }); + }); + + await t.step("if already connected to local-server", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + await host.call("denops#server#start"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "silent! unlet g:__test_denops_ready_fired", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("does not change status from 'running'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "running"); + }); + + await t.step("does not fire DenopsReady", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_ready_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + }); + + await t.step("if already connected to shared-server", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "silent! unlet g:__test_denops_ready_fired", + "let g:__test_denops_server_connect_result = denops#server#connect()", + "let g:__test_denops_server_status_when_connect_called = denops#server#status()", + ], ""); + + await t.step("returns falsy", async () => { + assertFalse( + await host.call("eval", "g:__test_denops_server_connect_result"), + ); + }); + + await t.step("does not change status from 'running'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_connect_called", + ); + assertEquals(actual, "running"); + }); + + await t.step("does not fire DenopsReady", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_ready_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + }); + + await t.step("if the channel is closed unexpectedly", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "let g:denops#server#reconnect_delay = 1000", + "let g:denops#server#reconnect_interval = 1000", + "let g:denops#server#reconnect_threshold = 1", + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + await host.call("execute", [ + "silent! unlet g:__test_denops_closed_fired", + ], ""); + outputs = []; + + // Close the channel by stop the shared-server. + await server[Symbol.asyncDispose](); + + await t.step("fires DenopsClosed", async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("changes status to 'stopped' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "stopped"); + }); + + await t.step("reconnect to the server", async (t) => { + await host.call("execute", [ + "silent! unlet g:__test_denops_ready_fired", + ], ""); + + // Start the shared-server with the same port number. + stack.use(await useSharedServer({ port: server.addr.port })); + + await t.step("fires DenopsReady", async () => { + await wait(() => host.call("exists", "g:__test_denops_ready_fired")); + }); + + await t.step("changes status to 'running' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "running"); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Channel closed\. Reconnecting\.\.\./, + ); + }); + }); + }); + + await t.step("if reconnect count exceed", async (t) => { + await using stack = new AsyncDisposableStack(); + const listener = stack.use( + Deno.listen({ hostname: "127.0.0.1", port: 0 }), + ); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + await host.call("execute", [ + "let g:denops#disabled = 0", + ], ""); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '127.0.0.1:${listener.addr.port}'`, + "let g:denops#server#reconnect_delay = 10", + "let g:denops#server#reconnect_interval = 30000", + "let g:denops#server#reconnect_threshold = 3", + "call denops#server#connect()", + ], ""); + outputs = []; + + // Close the channel from server side. + (async () => { + for await (const conn of listener) { + conn.close(); + } + })(); + + await t.step("changes `g:denops#disabled` to truthy", async () => { + await wait(() => host.call("eval", "g:denops#disabled")); + }); + + await t.step("changes status to 'stopped'", async () => { + assertEquals(await host.call("denops#server#status"), "stopped"); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Channel closed 4 times within 30000 millisec/, + ); + }); + }); + }, +}); + +testHost({ + name: "denops#server#close()", + mode: "all", + postlude: [ + // NOTE: The `plugin/denops.vim` must be sourced to initialize the environment. + "runtime plugin/denops.vim", + // NOTE: Disable startup on VimEnter. + "autocmd! denops_plugin_internal_startup VimEnter", + ], + fn: async ({ host, t, stderr }) => { + let outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + + await host.call("execute", [ + "autocmd User DenopsClosed let g:__test_denops_closed_fired = 1", + ], ""); + + await t.step("if not yet connected", async (t) => { + await host.call("execute", [ + "silent! unlet g:__test_denops_closed_fired", + "call denops#server#close()", + "let g:__test_denops_server_status_when_close_called = denops#server#status()", + ], ""); + + await t.step("does not change status from 'stopped'", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_close_called", + ); + assertEquals(actual, "stopped"); + }); + + await t.step("does not fire DenopsClosed", async () => { + await assertRejects( + () => + wait( + () => host.call("exists", "g:__test_denops_closed_fired"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + }); + + await t.step("if already connected to local-server", async (t) => { + await using stack = new AsyncDisposableStack(); + stack.defer(async () => { + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + await host.call("denops#server#start"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "call denops#server#close()", + "let g:__test_denops_server_status_when_close_called = denops#server#status()", + "silent! unlet g:__test_denops_closed_fired", + ], ""); + + await t.step("changes status to 'closing' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_close_called", + ); + assertEquals(actual, "closing"); + }); + + await t.step("fires DenopsClosed", async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("changes status to 'closed' asynchronously", async () => { + const actual = await host.call("denops#server#status"); + assertEquals(actual, "closed"); + }); + + await t.step("does not stop the local-server", async () => { + await assertRejects( + () => + wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + + await host.call("denops#server#stop"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + await host.call("denops#server#start"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("if timeouted", async (t) => { + outputs = []; + + await host.call("execute", [ + "silent! unlet g:__test_denops_closed_fired", + "let g:denops#server#close_timeout = 0", + "call denops#server#close()", + ], ""); + + await host.call("execute", [ + "let g:__test_denops_server_status_after_close_called = denops#server#status()", + ], ""); + + await t.step("closes the channel forcibly", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_after_close_called", + ); + assertEquals(actual, "closed"); + }); + + await t.step("fires DenopsClosed", async () => { + assert(await host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Channel cannot close gracefully within 0 millisec, force close/, + ); + }); + + await t.step("does not stop the local-server", async () => { + await assertRejects( + () => + wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + { timeout: 1000, interval: 100 }, + ), + Error, + "Timeout", + ); + }); + }); + }); + + await t.step("if already connected to shared-server", async (t) => { + await using stack = new AsyncDisposableStack(); + const server = stack.use(await useSharedServer()); + stack.defer(async () => { + await host.call("denops#server#close"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'stopped'"), + ); + }); + + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "call denops#server#connect()", + ], ""); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await host.call("execute", [ + "call denops#server#close()", + "let g:__test_denops_server_status_when_close_called = denops#server#status()", + "silent! unlet g:__test_denops_closed_fired", + ], ""); + + await t.step("changes status to 'closing' immediately", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_when_close_called", + ); + assertEquals(actual, "closing"); + }); + + await t.step("fires DenopsClosed", async () => { + await wait(() => host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("changes status to 'stopped' asynchronously", async () => { + assertEquals(await host.call("denops#server#status"), "stopped"); + }); + + await host.call("denops#server#connect"); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("if timeouted", async (t) => { + outputs = []; + + await host.call("execute", [ + "silent! unlet g:__test_denops_closed_fired", + "let g:denops#server#close_timeout = 0", + "call denops#server#close()", + ], ""); + + await host.call("execute", [ + "let g:__test_denops_server_status_after_close_called = denops#server#status()", + ], ""); + + await t.step("closes the channel forcibly", async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_after_close_called", + ); + assertEquals(actual, "stopped"); + }); + + await t.step("fires DenopsClosed", async () => { + assert(await host.call("exists", "g:__test_denops_closed_fired")); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Channel cannot close gracefully within 0 millisec, force close/, + ); + }); + }); + }); + }, +}); diff --git a/tests/denops/runtime/plugin_test.ts b/tests/denops/runtime/plugin_test.ts new file mode 100644 index 00000000..2239d8a3 --- /dev/null +++ b/tests/denops/runtime/plugin_test.ts @@ -0,0 +1,359 @@ +import { assertEquals, assertMatch } from "jsr:@std/assert@1.0.1"; +import { delay } from "jsr:@std/async@^0.224.0/delay"; +import { withHost } from "/denops-testutil/host.ts"; +import { useSharedServer } from "/denops-testutil/shared_server.ts"; +import { wait } from "/denops-testutil/wait.ts"; + +const MESSAGE_DELAY = 200; +const MODES = ["vim", "nvim"] as const; + +for (const mode of MODES) { + Deno.test(`plugin/denops.vim (${mode})`, async (t) => { + await t.step("if sourced before VimEnter", async (t) => { + await t.step("if `g:denops_server_addr` is not specified", async (t) => { + await withHost({ + mode, + postlude: [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + // Test target + "runtime plugin/denops.vim", + "let g:__test_denops_server_status_before_vimenter = denops#server#status()", + ], + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await t.step( + "does not start a local server before VimEnter", + async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_before_vimenter", + ); + assertEquals(actual, "stopped"); + }, + ); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is empty", async (t) => { + await withHost({ + mode, + postlude: [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + // Test target + "let g:denops_server_addr = ''", + "runtime plugin/denops.vim", + "let g:__test_denops_server_status_before_vimenter = denops#server#status()", + ], + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await t.step( + "does not start a local server before VimEnter", + async () => { + const actual = await host.call( + "eval", + "g:__test_denops_server_status_before_vimenter", + ); + assertEquals(actual, "stopped"); + }, + ); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is valid", async (t) => { + await using server = await useSharedServer(); + + await withHost({ + mode, + postlude: [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + // Test target + `let g:denops_server_addr = '${server.addr}'`, + "runtime plugin/denops.vim", + ], + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("connects to the shared server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is invalid", async (t) => { + // NOTE: Get a non-existent address. + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const not_exists_address = `127.0.0.1:${listener.addr.port}`; + listener.close(); + + await withHost({ + mode, + postlude: [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + // Test target + `let g:denops_server_addr = '${not_exists_address}'`, + "runtime plugin/denops.vim", + ], + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to connect channel `127\.0\.0\.1:[0-9]+`:/, + ); + }); + }, + }); + }); + }); + + await t.step("if sourced after VimEnter", async (t) => { + await t.step("if `g:denops_server_addr` is not specified", async (t) => { + await withHost({ + mode, + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await host.call("execute", [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + ], ""); + await wait(() => host.call("eval", "!has('vim_starting')")); + + // Test target + await host.call("execute", [ + "runtime plugin/denops.vim", + ], ""); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is empty", async (t) => { + await withHost({ + mode, + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await host.call("execute", [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + ], ""); + await wait(() => host.call("eval", "!has('vim_starting')")); + + // Test target + await host.call("execute", [ + "let g:denops_server_addr = ''", + "runtime plugin/denops.vim", + ], ""); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is valid", async (t) => { + await using server = await useSharedServer(); + + await withHost({ + mode, + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await host.call("execute", [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + ], ""); + await wait(() => host.call("eval", "!has('vim_starting')")); + + // Test target + await host.call("execute", [ + `let g:denops_server_addr = '${server.addr}'`, + "runtime plugin/denops.vim", + ], ""); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("connects to the shared server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsReady"], + ); + }); + + await t.step("does not output messages", async () => { + await delay(MESSAGE_DELAY); + assertEquals(outputs, []); + }); + }, + }); + }); + + await t.step("if `g:denops_server_addr` is invalid", async (t) => { + // NOTE: Get a non-existent address. + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const not_exists_address = `127.0.0.1:${listener.addr.port}`; + listener.close(); + + await withHost({ + mode, + fn: async ({ host, stderr }) => { + const outputs: string[] = []; + stderr.pipeTo( + new WritableStream({ write: (s) => void outputs.push(s) }), + ).catch(() => {}); + await host.call("execute", [ + "let g:__test_denops_events = []", + "autocmd User DenopsProcessStarted call add(g:__test_denops_events, 'DenopsProcessStarted')", + "autocmd User DenopsReady call add(g:__test_denops_events, 'DenopsReady')", + ], ""); + await wait(() => host.call("eval", "!has('vim_starting')")); + + // Test target + await host.call("execute", [ + `let g:denops_server_addr = '${not_exists_address}'`, + "runtime plugin/denops.vim", + ], ""); + + await wait( + () => host.call("eval", "denops#server#status() ==# 'running'"), + ); + + await t.step("starts a local server", async () => { + assertEquals( + await host.call("eval", "g:__test_denops_events"), + ["DenopsProcessStarted", "DenopsReady"], + ); + }); + + await t.step("outputs warning message after delayed", async () => { + await delay(MESSAGE_DELAY); + assertMatch( + outputs.join(""), + /Failed to connect channel `127\.0\.0\.1:[0-9]+`:/, + ); + }); + }, + }); + }); + }); + }); +} diff --git a/denops/@denops-private/testdata/dummy_invalid_constraint_plugin.ts b/tests/denops/testdata/dummy_invalid_constraint_plugin.ts similarity index 69% rename from denops/@denops-private/testdata/dummy_invalid_constraint_plugin.ts rename to tests/denops/testdata/dummy_invalid_constraint_plugin.ts index 7d490768..66f512a9 100644 --- a/denops/@denops-private/testdata/dummy_invalid_constraint_plugin.ts +++ b/tests/denops/testdata/dummy_invalid_constraint_plugin.ts @@ -1,4 +1,4 @@ -import type { Entrypoint } from "https://deno.land/x/denops_core@v6.1.0/mod.ts"; +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; export const main: Entrypoint = (_denops) => { // Mimic the situation diff --git a/denops/@denops-private/testdata/dummy_invalid_constraint_plugin2.ts b/tests/denops/testdata/dummy_invalid_constraint_plugin2.ts similarity index 71% rename from denops/@denops-private/testdata/dummy_invalid_constraint_plugin2.ts rename to tests/denops/testdata/dummy_invalid_constraint_plugin2.ts index d0d8c59c..e38a3fe9 100644 --- a/denops/@denops-private/testdata/dummy_invalid_constraint_plugin2.ts +++ b/tests/denops/testdata/dummy_invalid_constraint_plugin2.ts @@ -1,4 +1,4 @@ -import type { Entrypoint } from "https://deno.land/x/denops_core@v6.1.0/mod.ts"; +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; export const main: Entrypoint = (_denops) => { // Mimic the situation diff --git a/tests/denops/testdata/dummy_invalid_dispose_plugin.ts b/tests/denops/testdata/dummy_invalid_dispose_plugin.ts new file mode 100644 index 00000000..7d2cc862 --- /dev/null +++ b/tests/denops/testdata/dummy_invalid_dispose_plugin.ts @@ -0,0 +1,9 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +export const main: Entrypoint = (_denops) => { + return { + [Symbol.asyncDispose]: () => { + throw new Error("This is dummy error in async dispose"); + }, + }; +}; diff --git a/denops/@denops-private/testdata/dummy_invalid_plugin.ts b/tests/denops/testdata/dummy_invalid_plugin.ts similarity index 53% rename from denops/@denops-private/testdata/dummy_invalid_plugin.ts rename to tests/denops/testdata/dummy_invalid_plugin.ts index 5cb57ab6..c2551a5d 100644 --- a/denops/@denops-private/testdata/dummy_invalid_plugin.ts +++ b/tests/denops/testdata/dummy_invalid_plugin.ts @@ -1,4 +1,4 @@ -import type { Entrypoint } from "https://deno.land/x/denops_core@v6.1.0/mod.ts"; +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; export const main: Entrypoint = (_denops) => { throw new Error("This is dummy error"); diff --git a/tests/denops/testdata/dummy_invalid_wait_plugin.ts b/tests/denops/testdata/dummy_invalid_wait_plugin.ts new file mode 100644 index 00000000..f66bd16f --- /dev/null +++ b/tests/denops/testdata/dummy_invalid_wait_plugin.ts @@ -0,0 +1,7 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; +import { delay } from "jsr:@std/async@1.0.1/delay"; + +export const main: Entrypoint = async (_denops) => { + await delay(1000); + throw new Error("This is dummy error"); +}; diff --git a/tests/denops/testdata/dummy_plugins/denops/@dummy_namespace/main.ts b/tests/denops/testdata/dummy_plugins/denops/@dummy_namespace/main.ts new file mode 100644 index 00000000..7501ca3a --- /dev/null +++ b/tests/denops/testdata/dummy_plugins/denops/@dummy_namespace/main.ts @@ -0,0 +1,6 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +// NOTE: This should not be called, a directory starting with '@' is not a denops plugin. +export const main: Entrypoint = async (denops) => { + await denops.cmd("echo 'Hello, Denops!'"); +}; diff --git a/tests/denops/testdata/dummy_plugins/denops/dummy.invalid_name/main.ts b/tests/denops/testdata/dummy_plugins/denops/dummy.invalid_name/main.ts new file mode 100644 index 00000000..93cff944 --- /dev/null +++ b/tests/denops/testdata/dummy_plugins/denops/dummy.invalid_name/main.ts @@ -0,0 +1,6 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +// NOTE: This should not be called, a directory contains '.' is not a valid denops plugin. +export const main: Entrypoint = async (denops) => { + await denops.cmd("echo 'Hello, Denops!'"); +}; diff --git a/tests/denops/testdata/dummy_plugins/denops/dummy_invalid/main.ts b/tests/denops/testdata/dummy_plugins/denops/dummy_invalid/main.ts new file mode 100644 index 00000000..c2551a5d --- /dev/null +++ b/tests/denops/testdata/dummy_plugins/denops/dummy_invalid/main.ts @@ -0,0 +1,5 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +export const main: Entrypoint = (_denops) => { + throw new Error("This is dummy error"); +}; diff --git a/tests/denops/testdata/dummy_plugins/denops/dummy_valid/main.ts b/tests/denops/testdata/dummy_plugins/denops/dummy_valid/main.ts new file mode 100644 index 00000000..342d9196 --- /dev/null +++ b/tests/denops/testdata/dummy_plugins/denops/dummy_valid/main.ts @@ -0,0 +1,5 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +export const main: Entrypoint = async (denops) => { + await denops.cmd("echo 'Hello, Denops!'"); +}; diff --git a/tests/denops/testdata/dummy_valid_dispose_plugin.ts b/tests/denops/testdata/dummy_valid_dispose_plugin.ts new file mode 100644 index 00000000..9ab42ba0 --- /dev/null +++ b/tests/denops/testdata/dummy_valid_dispose_plugin.ts @@ -0,0 +1,9 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; + +export const main: Entrypoint = (denops) => { + return { + [Symbol.asyncDispose]: async () => { + await denops.cmd("echo 'Goodbye, Denops!'"); + }, + }; +}; diff --git a/denops/@denops-private/testdata/dummy_valid_plugin.ts b/tests/denops/testdata/dummy_valid_plugin.ts similarity index 75% rename from denops/@denops-private/testdata/dummy_valid_plugin.ts rename to tests/denops/testdata/dummy_valid_plugin.ts index bfda6538..b19c72e0 100644 --- a/denops/@denops-private/testdata/dummy_valid_plugin.ts +++ b/tests/denops/testdata/dummy_valid_plugin.ts @@ -1,4 +1,4 @@ -import type { Entrypoint } from "https://deno.land/x/denops_core@v6.1.0/mod.ts"; +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; export const main: Entrypoint = async (denops) => { denops.dispatcher = { diff --git a/tests/denops/testdata/dummy_valid_wait_plugin.ts b/tests/denops/testdata/dummy_valid_wait_plugin.ts new file mode 100644 index 00000000..bcd80b04 --- /dev/null +++ b/tests/denops/testdata/dummy_valid_wait_plugin.ts @@ -0,0 +1,6 @@ +import type { Entrypoint } from "jsr:@denops/core@7.0.0"; +import { delay } from "jsr:@std/async@1.0.1/delay"; + +export const main: Entrypoint = async (_denops) => { + await delay(1000); +}; diff --git a/tests/denops/testdata/shared_server_test_no_verbose.ts b/tests/denops/testdata/shared_server_test_no_verbose.ts new file mode 100644 index 00000000..ef15f41c --- /dev/null +++ b/tests/denops/testdata/shared_server_test_no_verbose.ts @@ -0,0 +1,7 @@ +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { useSharedServer } from "/denops-testutil/shared_server.ts"; + +{ + await using _server = await useSharedServer(); + await delay(100); +} diff --git a/tests/denops/testdata/shared_server_test_verbose_true.ts b/tests/denops/testdata/shared_server_test_verbose_true.ts new file mode 100644 index 00000000..d55c7b31 --- /dev/null +++ b/tests/denops/testdata/shared_server_test_verbose_true.ts @@ -0,0 +1,7 @@ +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { useSharedServer } from "/denops-testutil/shared_server.ts"; + +{ + await using _server = await useSharedServer({ verbose: true }); + await delay(100); +} diff --git a/denops/@denops-private/testutil/cli.ts b/tests/denops/testutil/cli.ts similarity index 100% rename from denops/@denops-private/testutil/cli.ts rename to tests/denops/testutil/cli.ts diff --git a/denops/@denops-private/testutil/conf.ts b/tests/denops/testutil/conf.ts similarity index 76% rename from denops/@denops-private/testutil/conf.ts rename to tests/denops/testutil/conf.ts index 61b66fda..743f5581 100644 --- a/denops/@denops-private/testutil/conf.ts +++ b/tests/denops/testutil/conf.ts @@ -1,8 +1,6 @@ -import { - fromFileUrl, - resolve, - SEPARATOR as SEP, -} from "https://deno.land/std@0.217.0/path/mod.ts"; +import { fromFileUrl } from "jsr:@std/path@1.0.2/from-file-url"; +import { resolve } from "jsr:@std/path@1.0.2/resolve"; +import { SEPARATOR as SEP } from "jsr:@std/path@1.0.2/constants"; let conf: Config | undefined; @@ -11,6 +9,7 @@ export interface Config { vimExecutable: string; nvimExecutable: string; verbose: boolean; + timeout?: number; } export function getConfig(): Config { @@ -20,11 +19,15 @@ export function getConfig(): Config { const denopsPath = Deno.env.get("DENOPS_TEST_DENOPS_PATH") ?? fromFileUrl(new URL("../../..", import.meta.url)); const verbose = Deno.env.get("DENOPS_TEST_VERBOSE"); + const timeout = Number.parseFloat( + Deno.env.get("DENOPS_TEST_TIMEOUT") ?? "NaN", + ); conf = { denopsPath: removeTrailingSep(resolve(denopsPath)), vimExecutable: Deno.env.get("DENOPS_TEST_VIM_EXECUTABLE") ?? "vim", nvimExecutable: Deno.env.get("DENOPS_TEST_NVIM_EXECUTABLE") ?? "nvim", verbose: verbose === "1" || verbose === "true", + timeout: timeout >= 0 ? timeout : undefined, }; return conf; } diff --git a/denops/@denops-private/testutil/conf_test.ts b/tests/denops/testutil/conf_test.ts similarity index 96% rename from denops/@denops-private/testutil/conf_test.ts rename to tests/denops/testutil/conf_test.ts index 3f1bfe15..05417ad2 100644 --- a/denops/@denops-private/testutil/conf_test.ts +++ b/tests/denops/testutil/conf_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { assertEquals } from "jsr:@std/assert@1.0.1"; import { _internal } from "./conf.ts"; Deno.test({ diff --git a/tests/denops/testutil/host.ts b/tests/denops/testutil/host.ts new file mode 100644 index 00000000..a95aebda --- /dev/null +++ b/tests/denops/testutil/host.ts @@ -0,0 +1,87 @@ +import { Host } from "/denops-private/host.ts"; +import { Neovim } from "/denops-private/host/nvim.ts"; +import { Vim } from "/denops-private/host/vim.ts"; +import { withNeovim, WithOptions, withVim } from "./with.ts"; + +export type HostFn = (helper: { + mode: "vim" | "nvim"; + host: Host; + stdout: ReadableStream; + stderr: ReadableStream; +}) => T; + +export interface WithHostOptions extends Omit, "fn"> { + fn: HostFn; + /** Run mode. */ + mode: "vim" | "nvim"; +} + +export function withHost( + options: WithHostOptions, +): Promise> { + const { mode, fn, ...withOptions } = options; + if (mode === "vim") { + return withVim({ + fn: async ({ reader, writer, stdout, stderr }) => { + await using host = new Vim(reader, writer); + return await fn({ mode, host, stdout, stderr }); + }, + ...withOptions, + }); + } + if (mode === "nvim") { + return withNeovim({ + fn: async ({ reader, writer, stdout, stderr }) => { + await using host = new Neovim(reader, writer); + return await fn({ mode, host, stdout, stderr }); + }, + ...withOptions, + }); + } + return Promise.reject(new TypeError(`Invalid mode: ${mode}`)); +} + +export type TestFn = (helper: { + mode: "vim" | "nvim"; + host: Host; + t: Deno.TestContext; + stdout: ReadableStream; + stderr: ReadableStream; +}) => void | Promise; + +export interface TestHostOptions + extends + Omit>, "fn" | "mode">, + Pick { + fn: TestFn; + /** Test mode. */ + mode?: "vim" | "nvim" | "all"; + /** Test name. */ + name?: string; +} + +export function testHost( + options: TestHostOptions, +): void { + const { mode = "all", fn, name, ignore, only, ...hostOptions } = options; + if (mode === "all") { + testHost({ ...options, mode: "vim" }); + testHost({ ...options, mode: "nvim" }); + } else if (mode === "vim" || mode === "nvim") { + const prefix = name ? `${name} ` : ""; + Deno.test({ + ignore, + only, + name: `${prefix}(${mode})`, + fn: async (t) => { + await withHost>({ + mode, + fn: (helper) => fn({ ...helper, t }), + ...hostOptions, + }); + }, + }); + } else { + throw new TypeError(`Invalid mode: ${mode}`); + } +} diff --git a/tests/denops/testutil/mock.ts b/tests/denops/testutil/mock.ts new file mode 100644 index 00000000..fc32b318 --- /dev/null +++ b/tests/denops/testutil/mock.ts @@ -0,0 +1,114 @@ +import { AssertionError, unimplemented } from "jsr:@std/assert@1.0.1"; +import type { Meta } from "jsr:@denops/core@7.0.0"; + +/** Returns a Promise that is never resolves or rejects. */ +export function pendingPromise(): Promise { + return new Promise(() => {}); +} + +/** Returns a fake `TcpListener` instance. */ +export function createFakeTcpListener(): Deno.TcpListener { + let closeWaiter: PromiseWithResolvers | undefined = Promise + .withResolvers(); + closeWaiter.promise.catch(() => {}); + return { + get addr(): Deno.NetAddr { + return unimplemented(); + }, + get rid() { + return unimplemented(); + }, + ref: () => unimplemented(), + unref: () => unimplemented(), + accept: () => pendingPromise(), + close() { + // NOTE: Listener.close() throws if already closed. + if (closeWaiter == null) { + throw new AssertionError("fake-tcp-listner-already-closed"); + } + closeWaiter.reject("listener-closed"); + closeWaiter = undefined; + }, + async *[Symbol.asyncIterator]() { + // NOTE: Listener[@@asyncIterator]() returns immediately if already closed. + if (closeWaiter == null) { + return; + } + try { + for (;;) { + yield Promise.race([this.accept(), closeWaiter.promise]); + } + } catch (e) { + if (e !== "listener-closed") { + throw e; + } + } + }, + [Symbol.dispose]() { + // NOTE: Listener[@@dispose]() does not calls .close() if already closed. + if (closeWaiter != null) { + this.close(); + } + }, + }; +} + +/** Returns a fake `TcpConn` instance. */ +export function createFakeTcpConn(): Deno.TcpConn { + return { + get localAddr() { + return unimplemented(); + }, + get remoteAddr() { + return unimplemented(); + }, + get rid() { + return unimplemented(); + }, + get readable() { + return unimplemented(); + }, + get writable() { + return unimplemented(); + }, + ref: () => unimplemented(), + unref: () => unimplemented(), + setNoDelay: () => unimplemented(), + setKeepAlive: () => unimplemented(), + read: () => unimplemented(), + write: () => unimplemented(), + close: () => unimplemented(), + closeWrite: () => unimplemented(), + [Symbol.dispose]() { + try { + this.close(); + } catch { + // NOTE: TcpConn[@@dispose]() does not throws if already closed. + } + }, + }; +} + +/** Returns a fake `Worker` instance. */ +export function createFakeWorker(): Worker { + return { + onerror: () => unimplemented(), + onmessage: () => unimplemented(), + onmessageerror: () => unimplemented(), + postMessage: () => unimplemented(), + addEventListener: () => unimplemented(), + removeEventListener: () => unimplemented(), + dispatchEvent: () => unimplemented(), + terminate: () => unimplemented(), + }; +} + +/** Returns as fake `Meta` object. */ +export function createFakeMeta(): Meta { + return { + mode: "test", + host: "vim", + version: "9.1.457", + platform: "linux", + }; +} diff --git a/tests/denops/testutil/mock_test.ts b/tests/denops/testutil/mock_test.ts new file mode 100644 index 00000000..58ed763c --- /dev/null +++ b/tests/denops/testutil/mock_test.ts @@ -0,0 +1,337 @@ +import { + assert, + assertEquals, + assertInstanceOf, + assertRejects, +} from "jsr:@std/assert@^0.225.2"; +import { promiseState } from "jsr:@lambdalisue/async@2.1.1"; +import { + createFakeTcpConn, + createFakeTcpListener, + createFakeWorker, + pendingPromise, +} from "./mock.ts"; +import { assertThrows } from "jsr:@std/assert@^0.225.1/assert-throws"; +import { + assertSpyCalls, + resolvesNext, + spy, + stub, +} from "jsr:@std/testing@1.0.0-rc.5/mock"; + +// deno-lint-ignore no-explicit-any +type AnyFn = (...args: any[]) => unknown; + +// deno-lint-ignore no-explicit-any +type AnyRecord = Record; + +type MethodKeyOf = ({ + [K in keyof T]: T[K] extends AnyFn ? K : never; +})[keyof T]; + +type GetterKeyOf = ({ + [K in keyof T]: T[K] extends AnyFn ? never : K; +})[keyof T]; + +Deno.test("pendingPromise()", async (t) => { + await t.step("returns a pending Promise", async () => { + const actual = pendingPromise(); + + assertInstanceOf(actual, Promise); + assertEquals(await promiseState(actual), "pending"); + }); +}); + +Deno.test("createFakeTcpListener()", async (t) => { + await t.step("returns a TcpListener like object", async (t) => { + const listener = createFakeTcpListener(); + + await t.step("has own key", async (t) => { + const keys = Reflect.ownKeys( + { + addr: 0, + rid: 0, + ref: 0, + unref: 0, + accept: 0, + close: 0, + [Symbol.asyncIterator]: 0, + [Symbol.dispose]: 0, + } as const satisfies Record, + ); + for (const key of keys) { + await t.step(key.toString(), () => { + assert(key in listener); + }); + } + }); + + await t.step("unimplements", async (t) => { + const unimplementedProps = [ + "addr", + "rid", + ] as const satisfies readonly GetterKeyOf[]; + for (const key of unimplementedProps) { + await t.step(`.${key}`, () => { + assertThrows(() => listener[key], Error, "Unimplemented"); + }); + } + + const unimplementedMethods = [ + "ref", + "unref", + ] as const satisfies readonly MethodKeyOf[]; + for (const key of unimplementedMethods) { + await t.step(`.${key}()`, () => { + assertThrows(() => listener[key](), Error, "Unimplemented"); + }); + } + }); + + await t.step(".accept()", async (t) => { + await t.step("returns a pending Promise", async () => { + const promise = listener.accept(); + + assertInstanceOf(promise, Promise); + assertEquals(await promiseState(promise), "pending"); + }); + }); + + await t.step(".close()", async (t) => { + const iterator = listener[Symbol.asyncIterator](); + const resultPromise = iterator.next(); + + await t.step("closes the conn iterator", async () => { + assertEquals(await promiseState(resultPromise), "pending"); + + listener.close(); + + assertEquals(await promiseState(resultPromise), "fulfilled"); + assertEquals(await resultPromise, { + done: true, + value: undefined, + }); + }); + + await t.step("throws if already closed", () => { + assertThrows(() => listener.close(), Error, "closed"); + }); + }); + + await t.step("[Symbol.asyncIterator]()", async (t) => { + // deno-lint-ignore no-explicit-any + const firstAcceptWaiter = Promise.withResolvers(); + const listener = createFakeTcpListener(); + using listener_accept = stub( + listener, + "accept", + resolvesNext([ + firstAcceptWaiter.promise, + pendingPromise(), + ]), + ); + + let iterator: AsyncIterator; + await t.step("returns the conn iterator", () => { + iterator = listener[Symbol.asyncIterator](); + + assertInstanceOf(iterator.next, Function); + }); + + await t.step("yield a TcpConn when .accept() resolves", async () => { + const resultPromise = iterator.next(); + + assertSpyCalls(listener_accept, 1); + assertEquals(await promiseState(resultPromise), "pending"); + + firstAcceptWaiter.resolve("fake-tcp-conn"); + + assertEquals(await promiseState(resultPromise), "fulfilled"); + assertEquals(await resultPromise, { + done: false, + // deno-lint-ignore no-explicit-any + value: "fake-tcp-conn" as any, + }); + }); + + await t.step("rejects when .accept() rejcets", async () => { + const listener = createFakeTcpListener(); + using listener_accept = stub( + listener, + "accept", + resolvesNext([ + Promise.reject("fake-tcp-listener-accept-error"), + ]), + ); + + const iterator = listener[Symbol.asyncIterator](); + const error = await assertRejects(() => iterator.next()); + + assertEquals(error, "fake-tcp-listener-accept-error"); + assertSpyCalls(listener_accept, 1); + }); + + await t.step( + "returns the closed iterator after .close() calls", + async () => { + listener.close(); + + const iterator = listener[Symbol.asyncIterator](); + const result = await iterator.next(); + + assertEquals(result, { done: true, value: undefined }); + }, + ); + }); + + await t.step("[Symbol.dispose]()", async (t) => { + const listener = createFakeTcpListener(); + const listener_close = spy(listener, "close"); + + await t.step("calls .close()", () => { + assertSpyCalls(listener_close, 0); + + listener[Symbol.dispose](); + + assertSpyCalls(listener_close, 1); + }); + + await t.step("does not calls .close() if already closed", () => { + assertSpyCalls(listener_close, 1); + + listener[Symbol.dispose](); + + assertSpyCalls(listener_close, 1); + }); + }); + }); +}); + +Deno.test("createFakeTcpConn()", async (t) => { + await t.step("returns a TcpConn like object", async (t) => { + const conn = createFakeTcpConn(); + + await t.step("has own key", async (t) => { + const keys = Reflect.ownKeys( + { + localAddr: 0, + remoteAddr: 0, + readable: 0, + writable: 0, + rid: 0, + ref: 0, + unref: 0, + setNoDelay: 0, + setKeepAlive: 0, + read: 0, + write: 0, + close: 0, + closeWrite: 0, + [Symbol.dispose]: 0, + } as const satisfies Record, + ); + for (const key of keys) { + await t.step(key.toString(), () => { + assert(key in conn); + }); + } + }); + + await t.step("unimplements", async (t) => { + const unimplementedProps = [ + "localAddr", + "remoteAddr", + "rid", + "readable", + "writable", + ] as const satisfies readonly GetterKeyOf[]; + for (const key of unimplementedProps) { + await t.step(`.${key}`, () => { + assertThrows(() => conn[key], Error, "Unimplemented"); + }); + } + + const unimplementedMethods = [ + "ref", + "unref", + "setNoDelay", + "setKeepAlive", + "read", + "write", + "close", + "closeWrite", + ] as const satisfies readonly MethodKeyOf[]; + for (const key of unimplementedMethods) { + await t.step(`.${key}()`, () => { + assertThrows(() => (conn[key] as AnyFn)(), Error, "Unimplemented"); + }); + } + }); + + await t.step("[Symbol.dispose]()", async (t) => { + await t.step("calls .close()", () => { + using listener_close = stub(conn, "close"); + assertSpyCalls(listener_close, 0); + + conn[Symbol.dispose](); + + assertSpyCalls(listener_close, 1); + }); + + await t.step("does not throws if .close() throws", () => { + using listener_close = stub(conn, "close", () => { + throw "fake-close-throws-a-error"; + }); + assertSpyCalls(listener_close, 0); + + conn[Symbol.dispose](); + + assertSpyCalls(listener_close, 1); + }); + }); + }); +}); + +Deno.test("createFakeWorker()", async (t) => { + await t.step("returns a Worker like object", async (t) => { + const worker = createFakeWorker(); + + await t.step("has own key", async (t) => { + const keys = Reflect.ownKeys( + { + onerror: 0, + onmessage: 0, + onmessageerror: 0, + postMessage: 0, + addEventListener: 0, + removeEventListener: 0, + dispatchEvent: 0, + terminate: 0, + } as const satisfies Record, + ); + for (const key of keys) { + await t.step(key.toString(), () => { + assert(key in worker); + }); + } + }); + + await t.step("unimplements", async (t) => { + const unimplementedMethods = [ + "onerror", + "onmessage", + "onmessageerror", + "postMessage", + "addEventListener", + "removeEventListener", + "dispatchEvent", + "terminate", + ] as const satisfies readonly MethodKeyOf[]; + for (const key of unimplementedMethods) { + await t.step(`.${key}()`, () => { + assertThrows(() => (worker[key] as AnyFn)(), Error, "Unimplemented"); + }); + } + }); + }); +}); diff --git a/tests/denops/testutil/shared_server.ts b/tests/denops/testutil/shared_server.ts new file mode 100644 index 00000000..8756663e --- /dev/null +++ b/tests/denops/testutil/shared_server.ts @@ -0,0 +1,117 @@ +import { assert } from "jsr:@std/assert@1.0.1"; +import { deadline } from "jsr:@std/async@1.0.1/deadline"; +import { resolve } from "jsr:@std/path@1.0.2/resolve"; +import { channel, pop } from "jsr:@lambdalisue/streamtools@1.0.0"; +import { tap } from "jsr:@milly/streams@^1.0.0/transform/tap"; +import { getConfig } from "./conf.ts"; + +const DEFAULT_TIMEOUT = 30_000; +const origLog = console.log.bind(console); +const origError = console.error.bind(console); +const noop = () => {}; + +export interface UseSharedServerOptions { + /** The port number of the shared server. */ + port?: number; + /** Print shared-server messages. */ + verbose?: boolean; + /** Environment variables. */ + env?: Record; + /** Timeout for shared server initialization. */ + timeout?: number; +} + +export interface UseSharedServerResult extends AsyncDisposable { + /** Address to connect to the shared server. */ + addr: { + host: string; + port: number; + toString(): string; + }; + /** Shared server standard output. */ + stdout: ReadableStream; + /** Shared server error output. */ + stderr: ReadableStream; +} + +/** + * Start a shared server and return an address for testing. + */ +export async function useSharedServer( + options?: UseSharedServerOptions, +): Promise { + const { denopsPath, port = 0, verbose, env, timeout = DEFAULT_TIMEOUT } = { + ...getConfig(), + ...options, + }; + const aborter = new AbortController(); + const { signal } = aborter; + + const cmd = Deno.execPath(); + const script = resolve(denopsPath, "denops/@denops-private/cli.ts"); + const args = [ + "run", + "-A", + "--no-lock", + script, + "--identity", + "--port", + `${port}`, + ]; + const proc = new Deno.Command(cmd, { + args, + stdout: "piped", + stderr: "piped", + env, + signal, + }).spawn(); + + let stdout = proc.stdout.pipeThrough(new TextDecoderStream(), { signal }); + let stderr = proc.stderr.pipeThrough(new TextDecoderStream(), { signal }); + if (verbose) { + stdout = stdout.pipeThrough(tap((s) => origLog(s))); + stderr = stderr.pipeThrough(tap((s) => origError(s))); + } + const { writer: stdoutWriter, reader: stdoutReader } = channel(); + stdout.pipeTo(stdoutWriter).catch(noop); + const { writer: stderrWriter, reader: stderrReader } = channel(); + stderr.pipeTo(stderrWriter).catch(noop); + + const [addrReader, stdoutReader2] = stdoutReader.tee(); + const addrPromise = pop(addrReader); + addrPromise.finally(() => addrReader.cancel()); + + const abort = async (reason: unknown) => { + try { + aborter.abort(reason); + } catch { + // Already exited, do nothing. + } + await proc.status; + await Promise.allSettled([ + proc.stdout.cancel(reason), + proc.stderr.cancel(reason), + ]); + }; + + try { + const addr = await deadline(addrPromise, timeout); + assert(typeof addr === "string"); + const [_, host, port] = addr.match(/^([^:]*):(\d+)(?:\n|$)/) ?? []; + return { + addr: { + host, + port: Number.parseInt(port), + toString: () => `${host}:${port}`, + }, + stdout: stdoutReader2, + stderr: stderrReader, + async [Symbol.asyncDispose]() { + await abort("useSharedServer disposed"); + }, + }; + } catch (e) { + await abort(e); + throw e; + } +} diff --git a/tests/denops/testutil/shared_server_test.ts b/tests/denops/testutil/shared_server_test.ts new file mode 100644 index 00000000..8370de3e --- /dev/null +++ b/tests/denops/testutil/shared_server_test.ts @@ -0,0 +1,121 @@ +import { + assertEquals, + assertInstanceOf, + assertMatch, + assertNotMatch, + assertRejects, +} from "jsr:@std/assert@1.0.1"; +import { delay } from "jsr:@std/async@1.0.1/delay"; +import { join } from "jsr:@std/path@1.0.2/join"; +import { useSharedServer } from "./shared_server.ts"; + +Deno.test("useSharedServer()", async (t) => { + const HAS_DENOPS_TEST_VERBOSE = Deno.env.has("DENOPS_TEST_VERBOSE"); + + await t.step({ + name: "if `verbose` is not specified", + ignore: HAS_DENOPS_TEST_VERBOSE, + fn: async (t) => { + await t.step("returns `result.addr`", async (t) => { + await using server = await useSharedServer(); + const { addr } = server; + + await t.step("`addr.host` is string", () => { + assertEquals(addr.host, "127.0.0.1"); + }); + await t.step("`addr.port` is number", () => { + assertEquals(typeof addr.port, "number"); + }); + await t.step("`addr.toString()` returns the address", () => { + assertMatch(addr.toString(), /^127\.0\.0\.1:\d+$/); + }); + }); + + await t.step("returns `result.stdout`", async () => { + await using server = await useSharedServer(); + assertInstanceOf(server.stdout, ReadableStream); + const outputs: string[] = []; + server.stdout.pipeTo( + new WritableStream({ write: (line) => void outputs.push(line) }), + ).catch(() => {}); + await delay(100); + assertMatch(outputs.join("\n"), /Listen denops clients on/); + }); + + await t.step("does not output stdout", async () => { + const proc = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-env", + "--allow-read", + "--allow-run", + resolve("shared_server_test_no_verbose.ts"), + ], + stdout: "piped", + }).spawn(); + const output = await proc.output(); + const stdout = new TextDecoder().decode(output.stdout); + assertNotMatch(stdout, /Listen denops clients on/); + }); + }, + }); + + await t.step("if `verbose` is true", async (t) => { + await t.step("returns `result.addr`", async (t) => { + await using server = await useSharedServer({ verbose: true }); + const { addr } = server; + + await t.step("`addr.host` is string", () => { + assertEquals(addr.host, "127.0.0.1"); + }); + await t.step("`addr.port` is number", () => { + assertEquals(typeof addr.port, "number"); + }); + await t.step("`addr.toString()` returns the address", () => { + assertMatch(addr.toString(), /^127\.0\.0\.1:\d+$/); + }); + }); + + await t.step("returns `result.stdout`", async () => { + await using server = await useSharedServer({ verbose: true }); + assertInstanceOf(server.stdout, ReadableStream); + const outputs: string[] = []; + server.stdout.pipeTo( + new WritableStream({ write: (line) => void outputs.push(line) }), + ).catch(() => {}); + await delay(100); + assertMatch(outputs.join("\n"), /Listen denops clients on/); + }); + + await t.step("outputs stdout", async () => { + const proc = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-env", + "--allow-read", + "--allow-run", + resolve("shared_server_test_verbose_true.ts"), + ], + stdout: "piped", + }).spawn(); + const output = await proc.output(); + const stdout = new TextDecoder().decode(output.stdout); + assertMatch(stdout, /Listen denops clients on/); + }); + }); + + await t.step("closes child process when rejectes", async () => { + await assertRejects( + async () => { + await useSharedServer({ timeout: 0 }); + }, + Error, + "Signal timed out", + ); + }); +}); + +/** Resolve testdata script path. */ +function resolve(path: string): string { + return join(import.meta.dirname!, `../testdata/${path}`); +} diff --git a/tests/denops/testutil/wait.ts b/tests/denops/testutil/wait.ts new file mode 100644 index 00000000..96a93141 --- /dev/null +++ b/tests/denops/testutil/wait.ts @@ -0,0 +1,71 @@ +import { AssertionError } from "jsr:@std/assert@1.0.1/assertion-error"; +import { getConfig } from "./conf.ts"; + +const DEFAULT_TIMEOUT = 30_000; +const DEFAULT_INTERVAL = 50; + +export type WaitOptions = { + /** + * Timeout period to an exception is thrown. + * @default {10_000} + */ + timeout?: number; + /** + * Polling interval. + * @default {50} + */ + interval?: number; + /** Message for timeout error. */ + message?: string; +}; + +/** + * Calls `fn` periodically and returns the result if it is TRUE. + * An exception is thrown when the timeout expires. + */ +export async function wait( + fn: () => T | Promise, + options?: WaitOptions, +): Promise { + const { + timeout = DEFAULT_TIMEOUT, + interval = DEFAULT_INTERVAL, + message, + } = { ...getConfig(), ...options }; + const TIMEOUT = {}; + + let timeoutId: number | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(TIMEOUT), timeout); + }); + + let intervalId: number | undefined; + const delay = () => + new Promise((resolve) => { + intervalId = setTimeout(resolve, interval); + }); + + try { + return await Promise.race([ + (async () => { + for (;;) { + const res = await fn(); + if (res) { + return res; + } + await delay(); + } + })(), + timeoutPromise, + ]); + } catch (e) { + if (e === TIMEOUT) { + const suffix = message ? `: ${message}` : "."; + throw new AssertionError(`Timeout in ${timeout} millisec${suffix}`); + } + throw e; + } finally { + clearTimeout(timeoutId); + clearTimeout(intervalId); + } +} diff --git a/tests/denops/testutil/wait_test.ts b/tests/denops/testutil/wait_test.ts new file mode 100644 index 00000000..d9f2f1ed --- /dev/null +++ b/tests/denops/testutil/wait_test.ts @@ -0,0 +1,75 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.1"; +import { + assertSpyCalls, + resolvesNext, + returnsNext, + spy, +} from "jsr:@std/testing@1.0.0-rc.5/mock"; +import { FakeTime } from "jsr:@std/testing@1.0.0-rc.5/time"; +import { wait } from "./wait.ts"; + +Deno.test("wait()", async (t) => { + await t.step("calls `fn` periodically", async () => { + using time = new FakeTime(); + const fn = spy(returnsNext([0, 0, 1])); + const p = wait(fn); + assertSpyCalls(fn, 1); + await time.tickAsync(50); + assertSpyCalls(fn, 2); + await time.tickAsync(50); + assertSpyCalls(fn, 3); + assertEquals(await p, 1); + }); + + await t.step("calls `fn` specified `interval`", async () => { + using time = new FakeTime(); + const fn = spy(returnsNext([0, 0, 1])); + const p = wait(fn, { interval: 100 }); + assertSpyCalls(fn, 1); + await time.tickAsync(100); + assertSpyCalls(fn, 2); + await time.tickAsync(100); + assertSpyCalls(fn, 3); + assertEquals(await p, 1); + }); + + await t.step("resolves with `fn` return value", async () => { + const fn = spy(returnsNext([42])); + const actual = await wait(fn); + assertEquals(actual, 42); + }); + + await t.step("resolves with only truthy `fn` return value", async () => { + const fn = spy(returnsNext([0, false, null, undefined, "", "foo"])); + const actualPromise = wait(fn, { interval: 0 }); + assertEquals(await actualPromise, "foo"); + }); + + await t.step("rejects when `fn` throws", async () => { + const fn = spy(returnsNext([new Error("fn-throws")])); + await assertRejects(() => wait(fn), Error, "fn-throws"); + }); + + await t.step("rejects when `fn` rejects", async () => { + const fn = spy(resolvesNext([new Error("fn-throws")])); + await assertRejects(() => wait(fn), Error, "fn-throws"); + }); + + await t.step("rejects when `timeout`", async () => { + using time = new FakeTime(); + const fn = spy(returnsNext([0, 0])); + const p = wait(fn, { timeout: 150, interval: 100 }); + await time.tickAsync(150); + await assertRejects(() => p, Error, "Timeout in 150 millisec."); + assertSpyCalls(fn, 2); + }); + + await t.step("rejects with `message`", async () => { + using time = new FakeTime(); + const fn = spy(returnsNext([0, 0])); + const p = wait(fn, { message: "foo bar", timeout: 150, interval: 100 }); + await time.tickAsync(150); + await assertRejects(() => p, Error, "Timeout in 150 millisec: foo bar"); + assertSpyCalls(fn, 2); + }); +}); diff --git a/tests/denops/testutil/with.ts b/tests/denops/testutil/with.ts new file mode 100644 index 00000000..421eba7e --- /dev/null +++ b/tests/denops/testutil/with.ts @@ -0,0 +1,152 @@ +import { channel } from "jsr:@lambdalisue/streamtools@1.0.0"; +import { tap } from "jsr:@milly/streams@^1.0.0/transform/tap"; +import { ADDR_ENV_NAME } from "./cli.ts"; +import { getConfig } from "./conf.ts"; + +const script = new URL("./cli.ts", import.meta.url); +const origLog = console.log.bind(console); +const origError = console.error.bind(console); +const noop = () => {}; + +export type Fn = (helper: { + reader: ReadableStream; + writer: WritableStream; + stdout: ReadableStream; + stderr: ReadableStream; +}) => T; + +export interface WithOptions { + fn: Fn; + /** Print Vim messages (echomsg). */ + verbose?: boolean; + /** Vim commands to be executed before the startup. */ + prelude?: string[]; + /** Vim commands to be executed after the startup. */ + postlude?: string[]; + /** Environment variables. */ + env?: Record; +} + +export function withVim( + options: WithOptions, +): Promise> { + const conf = getConfig(); + const exec = Deno.execPath(); + const commands = [ + ...(options.prelude ?? []), + `set runtimepath^=${conf.denopsPath}`, + "let g:denops_test_channel = job_start(" + + ` ['${exec}', 'run', '--allow-all', '${script}'],` + + ` {'mode': 'json', 'err_mode': 'nl'}` + + ")", + ...(options.postlude ?? []), + ]; + const cmd = conf.vimExecutable; + const args = [ + "-u", + "NONE", // Disable vimrc, plugins, defaults.vim + "-i", + "NONE", // Disable viminfo + "-n", // Disable swap file + "-N", // Disable compatible mode + "-X", // Disable xterm + "-e", // Start Vim in Ex mode + "-s", // Silent or batch mode ("-e" is required before) + "-V1", // Verbose level 1 (Echo messages to stderr) + "-c", + "visual", // Go to Normal mode + "-c", + "set columns=9999", // Avoid unwilling output newline + ...commands.flatMap((c) => ["-c", c]), + ]; + return withProcess(cmd, args, { verbose: conf.verbose, ...options }); +} + +export function withNeovim( + options: WithOptions, +): Promise> { + const conf = getConfig(); + const exec = Deno.execPath(); + const commands = [ + ...(options.prelude ?? []), + `set runtimepath^=${conf.denopsPath}`, + "let g:denops_test_channel = jobstart(" + + ` ['${exec}', 'run', '--allow-all', '${script}'],` + + ` {'rpc': v:true}` + + ")", + ...(options.postlude ?? []), + ]; + const cmd = conf.nvimExecutable; + const args = [ + "--clean", + "--headless", + "-n", // Disable swap file + "-V1", // Verbose level 1 (Echo messages to stderr) + "-c", + "set columns=9999", // Avoid unwilling output newline + ...commands.flatMap((c) => ["-c", c]), + ]; + return withProcess(cmd, args, { verbose: conf.verbose, ...options }); +} + +async function withProcess( + cmd: string, + args: string[], + { fn, env, verbose }: WithOptions, +): Promise> { + const aborter = new AbortController(); + const { signal } = aborter; + const listener = Deno.listen({ + hostname: "127.0.0.1", + port: 0, // Automatically select free port + }); + + const command = new Deno.Command(cmd, { + args, + stdin: "piped", + stdout: "piped", + stderr: "piped", + env: { + ...env, + [ADDR_ENV_NAME]: JSON.stringify(listener.addr), + }, + signal, + }); + const proc = command.spawn(); + + let stdout = proc.stdout.pipeThrough(new TextDecoderStream(), { signal }); + let stderr = proc.stderr.pipeThrough(new TextDecoderStream(), { signal }); + if (verbose) { + stdout = stdout.pipeThrough(tap((s) => origLog(s))); + stderr = stderr.pipeThrough(tap((s) => origError(s))); + } + const { writer: stdoutWriter, reader: stdoutReader } = channel(); + stdout.pipeTo(stdoutWriter).catch(noop); + const { writer: stderrWriter, reader: stderrReader } = channel(); + stderr.pipeTo(stderrWriter).catch(noop); + + const conn = await listener.accept(); + try { + return await fn({ + reader: conn.readable, + writer: conn.writable, + stdout: stdoutReader, + stderr: stderrReader, + }); + } finally { + listener.close(); + try { + aborter.abort("withProcess disposed"); + } catch { + // Already exited, do nothing. + } + await Promise.all([ + proc.stdin.close(), + proc.status, + ]); + await Promise.all([ + proc.stdout.cancel(), + proc.stderr.cancel(), + ]); + } +}