Repository: vscode-neovim/vscode-neovim
Pull request #1890 (2024-04-15 13:12:19)

fix: highlight calculations race with buffer updates
Addresses #1888 (I'm unsure if it's fully fixed, but my preliminary testing shows great improvement. I'll probably run this as a daily driver for a bit to see what I can uncover)

Demo:

https://github.com/vscode-neovim/vscode-neovim/assets/977151/1355c0a8-4e69-499e-ae32-eed44dbf6392



The issue stems from the fact that highlight updates were calculated based on text which may have not yet been committed to the vscode editor. I refactored part of the `HighlightManager` to allow the deferral of this calculation until we can acquire a lock on the editor.

ollien: FWIW, I've been using this for the past couple of days and not seen any strange behavior with highlights :)
justinmk: Very cool. Is there any way to simplify the code? Or to re-use it in other places in the codebase? Or at least delete some old code?

Since you have abstracted `PendingHLUpdates`, it seems like it could get a lot of test coverage simply by writing tests against the `PendingHLUpdates` interface (without involving actualy vscode operations).
xiyaowong: Nice💯
justinmk: > there weren't really "unit" tests of things like this, but I'm happy to work some in :)

Ah right. IIRC, the tests don't have access to the direct process, they all go over RPC. So you may need to register a special-purpose vscode command which acts as an interface to the tests.
ollien: _phew_ this PR blew up a bit, but just a summary of the changes I just pushed
1. I added unit tests, which run on every `npm test` invocation. Just for clarity, I renamed some of the existing test scripts to include `integration` explicitly, which blew up the number of files
2. I couldn't find any useful places to reuse this PendingUpdates structure without forcing it a bit, but I did break it out and added tests
3. I also did some minor reshuffling of the `HighlightManager` for clarity :)
theol0403: Thanks for this! I'd keep daily driving the highlights for a bit longer, and then I'm good to merge.

@ollien if you have the time/the will, you are more than welcome to use your newfound knowledge of the highlight provider to make a new PR which continues to simplify things :). It's the piece of this project with the most technical debt atm. Either way, this PR is much appreciated!
ollien: @theol0403 Yeah! I was actually thinking of throwing some unit tests around `HighlightProvider` and working on it. Is there anything in particular you have a particular affliction to (e.g. what carries the most debt)? Or is it just a "big ball of code I'd like to see untangled"?
theol0403: @ollien Big ball of code, definitely. It's also the source of a large number of issues in this project. Many of those issues are now closed, as @xiyaowong did a lot of work on the highlight provider. However, it can still be simplified.

No pressure at all to fix the following open issues, simplifying the code + adding tests is useful enough!
* https://github.com/vscode-neovim/vscode-neovim/issues/1766
* https://github.com/vscode-neovim/vscode-neovim/issues/1739
* https://github.com/vscode-neovim/vscode-neovim/issues/329#issuecomment-1215278530
* https://github.com/vscode-neovim/vscode-neovim/issues/1367
ollien: Daily driving is going well :) I'll keep it up. Separately, I've been investigating some of the bugs @theol0403 mentioned this morning, and I think this also fixes #1766, or at least the test case mentioned here https://github.com/vscode-neovim/vscode-neovim/issues/1766#issuecomment-1919864798

On version `1.8.1` if I enter `sample text` on both lines, indent them with `>>` (bottom up), then execute the steps listed here, I get garbled text


https://github.com/vscode-neovim/vscode-neovim/assets/977151/e526d75a-89bf-4f1c-b163-62e2455040a7

However, with this PR, that doesn't seem to happen :tada: 



https://github.com/vscode-neovim/vscode-neovim/assets/977151/a069f214-3a08-4002-85d9-a795c984376e




Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1fce029..6952f54 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -24,7 +24,7 @@
             "args": [
                 "--disable-extensions",
                 "--extensionDevelopmentPath=${workspaceFolder}",
-                "--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
+                "--extensionTestsPath=${workspaceFolder}/out/test/integ/index"
             ],
             "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/out/test/**/*.js"],
             "preLaunchTask": "Test PreLaunch",
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 203eb94..a0f9801 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -45,12 +45,22 @@ You can observe the extension logs via the `vscode-neovim` Output channel or fro
 Note: some messages are not logged to the Output channel, to avoid infinite loop. This is decided by the
 [`logToOutputChannel` parameter](https://github.com/vscode-neovim/vscode-neovim/blob/7337ffd5009067d074af5371171f277cb522aa9b/src/logger.ts#L184).
 
-### Run Tests
+### Run Unit Tests
+
+Unit tests run in node only (not vscode), and can call code directly (unlike the integration tests). You can run unit
+tests independently by doing `npm run test:unit`.
+
+### Run Integration Tests
+
+Integration tests exercise a vscode instance which runs the vscode-neovim extension in a separate "extension host"
+process. These tests call the vscode API which indirectly exercises the extension; they cannot access the memory of the
+extension directly. You can run integration tests by running `npm run test:integration` or interactively through VSCode
+by:
 
 1. Open the repo in VSCode.
 2. Go to debug view and click `Extension Tests` (F5).
-3. To run individual tests, modify `grep: ".*"` in `src/test/suite/index.ts` or set the `NEOVIM_TEST_REGEX` environment
-   variable, e.g. `NEOVIM_TEST_REGEX="foo bar" npm run test`.
+3. To run individual tests, modify `test_regex = ".*"` in `src/test/integ/index.ts` or set the `NEOVIM_TEST_REGEX`
+   environment variable, e.g. `NEOVIM_TEST_REGEX="foo bar" npm run test`.
 
 ## Style and Tools
 
diff --git a/package.json b/package.json
index ae1e0d8..02599a2 100644
--- a/package.json
+++ b/package.json
@@ -2089,8 +2089,11 @@
         "lint": "pre-commit run --all eslint",
         "format": "pre-commit run --all prettier && pre-commit run --all stylua-github",
         "keybind": "node ./scripts/keybindings/index.cjs",
-        "test": "node ./out/test/runTest.js",
-        "pretest": "npm run test-compile",
+        "test": "npm run test:unit && npm run test:integration",
+        "test:unit": "mocha ./out/test/unit",
+        "test:integration": "node ./out/test/runIntegrationTest.js",
+        "pretest:unit": "npm run test-compile",
+        "pretest:integration": "npm run test-compile",
         "vscode:prepublish": "webpack --mode production",
         "webpack": "webpack --mode development",
         "webpack-dev": "webpack --mode development --watch",
diff --git a/src/highlight_manager.ts b/src/highlight_manager.ts
index c4e7cca..d6fab67 100644
--- a/src/highlight_manager.ts
+++ b/src/highlight_manager.ts
@@ -1,9 +1,12 @@
-import { Disposable } from "vscode";
+import { Disposable, TextEditor } from "vscode";
 
 import { EventBusData, eventBus } from "./eventBus";
 import { HighlightProvider } from "./highlight_provider";
 import { MainController } from "./main_controller";
 import { disposeAll } from "./utils";
+import { PendingUpdates } from "./pending_updates";
+
+type GridCell = [string, number, number];
 
 export class HighlightManager implements Disposable {
     private disposables: Disposable[] = [];
@@ -19,8 +22,7 @@ export class HighlightManager implements Disposable {
     private async handleRedraw(data: EventBusData<"redraw">): Promise<void> {
         await this.main.viewportManager.isSyncDone;
 
-        const gridHLUpdates: Set<number> = new Set();
-
+        const pendingUpdates = new PendingUpdates<number>();
         for (const { name, args } of data) {
             switch (name) {
                 case "hl_attr_define": {
@@ -37,75 +39,104 @@ export class HighlightManager implements Disposable {
                 case "grid_scroll": {
                     for (const [grid, top, , , , by] of args) {
                         if (grid !== 1) {
-                            // by > 0 - scroll down, must remove existing elements from first and shift row hl left
-                            // by < 0 - scroll up, must remove existing elements from right shift row hl right
-                            this.highlightProvider.shiftGridHighlights(grid, by, top);
-                            gridHLUpdates.add(grid);
+                            this.scrollHighlights(pendingUpdates, grid, top, by);
                         }
                     }
                     break;
                 }
                 case "grid_line": {
-                    // eslint-disable-next-line prefer-const
-                    for (let [grid, row, col, cells] of args) {
-                        const gridOffset = this.main.viewportManager.getGridOffset(grid);
-                        if (!gridOffset) {
-                            continue;
-                        }
-
-                        const editor = this.main.bufferManager.getEditorFromGridId(grid);
-                        if (!editor) {
-                            continue;
-                        }
-
-                        // const topScreenLine = gridConf.cursorLine === 0 ? 0 : gridConf.cursorLine - gridConf.screenLine;
-                        const topScreenLine = gridOffset.line;
-                        const highlightLine = topScreenLine + row;
-                        if (highlightLine >= editor.document.lineCount || highlightLine < 0) {
-                            if (highlightLine > 0) {
-                                this.highlightProvider.cleanRow(grid, row);
-                                gridHLUpdates.add(grid);
-                            }
-                            continue;
-                        }
-                        const lineText = editor.document.lineAt(highlightLine).text;
-                        let vimCol = col + gridOffset.character;
-
-                        // remove cells from statuscolumn
-                        if (vimCol < 20) {
-                            vimCol = 0;
-                            cells.splice(0, 1);
-                        } else {
-                            vimCol -= 20;
-                        }
-
-                        if (cells.length) {
-                            const tabSize = editor.options.tabSize as number;
-                            const update = this.highlightProvider.processHLCellsEvent(
-                                grid,
-                                row,
-                                vimCol,
-                                cells,
-                                lineText,
-                                tabSize,
-                            );
-                            if (update) {
-                                gridHLUpdates.add(grid);
-                            }
-                        }
+                    for (const [grid, row, col, cells] of args) {
+                        this.stageGridLineUpdates(pendingUpdates, grid, row, col, cells);
                     }
                     break;
                 }
             }
         }
 
-        if (gridHLUpdates.size) {
-            this.applyHLGridUpdates(gridHLUpdates);
+        if (pendingUpdates.size() > 0) {
+            this.applyHLGridUpdates(pendingUpdates);
         }
     }
 
-    private applyHLGridUpdates = (updates: Set<number>): void => {
-        for (const grid of updates) {
+    private scrollHighlights(pendingUpdates: PendingUpdates<number>, grid: number, top: number, by: number) {
+        // by > 0 - scroll down, must remove existing elements from first and shift row hl left
+        // by < 0 - scroll up, must remove existing elements from right shift row hl right
+        this.highlightProvider.shiftGridHighlights(grid, by, top);
+        pendingUpdates.addForceUpdate(grid);
+    }
+
+    private stageGridLineUpdates(
+        pendingUpdates: PendingUpdates<number>,
+        grid: number,
+        row: number,
+        col: number,
+        cells: GridCell[],
+    ): void {
+        const gridOffset = this.main.viewportManager.getGridOffset(grid);
+        if (!gridOffset) {
+            return;
+        }
+
+        const editor = this.main.bufferManager.getEditorFromGridId(grid);
+        if (!editor) {
+            return;
+        }
+
+        const topScreenLine = gridOffset.line;
+        const highlightLine = topScreenLine + row;
+        if (this.highlightLineOutOfBounds(editor, highlightLine)) {
+            // Clear any highlights that we already know are out of bounds
+            this.cleanHighlightLine(grid, row, highlightLine);
+            pendingUpdates.addForceUpdate(grid);
+            return;
+        }
+
+        if (cells.length === 0) {
+            // Nothing to highlight
+            return;
+        }
+
+        const { vimCol, cells: statusLineCells } = this.offsetForStatusLine(col + gridOffset.character);
+        cells.splice(0, statusLineCells);
+
+        const tabSize = editor.options.tabSize as number;
+        pendingUpdates.addConditionalUpdate(
+            grid,
+            // Defer the update so that it can be done with the document lock
+            () => {
+                // Ideally we wouldn't have to check this again, but it's possible we've become desynced
+                // since we checked this before, and lineAt will throw if highlightLine is out of bounds.
+                // If we end up in this state, we should clear the line again and move on
+                if (this.highlightLineOutOfBounds(editor, highlightLine)) {
+                    return this.cleanHighlightLine(grid, row, highlightLine);
+                }
+
+                const lineText = editor.document.lineAt(highlightLine).text;
+                return this.highlightProvider.processHLCellsEvent(grid, row, vimCol, cells, lineText, tabSize);
+            },
+        );
+    }
+
+    private cleanHighlightLine(grid: number, vimRow: number, editorHighlightLine: number): boolean {
+        if (editorHighlightLine < 0) {
+            return false;
+        }
+
+        this.highlightProvider.cleanRow(grid, vimRow);
+        return true;
+    }
+
+    private offsetForStatusLine(vimCol: number): { vimCol: number; cells: number } {
+        if (vimCol < 20) {
+            vimCol = 0;
+            return { vimCol: 0, cells: 1 };
+        } else {
+            return { vimCol: vimCol - 20, cells: 0 };
+        }
+    }
+
+    private applyHLGridUpdates(pendingUpdates: PendingUpdates<number>): void {
+        for (const [grid, update] of pendingUpdates.entries()) {
             const gridOffset = this.main.viewportManager.getGridOffset(grid);
             const editor = this.main.bufferManager.getEditorFromGridId(grid);
             if (!editor || !gridOffset) {
@@ -114,13 +145,22 @@ export class HighlightManager implements Disposable {
             // !For text changes neovim sends first buf_lines_event followed by redraw event
             // !But since changes are asynchronous and will happen after redraw event we need to wait for them first
             this.main.changeManager.getDocumentChangeCompletionLock(editor.document).then(() => {
+                const changed = update();
+                if (!changed) {
+                    return;
+                }
+
                 const hls = this.highlightProvider.getGridHighlights(editor, grid, gridOffset.line);
                 for (const [decorator, ranges] of hls) {
                     editor.setDecorations(decorator, ranges);
                 }
             });
         }
-    };
+    }
+
+    private highlightLineOutOfBounds(editor: TextEditor, editorHighlightLine: number): boolean {
+        return editorHighlightLine >= editor.document.lineCount || editorHighlightLine < 0;
+    }
 
     public dispose(): void {
         disposeAll(this.disposables);
diff --git a/src/pending_updates.ts b/src/pending_updates.ts
new file mode 100644
index 0000000..4a64154
--- /dev/null
+++ b/src/pending_updates.ts
@@ -0,0 +1,69 @@
+/**
+ * Holds a set of batched updates to perform, bucketed by some type of resource identifier (K).
+ */
+export class PendingUpdates<K> {
+    private pendingUpdates: Map<K, (() => boolean)[]>;
+
+    constructor() {
+        this.pendingUpdates = new Map();
+    }
+
+    /**
+     * @returns The number of resources which are staged to update
+     */
+    size(): number {
+        return this.pendingUpdates.size;
+    }
+
+    /**
+     * Get all resources pending updates, and functions which indicate whether or not the updates performed
+     * any changes.
+     *
+     * @returns An array of [K, updateFUnction] pairs. The updateFUnction will
+     *          return true if that resource has been updated.
+     */
+    entries(): [K, () => boolean][] {
+        return Array.from(this.pendingUpdates.entries()).map(([key, update]) => {
+            // This cannot use checks.some(), as some() stops evaluation once an update returns true
+            const anyValid = () => this.evaluateUpdates(update);
+            return [key, anyValid];
+        });
+    }
+
+    /**
+     * Add a new update for the given resource.
+     *
+     * @param resource The resource that this check applies to
+     * @param check A function that returns true if this resource should update.
+     */
+    addConditionalUpdate(resource: K, check: () => boolean) {
+        this.push(resource, check);
+    }
+
+    /**
+     * Indicate that this resource has been updated externally, and any post-update processing should run
+     *
+     * @param resource The resource that this update applies to
+     */
+    addForceUpdate(resource: K) {
+        this.push(resource, () => true);
+    }
+
+    private evaluateUpdates(updates: (() => boolean)[]): boolean {
+        let someUpdateDidChange = false;
+        for (const update of updates) {
+            const updateRes = update();
+            if (updateRes) {
+                someUpdateDidChange = true;
+            }
+        }
+
+        return someUpdateDidChange;
+    }
+
+    private push(resource: K, check: () => boolean): void {
+        const currentUpdates = this.pendingUpdates.get(resource) ?? [];
+        currentUpdates.push(check);
+        this.pendingUpdates.set(resource, currentUpdates);
+    }
+}


Test patch:
diff --git a/src/test/suite/actions.test.ts b/src/test/integ/actions.test.ts
similarity index 99%
rename from src/test/suite/actions.test.ts
rename to src/test/integ/actions.test.ts
index 0a85bf3..03e1c3f 100644
--- a/src/test/suite/actions.test.ts
+++ b/src/test/integ/actions.test.ts
@@ -12,7 +12,7 @@ import {
     closeNvimClient,
     openTextDocument,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 async function eval_from_nvim(client: NeovimClient, code: string): Promise<any> {
     return JSON.parse(
diff --git a/src/test/suite/basic-editing-navigation.test.ts b/src/test/integ/basic-editing-navigation.test.ts
similarity index 99%
rename from src/test/suite/basic-editing-navigation.test.ts
rename to src/test/integ/basic-editing-navigation.test.ts
index 81a3fd6..642c3a9 100644
--- a/src/test/suite/basic-editing-navigation.test.ts
+++ b/src/test/integ/basic-editing-navigation.test.ts
@@ -12,7 +12,7 @@ import {
     closeNvimClient,
     openTextDocument,
     sendInsertKey,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Basic editing and navigation", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/command-line.test.ts b/src/test/integ/command-line.test.ts
similarity index 99%
rename from src/test/suite/command-line.test.ts
rename to src/test/integ/command-line.test.ts
index f6794ec..964e474 100644
--- a/src/test/suite/command-line.test.ts
+++ b/src/test/integ/command-line.test.ts
@@ -9,7 +9,7 @@ import {
     closeAllActiveEditors,
     closeNvimClient,
     openTextDocument,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Command line", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/composite-escape.test.ts b/src/test/integ/composite-escape.test.ts
similarity index 98%
rename from src/test/suite/composite-escape.test.ts
rename to src/test/integ/composite-escape.test.ts
index 10e1108..9440c5d 100644
--- a/src/test/suite/composite-escape.test.ts
+++ b/src/test/integ/composite-escape.test.ts
@@ -11,7 +11,7 @@ import {
     sendEscapeKey,
     openTextDocument,
     sendInsertKey,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Composite escape key", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/dot-repeat.test.ts b/src/test/integ/dot-repeat.test.ts
similarity index 99%
rename from src/test/suite/dot-repeat.test.ts
rename to src/test/integ/dot-repeat.test.ts
index ec9a348..12abb90 100644
--- a/src/test/suite/dot-repeat.test.ts
+++ b/src/test/integ/dot-repeat.test.ts
@@ -15,7 +15,7 @@ import {
     openTextDocument,
     sendInsertKey,
     sendVSCodeKeysAtomic,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Dot-repeat", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/external-buffers.test.ts b/src/test/integ/external-buffers.test.ts
similarity index 98%
rename from src/test/suite/external-buffers.test.ts
rename to src/test/integ/external-buffers.test.ts
index 0b4b5ea..7b1d427 100644
--- a/src/test/suite/external-buffers.test.ts
+++ b/src/test/integ/external-buffers.test.ts
@@ -15,7 +15,7 @@ import {
     openTextDocument,
     sendVSCodeKeysAtomic,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Neovim external buffers", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/external-changes.test.ts b/src/test/integ/external-changes.test.ts
similarity index 98%
rename from src/test/suite/external-changes.test.ts
rename to src/test/integ/external-changes.test.ts
index 21f1fe9..3b7bcf2 100644
--- a/src/test/suite/external-changes.test.ts
+++ b/src/test/integ/external-changes.test.ts
@@ -12,7 +12,7 @@ import {
     wait,
     closeActiveEditor,
     openTextDocument,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("External changes in file", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/extmark.test.ts b/src/test/integ/extmark.test.ts
similarity index 98%
rename from src/test/suite/extmark.test.ts
rename to src/test/integ/extmark.test.ts
index 6e65e63..4a46da4 100644
--- a/src/test/suite/extmark.test.ts
+++ b/src/test/integ/extmark.test.ts
@@ -3,7 +3,7 @@ import assert from "assert";
 import { NeovimClient } from "neovim";
 import vscode, { DecorationOptions, Position, window } from "vscode";
 
-import { attachTestNvimClient, closeAllActiveEditors, closeNvimClient, wait } from "../utils";
+import { attachTestNvimClient, closeAllActiveEditors, closeNvimClient, wait } from "./integrationUtils";
 
 describe("Test ext mark", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/index.ts b/src/test/integ/index.ts
similarity index 100%
rename from src/test/suite/index.ts
rename to src/test/integ/index.ts
diff --git a/src/test/suite/insert-mode-additions.test.ts b/src/test/integ/insert-mode-additions.test.ts
similarity index 99%
rename from src/test/suite/insert-mode-additions.test.ts
rename to src/test/integ/insert-mode-additions.test.ts
index 4354d1a..b06960a 100644
--- a/src/test/suite/insert-mode-additions.test.ts
+++ b/src/test/integ/insert-mode-additions.test.ts
@@ -13,7 +13,7 @@ import {
     openTextDocument,
     sendInsertKey,
     sendVSCodeCommand,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Simulated insert keys", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/insert-mode.test.ts b/src/test/integ/insert-mode.test.ts
similarity index 99%
rename from src/test/suite/insert-mode.test.ts
rename to src/test/integ/insert-mode.test.ts
index 79e0caa..0fd7b47 100644
--- a/src/test/suite/insert-mode.test.ts
+++ b/src/test/integ/insert-mode.test.ts
@@ -17,7 +17,7 @@ import {
     sendInsertKey,
     sendVSCodeCommand,
     sendVSCodeKeysAtomic,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Insert mode and buffer synchronization", () => {
     let client: NeovimClient;
diff --git a/src/test/utils.ts b/src/test/integ/integrationUtils.ts
similarity index 100%
rename from src/test/utils.ts
rename to src/test/integ/integrationUtils.ts
diff --git a/src/test/suite/jumping.test.ts b/src/test/integ/jumping.test.ts
similarity index 98%
rename from src/test/suite/jumping.test.ts
rename to src/test/integ/jumping.test.ts
index 2739a5a..1299d12 100644
--- a/src/test/suite/jumping.test.ts
+++ b/src/test/integ/jumping.test.ts
@@ -12,7 +12,7 @@ import {
     sendNeovimKeys,
     openTextDocument,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Jumplist & jump actions & marks", () => {
     // abc
diff --git a/src/test/suite/macros.test.ts b/src/test/integ/macros.test.ts
similarity index 99%
rename from src/test/suite/macros.test.ts
rename to src/test/integ/macros.test.ts
index 808f67b..1695322 100644
--- a/src/test/suite/macros.test.ts
+++ b/src/test/integ/macros.test.ts
@@ -10,7 +10,7 @@ import {
     sendVSCodeSpecialKey,
     openTextDocument,
     sendInsertKey,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Macros", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/multi-byte-chars.test.ts b/src/test/integ/multi-byte-chars.test.ts
similarity index 99%
rename from src/test/suite/multi-byte-chars.test.ts
rename to src/test/integ/multi-byte-chars.test.ts
index 65565a1..680bfaf 100644
--- a/src/test/suite/multi-byte-chars.test.ts
+++ b/src/test/integ/multi-byte-chars.test.ts
@@ -10,7 +10,7 @@ import {
     sendEscapeKey,
     openTextDocument,
     sendInsertKey,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Multi-width characters", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/options.test.ts b/src/test/integ/options.test.ts
similarity index 99%
rename from src/test/suite/options.test.ts
rename to src/test/integ/options.test.ts
index d6f2728..c1844b2 100644
--- a/src/test/suite/options.test.ts
+++ b/src/test/integ/options.test.ts
@@ -14,7 +14,7 @@ import {
     sendVSCodeKeys,
     setCursor,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Synchronize editor options", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/undo.test.ts b/src/test/integ/undo.test.ts
similarity index 99%
rename from src/test/suite/undo.test.ts
rename to src/test/integ/undo.test.ts
index a5bbbc8..59d74ed 100644
--- a/src/test/suite/undo.test.ts
+++ b/src/test/integ/undo.test.ts
@@ -11,7 +11,7 @@ import {
     closeNvimClient,
     openTextDocument,
     sendInsertKey,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Undo", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/visual-modes.test.ts b/src/test/integ/visual-modes.test.ts
similarity index 99%
rename from src/test/suite/visual-modes.test.ts
rename to src/test/integ/visual-modes.test.ts
index 44231df..397cad0 100644
--- a/src/test/suite/visual-modes.test.ts
+++ b/src/test/integ/visual-modes.test.ts
@@ -12,7 +12,7 @@ import {
     openTextDocument,
     sendInsertKey,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Visual modes test", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/vscode-integration-specific.test.ts b/src/test/integ/vscode-integration-specific.test.ts
similarity index 99%
rename from src/test/suite/vscode-integration-specific.test.ts
rename to src/test/integ/vscode-integration-specific.test.ts
index acd8d94..de619d7 100644
--- a/src/test/suite/vscode-integration-specific.test.ts
+++ b/src/test/integ/vscode-integration-specific.test.ts
@@ -21,7 +21,7 @@ import {
     sendInsertKey,
     sendVSCodeCommand,
     sendNeovimKeys,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("VSCode integration specific stuff", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/window-changed.test.ts b/src/test/integ/window-changed.test.ts
similarity index 98%
rename from src/test/suite/window-changed.test.ts
rename to src/test/integ/window-changed.test.ts
index a2ae3f7..b9dcd04 100644
--- a/src/test/suite/window-changed.test.ts
+++ b/src/test/integ/window-changed.test.ts
@@ -4,7 +4,7 @@ import path from "path";
 import { NeovimClient } from "neovim";
 import vscode, { Uri, ViewColumn, commands, window, workspace } from "vscode";
 
-import { attachTestNvimClient, closeAllActiveEditors, closeNvimClient, wait } from "../utils";
+import { attachTestNvimClient, closeAllActiveEditors, closeNvimClient, wait } from "./integrationUtils";
 
 describe("handle window changed event", () => {
     let client: NeovimClient;
diff --git a/src/test/suite/yanking.test.ts b/src/test/integ/yanking.test.ts
similarity index 98%
rename from src/test/suite/yanking.test.ts
rename to src/test/integ/yanking.test.ts
index 1f4dc50..693b581 100644
--- a/src/test/suite/yanking.test.ts
+++ b/src/test/integ/yanking.test.ts
@@ -9,7 +9,7 @@ import {
     closeNvimClient,
     openTextDocument,
     wait,
-} from "../utils";
+} from "./integrationUtils";
 
 describe("Yanking and pasting", () => {
     let client: NeovimClient;
diff --git a/src/test/runTest.ts b/src/test/runIntegrationTest.ts
similarity index 91%
rename from src/test/runTest.ts
rename to src/test/runIntegrationTest.ts
index 11bb429..0c70019 100644
--- a/src/test/runTest.ts
+++ b/src/test/runIntegrationTest.ts
@@ -9,7 +9,7 @@ async function main(): Promise<void> {
         const extensionDevelopmentPath = path.resolve(__dirname, "../../");
 
         // The path to the extension test runner script
-        const extensionTestsPath = path.resolve(__dirname, "./suite/index");
+        const extensionTestsPath = path.resolve(__dirname, "./integ/index");
 
         // Download VS Code, unzip it and run the integration test
         await runTests({
diff --git a/src/test/unit/pending_updates.test.ts b/src/test/unit/pending_updates.test.ts
new file mode 100644
index 0000000..73d2ce2
--- /dev/null
+++ b/src/test/unit/pending_updates.test.ts
@@ -0,0 +1,67 @@
+import { strict as assert } from "assert";
+
+import { PendingUpdates } from "../../pending_updates";
+
+describe("PendingUpdates", () => {
+    it("size should be zero on initialization", () => {
+        const updates = new PendingUpdates<string>();
+        assert.equal(updates.size(), 0);
+    });
+
+    it("size should increase by one for every unique resource", () => {
+        const updates = new PendingUpdates<string>();
+        updates.addConditionalUpdate("resource1", () => false);
+        updates.addConditionalUpdate("resource1", () => false);
+        updates.addConditionalUpdate("resource2", () => false);
+
+        assert.equal(updates.size(), 2);
+    });
+
+    it("entries return a function for each key that evaluates all updates", () => {
+        const updates = new PendingUpdates<string>();
+        const evaluated: number[] = [];
+        const makeUpdateFunc = (id: number, returnVal: boolean) => () => {
+            evaluated.push(id);
+            return returnVal;
+        };
+
+        updates.addConditionalUpdate("resource1", makeUpdateFunc(0, true));
+        updates.addConditionalUpdate("resource1", makeUpdateFunc(1, true));
+        updates.addConditionalUpdate("resource2", makeUpdateFunc(2, false));
+
+        const entries = Object.fromEntries(updates.entries());
+        assert.equal(entries["resource1"](), true);
+        assert.equal(entries["resource2"](), false);
+
+        evaluated.sort();
+        assert.deepEqual(evaluated, [0, 1, 2]);
+    });
+
+    it("entries return a function for each key that evaluates all updates, even if one is a force update", () => {
+        const updates = new PendingUpdates<string>();
+        const evaluated: number[] = [];
+        const makeUpdateFunc = (id: number, returnVal: boolean) => () => {
+            evaluated.push(id);
+            return returnVal;
+        };
+
+        updates.addForceUpdate("resource1");
+        updates.addConditionalUpdate("resource1", makeUpdateFunc(0, false));
+        updates.addConditionalUpdate("resource2", makeUpdateFunc(1, false));
+
+        const entries = Object.fromEntries(updates.entries());
+        assert.equal(entries["resource1"](), true);
+        assert.equal(entries["resource2"](), false);
+
+        evaluated.sort();
+        assert.deepEqual(evaluated, [0, 1]);
+    });
+
+    it("force updates should always result in an entry that returns true", () => {
+        const updates = new PendingUpdates<string>();
+        updates.addForceUpdate("alreadyUpdated");
+
+        const entries = Object.fromEntries(updates.entries());
+        assert.equal(entries["alreadyUpdated"](), true);
+    });
+});