Testing
deco is tested at two levels, and they are testing different things.
Unit tests sit next to the code, one per crate, and there are about 1,700 of
them. They build the struct they are about, call the function they are about, and
assert on what came back. That is where most of the confidence in this codebase
comes from: deco-keymap resolves a chord correctly, deco-config layers a
settings.json correctly, deco_tui::render lays a session out correctly.
End-to-end scenarios live in crates/deco-e2e and
there are about a hundred and twenty. They start the editor on a machine the test built, press
keys, and look at the screen and the disk. They exist because the way an editor
breaks in practice is rarely one function returning the wrong value. It is a
settings.json read from the wrong directory, a keybinding that resolved but
never reached the command, a file saved to a path nobody meant, a frame drawn
taller than the terminal it was painted into. Each of the parts is right and the
editor is wrong.
$ cargo test --workspace # both, and what CI runs
$ cargo test -p deco-e2e # just the scenarios
$ cargo xtask ci # fmt, clippy, rustdoc and all of it
What a scenario is made of
use deco_e2e::Scenario;
#[test]
fn a_workspace_settings_file_beats_the_users_own() {
let scenario = Scenario::new("workspace-layer")
.user_settings(r#"{ "editor.tabSize": 8, "editor.insertSpaces": true }"#)
.workspace_settings(r#"{ "editor.tabSize": 2 }"#)
.file("a.txt", "x\n");
let mut editor = scenario.launch(&["a.txt"]);
editor.press("tab");
assert_eq!(editor.text(), " x\n");
}
Four things are deliberately real:
- The configuration directory.
user_settings,vscode_settings,workspace_settingsanduser_keybindingswrite JSON to a temporary home in the layout the platform really uses, and the session is built bydeco::startup::session— the same call the binary makes. Nothing is handed a pre-builtSettings. - The workspace.
filewrites files to disk. Quick open walks them, search-in-files greps them, saving overwrites them, andeditor.on_disk(…)is what acatwould show. - The keystrokes.
press("ctrl+shift+p")builds the crosstermKeyEventa terminal would send, hands it todeco_tui::keys::chord_from_event, and then todeco_tui::Driver— the editor’s own event loop with the terminal taken out of it. A scenario cannot reach a command except by pressing keys bound to it. - The screen.
editor.screen()renders a frame at the terminal size the scenario asked for and applies the same substitutionpaintapplies on the way to a terminal.assert_shows,assert_statusandassert_fitsprint the whole screen when they fail, framed, because the useful question about a missing string is never “is it missing” but “what is there instead”.
What it deliberately does not do
- No terminal. Nothing here proves crossterm writes what it is queued.
painthas its own unit tests for that. - No process environment. It is shared by every test thread, so mutating it
cannot be done safely. Home, the platform’s configuration layout, which
platform’s keybindings win and the working directory are all fields on
Scenario— which is also why a scenario can be a Mac while running on Linux. - No line-ending default. With
files.eolleft atautoa new file’s ending follows the platform, so a scenario asserting the bytes of a file it created names the ending it expects. A scenario that cares setsfiles.eolitself. -
No language servers, unless asked. A machine with
rust-analyzerinstalled is a different machine from one without, and a scenario about saving a file should not start failing because of something it never mentioned. The default machine has none, said the way a user would say it —"deco.lsp.enabled": false.A scenario that is about a language server asks for one with
Scenario::language_server("rust", "full"), which writes adeco.lsp.serversdefinition pointing atexamples/language_server.rs— a real program on a real pipe, answering real LSP.deco-lsphas a fake server too and it is the opposite instrument: that one acts out failure modes and cannot tell you whether go-to-definition works.Waiting on one needs real time, so
Editor::settle_untilsleeps and polls the editor’s own idle path.Editor::waitdoes not — it advances only the clock the editor is handed, which is right forfiles.autoSaveand useless for a subprocess.
Where the scenarios are
| File | What it covers |
|---|---|
tests/editing.rs |
Typing and saving: indentation from settings, undo grouping at a human typing rate, comments, CRLF and final newlines, Unicode, 200,000 lines |
tests/configuration.rs |
Which settings file wins, VS Code’s read when deco has none, workspace layers, broken JSON, --clean, --print-config |
tests/keybindings.rs |
Rebinding, -command removals, chords, when clauses, per-platform keys, a broken keybindings.json |
tests/files.rs |
Save-as, untitled buffers, tabs, save-all, auto-save, revert after an external change |
tests/navigation.rs |
Quick open, go to line, find and replace, search in files, the command palette |
tests/appearance.rs |
Themes from an installed extension, the frame at every terminal size, escape sequences in a file name, wide characters |
tests/workflow.rs |
Long sessions: several hundred keystrokes at one editor, because a class of bug only exists after the fifth thing |
tests/language_servers.rs |
A real server on a real pipe: diagnostics, hover, definition, references, symbols, completion, formatting — and a server a cloned repository asked for, which is not run |
Remote sessions have scenarios of their own in
crates/deco/tests/remote_editor.rs,
where CARGO_BIN_EXE_deco names the binary to run as the far end.
Scenario::remote_file puts files on a directory this machine does not have, so
that a file arriving over the connection is one the local disk could not have
supplied.
A scenario that fails leaves its directory on disk and prints where it is, so the first question — what was actually in that home directory — can be answered by looking.
What this found
The suite was written against an editor whose parts were already well tested, and it still turned up five defects that no unit test could have seen, because each one is a disagreement between two components rather than a fault in either:
- Workspace settings were never loaded for a relative path on the command
line. The walk that looks for a
.gitor a.vscodeabove the file asked the filesystem about a relative path, so every question it asked was a question about the process’s working directory. It reached the right answer only for as long as that directory and the one the path was relative to were the same. - Save-as could open the same file twice. A relative path typed into the save
prompt was stored unresolved, and every other way of opening a file produces an
absolute one — so saving an untitled buffer as
notes.txtand then choosingnotes.txtfrom quick open opened it again, in a second buffer with a second undo history, and whichever tab was saved last won. - The frame could be taller than the terminal. Eight palette choices, an input line and a status bar are ten rows; a terminal can be five. Painting ten rows into five scrolls a real terminal and walks the editor off the screen. The prompt’s list now gives up the rows it does not have.
- A one-row terminal drew two rows. The unit test for it said in a comment that the status bar wins, and then asserted that both were drawn.
- A seeded prompt appended to its seed instead of replacing it. Save As and
Find in Files open with an answer already in them, and the one-line input had
no selection — so the next key appended:
ctrl+shift+fonfnand typingprintlnsearched forfnprintln. The unit tests for the input and for the seeding each passed; only pressing the keys in order shows what they add up to. Fixed by giving the field the one selection it needs, all of it.
And one it found while the suite was being made to run on Windows:
files.eolwas wired to the wrong half of what it names. VS Code treats it as the ending a new file gets, leaving existing files with the ending they already had. deco applied it inDocument::from_file, so opening a CRLF file with"files.eol": "\n"converted it and the next save wrote every line back changed — whileDocument::untitledbuilt aBuffer::new()and never looked at the setting at all, so the one file it should have decided for ignored it. Fixed: the setting now applies where a buffer has no ending of its own to keep.
What the second round found
The scenarios above deliberately turned language servers off and left remote sessions to their own protocol tests. Both gaps were where the next defects were:
- The completion list was never drawn.
overlay_suggestrenders one beside the cursor and has seven unit tests; the event loop asked the renderer for a frame with a hover in it, and there is no way to mention a completion list to that function. The list was fetched, filtered, navigable and invisible. Fixed. ctrl+spacecannot reach Trigger Suggest in a terminal. A terminal sends NUL for it; crossterm turns that intoChar(' ')with Control; deco mapped onlyKeyCode::Nulltospace, and the binding parsed toNamed(Space). The two never met. The GUI mapped its space bar the other way, so the frontends disagreed about what akeybindings.jsonmeant — and an unbound space typed nothing there, because typing is decided by matchingKey::Char. Fixed:spaceis one key with one representation, the character.- A refused server is not mentioned when the user has one of their own.
Lsp::attachcollected refusals and reported them after the loop, and the loop returns as soon as a trusted candidate starts — so the disclosure was reached only when there was nothing else to try. Fixed: the refusals are named before anything is tried, and they go in the problem list, whichattachrunning on every tab switch cannot make repeat. - Save-as in a remote session renames the document to a local path, which every later save then asks the server to write, outside the workspace it serves. Fixed: the typed name is the far end’s and the write goes through the connection.
- Revert in a remote session reads this machine, at the far end’s relative path. Fixed: it reads through the connection too.