I’d written a fairly thorough set of unit tests for the Obsidian plugin I’m building, yet bugs kept showing up only in real use. Whether one command automatically kicks off the next when it finishes, whether buttons get pressed in the order cards appear in the chat panel: things like that simply can’t be reproduced by testing one function in isolation. I needed a real, running Obsidian, but I was writing the code on a remote server, and the only machine that could run Obsidian was an old Linux PC at home.
Why not scp? Moving files wasn’t the whole job (Electron CDP remote debugging)
Until then, every time I changed the plugin I published a GitHub release first, then downloaded that release to test it. One release per test: the order was backwards. I wanted to drop the build output straight into a test vault, check it in a real Obsidian, and only release once it passed.
The obstacle was transfer. The remote server where I write code had no SSH key for the home PC, so the scp route was blocked from the start. But I was already connected remotely to the Obsidian on the home PC for another check. I wondered whether that same connection could carry files too, and it could. What I needed wasn’t “move files” but “programmatically drive an app that’s already running,” and writing files was just part of that.
Obsidian is an Electron app. That means it runs on Chromium under the hood, and Chromium opens the
Chrome DevTools Protocol (CDP) with a single --remote-debugging-port flag. Attach to that port over a
WebSocket and you can run arbitrary JS inside the app’s renderer process: what the browser developer
tools do in the console, done remotely by script. Obsidian’s globals like app.workspace and app.vault
are reachable as-is, so everything from reading and writing files to manipulating the plugin instance was
possible over this one channel.
First stumble: Restricted Mode (Obsidian community plugins)
Over CDP, I put the plugin’s three files (main.js, manifest.json, styles.css) into the plugin
folder, added its name to the list of enabled plugins, and told it to load. Nothing happened. The load
function just returned failure, without a single error. After a lot of head-scratching, I finally found
the cause by asking whether the community plugin feature itself was turned on: it was Restricted
Mode. A newly created vault starts in Restricted Mode with community plugins off, and in that mode it
won’t load a plugin at all, even with the files in place. Only after first making the call that turns
community plugins on did it load. Once turned on, that stays in the vault’s settings, so I never had to
do it again.
Deploying without scp: base64 over the WebSocket (WebSocket frame length)
Getting files onto the remote PC was solved over CDP as well. JS running in the renderer process can
reach Node’s fs module, so I base64-encoded the locally built main.js, embedded it wholesale as a
string literal in the remote script, and had the remote side decode and save it with fs.writeFileSync.
No separate scp session needed: deployment and control both ran entirely over CDP.
The problem was that main.js comes to about 380KB once base64-encoded. My first WebSocket frame
implementation only handled up to 65,535 bytes. WebSocket puts the length in a single byte up to 125
bytes, in a 2-byte extended length up to 65,535 bytes, and in an 8-byte extended length beyond that, and
I’d left out that last case. 380KB was exactly that case. It was my own fault for not implementing the
standard all the way, and deployment only became stable after I added the 8-byte length.
There was one more. To run an async function remotely and get its result back, the run request needs an
option telling it to wait until the promise finishes (awaitPromise). Leave it out and you get an empty
object instead of the result. The function that turns the plugin on looked as if it had quietly failed,
when it hadn’t even run yet, and I spent a while looking in the wrong place.
Driving the screen too: the chat input and the run buttons (Obsidian UI automation)
With file deployment working, what remained was reproducing the actual usage flow. I found the chat
view’s instance with app.workspace.getLeavesOfType(...), put a value directly into its input element,
and called its send function to run the note-processing command (/ingest). Calling the methods the view
already exposes was far more stable than faking synthetic keyboard events.
The buttons tripped me up twice. At first I found the run button as “the last button with this class,” but the button that runs several suggestion cards at once and the individual run button on each card used the same class. So instead of the batch run, the individual button on the second merge card got pressed, and only one of the two merges ran. I only noticed because the work log had no trace of the other one and the note count had dropped by just one. After that, I found buttons by their exact text.
About 15 hours later I hit the opposite problem. The “batch run” (“일괄 실행”) button under a card does nothing if no item is checked; it just flashes a notice. The “run all” (“전체 실행”) bar pinned to the bottom of the screen runs everything when nothing is selected, but the card’s button doesn’t. That notice isn’t visible when driving the app remotely, so the click reported success while, 20 seconds later, the merge still hadn’t happened. It only ran after I pressed “select all” (“전체 선택”) first.
Final thoughts
This setup isn’t a proper E2E test framework, nor is it manual testing with a person at the screen every time. It’s somewhere in between: grabbing one real app instance with a script and shaking it. Everything it caught that unit tests structurally can’t see (the timing of commands chaining automatically, the actual text on cards shown on screen, buttons sharing one class and each button’s own conditions for running) came from this setup. In exchange, I had to design and fix the test tool’s own bugs myself (frame length, getting async results back, button behavior), the kind of effort an existing framework would have spared me.