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.
-[](https://github.com/denoland/deno/tree/v1.38.5)
-[](https://github.com/vim/vim/tree/v9.0.2189)
-[](https://github.com/neovim/neovim/tree/v0.9.4)
+[](https://github.com/denoland/deno/tree/v1.45.0)
+[](https://github.com/vim/vim/tree/v9.1.0448)
+[](https://github.com/neovim/neovim/tree/v0.10.0)
[](LICENSE)
-[](https://deno.land/x/denops_core)
[](https://github.com/vim-denops/denops.vim/actions/workflows/test.yml)
[](https://codecov.io/github/vim-denops/denops.vim)
[](doc/denops.txt)
-[](https://doc.deno.land/https/deno.land/x/denops_core/mod.ts)
[](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(),
+ ]);
+ }
+}