├── .github └── workflows │ ├── jsr.yml │ ├── test.yml │ └── update.yml ├── .gitignore ├── .gitmessage ├── .scripts └── apply-supported-versions.ts ├── LICENSE ├── README.md ├── conf.ts ├── conf_test.ts ├── deno.jsonc ├── denops.ts ├── error.ts ├── mod.ts ├── plugin.ts ├── runner.ts ├── stub.ts ├── stub_test.ts ├── tester.ts ├── tester_test.ts ├── with.ts └── with_test.ts /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: jsr 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: ${{ env.DENO_VERSION }} 25 | - name: Publish 26 | run: | 27 | deno run -A jsr:@david/publish-on-tag@0.1.3 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - "**.md" 10 | - "**.ts" 11 | - "deno.jsonc" 12 | - ".github/workflows/test.yml" 13 | workflow_dispatch: 14 | inputs: 15 | denops_branch: 16 | description: 'Denops revision to test' 17 | required: false 18 | default: 'main' 19 | verbose: 20 | type: boolean 21 | required: false 22 | description: 'Enable verbose output' 23 | default: false 24 | 25 | defaults: 26 | run: 27 | shell: bash --noprofile --norc -eo pipefail {0} 28 | 29 | env: 30 | DENOPS_BRANCH: ${{ github.event.inputs.denops_branch || 'main' }} 31 | DENOPS_TEST_VERBOSE: ${{ github.event.inputs.verbose }} 32 | 33 | jobs: 34 | check: 35 | strategy: 36 | matrix: 37 | runner: 38 | - ubuntu-latest 39 | deno_version: 40 | - "1.x" 41 | runs-on: ${{ matrix.runner }} 42 | steps: 43 | - run: git config --global core.autocrlf false 44 | if: runner.os == 'Windows' 45 | 46 | - uses: actions/checkout@v4 47 | 48 | - uses: denoland/setup-deno@v1 49 | with: 50 | deno-version: "${{ matrix.deno_version }}" 51 | 52 | - name: Lint check 53 | run: deno lint 54 | 55 | - name: Format check 56 | run: deno fmt --check 57 | 58 | - name: Type check 59 | run: deno task check 60 | 61 | - name: Doc check 62 | run: deno task check:doc 63 | 64 | - name: Supported version inconsistency check 65 | run: | 66 | deno task apply:supported-versions 67 | git diff --exit-code 68 | 69 | test: 70 | needs: check 71 | 72 | strategy: 73 | fail-fast: false 74 | matrix: 75 | runner: 76 | - windows-latest 77 | - macos-latest 78 | - ubuntu-latest 79 | deno_version: 80 | - "1.45.0" 81 | - "1.x" 82 | host_version: 83 | - vim: "v9.1.0448" 84 | nvim: "v0.10.0" 85 | 86 | runs-on: ${{ matrix.runner }} 87 | 88 | steps: 89 | - run: git config --global core.autocrlf false 90 | if: runner.os == 'Windows' 91 | 92 | - uses: actions/checkout@v4 93 | 94 | - uses: denoland/setup-deno@v1 95 | with: 96 | deno-version: ${{ matrix.deno_version }} 97 | 98 | - name: Get denops 99 | run: | 100 | git clone https://github.com/vim-denops/denops.vim /tmp/denops.vim 101 | echo "DENOPS_TEST_DENOPS_PATH=/tmp/denops.vim" >> "$GITHUB_ENV" 102 | 103 | - name: Try switching denops branch 104 | run: | 105 | git -C /tmp/denops.vim switch ${{ env.DENOPS_BRANCH }} || true 106 | git -C /tmp/denops.vim branch 107 | 108 | - uses: rhysd/action-setup-vim@v1 109 | id: vim 110 | with: 111 | version: "${{ matrix.host_version.vim }}" 112 | 113 | - uses: rhysd/action-setup-vim@v1 114 | id: nvim 115 | with: 116 | neovim: true 117 | version: "${{ matrix.host_version.nvim }}" 118 | 119 | - name: Export executables 120 | run: | 121 | echo "DENOPS_TEST_VIM_EXECUTABLE=${{ steps.vim.outputs.executable }}" >> "$GITHUB_ENV" 122 | echo "DENOPS_TEST_NVIM_EXECUTABLE=${{ steps.nvim.outputs.executable }}" >> "$GITHUB_ENV" 123 | 124 | - name: Check versions 125 | run: | 126 | deno --version 127 | ${DENOPS_TEST_VIM_EXECUTABLE} --version 128 | ${DENOPS_TEST_NVIM_EXECUTABLE} --version 129 | 130 | - name: Perform pre-cache 131 | run: | 132 | deno cache ${DENOPS_TEST_DENOPS_PATH}/denops/@denops-private/mod.ts ./mod.ts 133 | 134 | - name: Run tests 135 | run: deno task test:coverage 136 | timeout-minutes: 15 137 | 138 | - run: | 139 | deno task coverage --lcov > coverage.lcov 140 | 141 | - uses: codecov/codecov-action@v4 142 | with: 143 | os: ${{ runner.os }} 144 | files: ./coverage.lcov 145 | token: ${{ secrets.CODECOV_TOKEN }} 146 | 147 | jsr-publish: 148 | needs: check 149 | runs-on: ubuntu-latest 150 | steps: 151 | - uses: actions/checkout@v4 152 | - uses: denoland/setup-deno@v1 153 | with: 154 | deno-version: "1.x" 155 | - name: Publish (dry-run) 156 | run: | 157 | deno publish --dry-run 158 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: hasundue/molt-action@v1 14 | with: 15 | branch: automation/update-dependencies 16 | config: false 17 | labels: automation 18 | token: ${{ secrets.PA_TOKEN }} 19 | source: "**/*.ts" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | deno.lock 2 | .coverage 3 | -------------------------------------------------------------------------------- /.gitmessage: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Guide (v1.0) 4 | # 5 | # 👍 :+1: Apply changes. 6 | # 7 | # 🌿 :herb: Add or update things for tests. 8 | # ☕ :coffee: Add or update things for developments. 9 | # 📦 :package: Add or update dependencies. 10 | # 📝 :memo: Add or update documentations. 11 | # 12 | # 🐛 :bug: Bugfixes. 13 | # 💋 :kiss: Critical hotfixes. 14 | # 🚿 :shower: Remove features, codes, or files. 15 | # 16 | # 🚀 :rocket: Improve performance. 17 | # 💪 :muscle: Refactor codes. 18 | # 💥 :boom: Breaking changes. 19 | # 💩 :poop: Bad codes needs to be improved. 20 | # 21 | # How to use: 22 | # git config commit.template .gitmessage 23 | # 24 | # Reference: 25 | # https://github.com/lambdalisue/emojiprefix 26 | -------------------------------------------------------------------------------- /.scripts/apply-supported-versions.ts: -------------------------------------------------------------------------------- 1 | import { ensure, is, type Predicate } from "jsr:@core/unknownutil@^4.0.0"; 2 | 3 | export type SupportedVersions = { 4 | deno: string; 5 | vim: string; 6 | neovim: string; 7 | }; 8 | 9 | const isSupportedVersions = is.ObjectOf({ 10 | deno: is.String, 11 | vim: is.String, 12 | neovim: is.String, 13 | }) satisfies Predicate; 14 | 15 | function getSupportedVersionJsonUrl(branch: string): URL { 16 | return new URL( 17 | `https://raw.githubusercontent.com/vim-denops/denops.vim/${branch}/denops/supported_versions.json`, 18 | ); 19 | } 20 | 21 | export async function loadSupportedVersions( 22 | branch?: string, 23 | ): Promise { 24 | const url = getSupportedVersionJsonUrl( 25 | branch ?? Deno.env.get("DENOPS_BRANCH") ?? "main", 26 | ); 27 | const resp = await fetch(url); 28 | const json = await resp.json(); 29 | return ensure(json, isSupportedVersions); 30 | } 31 | 32 | async function updateREADME( 33 | supportedVersions: SupportedVersions, 34 | ): Promise { 35 | const url = new URL(import.meta.resolve("../README.md")); 36 | let text = await Deno.readTextFile(url); 37 | // Deno 38 | text = text.replace( 39 | /Deno\s+\d+\.\d+\.\d+/, 40 | `Deno ${supportedVersions.deno}`, 41 | ); 42 | text = text.replace( 43 | /Deno-Support%20\d+\.\d+\.\d+/, 44 | `Deno-Support%20${supportedVersions.deno}`, 45 | ); 46 | text = text.replace( 47 | /https:\/\/github\.com\/denoland\/deno\/tree\/v\d+\.\d+\.\d+/, 48 | `https://github.com/denoland/deno/tree/v${supportedVersions.deno}`, 49 | ); 50 | // Vim 51 | text = text.replace( 52 | /Vim\s+\d+\.\d+\.\d+/, 53 | `Vim ${supportedVersions.vim}`, 54 | ); 55 | text = text.replace( 56 | /Vim-Support%20\d+\.\d+\.\d+/, 57 | `Vim-Support%20${supportedVersions.vim}`, 58 | ); 59 | text = text.replace( 60 | /https:\/\/github\.com\/vim\/vim\/tree\/v\d+\.\d+\.\d+/, 61 | `https://github.com/vim/vim/tree/v${supportedVersions.vim}`, 62 | ); 63 | // Neovim 64 | text = text.replace( 65 | /Neovim\s+\d+\.\d+\.\d+/, 66 | `Neovim ${supportedVersions.neovim}`, 67 | ); 68 | text = text.replace( 69 | /Neovim-Support%20\d+\.\d+\.\d+/, 70 | `Neovim-Support%20${supportedVersions.neovim}`, 71 | ); 72 | text = text.replace( 73 | /https:\/\/github\.com\/neovim\/neovim\/tree\/v\d+\.\d+\.\d+/, 74 | `https://github.com/neovim/neovim/tree/v${supportedVersions.neovim}`, 75 | ); 76 | await Deno.writeTextFile(url, text); 77 | } 78 | 79 | async function updateGithubWorkflowsTest( 80 | supportedVersions: SupportedVersions, 81 | ): Promise { 82 | const url = new URL(import.meta.resolve("../.github/workflows/test.yml")); 83 | let text = await Deno.readTextFile(url); 84 | // Deno 85 | text = text.replace( 86 | /deno_version:(.*?)"\d+\.\d+\.\d+"/s, 87 | `deno_version:$1"${supportedVersions.deno}"`, 88 | ); 89 | // Vim 90 | text = text.replace( 91 | /vim:(.*?)"v\d+\.\d+\.\d+"/s, 92 | `vim:$1"v${supportedVersions.vim}"`, 93 | ); 94 | // Neovim 95 | text = text.replace( 96 | /nvim:(.*?)"v\d+\.\d+\.\d+"/s, 97 | `nvim:$1"v${supportedVersions.neovim}"`, 98 | ); 99 | await Deno.writeTextFile(url, text); 100 | } 101 | 102 | async function main(): Promise { 103 | const supportedVersions = await loadSupportedVersions(); 104 | await updateREADME(supportedVersions); 105 | await updateGithubWorkflowsTest(supportedVersions); 106 | } 107 | 108 | if (import.meta.main) { 109 | try { 110 | await main(); 111 | } catch (error) { 112 | console.error(error); 113 | Deno.exit(1); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 vim-denops 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📝 @denops/test 2 | 3 | [![JSR](https://jsr.io/badges/@denops/test)](https://jsr.io/@denops/test) 4 | [![Test](https://github.com/vim-denops/deno-denops-test/actions/workflows/test.yml/badge.svg)](https://github.com/vim-denops/deno-denops-test/actions/workflows/test.yml) 5 | [![codecov](https://codecov.io/github/vim-denops/deno-denops-test/branch/main/graph/badge.svg?token=X9O5XB4O1S)](https://codecov.io/github/vim-denops/deno-denops-test) 6 | 7 | [![Deno 1.45.0 or above](https://img.shields.io/badge/Deno-Support%201.45.0-yellowgreen.svg?logo=deno)](https://github.com/denoland/deno/tree/v1.45.0) 8 | [![Vim 9.1.0448 or above](https://img.shields.io/badge/Vim-Support%209.1.0448-yellowgreen.svg?logo=vim)](https://github.com/vim/vim/tree/v9.1.0448) 9 | [![Neovim 0.10.0 or above](https://img.shields.io/badge/Neovim-Support%200.10.0-yellowgreen.svg?logo=neovim&logoColor=white)](https://github.com/neovim/neovim/tree/v0.10.0) 10 | 11 | A [Deno] module designed for testing [denops.vim]. This module is intended to be 12 | used in the unit tests of denops plugins. 13 | 14 | [deno]: https://deno.land/ 15 | [denops.vim]: https://github.com/vim-denops/denops.vim 16 | 17 | > [!NOTE] 18 | > 19 | > To use the `test` function, an environment variable `DENOPS_TEST_DENOPS_PATH` 20 | > is required. Clone the [denops.vim] repository and set the path to this 21 | > environment variable. 22 | > 23 | > Additionally, the following environment variables are available to configure 24 | > the behavior of the `test` function: 25 | > 26 | > - `DENOPS_TEST_VIM_EXECUTABLE`: Path to the Vim executable (default: "vim") 27 | > - `DENOPS_TEST_NVIM_EXECUTABLE`: Path to the Neovim executable (default: 28 | > "nvim") 29 | > - `DENOPS_TEST_VERBOSE`: `1` or `true` to print Vim messages (echomsg) 30 | > - `DENOPS_TEST_CONNECT_TIMEOUT`: Timeout [ms] for connecting to Vim/Neovim 31 | > (default: 30000) 32 | 33 | If you want to test denops plugins with a real Vim and/or Neovim process, use 34 | the `test` function to define a test case, as shown below: 35 | 36 | ```typescript 37 | import { assert, assertEquals, assertFalse } from "jsr:@std/assert"; 38 | import { test } from "jsr:@denops/test"; 39 | 40 | test("vim", "Start Vim to test denops features", async (denops) => { 41 | assertFalse(await denops.call("has", "nvim")); 42 | }); 43 | 44 | test({ 45 | mode: "nvim", 46 | name: "Start Neovim to test denops features", 47 | fn: async (denops) => { 48 | assert(await denops.call("has", "nvim")); 49 | }, 50 | }); 51 | 52 | test({ 53 | mode: "all", 54 | name: "Start Vim and Neovim to test denops features", 55 | fn: async (denops) => { 56 | assertEquals(await denops.call("abs", -4), 4); 57 | }, 58 | }); 59 | 60 | test({ 61 | mode: "any", 62 | name: "Start Vim or Neovim to test denops features", 63 | fn: async (denops) => { 64 | assertEquals(await denops.call("abs", -4), 4); 65 | }, 66 | }); 67 | ``` 68 | 69 | If you want to test denops plugins without a real Vim and/or Neovim process, use 70 | the `DenopsStub` class to create a stub instance of the `Denops` interface, as 71 | shown below: 72 | 73 | ```typescript 74 | import { assertEquals } from "jsr:@std/assert"; 75 | import { DenopsStub } from "jsr:@denops/test"; 76 | 77 | Deno.test("denops.call", async () => { 78 | const denops = new DenopsStub({ 79 | call: (fn, ...args) => { 80 | return Promise.resolve([fn, ...args]); 81 | }, 82 | }); 83 | assertEquals(await denops.call("foo", "bar"), ["foo", "bar"]); 84 | }); 85 | ``` 86 | 87 | ## GitHub Action 88 | 89 | Copy and modify the following GitHub Workflow to run tests in GitHub Action 90 | 91 | ```yaml 92 | name: Test 93 | 94 | on: 95 | push: 96 | branches: 97 | - main 98 | pull_request: 99 | paths: 100 | - "**.md" 101 | - "**.ts" 102 | - "deno.jsonc" 103 | - ".github/workflows/test.yml" 104 | workflow_dispatch: 105 | inputs: 106 | denops_branch: 107 | description: 'Denops branch to test' 108 | required: false 109 | default: 'main' 110 | 111 | # Use 'bash' as default shell even on Windows 112 | defaults: 113 | run: 114 | shell: bash --noprofile --norc -eo pipefail {0} 115 | 116 | env: 117 | DENOPS_BRANCH: ${{ github.event.inputs.denops_branch || 'main' }} 118 | 119 | jobs: 120 | test: 121 | strategy: 122 | matrix: 123 | runner: 124 | - windows-latest 125 | - macos-latest 126 | - ubuntu-latest 127 | deno_version: 128 | - "1.45.0" 129 | - "1.x" 130 | host_version: 131 | - vim: "v9.1.0448" 132 | nvim: "v0.10.0" 133 | 134 | runs-on: ${{ matrix.runner }} 135 | 136 | steps: 137 | - run: git config --global core.autocrlf false 138 | if: runner.os == 'Windows' 139 | 140 | - uses: actions/checkout@v4 141 | 142 | - uses: denoland/setup-deno@v1 143 | with: 144 | deno-version: ${{ matrix.deno_version }} 145 | 146 | - name: Get denops 147 | run: | 148 | git clone https://github.com/vim-denops/denops.vim /tmp/denops.vim 149 | echo "DENOPS_TEST_DENOPS_PATH=/tmp/denops.vim" >> "$GITHUB_ENV" 150 | 151 | - name: Try switching denops branch 152 | run: | 153 | git -C /tmp/denops.vim switch ${{ env.DENOPS_BRANCH }} || true 154 | git -C /tmp/denops.vim branch 155 | 156 | - uses: rhysd/action-setup-vim@v1 157 | id: vim 158 | with: 159 | version: ${{ matrix.host_version.vim }} 160 | 161 | - uses: rhysd/action-setup-vim@v1 162 | id: nvim 163 | with: 164 | neovim: true 165 | version: ${{ matrix.host_version.nvim }} 166 | 167 | - name: Export executables 168 | run: | 169 | echo "DENOPS_TEST_VIM_EXECUTABLE=${{ steps.vim.outputs.executable }}" >> "$GITHUB_ENV" 170 | echo "DENOPS_TEST_NVIM_EXECUTABLE=${{ steps.nvim.outputs.executable }}" >> "$GITHUB_ENV" 171 | 172 | - name: Check versions 173 | run: | 174 | deno --version 175 | ${DENOPS_TEST_VIM_EXECUTABLE} --version 176 | ${DENOPS_TEST_NVIM_EXECUTABLE} --version 177 | 178 | - name: Perform pre-cache 179 | run: | 180 | deno cache ${DENOPS_TEST_DENOPS_PATH}/denops/@denops-private/mod.ts 181 | deno cache ./denops/your_plugin/main.ts 182 | 183 | - name: Run tests 184 | run: deno test -A 185 | ``` 186 | 187 | ## For developers 188 | 189 | This library may be called from denops itself so import map is not available. 190 | 191 | ## License 192 | 193 | The code follows the MIT license, as stated in [LICENSE](./LICENSE). 194 | Contributors are required to agree that any modifications submitted to this 195 | repository adhere to the license. 196 | -------------------------------------------------------------------------------- /conf.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "jsr:@std/path@^1.0.1/resolve"; 2 | 3 | let conf: Config | undefined; 4 | 5 | /** 6 | * Configuration settings for denops testing. 7 | */ 8 | export interface Config { 9 | /** 10 | * Local path to the denops.vim repository. 11 | * It refers to the environment variable 'DENOPS_TEST_DENOPS_PATH'. 12 | */ 13 | denopsPath: string; 14 | 15 | /** 16 | * Path to the Vim executable (default: "vim"). 17 | * It refers to the environment variable 'DENOPS_TEST_VIM_EXECUTABLE'. 18 | */ 19 | vimExecutable: string; 20 | 21 | /** 22 | * Path to the Neovim executable (default: "nvim"). 23 | * It refers to the environment variable 'DENOPS_TEST_NVIM_EXECUTABLE'. 24 | */ 25 | nvimExecutable: string; 26 | 27 | /** 28 | * Print Vim messages (echomsg). 29 | * It refers to the environment variable 'DENOPS_TEST_VERBOSE'. 30 | */ 31 | verbose: boolean; 32 | 33 | /** 34 | * Timeout for connecting to Vim/Neovim. 35 | * It refers to the environment variable 'DENOPS_TEST_CONNECT_TIMEOUT'. 36 | */ 37 | connectTimeout?: number; 38 | } 39 | 40 | /** 41 | * Retrieves the configuration settings for denops testing. 42 | * If the configuration has already been retrieved, it returns the cached 43 | * configuration. Otherwise, it reads the environment variables and constructs 44 | * the configuration object. 45 | * 46 | * It reads environment variables below: 47 | * 48 | * - `DENOPS_TEST_DENOPS_PATH`: Local path to the denops.vim repository (required) 49 | * - `DENOPS_TEST_VIM_EXECUTABLE`: Path to the Vim executable (default: "vim") 50 | * - `DENOPS_TEST_NVIM_EXECUTABLE`: Path to the Neovim executable (default: "nvim") 51 | * - `DENOPS_TEST_VERBOSE`: `1` or `true` to print Vim messages (echomsg) 52 | * - `DENOPS_TEST_CONNECT_TIMEOUT`: Timeout [ms] for connecting to Vim/Neovim (default: 30000) 53 | * 54 | * It throws an error if the environment variable 'DENOPS_TEST_DENOPS_PATH' is 55 | * not set. 56 | */ 57 | export function getConfig(): Config { 58 | if (conf) { 59 | return conf; 60 | } 61 | const denopsPath = Deno.env.get("DENOPS_TEST_DENOPS_PATH"); 62 | if (!denopsPath) { 63 | throw new Error( 64 | "Environment variable 'DENOPS_TEST_DENOPS_PATH' is required", 65 | ); 66 | } 67 | const verbose = Deno.env.get("DENOPS_TEST_VERBOSE"); 68 | const connectTimeout = Number.parseInt( 69 | Deno.env.get("DENOPS_TEST_CONNECT_TIMEOUT") ?? "", 70 | ); 71 | conf = { 72 | denopsPath: resolve(denopsPath), 73 | vimExecutable: Deno.env.get("DENOPS_TEST_VIM_EXECUTABLE") ?? "vim", 74 | nvimExecutable: Deno.env.get("DENOPS_TEST_NVIM_EXECUTABLE") ?? "nvim", 75 | verbose: verbose === "1" || verbose === "true", 76 | connectTimeout: Number.isNaN(connectTimeout) || connectTimeout <= 0 77 | ? undefined 78 | : connectTimeout, 79 | }; 80 | return conf; 81 | } 82 | 83 | /** @internal for test */ 84 | function resetConfig(newConf?: Config): void { 85 | conf = newConf; 86 | } 87 | 88 | /** @internal */ 89 | export const _internal = { 90 | resetConfig, 91 | }; 92 | -------------------------------------------------------------------------------- /conf_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | assertEquals, 4 | assertObjectMatch, 5 | assertThrows, 6 | } from "jsr:@std/assert@^1.0.0"; 7 | import { stub } from "jsr:@std/testing@^1.0.0/mock"; 8 | import { basename, isAbsolute } from "jsr:@std/path@^1.0.1"; 9 | import { _internal, getConfig } from "./conf.ts"; 10 | 11 | const ENV_VARS: Readonly> = { 12 | DENOPS_TEST_DENOPS_PATH: "denops.vim", 13 | DENOPS_TEST_VIM_EXECUTABLE: undefined, 14 | DENOPS_TEST_NVIM_EXECUTABLE: undefined, 15 | DENOPS_TEST_VERBOSE: undefined, 16 | DENOPS_TEST_CONNECT_TIMEOUT: undefined, 17 | }; 18 | 19 | function stubEnvVars(envVars: Readonly>) { 20 | return stub(Deno.env, "get", (name) => envVars[name]); 21 | } 22 | 23 | function stubConfModule(): Disposable { 24 | const savedConf = getConfig(); 25 | _internal.resetConfig(undefined); 26 | return { 27 | [Symbol.dispose]() { 28 | _internal.resetConfig(savedConf); 29 | }, 30 | }; 31 | } 32 | 33 | Deno.test("getConfig() throws if DENOPS_TEST_DENOPS_PATH env var is not set", () => { 34 | using _module = stubConfModule(); 35 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_DENOPS_PATH: undefined }); 36 | assertThrows( 37 | () => { 38 | getConfig(); 39 | }, 40 | Error, 41 | "'DENOPS_TEST_DENOPS_PATH' is required", 42 | ); 43 | }); 44 | 45 | Deno.test("getConfig() returns `{ denopsPath: ... }` with resolved DENOPS_TEST_DENOPS_PATH env var", () => { 46 | using _module = stubConfModule(); 47 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_DENOPS_PATH: "foo" }); 48 | const actual = getConfig(); 49 | assert(isAbsolute(actual.denopsPath), "`denopsPath` should be absolute path"); 50 | assertEquals(basename(actual.denopsPath), "foo"); 51 | }); 52 | 53 | Deno.test("getConfig() returns `{ vimExecutable: 'vim' }` if DENOPS_TEST_VIM_EXECUTABLE env var is not set", () => { 54 | using _module = stubConfModule(); 55 | using _env = stubEnvVars({ 56 | ...ENV_VARS, 57 | DENOPS_TEST_VIM_EXECUTABLE: undefined, 58 | }); 59 | const actual = getConfig(); 60 | assertObjectMatch(actual, { vimExecutable: "vim" }); 61 | }); 62 | 63 | Deno.test("getConfig() returns `{ vimExecutable: ... }` with DENOPS_TEST_VIM_EXECUTABLE env var", () => { 64 | using _module = stubConfModule(); 65 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_VIM_EXECUTABLE: "foo" }); 66 | const actual = getConfig(); 67 | assertObjectMatch(actual, { vimExecutable: "foo" }); 68 | }); 69 | 70 | Deno.test("getConfig() returns `{ nvimExecutable: 'nvim' }` if DENOPS_TEST_NVIM_EXECUTABLE env var is not set", () => { 71 | using _module = stubConfModule(); 72 | using _env = stubEnvVars({ 73 | ...ENV_VARS, 74 | DENOPS_TEST_NVIM_EXECUTABLE: undefined, 75 | }); 76 | const actual = getConfig(); 77 | assertObjectMatch(actual, { nvimExecutable: "nvim" }); 78 | }); 79 | 80 | Deno.test("getConfig() returns `{ nvimExecutable: ... }` with DENOPS_TEST_NVIM_EXECUTABLE env var", () => { 81 | using _module = stubConfModule(); 82 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_NVIM_EXECUTABLE: "foo" }); 83 | const actual = getConfig(); 84 | assertObjectMatch(actual, { nvimExecutable: "foo" }); 85 | }); 86 | 87 | Deno.test("getConfig() returns `{ verbose: false }` if DENOPS_TEST_VERBOSE env var is not set", () => { 88 | using _module = stubConfModule(); 89 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_VERBOSE: undefined }); 90 | const actual = getConfig(); 91 | assertObjectMatch(actual, { verbose: false }); 92 | }); 93 | 94 | for ( 95 | const [input, expected] of [ 96 | ["false", false], 97 | ["0", false], 98 | ["invalid", false], 99 | ["true", true], 100 | ["1", true], 101 | ] as const 102 | ) { 103 | Deno.test(`getConfig() returns \`{ verbose: ${expected} }\` if DENOPS_TEST_VERBOSE env var is '${input}'`, () => { 104 | using _module = stubConfModule(); 105 | using _env = stubEnvVars({ ...ENV_VARS, DENOPS_TEST_VERBOSE: input }); 106 | const actual = getConfig(); 107 | assertObjectMatch(actual, { verbose: expected }); 108 | }); 109 | } 110 | 111 | for ( 112 | const [input, expected] of [ 113 | ["123", 123], 114 | ["123.456", 123], 115 | ["0", undefined], 116 | ["-123", undefined], 117 | ["string", undefined], 118 | ] as const 119 | ) { 120 | Deno.test(`getConfig() returns \`{ connectTimeout: ${expected} }\` if DENOPS_TEST_CONNECT_TIMEOUT env var is '${input}'`, () => { 121 | using _module = stubConfModule(); 122 | using _env = stubEnvVars({ 123 | ...ENV_VARS, 124 | DENOPS_TEST_CONNECT_TIMEOUT: input, 125 | }); 126 | const actual = getConfig(); 127 | assertObjectMatch(actual, { connectTimeout: expected }); 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@denops/test", 3 | "version": "0.0.0", 4 | "exports": { 5 | ".": "./mod.ts", 6 | "./stub": "./stub.ts", 7 | "./tester": "./tester.ts", 8 | "./with": "./with.ts" 9 | }, 10 | "exclude": [ 11 | ".coverage" 12 | ], 13 | "publish": { 14 | "include": [ 15 | "**/*.ts", 16 | "README.md", 17 | "LICENSE" 18 | ], 19 | "exclude": [ 20 | "**/*_test.ts", 21 | ".*" 22 | ] 23 | }, 24 | "tasks": { 25 | "check": "deno check **/*.ts", 26 | "check:doc": "deno test --doc --no-run", 27 | "test": "deno test -A --parallel --shuffle", 28 | "test:coverage": "deno task test --coverage=.coverage", 29 | "coverage": "deno coverage .coverage", 30 | "update": "deno run --allow-env --allow-read --allow-write=. --allow-run=git,deno --allow-net=jsr.io,registry.npmjs.org jsr:@molt/cli ./*.ts", 31 | "update:commit": "deno task -q update --commit --prefix :package: --pre-commit=fmt,lint", 32 | "apply:supported-versions": "deno run --allow-env --allow-net --allow-read --allow-write .scripts/apply-supported-versions.ts" 33 | }, 34 | "imports": { 35 | "jsr:@denops/test": "./mod.ts" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /denops.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Context, 3 | Denops, 4 | Dispatcher, 5 | Meta, 6 | } from "jsr:@denops/core@^7.0.0"; 7 | import type { Client } from "jsr:@lambdalisue/messagepack-rpc@^2.1.1"; 8 | 9 | export class DenopsImpl implements Denops { 10 | readonly name: string; 11 | readonly meta: Meta; 12 | readonly context: Record = {}; 13 | readonly interrupted = AbortSignal.any([]); 14 | 15 | dispatcher: Dispatcher = {}; 16 | 17 | #client: Client; 18 | 19 | constructor( 20 | name: string, 21 | meta: Meta, 22 | client: Client, 23 | ) { 24 | this.name = name; 25 | this.meta = meta; 26 | this.#client = client; 27 | } 28 | 29 | redraw(force?: boolean): Promise { 30 | return this.#client.call("invoke", "redraw", [force]) as Promise; 31 | } 32 | 33 | call(fn: string, ...args: unknown[]): Promise { 34 | return this.#client.call("invoke", "call", [fn, ...args]); 35 | } 36 | 37 | batch( 38 | ...calls: [string, ...unknown[]][] 39 | ): Promise { 40 | return this.#client.call("invoke", "batch", calls) as Promise; 41 | } 42 | 43 | cmd(cmd: string, ctx: Context = {}): Promise { 44 | return this.#client.call("invoke", "cmd", [cmd, ctx]) as Promise; 45 | } 46 | 47 | eval(expr: string, ctx: Context = {}): Promise { 48 | return this.#client.call("invoke", "eval", [expr, ctx]); 49 | } 50 | 51 | dispatch(name: string, fn: string, ...args: unknown[]): Promise { 52 | return this.#client.call("invoke", "dispatch", [name, fn, ...args]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /error.ts: -------------------------------------------------------------------------------- 1 | import { is } from "jsr:@core/unknownutil@^4.0.0"; 2 | import { 3 | fromErrorObject, 4 | isErrorObject, 5 | toErrorObject, 6 | tryOr, 7 | } from "jsr:@lambdalisue/errorutil@^1.0.0"; 8 | 9 | export function errorSerializer(err: unknown): unknown { 10 | if (err instanceof Error) { 11 | return JSON.stringify(toErrorObject(err)); 12 | } 13 | return String(err); 14 | } 15 | 16 | export function errorDeserializer(err: unknown): unknown { 17 | if (is.String(err)) { 18 | const obj = tryOr(() => JSON.parse(err), undefined); 19 | if (isErrorObject(obj)) { 20 | return fromErrorObject(obj); 21 | } 22 | } 23 | return err; 24 | } 25 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A [Deno] module designed for testing [denops.vim]. This module is intended to be 3 | * used in the unit tests of denops plugins. 4 | * 5 | * [deno]: https://deno.land/ 6 | * [denops.vim]: https://github.com/vim-denops/denops.vim 7 | * 8 | * > [!NOTE] 9 | * > 10 | * > To use the `test` function, an environment variable `DENOPS_TEST_DENOPS_PATH` 11 | * > is required. Clone the [denops.vim] repository and set the path to this 12 | * > environment variable. 13 | * > 14 | * > Additionally, the following environment variables are available to configure 15 | * > the behavior of the `test` function: 16 | * > 17 | * > - `DENOPS_TEST_VIM_EXECUTABLE`: Path to the Vim executable (default: "vim") 18 | * > - `DENOPS_TEST_NVIM_EXECUTABLE`: Path to the Neovim executable (default: 19 | * > "nvim") 20 | * > - `DENOPS_TEST_VERBOSE`: `1` or `true` to print Vim messages (echomsg) 21 | * 22 | * If you want to test denops plugins with a real Vim and/or Neovim process, use 23 | * the `test` function to define a test case, as shown below: 24 | * 25 | * ```typescript 26 | * import { 27 | * assert, 28 | * assertEquals, 29 | * assertFalse, 30 | * } from "jsr:@std/assert"; 31 | * import { test } from "jsr:@denops/test"; 32 | * 33 | * test( 34 | * "vim", 35 | * "Start Vim to test denops features", 36 | * async (denops) => { 37 | * assertFalse(await denops.call("has", "nvim")); 38 | * }, 39 | * ); 40 | * 41 | * test({ 42 | * mode: "nvim", 43 | * name: "Start Neovim to test denops features", 44 | * fn: async (denops) => { 45 | * assert(await denops.call("has", "nvim")); 46 | * }, 47 | * }); 48 | * 49 | * test({ 50 | * mode: "all", 51 | * name: "Start Vim and Neovim to test denops features", 52 | * fn: async (denops) => { 53 | * assertEquals(await denops.call("abs", -4), 4); 54 | * }, 55 | * }); 56 | * 57 | * test({ 58 | * mode: "any", 59 | * name: "Start Vim or Neovim to test denops features", 60 | * fn: async (denops) => { 61 | * assertEquals(await denops.call("abs", -4), 4); 62 | * }, 63 | * }); 64 | * ``` 65 | * 66 | * If you want to test denops plugins without a real Vim and/or Neovim process, use 67 | * the `DenopsStub` class to create a stub instance of the `Denops` interface, as 68 | * shown below: 69 | * 70 | * ```typescript 71 | * import { assertEquals } from "jsr:@std/assert"; 72 | * import { DenopsStub } from "jsr:@denops/test"; 73 | * 74 | * Deno.test("denops.call", async () => { 75 | * const denops = new DenopsStub({ 76 | * call: (fn, ...args) => { 77 | * return Promise.resolve([fn, ...args]); 78 | * }, 79 | * }); 80 | * assertEquals(await denops.call("foo", "bar"), ["foo", "bar"]); 81 | * }); 82 | * ``` 83 | * 84 | * @module 85 | */ 86 | export type { DenopsStubber } from "./stub.ts"; 87 | export type { TestDefinition } from "./tester.ts"; 88 | export type { WithDenopsOptions } from "./with.ts"; 89 | export { DenopsStub } from "./stub.ts"; 90 | export { test } from "./tester.ts"; 91 | export { withDenops } from "./with.ts"; 92 | -------------------------------------------------------------------------------- /plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "jsr:@denops/core@^7.0.0"; 2 | import { as, assert, ensure, is } from "jsr:@core/unknownutil@^4.0.0"; 3 | import { Client, Session } from "jsr:@lambdalisue/messagepack-rpc@^2.1.1"; 4 | import { errorDeserializer, errorSerializer } from "./error.ts"; 5 | 6 | export async function main(denops: Denops): Promise { 7 | const addr = Deno.env.get("DENOPS_TEST_ADDRESS"); 8 | if (!addr) { 9 | throw new Error("Environment variable 'DENOPS_TEST_ADDRESS' is not set"); 10 | } 11 | const conn = await Deno.connect(JSON.parse(addr)); 12 | const session = new Session(conn.readable, conn.writable, { 13 | errorSerializer, 14 | }); 15 | session.onInvalidMessage = (message) => { 16 | console.error(`[denops-test] Unexpected message: ${message}`); 17 | }; 18 | session.onMessageError = (err, message) => { 19 | console.error( 20 | `[denops-test] Unexpected error occured for message ${message}: ${err}`, 21 | ); 22 | }; 23 | session.start(); 24 | const client = new Client(session, { 25 | errorDeserializer, 26 | }); 27 | session.dispatcher = { 28 | invoke: (name, args) => { 29 | assert(name, is.String); 30 | assert(args, is.Array); 31 | return invoke(denops, name, args); 32 | }, 33 | }; 34 | denops.dispatcher = new Proxy({}, { 35 | get: (_, prop) => { 36 | assert(prop, is.String); 37 | return (...args: unknown[]) => { 38 | return client.call("dispatch", prop, args); 39 | }; 40 | }, 41 | set: () => { 42 | throw new Error("This dispatcher is for test and read-only"); 43 | }, 44 | deleteProperty: () => { 45 | throw new Error("This dispatcher is for test and read-only"); 46 | }, 47 | }); 48 | } 49 | 50 | function invoke( 51 | denops: Denops, 52 | name: string, 53 | args: unknown[], 54 | ): Promise { 55 | switch (name) { 56 | case "redraw": 57 | return denops.redraw(...ensure(args, isRedrawArgs)); 58 | case "call": 59 | return denops.call(...ensure(args, isCallArgs)); 60 | case "batch": 61 | return denops.batch(...ensure(args, isBatchArgs)); 62 | case "cmd": 63 | return denops.cmd(...ensure(args, isCmdArgs)); 64 | case "eval": 65 | return denops.eval(...ensure(args, isEvalArgs)); 66 | case "dispatch": 67 | return denops.dispatch(...ensure(args, isDispatchArgs)); 68 | default: 69 | throw new Error(`Unknown denops method '${name}' is specified`); 70 | } 71 | } 72 | 73 | const isRedrawArgs = is.TupleOf([as.Optional(is.Boolean)] as const); 74 | 75 | const isCallArgs = (v: unknown): v is [string, ...unknown[]] => { 76 | return is.Array(v) && is.String(v[0]); 77 | }; 78 | 79 | const isBatchArgs = is.ArrayOf(isCallArgs); 80 | 81 | const isCmdArgs = is.TupleOf([is.String, as.Optional(is.Record)] as const); 82 | 83 | const isEvalArgs = is.TupleOf([is.String, as.Optional(is.Record)] as const); 84 | 85 | const isDispatchArgs = (v: unknown): v is [string, string, ...unknown[]] => { 86 | return is.Array(v) && is.String(v[0]) && is.String(v[1]); 87 | }; 88 | -------------------------------------------------------------------------------- /runner.ts: -------------------------------------------------------------------------------- 1 | import { mergeReadableStreams } from "jsr:@std/streams@^1.0.0/merge-readable-streams"; 2 | import { is } from "jsr:@core/unknownutil@^4.0.0"; 3 | import { unreachable } from "jsr:@lambdalisue/errorutil@^1.0.0"; 4 | import { type Config, getConfig } from "./conf.ts"; 5 | 6 | /** 7 | * Represents the mode in which the runner operates. 8 | */ 9 | export type RunMode = "vim" | "nvim"; 10 | 11 | /** 12 | * Represents options for the runner. 13 | */ 14 | export interface RunOptions 15 | extends Omit { 16 | /** 17 | * A flag indicating whether to enable verbose output. 18 | */ 19 | verbose?: boolean; 20 | } 21 | 22 | /** 23 | * Represents results of the runner. 24 | */ 25 | export interface RunResult extends AsyncDisposable { 26 | /** 27 | * Aborts the process. 28 | */ 29 | close(): void; 30 | /** 31 | * Wait the process closed and returns status. 32 | */ 33 | waitClosed(): Promise; 34 | } 35 | 36 | type WaitClosedResult = { 37 | status: Deno.CommandStatus; 38 | output?: string; 39 | }; 40 | 41 | /** 42 | * Checks if the provided mode is a valid `RunMode`. 43 | */ 44 | export const isRunMode = is.LiteralOneOf(["vim", "nvim"] as const); 45 | 46 | /** 47 | * Runs the specified commands in the runner. 48 | * 49 | * @param mode - The mode in which the runner operates (`vim` or `nvim`). 50 | * @param cmds - An array of commands to run. 51 | * @param options - Options for configuring the runner. 52 | */ 53 | export function run( 54 | mode: RunMode, 55 | cmds: string[], 56 | options: RunOptions = {}, 57 | ): RunResult { 58 | const conf = getConfig(); 59 | const { verbose = conf.verbose } = options; 60 | const [cmd, args] = buildArgs(conf, mode); 61 | args.push(...cmds.flatMap((c) => ["-c", c])); 62 | const aborter = new AbortController(); 63 | const { signal } = aborter; 64 | const command = new Deno.Command(cmd, { 65 | args, 66 | env: options.env, 67 | stdin: "piped", 68 | stdout: "piped", 69 | stderr: "piped", 70 | signal, 71 | }); 72 | const proc = command.spawn(); 73 | let outputStream = mergeReadableStreams( 74 | proc.stdout.pipeThrough(new TextDecoderStream(), { signal }), 75 | proc.stderr.pipeThrough(new TextDecoderStream(), { signal }), 76 | ); 77 | if (verbose) { 78 | const [consoleStream] = [, outputStream] = outputStream.tee(); 79 | consoleStream.pipeTo( 80 | new WritableStream({ write: (data) => console.error(data) }), 81 | ).catch(() => {}); 82 | } 83 | return { 84 | close() { 85 | aborter.abort("close"); 86 | }, 87 | async waitClosed() { 88 | const [status, output] = await Promise.all([ 89 | proc.status, 90 | Array.fromAsync(outputStream) 91 | .then((list) => list.join("")) 92 | .catch(() => undefined), 93 | ]); 94 | await proc.stdin.abort(); 95 | return { status, output }; 96 | }, 97 | async [Symbol.asyncDispose]() { 98 | this.close(); 99 | await this.waitClosed(); 100 | }, 101 | }; 102 | } 103 | 104 | function buildArgs(conf: Config, mode: RunMode): [string, string[]] { 105 | switch (mode) { 106 | case "vim": 107 | return [ 108 | conf.vimExecutable, 109 | [ 110 | "-u", 111 | "NONE", // Disable vimrc, plugins, defaults.vim 112 | "-i", 113 | "NONE", // Disable viminfo 114 | "-n", // Disable swap file 115 | "-N", // Disable compatible mode 116 | "-X", // Disable xterm 117 | "-e", // Start Vim in Ex mode 118 | "-s", // Silent or batch mode ("-e" is required before) 119 | "-V1", // Verbose level 1 (Echo messages to stderr) 120 | "-c", 121 | "visual", // Go to Normal mode 122 | "-c", 123 | "set columns=9999", // Avoid unwilling output newline 124 | ], 125 | ]; 126 | case "nvim": 127 | return [ 128 | conf.nvimExecutable, 129 | [ 130 | "--clean", 131 | "--headless", 132 | "-n", // Disable swap file 133 | "-V1", // Verbose level 1 (Echo messages to stderr) 134 | "-c", 135 | "set columns=9999", // Avoid unwilling output newline 136 | ], 137 | ]; 138 | default: 139 | unreachable(mode); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /stub.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Context, 3 | Denops, 4 | Dispatcher, 5 | Meta, 6 | } from "jsr:@denops/core@^7.0.0"; 7 | 8 | /** 9 | * Represents a stubber object for `Denops`. 10 | */ 11 | export interface DenopsStubber { 12 | /** 13 | * The name used to communicate with Vim. 14 | * If not specified, the default value is `denops-test-stub`. 15 | */ 16 | name?: string; 17 | /** 18 | * Environment meta information. 19 | * If not specified, the default value is: 20 | * ```json 21 | * { 22 | * "mode": "release", 23 | * "host": "vim", 24 | * "version": "0.0.0", 25 | * "platform": "linux" 26 | * } 27 | * ``` 28 | */ 29 | meta?: Meta; 30 | 31 | /** 32 | * AbortSignal instance that is triggered when the user invoke `denops#interrupt()` 33 | * If not specified, it returns a new instance of `AbortSignal`. 34 | */ 35 | interrupted?: AbortSignal; 36 | /** 37 | * A stub function for the `redraw` method of `Denops`. 38 | * If not specified, it returns a promise resolving to undefined. 39 | */ 40 | redraw?: (force?: boolean) => Promise; 41 | /** 42 | * A stub function for the `call` method of `Denops`. 43 | * If not specified, it returns a promise resolving to undefined. 44 | */ 45 | call?(fn: string, ...args: unknown[]): Promise; 46 | /** 47 | * A stub function for the `batch` method of `Denops`. 48 | * If not specified, it returns a promise resolving to an empty list. 49 | */ 50 | batch?(...calls: [string, ...unknown[]][]): Promise; 51 | /** 52 | * A stub function for the `cmd` method of `Denops`. 53 | * If not specified, it returns a promise resolving to undefined. 54 | */ 55 | cmd?(cmd: string, ctx: Context): Promise; 56 | /** 57 | * A stub function for the `eval` method of `Denops`. 58 | * If not specified, it returns a promise resolving to undefined. 59 | */ 60 | eval?(expr: string, ctx: Context): Promise; 61 | /** 62 | * A stub function for the `dispatch` method of `Denops`. 63 | * If not specified, it returns a promise resolving to undefined. 64 | */ 65 | dispatch?(name: string, fn: string, ...args: unknown[]): Promise; 66 | /** 67 | * Indicates whether to use the `call` function in the `batch` method. 68 | */ 69 | useCallInBatch?: boolean; 70 | /** 71 | * Indicates whether to use the `call` function in the `cmd` method. 72 | */ 73 | useCallInCmd?: boolean; 74 | /** 75 | * Indicates whether to use the `call` function in the `eval` method. 76 | */ 77 | useCallInEval?: boolean; 78 | } 79 | 80 | /** 81 | * Represents a stub object for `Denops`. 82 | */ 83 | export class DenopsStub implements Denops { 84 | readonly context: Record = {}; 85 | dispatcher: Dispatcher = {}; 86 | 87 | #stubber: DenopsStubber; 88 | 89 | /** 90 | * Creates a new instance of `DenopsStub`. 91 | * @param stubber - The `DenopsStubber` object. 92 | */ 93 | constructor(stubber: DenopsStubber = {}) { 94 | this.#stubber = stubber; 95 | } 96 | 97 | get name(): string { 98 | return this.#stubber.name ?? "denops-test-stub"; 99 | } 100 | 101 | get meta(): Meta { 102 | return this.#stubber.meta ?? { 103 | mode: "release", 104 | host: "vim", 105 | version: "0.0.0", 106 | platform: "linux", 107 | }; 108 | } 109 | 110 | get interrupted(): AbortSignal { 111 | return this.#stubber.interrupted ?? AbortSignal.any([]); 112 | } 113 | 114 | /** 115 | * A stub function for the `redraw` method of `Denops`. 116 | * If not specified, it returns a promise resolving to undefined. 117 | * 118 | * @param force - A boolean flag indicating whether to force redraw. 119 | */ 120 | redraw(force?: boolean): Promise { 121 | if (this.#stubber.redraw) { 122 | return this.#stubber.redraw(force); 123 | } 124 | return Promise.resolve(); 125 | } 126 | 127 | /** 128 | * A stub function for the `call` method of `Denops`. 129 | * If not specified, it returns a promise resolving to undefined. 130 | * 131 | * @param fn - The function name to call. 132 | * @param args - The arguments for the function. 133 | */ 134 | call(fn: string, ...args: unknown[]): Promise { 135 | if (this.#stubber.call) { 136 | args = normArgs(args); 137 | return this.#stubber.call(fn, ...args); 138 | } 139 | return Promise.resolve(); 140 | } 141 | 142 | /** 143 | * A stub function for the `batch` method of `Denops`. 144 | * If not specified, it returns a promise resolving to an empty list. 145 | * 146 | * @param calls - An array of tuples representing function calls. 147 | */ 148 | batch(...calls: [string, ...unknown[]][]): Promise { 149 | if (this.#stubber.batch) { 150 | calls = calls.map(([fn, ...args]) => [fn, ...normArgs(args)]); 151 | return this.#stubber.batch(...calls); 152 | } 153 | if (this.#stubber.call && this.#stubber.useCallInBatch) { 154 | return Promise.all( 155 | calls.map(([fn, ...args]) => this.call(fn, ...args)), 156 | ); 157 | } 158 | return Promise.resolve(calls.map(() => undefined)); 159 | } 160 | 161 | /** 162 | * A stub function for the `cmd` method of `Denops`. 163 | * If not specified, it returns a promise resolving to undefined. 164 | * 165 | * @param cmd - The command to execute. 166 | * @param ctx - The context object. 167 | */ 168 | cmd(cmd: string, ctx: Context = {}): Promise { 169 | if (this.#stubber.cmd) { 170 | return this.#stubber.cmd(cmd, ctx); 171 | } 172 | if (this.#stubber.call && this.#stubber.useCallInCmd) { 173 | return this.call("denops#api#cmd", cmd, ctx).then(() => {}); 174 | } 175 | return Promise.resolve(); 176 | } 177 | 178 | /** 179 | * A stub function for the `eval` method of `Denops`. 180 | * If not specified, it returns a promise resolving to undefined. 181 | * 182 | * @param expr - The expression to evaluate. 183 | * @param ctx - The context object. 184 | */ 185 | eval(expr: string, ctx: Context = {}): Promise { 186 | if (this.#stubber.eval) { 187 | return this.#stubber.eval(expr, ctx); 188 | } 189 | if (this.#stubber.call && this.#stubber.useCallInEval) { 190 | return this.call("denops#api#eval", expr, ctx); 191 | } 192 | return Promise.resolve(); 193 | } 194 | 195 | /** 196 | * A stub function for the `dispatch` method of `Denops`. 197 | * If not specified, it returns a promise resolving to undefined. 198 | * 199 | * @param name - The plugin registration name. 200 | * @param fn - The function name in the API registration. 201 | * @param args - The arguments for the function. 202 | */ 203 | dispatch( 204 | name: string, 205 | fn: string, 206 | ...args: unknown[] 207 | ): Promise { 208 | if (this.#stubber.dispatch) { 209 | return this.#stubber.dispatch(name, fn, ...args); 210 | } 211 | return Promise.resolve(); 212 | } 213 | } 214 | 215 | /** 216 | * Normalizes arguments by removing `undefined` values. 217 | * 218 | * @param args - The arguments to normalize. 219 | */ 220 | function normArgs(args: unknown[]): unknown[] { 221 | const normArgs = []; 222 | for (const arg of args) { 223 | if (arg === undefined) { 224 | break; 225 | } 226 | normArgs.push(arg); 227 | } 228 | return normArgs; 229 | } 230 | -------------------------------------------------------------------------------- /stub_test.ts: -------------------------------------------------------------------------------- 1 | import { assertSpyCall, spy } from "jsr:@std/testing@^1.0.0/mock"; 2 | import { assertEquals } from "jsr:@std/assert@^1.0.0"; 3 | import type { Denops } from "jsr:@denops/core@^7.0.0"; 4 | import { DenopsStub } from "./stub.ts"; 5 | 6 | Deno.test("`DenopsStub`", async (t) => { 7 | await t.step("implements `Denops` interface", () => { 8 | const _denops: Denops = new DenopsStub(); 9 | }); 10 | 11 | await t.step("`name` is a string", () => { 12 | const denops = new DenopsStub(); 13 | assertEquals(denops.name, "denops-test-stub"); 14 | }); 15 | 16 | await t.step("`meta` is a `Meta`", () => { 17 | const denops = new DenopsStub(); 18 | assertEquals(denops.meta, { 19 | mode: "release", 20 | host: "vim", 21 | version: "0.0.0", 22 | platform: "linux", 23 | }); 24 | }); 25 | 26 | await t.step("`context` is an empty `Record`", () => { 27 | const denops = new DenopsStub(); 28 | assertEquals(denops.context, {}); 29 | }); 30 | 31 | await t.step("`context` is writable", () => { 32 | const denops = new DenopsStub(); 33 | denops.context.foo = "bar"; 34 | assertEquals(denops.context, { "foo": "bar" }); 35 | }); 36 | 37 | await t.step("`dispatcher` is an empty `Dispatcher`", () => { 38 | const denops = new DenopsStub(); 39 | assertEquals(denops.dispatcher, {}); 40 | }); 41 | 42 | await t.step("`dispatcher` is writable", () => { 43 | const denops = new DenopsStub(); 44 | const fn = () => {}; 45 | denops.dispatcher.foo = fn; 46 | assertEquals(denops.dispatcher, { "foo": fn }); 47 | }); 48 | 49 | await t.step("`dispatcher` is assignable", () => { 50 | const denops = new DenopsStub(); 51 | const fn = () => {}; 52 | denops.dispatcher = { "foo": fn }; 53 | assertEquals(denops.dispatcher, { "foo": fn }); 54 | }); 55 | 56 | await t.step("`redraw` returns a promise resolves to undefined", () => { 57 | const denops = new DenopsStub(); 58 | assertEquals(denops.redraw(), Promise.resolve()); 59 | assertEquals(denops.redraw(false), Promise.resolve()); 60 | assertEquals(denops.redraw(true), Promise.resolve()); 61 | }); 62 | 63 | await t.step("`call` returns a promise resolves to undefined", () => { 64 | const denops = new DenopsStub(); 65 | assertEquals(denops.call("foo"), Promise.resolve()); 66 | assertEquals(denops.call("foo", "bar"), Promise.resolve()); 67 | }); 68 | 69 | await t.step("`batch` returns a promise resolves to an empty list", () => { 70 | const denops = new DenopsStub(); 71 | assertEquals(denops.batch(), Promise.resolve([])); 72 | assertEquals(denops.batch(["foo"]), Promise.resolve([])); 73 | assertEquals(denops.batch(["foo", "bar"]), Promise.resolve([])); 74 | assertEquals(denops.batch(["foo"], ["foo", "bar"]), Promise.resolve([])); 75 | }); 76 | 77 | await t.step("`cmd` returns a promise resolves to undefined", () => { 78 | const denops = new DenopsStub(); 79 | assertEquals(denops.cmd("foo"), Promise.resolve()); 80 | assertEquals(denops.cmd("foo", { "foo": "bar" }), Promise.resolve()); 81 | }); 82 | 83 | await t.step("`eval` returns a promise resolves to undefined", () => { 84 | const denops = new DenopsStub(); 85 | assertEquals(denops.eval("foo"), Promise.resolve()); 86 | assertEquals(denops.eval("foo", { "foo": "bar" }), Promise.resolve()); 87 | }); 88 | 89 | await t.step("`dispatch` returns a promise resolves to undefined", () => { 90 | const denops = new DenopsStub(); 91 | assertEquals(denops.dispatch("foo", "bar"), Promise.resolve()); 92 | assertEquals(denops.dispatch("foo", "bar", "hoge"), Promise.resolve()); 93 | }); 94 | }); 95 | 96 | Deno.test("`DenopsStub` with `stubber`", async (t) => { 97 | await t.step("`name` is a specified string", () => { 98 | const denops = new DenopsStub({ name: "this-is-test" }); 99 | assertEquals(denops.name, "this-is-test"); 100 | }); 101 | 102 | await t.step("`meta` is a specified `Meta`", () => { 103 | const denops = new DenopsStub({ 104 | meta: { 105 | mode: "debug", 106 | host: "nvim", 107 | version: "1.2.3", 108 | platform: "windows", 109 | }, 110 | }); 111 | assertEquals(denops.meta, { 112 | mode: "debug", 113 | host: "nvim", 114 | version: "1.2.3", 115 | platform: "windows", 116 | }); 117 | }); 118 | 119 | await t.step("`redraw` invokes a specified `redraw`", async () => { 120 | const stubber = { 121 | redraw: spy(() => { 122 | return Promise.resolve(); 123 | }), 124 | }; 125 | const denops = new DenopsStub(stubber); 126 | assertEquals(await denops.redraw(), undefined); 127 | assertSpyCall(stubber.redraw, 0, { 128 | args: [undefined], 129 | }); 130 | 131 | await denops.redraw(false); 132 | assertSpyCall(stubber.redraw, 1, { 133 | args: [false], 134 | }); 135 | 136 | await denops.redraw(true); 137 | assertSpyCall(stubber.redraw, 2, { 138 | args: [true], 139 | }); 140 | }); 141 | 142 | await t.step("`call` invokes a specified `call`", async () => { 143 | const stubber = { 144 | call: spy((fn, ...args) => { 145 | return Promise.resolve([fn, ...args]); 146 | }), 147 | }; 148 | const denops = new DenopsStub(stubber); 149 | assertEquals(await denops.call("foo"), ["foo"]); 150 | assertSpyCall(stubber.call, 0, { 151 | args: ["foo"], 152 | }); 153 | 154 | assertEquals(await denops.call("foo", "bar"), ["foo", "bar"]); 155 | assertSpyCall(stubber.call, 1, { 156 | args: ["foo", "bar"], 157 | }); 158 | 159 | // NOTE: args is normalized 160 | assertEquals(await denops.call("foo", undefined, "bar"), ["foo"]); 161 | assertSpyCall(stubber.call, 2, { 162 | args: ["foo"], 163 | }); 164 | }); 165 | 166 | await t.step("`batch` invokes a specified `batch`", async () => { 167 | const stubber = { 168 | batch: spy((...calls) => { 169 | return Promise.resolve(calls); 170 | }), 171 | }; 172 | const denops = new DenopsStub(stubber); 173 | assertEquals(await denops.batch(["foo"], ["foo", "bar"]), [ 174 | ["foo"], 175 | ["foo", "bar"], 176 | ]); 177 | assertSpyCall(stubber.batch, 0, { 178 | args: [["foo"], ["foo", "bar"]], 179 | }); 180 | 181 | // NOTE: args is normalized 182 | assertEquals(await denops.batch(["foo", undefined, "bar"]), [["foo"]]); 183 | assertSpyCall(stubber.batch, 1, { 184 | args: [["foo"]], 185 | }); 186 | }); 187 | 188 | await t.step( 189 | "`batch` invokes a specified `call` when `useCallInBatch` is true", 190 | async () => { 191 | const stubber = { 192 | call: spy((fn, ...args) => { 193 | return Promise.resolve([fn, ...args]); 194 | }), 195 | useCallInBatch: true, 196 | }; 197 | const denops = new DenopsStub(stubber); 198 | assertEquals(await denops.batch(["foo"], ["foo", "bar"]), [ 199 | ["foo"], 200 | ["foo", "bar"], 201 | ]); 202 | assertSpyCall(stubber.call, 0, { 203 | args: ["foo"], 204 | }); 205 | assertSpyCall(stubber.call, 1, { 206 | args: ["foo", "bar"], 207 | }); 208 | }, 209 | ); 210 | 211 | await t.step("`cmd` invokes a specified `cmd`", async () => { 212 | const stubber = { 213 | cmd: spy((_cmd, _ctx) => { 214 | return Promise.resolve(); 215 | }), 216 | }; 217 | const denops = new DenopsStub(stubber); 218 | assertEquals(await denops.cmd("foo"), undefined); 219 | assertSpyCall(stubber.cmd, 0, { 220 | args: ["foo", {}], 221 | }); 222 | 223 | assertEquals(await denops.cmd("foo", { "foo": "bar" }), undefined); 224 | assertSpyCall(stubber.cmd, 1, { 225 | args: ["foo", { "foo": "bar" }], 226 | }); 227 | }); 228 | 229 | await t.step( 230 | "`cmd` invokes a specified `call` when `useCallInCmd` is true", 231 | async () => { 232 | const stubber = { 233 | call: spy((fn, ...args) => { 234 | return Promise.resolve([fn, ...args]); 235 | }), 236 | useCallInCmd: true, 237 | }; 238 | const denops = new DenopsStub(stubber); 239 | assertEquals(await denops.cmd("foo"), undefined); 240 | assertSpyCall(stubber.call, 0, { 241 | args: ["denops#api#cmd", "foo", {}], 242 | }); 243 | 244 | assertEquals(await denops.cmd("foo", { "foo": "bar" }), undefined); 245 | assertSpyCall(stubber.call, 1, { 246 | args: ["denops#api#cmd", "foo", { "foo": "bar" }], 247 | }); 248 | }, 249 | ); 250 | 251 | await t.step("`eval` invokes a specified `eval`", async () => { 252 | const stubber = { 253 | eval: spy((expr, ctx) => { 254 | return Promise.resolve([expr, ctx]); 255 | }), 256 | }; 257 | const denops = new DenopsStub(stubber); 258 | assertEquals(await denops.eval("foo"), ["foo", {}]); 259 | assertSpyCall(stubber.eval, 0, { 260 | args: ["foo", {}], 261 | }); 262 | 263 | assertEquals(await denops.eval("foo", { "foo": "bar" }), ["foo", { 264 | "foo": "bar", 265 | }]); 266 | assertSpyCall(stubber.eval, 1, { 267 | args: ["foo", { "foo": "bar" }], 268 | }); 269 | }); 270 | 271 | await t.step( 272 | "`eval` invokes a specified `call` when `useCallInEval` is true", 273 | async () => { 274 | const stubber = { 275 | call: spy((fn, ...args) => { 276 | return Promise.resolve([fn, ...args]); 277 | }), 278 | useCallInEval: true, 279 | }; 280 | const denops = new DenopsStub(stubber); 281 | assertEquals(await denops.eval("foo"), ["denops#api#eval", "foo", {}]); 282 | assertSpyCall(stubber.call, 0, { 283 | args: ["denops#api#eval", "foo", {}], 284 | }); 285 | 286 | assertEquals(await denops.eval("foo", { "foo": "bar" }), [ 287 | "denops#api#eval", 288 | "foo", 289 | { 290 | "foo": "bar", 291 | }, 292 | ]); 293 | assertSpyCall(stubber.call, 1, { 294 | args: ["denops#api#eval", "foo", { "foo": "bar" }], 295 | }); 296 | }, 297 | ); 298 | 299 | await t.step("`dispatch` invokes a specified `dispatch`", async () => { 300 | const stubber = { 301 | dispatch: spy((name, fn, ...args) => { 302 | return Promise.resolve([name, fn, ...args]); 303 | }), 304 | }; 305 | const denops = new DenopsStub(stubber); 306 | assertEquals(await denops.dispatch("foo", "bar"), ["foo", "bar"]); 307 | assertSpyCall(stubber.dispatch, 0, { 308 | args: ["foo", "bar"], 309 | }); 310 | 311 | assertEquals(await denops.dispatch("foo", "bar", "hoge"), [ 312 | "foo", 313 | "bar", 314 | "hoge", 315 | ]); 316 | assertSpyCall(stubber.dispatch, 1, { 317 | args: ["foo", "bar", "hoge"], 318 | }); 319 | }); 320 | }); 321 | -------------------------------------------------------------------------------- /tester.ts: -------------------------------------------------------------------------------- 1 | import { sample } from "jsr:@std/collections@^1.0.5/sample"; 2 | import type { Denops } from "jsr:@denops/core@^7.0.0"; 3 | import type { RunMode } from "./runner.ts"; 4 | import { withDenops } from "./with.ts"; 5 | 6 | /** 7 | * Represents the running mode for tests. 8 | */ 9 | export type TestMode = RunMode | "any" | "all"; 10 | 11 | /** Represents a test definition used in the `test` function. */ 12 | export interface TestDefinition extends Omit { 13 | fn: (denops: Denops, t: Deno.TestContext) => void | Promise; 14 | /** 15 | * Test runner mode. 16 | * 17 | * - Specifying "vim" or "nvim" will run the test with the specified runner. 18 | * - If "any" is specified, Vim or Neovim is randomly selected and executed. 19 | * - When "all" is specified, the test is run with both Vim and Neovim. 20 | */ 21 | mode: TestMode; 22 | /** The plugin name of the test target. */ 23 | pluginName?: string; 24 | /** Prints Vim messages (echomsg). */ 25 | verbose?: boolean; 26 | /** Vim commands to be executed before the start of Denops. */ 27 | prelude?: string[]; 28 | /** Vim commands to be executed after the start of Denops. */ 29 | postlude?: string[]; 30 | } 31 | 32 | /** 33 | * Registers a test for Denops to be run when `deno test` is used. 34 | * 35 | * To use this function, the environment variable `DENOPS_TEST_DENOPS_PATH` must be set to the 36 | * local path to the `denops.vim` repository. 37 | * 38 | * The `DENOPS_TEST_VIM_EXECUTABLE` and `DENOPS_TEST_NVIM_EXECUTABLE` environment variables 39 | * allow you to change the Vim/Neovim execution command (default is `vim` and `nvim` respectively). 40 | * 41 | * Note that this is a time-consuming process, especially on Windows, since this function 42 | * internally spawns Vim/Neovim sub-process, which performs the tests. 43 | * 44 | * This function internally uses `Deno.test` and `withDenops` to run 45 | * tests by passing a `denops` instance to the registered test function. 46 | * 47 | * ```ts 48 | * import { assert, assertFalse } from "jsr:@std/assert"; 49 | * import { test } from "jsr:@denops/test"; 50 | * 51 | * test("vim", "Test with Vim", async (denops) => { 52 | * assertFalse(await denops.call("has", "nvim")); 53 | * }); 54 | * ``` 55 | */ 56 | export function test( 57 | mode: TestDefinition["mode"], 58 | name: string, 59 | fn: TestDefinition["fn"], 60 | ): void; 61 | /** 62 | * Registers a test for Denops to be run when `deno test` is used. 63 | * 64 | * To use this function, the environment variable `DENOPS_TEST_DENOPS_PATH` must be set to the 65 | * local path to the `denops.vim` repository. 66 | * 67 | * The `DENOPS_TEST_VIM_EXECUTABLE` and `DENOPS_TEST_NVIM_EXECUTABLE` environment variables 68 | * allow you to change the Vim/Neovim execution command (default is `vim` and `nvim` respectively). 69 | * 70 | * Note that this is a time-consuming process, especially on Windows, since this function 71 | * internally spawns Vim/Neovim sub-process, which performs the tests. 72 | * 73 | * This function internally uses `Deno.test` and `withDenops` to run 74 | * tests by passing a `denops` instance to the registered test function. 75 | * 76 | * ```ts 77 | * import { assert, assertFalse } from "jsr:@std/assert"; 78 | * import { test } from "jsr:@denops/test"; 79 | * 80 | * test({ 81 | * mode: "nvim", 82 | * name: "Test with Neovim", 83 | * fn: async (denops) => { 84 | * assert(await denops.call("has", "nvim")); 85 | * }, 86 | * }); 87 | * ``` 88 | */ 89 | export function test(def: TestDefinition): void; 90 | export function test( 91 | modeOrDefinition: TestDefinition["mode"] | TestDefinition, 92 | name?: string, 93 | fn?: TestDefinition["fn"], 94 | ): void { 95 | if (typeof modeOrDefinition === "string") { 96 | testInternal({ 97 | mode: modeOrDefinition, 98 | name, 99 | fn, 100 | }); 101 | } else { 102 | testInternal(modeOrDefinition); 103 | } 104 | } 105 | 106 | function testInternal(def: Partial): void { 107 | const { 108 | mode, 109 | name, 110 | fn, 111 | pluginName, 112 | verbose, 113 | prelude, 114 | postlude, 115 | ...denoTestDef 116 | } = def; 117 | if (!mode) { 118 | throw new Error("'mode' attribute is required"); 119 | } 120 | if (!name) { 121 | throw new Error("'name' attribute is required"); 122 | } 123 | if (!fn) { 124 | throw new Error("'fn' attribute is required"); 125 | } 126 | if (mode === "all") { 127 | testInternal({ 128 | ...def, 129 | name: `${name} (vim)`, 130 | mode: "vim", 131 | }); 132 | testInternal({ 133 | ...def, 134 | name: `${name} (nvim)`, 135 | mode: "nvim", 136 | }); 137 | } else if (mode === "any") { 138 | const m = sample(["vim", "nvim"] as const)!; 139 | testInternal({ 140 | ...def, 141 | name: `${name} (${m})`, 142 | mode: m, 143 | }); 144 | } else { 145 | if (!["vim", "nvim"].includes(mode)) { 146 | throw new Error(`'mode' attribute is invalid: ${mode}`); 147 | } 148 | Deno.test({ 149 | ...denoTestDef, 150 | name, 151 | fn: (t) => { 152 | return withDenops(mode, (denops) => fn.call(def, denops, t), { 153 | pluginName, 154 | verbose, 155 | prelude, 156 | postlude, 157 | }); 158 | }, 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tester_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | assertEquals, 4 | assertFalse, 5 | assertThrows, 6 | } from "jsr:@std/assert@^1.0.0"; 7 | import { test } from "./tester.ts"; 8 | 9 | test({ 10 | mode: "vim", 11 | name: "test(mode:vim) start vim to test denops features", 12 | fn: async (denops) => { 13 | assertFalse(await denops.call("has", "nvim")); 14 | }, 15 | }); 16 | test( 17 | "vim", 18 | "test(mode:vim) start vim to test denops features", 19 | async (denops) => { 20 | assertFalse(await denops.call("has", "nvim")); 21 | }, 22 | ); 23 | 24 | test({ 25 | mode: "nvim", 26 | name: "test(mode:nvim) start nvim to test denops features", 27 | fn: async (denops) => { 28 | assert(await denops.call("has", "nvim")); 29 | }, 30 | }); 31 | test( 32 | "nvim", 33 | "test(mode:nvim) start nvim to test denops features", 34 | async (denops) => { 35 | assert(await denops.call("has", "nvim")); 36 | }, 37 | ); 38 | 39 | test({ 40 | mode: "any", 41 | name: "test(mode:any) start vim or nvim to test denops features", 42 | fn: async (denops) => { 43 | // Test if `call` works 44 | assertEquals( 45 | await denops.call("range", 10), 46 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 47 | ); 48 | }, 49 | }); 50 | test( 51 | "any", 52 | "test(mode:any) start vim or nvim to test denops features", 53 | async (denops) => { 54 | // Test if `call` works 55 | assertEquals( 56 | await denops.call("range", 10), 57 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 58 | ); 59 | }, 60 | ); 61 | 62 | test({ 63 | mode: "all", 64 | name: "test(mode:all) start both vim and nvim to test denops features", 65 | fn: async (denops) => { 66 | // Test if `call` works 67 | assertEquals( 68 | await denops.call("range", 10), 69 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 70 | ); 71 | }, 72 | }); 73 | test( 74 | "all", 75 | "test(mode:all) start both vim and nvim to test denops features", 76 | async (denops) => { 77 | // Test if `call` works 78 | assertEquals( 79 | await denops.call("range", 10), 80 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 81 | ); 82 | }, 83 | ); 84 | 85 | test({ 86 | mode: "all", 87 | name: "test(mode:all) start both vim and nvim with plugin name", 88 | fn: (denops) => { 89 | assertEquals( 90 | denops.name, 91 | "denops-test", 92 | ); 93 | }, 94 | }); 95 | 96 | test({ 97 | mode: "all", 98 | name: "test(mode:all) pass TestContext to the second argument", 99 | fn: async (denops, t) => { 100 | await t.step("step1", async () => { 101 | assertEquals( 102 | await denops.call("range", 10), 103 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 104 | ); 105 | }); 106 | await t.step("step2", async () => { 107 | assertEquals( 108 | await denops.call("range", 10), 109 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 110 | ); 111 | }); 112 | }, 113 | }); 114 | 115 | Deno.test("test() throws if 'mode' option is empty", () => { 116 | assertThrows( 117 | () => { 118 | test({ 119 | mode: "" as "vim", 120 | name: "name", 121 | fn: () => {}, 122 | }); 123 | }, 124 | Error, 125 | "'mode' attribute is required", 126 | ); 127 | }); 128 | 129 | Deno.test("test() throws if 'mode' option is invalid", () => { 130 | assertThrows( 131 | () => { 132 | test({ 133 | mode: "foo" as "vim", 134 | name: "name", 135 | fn: () => {}, 136 | }); 137 | }, 138 | Error, 139 | "'mode' attribute is invalid", 140 | ); 141 | }); 142 | 143 | Deno.test("test() throws if 'name' option is empty", () => { 144 | assertThrows( 145 | () => { 146 | test({ 147 | mode: "vim", 148 | name: "", 149 | fn: () => {}, 150 | }); 151 | }, 152 | Error, 153 | "'name' attribute is required", 154 | ); 155 | }); 156 | 157 | Deno.test("test() throws if 'fn' option is empty", () => { 158 | assertThrows( 159 | () => { 160 | test({ 161 | mode: "vim", 162 | name: "name", 163 | fn: undefined as unknown as () => void, 164 | }); 165 | }, 166 | Error, 167 | "'fn' attribute is required", 168 | ); 169 | }); 170 | -------------------------------------------------------------------------------- /with.ts: -------------------------------------------------------------------------------- 1 | import { deadline } from "jsr:@std/async@^1.0.0/deadline"; 2 | import { assert, is } from "jsr:@core/unknownutil@^4.0.0"; 3 | import { Client, Session } from "jsr:@lambdalisue/messagepack-rpc@^2.1.1"; 4 | import type { Denops, Meta } from "jsr:@denops/core@^7.0.0"; 5 | import { getConfig } from "./conf.ts"; 6 | import { run, type RunMode } from "./runner.ts"; 7 | import { DenopsImpl } from "./denops.ts"; 8 | import { errorDeserializer, errorSerializer } from "./error.ts"; 9 | 10 | const PLUGIN_NAME = "denops-test"; 11 | 12 | // Timeout for connecting to Vim/Neovim 13 | // It takes a long time to start Vim/Neovim on Windows, so set a long timeout 14 | const CONNECT_TIMEOUT = 30000; 15 | 16 | /** Options for the `withDenops` function */ 17 | export interface WithDenopsOptions { 18 | /** Plugin name of the test target */ 19 | pluginName?: string; 20 | /** Print Vim messages (echomsg) */ 21 | verbose?: boolean; 22 | /** Vim commands to be executed before the start of Denops */ 23 | prelude?: string[]; 24 | /** Vim commands to be executed after the start of Denops */ 25 | postlude?: string[]; 26 | /** Timeout for connecting to Vim/Neovim */ 27 | connectTimeout?: number; 28 | } 29 | 30 | /** 31 | * Function to be executed by passing a Denops instance for testing to the specified function 32 | * 33 | * To use this function, the environment variable `DENOPS_TEST_DENOPS_PATH` must be set to the 34 | * local path to the `denops.vim` repository. 35 | * 36 | * The `DENOPS_TEST_VIM_EXECUTABLE` and `DENOPS_TEST_NVIM_EXECUTABLE` environment variables 37 | * allow you to change the Vim/Neovim execution command (default is `vim` and `nvim` respectively). 38 | * 39 | * Note that this is a time-consuming process, especially on Windows, since this function 40 | * internally spawns a Vim/Neovim sub-process, which performs the tests. 41 | * 42 | * ```ts 43 | * import { assert, assertFalse } from "jsr:@std/assert"; 44 | * import { withDenops } from "jsr:@denops/test"; 45 | * 46 | * Deno.test("Test Denops (Vim)", async () => { 47 | * await withDenops("vim", async (denops) => { 48 | assertFalse(await denops.call("has", "nvim")); 49 | * }); 50 | * }); 51 | * 52 | * Deno.test("Test Denops (Neovim)", async () => { 53 | * await withDenops("nvim", async (denops) => { 54 | assert(await denops.call("has", "nvim")); 55 | * }); 56 | * }); 57 | * ``` 58 | */ 59 | export async function withDenops( 60 | mode: RunMode, 61 | main: (denops: Denops) => Promise | void, 62 | options: WithDenopsOptions = {}, 63 | ) { 64 | const conf = getConfig(); 65 | const { 66 | pluginName = PLUGIN_NAME, 67 | verbose = conf.verbose, 68 | prelude = [], 69 | postlude = [], 70 | connectTimeout = conf.connectTimeout ?? CONNECT_TIMEOUT, 71 | } = options; 72 | const plugin = new URL("./plugin.ts", import.meta.url); 73 | const commands = [ 74 | ...prelude, 75 | "let g:denops#_test = 1", 76 | `set runtimepath^=${conf.denopsPath.replace(/ /g, "\\ ")}`, 77 | [ 78 | "try", 79 | ` call denops#server#wait_async({ -> denops#plugin#load('${pluginName}', '${plugin}') })`, 80 | "catch /^Vim\\%((\\a\\+)\\)\\=:E117:/", 81 | ` execute 'autocmd User DenopsReady call denops#plugin#register(''${pluginName}'', ''${plugin}'')'`, 82 | "endtry", 83 | ].join(" | "), 84 | "call denops#server#start()", 85 | ...postlude, 86 | ]; 87 | const aborter = new AbortController(); 88 | const { signal } = aborter; 89 | using listener = Deno.listen({ 90 | hostname: "127.0.0.1", 91 | port: 0, // Automatically select a free port 92 | }); 93 | const getConn = async () => { 94 | try { 95 | return await deadline(listener.accept(), connectTimeout, { signal }); 96 | } catch (cause: unknown) { 97 | throw new Error("[denops-test] Connection failed.", { cause }); 98 | } finally { 99 | listener.close(); 100 | } 101 | }; 102 | const createSession = (conn: Deno.Conn) => { 103 | const session = new Session(conn.readable, conn.writable, { 104 | errorSerializer, 105 | }); 106 | session.onInvalidMessage = (message) => { 107 | console.error(`[denops-test] Unexpected message: ${message}`); 108 | }; 109 | session.onMessageError = (err, message) => { 110 | console.error( 111 | `[denops-test] Unexpected error occurred for message ${message}: ${err}`, 112 | ); 113 | }; 114 | return session; 115 | }; 116 | const createDenops = async (session: Session) => { 117 | const client = new Client(session, { 118 | errorDeserializer, 119 | }); 120 | const meta = await client.call( 121 | "invoke", 122 | "call", 123 | ["denops#_internal#meta#get"], 124 | ) as Meta; 125 | const denops = new DenopsImpl(pluginName, meta, client); 126 | session.dispatcher = { 127 | dispatch: (name, args) => { 128 | assert(name, is.String); 129 | assert(args, is.Array); 130 | return denops.dispatcher[name](...args); 131 | }, 132 | }; 133 | return denops; 134 | }; 135 | const perform = async () => { 136 | using conn = await getConn(); 137 | const session = createSession(conn); 138 | session.start(); 139 | try { 140 | const denops = await createDenops(session); 141 | await main(denops); 142 | } finally { 143 | try { 144 | await session.shutdown(); 145 | } catch { 146 | // Already shutdown, do nothing. 147 | } 148 | } 149 | }; 150 | await using runner = run(mode, commands, { 151 | verbose, 152 | env: { 153 | "DENOPS_TEST_ADDRESS": JSON.stringify(listener.addr), 154 | }, 155 | }); 156 | await Promise.race([ 157 | perform(), 158 | runner.waitClosed().then(({ status, output }) => { 159 | aborter.abort("closed"); 160 | if (!status.success) { 161 | const suffix = output?.length 162 | ? `:\n------- output -------\n${output}\n----- output end -----` 163 | : "."; 164 | throw new Error( 165 | `[denops-test] Process aborted (${mode}, code=${status.code})${suffix}`, 166 | ); 167 | } 168 | }), 169 | ]); 170 | } 171 | -------------------------------------------------------------------------------- /with_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | assertArrayIncludes, 4 | assertEquals, 5 | assertFalse, 6 | assertRejects, 7 | } from "jsr:@std/assert@^1.0.0"; 8 | import { assertSpyCalls, spy, stub } from "jsr:@std/testing@^1.0.0/mock"; 9 | import type { Denops } from "jsr:@denops/core@^7.0.0"; 10 | import { withDenops } from "./with.ts"; 11 | 12 | Deno.test("test(mode:vim) start vim to test denops features", async () => { 13 | const main = spy(async (denops: Denops) => { 14 | assertFalse(await denops.call("has", "nvim")); 15 | }); 16 | await withDenops("vim", main); 17 | assertSpyCalls(main, 1); 18 | }); 19 | 20 | Deno.test("test(mode:nvim) start nvim to test denops features", async () => { 21 | const main = spy(async (denops: Denops) => { 22 | assert(await denops.call("has", "nvim")); 23 | }); 24 | await withDenops("nvim", main); 25 | assertSpyCalls(main, 1); 26 | }); 27 | 28 | for (const mode of ["vim", "nvim"] as const) { 29 | Deno.test(`test(mode:${mode}) outputs ${mode} messages if 'verbose' option is true`, async () => { 30 | using s = stub(console, "error"); 31 | await withDenops(mode, async (denops: Denops) => { 32 | await denops.cmd("echomsg 'Hello. Hello. Hello. Hello. Hello. Hello.'"); 33 | await denops.cmd("echomsg 'World. World. World. World. World. World.'"); 34 | // To avoid message cutoff, execute arbitrary command. 35 | await denops.cmd("redraw"); 36 | }, { verbose: true }); 37 | const rawOutput = s.calls.map((c) => c.args[0]); 38 | const normOutput = rawOutput.join("").split("\r\n").map((v) => v.trim()); 39 | // 40 | // NOTE: 41 | // 42 | // It appears that Neovim doesn't insert any delimiters between consecutive 'echomsg' calls, 43 | // and the chunk lengths are unstable as a result. 44 | // This inconsistency causes issues with Neovim's verbose output, but we couldn't find a workaround 45 | // to resolve this problem. 46 | // Interestingly, this issue only arises when producing verbose output using denops.vim, making it 47 | // difficult for us to reproduce the phenomenon and report it to Neovim's issue tracker. 48 | // While verbose output is essential for debugging, we're forced to accept our current situation. 49 | // 50 | if (mode === "vim") { 51 | assertArrayIncludes(normOutput, [ 52 | "Hello. Hello. Hello. Hello. Hello. Hello.", 53 | "World. World. World. World. World. World.", 54 | ]); 55 | } else { 56 | assertArrayIncludes(normOutput, [ 57 | "Hello. Hello. Hello. Hello. Hello. Hello.World. World. World. World. World. World.", 58 | ]); 59 | } 60 | }); 61 | 62 | Deno.test(`test(mode:${mode}) should be able to call Denops#redraw()`, async () => { 63 | await withDenops("vim", async (denops: Denops) => { 64 | await denops.redraw(); 65 | await denops.redraw(true); 66 | // FIXME: assert redraw is correctly called. 67 | }); 68 | }); 69 | 70 | Deno.test(`test(mode:${mode}) should be able to call Denops#call()`, async () => { 71 | await withDenops("vim", async (denops: Denops) => { 72 | await denops.call("execute", [`let g:with_test__${mode}__call = 'foo'`]); 73 | assertEquals(await denops.eval(`g:with_test__${mode}__call`), "foo"); 74 | }); 75 | }); 76 | 77 | Deno.test(`test(mode:${mode}) should be able to call Denops#batch()`, async () => { 78 | await withDenops("vim", async (denops: Denops) => { 79 | await denops.batch( 80 | ["execute", [`let g:with_test__${mode}__batch_1 = 'foo'`]], 81 | ["execute", [`let g:with_test__${mode}__batch_2 = 'bar'`]], 82 | ); 83 | assertEquals(await denops.eval(`g:with_test__${mode}__batch_1`), "foo"); 84 | assertEquals(await denops.eval(`g:with_test__${mode}__batch_2`), "bar"); 85 | }); 86 | }); 87 | 88 | Deno.test(`test(mode:${mode}) should be able to call Denops#cmd()`, async () => { 89 | await withDenops("vim", async (denops: Denops) => { 90 | await denops.cmd(`let g:with_test__${mode}__cmd = 'foo'`); 91 | assertEquals(await denops.eval(`g:with_test__${mode}__cmd`), "foo"); 92 | }); 93 | }); 94 | 95 | Deno.test(`test(mode:${mode}) should be able to call Denops#eval()`, async () => { 96 | await withDenops("vim", async (denops: Denops) => { 97 | await denops.eval(`execute('let g:with_test__${mode}__eval = "foo"')`); 98 | assertEquals(await denops.eval(`g:with_test__${mode}__eval`), "foo"); 99 | }); 100 | }); 101 | 102 | Deno.test(`test(mode:${mode}) should be able to call Denops#dispatch()`, async () => { 103 | const api = spy(() => Promise.resolve()); 104 | await withDenops("vim", async (denops: Denops) => { 105 | denops.dispatcher = { 106 | foo: api, 107 | }; 108 | await denops.dispatch(denops.name, "foo", [123, "bar"]); 109 | assertSpyCalls(api, 1); 110 | }); 111 | }); 112 | 113 | Deno.test(`test(mode:${mode}) calls plugin dispatcher from ${mode}`, async () => { 114 | const api = spy(() => Promise.resolve()); 115 | await withDenops("vim", async (denops: Denops) => { 116 | denops.dispatcher = { 117 | foo: api, 118 | }; 119 | await denops.call("denops#notify", denops.name, "foo", [123, "bar"]); 120 | assertSpyCalls(api, 1); 121 | }); 122 | }); 123 | 124 | Deno.test(`test(mode:${mode}) rejects if process aborted`, async () => { 125 | const fn = spy(() => {}); 126 | await assertRejects( 127 | async () => { 128 | await withDenops(mode, fn, { 129 | prelude: [ 130 | "echomsg 'foobar'", 131 | "cquit", 132 | ], 133 | }); 134 | }, 135 | Error, 136 | "foobar", 137 | ); 138 | assertSpyCalls(fn, 0); 139 | }); 140 | 141 | Deno.test(`test(mode:${mode}) rejects if connection failed`, async () => { 142 | const fn = spy(() => {}); 143 | await assertRejects( 144 | async () => { 145 | await withDenops(mode, fn, { 146 | prelude: ["sleep 1"], // Set sleep [s] longer than timeout 147 | connectTimeout: 10, // Set timeout [ms] shorter than sleep 148 | }); 149 | }, 150 | Error, 151 | "Connection failed", 152 | ); 153 | assertSpyCalls(fn, 0); 154 | }); 155 | } 156 | --------------------------------------------------------------------------------