Notifio is an Electron app with no test framework in it. The whole testing arrangement is three scripts run by tsx:
"test": "pnpm test:baseline && pnpm test:backoff && pnpm test:scraper",
That works because of one rule, which I wrote about when I described the setup: a file you want to test may not import electron. Nothing enforces it. It is a convention held up by the fact that breaking it makes tsx somefile.ts fail immediately.
What I had not noticed until I went looking is that the codebase obeys that rule in four different ways, and they are not interchangeable.
Why importing electron outside Electron breaks
It is not the import that fails. require('electron') in plain Node resolves to a module that exports the path to the Electron binary, a string. So the import succeeds and every named member you asked for is undefined, and the failure arrives at the first property access.
Which is exactly what one of our modules does at import time:
import path from 'path';
import fs from 'fs';
import { app } from 'electron';
// Root of all writable app data
export const USER_DATA_DIR = app.getPath('userData');
export const DATA_DIR = path.join(USER_DATA_DIR, 'data');
export const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json');
// ...
paths.ts is the module that knows where writable data lives, because inside a packaged asar __dirname is read only. Those are consts at module scope, so app.getPath runs the moment anything imports this file, and outside Electron it throws a TypeError on undefined.
Ten modules import it:
$ grep -rln "from './paths'" src/*.ts
src/entitlement.ts src/finds.ts src/ledger.ts src/main.ts src/monitor.ts
src/notify.ts src/recipes.ts src/scraper.ts src/server.ts src/settings.ts
So the rule in practice is not "do not import electron". It is "do not import anything that reaches paths.ts", and that single module is why the testable half of the app is the half that does not touch the data directory: the diffing logic, the retry ladder, the URL guards, the limits, the type definitions, the live per-search state that is deliberately in memory only.
Answer one: a bridge, times two
The embedded Express server inside the main process needs to open a real BrowserWindow for one flow, and it may not import electron to do it. The fix is dependency inversion:
class LoginBridge extends EventEmitter {
private _openHandler: OpenHandler | null = null;
private _confirmHandler: ConfirmHandler | null = null;
private _resetHandler: ResetHandler | null = null;
/** Called once by main.ts to register the Electron-side handlers */
register(handlers: { open: OpenHandler; confirm: ConfirmHandler; reset: ResetHandler }): void {
this._openHandler = handlers.open;
this._confirmHandler = handlers.confirm;
this._resetHandler = handlers.reset;
}
async requestOpen(url: string): Promise<void> {
if (!this._openHandler) throw new Error('Login bridge not initialised');
return this._openHandler(url);
}
// ...
}
export const loginBridge = new LoginBridge();
main.ts is allowed to import Electron, so it registers handlers that open windows. server.ts imports only the singleton. Neither file imports the other's world.
There are two of these, the second one for the recorder window, and both of them extend EventEmitter. Neither emits an event. I checked:
$ grep -rn "loginBridge\.\|recorderBridge\." src/*.ts | grep -E "\.emit|\.on\("
Nothing. The mechanism is three nullable function slots and a register call, which is not an event bus, and the extends EventEmitter on both classes is a first draft that survived into a shipped app. It is harmless and it is also a lie about how the file works, which is the kind of thing a reader pays for later.
The two bridges disagree about the same failure
Both bridges have to answer the question "what if nobody registered?". They answer it differently.
The login bridge throws a sentence I wrote for myself:
if (!this._openHandler) throw new Error('Login bridge not initialised');
The recorder bridge throws a sentence written for a user, and it also lets a caller ask first:
private require(): Handlers {
if (!this.handlers) {
throw new Error('Recording is only available in the desktop app.');
}
return this.handlers;
}
getState(): RecorderState {
if (!this.handlers) return { recording: false, stepCount: 0 };
return this.handlers.state();
}
get available(): boolean {
return this.handlers !== null;
}
That difference matters because of where those strings end up. Both bridges are called from HTTP routes that catch and return the message as the body of a 500:
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
And the renderer shows the body. So one unregistered bridge produces "Recording is only available in the desktop app." in the UI, and the other produces "Login bridge not initialised". The recorder's available flag is served to the renderer as part of a normal response, so the UI can hide the button instead of offering an action that cannot work. The login bridge gives its callers no way to ask, so the only way to find out is to try it and read the error.
Worth saying: the login route does not actually rely on the bridge being there. It branches first.
if (process.env.ELECTRON_APP) {
await loginBridge.requestOpen(url);
} else {
await openLoginPage(url);
}
An env var set by main.ts before the server module loads, which is its own small adventure that I have written about. So the bridge-not-registered message is unreachable in the shipped app and reachable in exactly one situation: running the server outside Electron with ELECTRON_APP set by hand. Which is to say, it is a message for me, in a place only I can reach. That is the correct call, and it is the opposite of what the sentence's tone suggests.
Answer two: require it lazily and accept null
Desktop notifications need Notification and shell, and the module they live in is reachable from the monitor, which has to run outside Electron for development. So it requires Electron at call time and copes with not getting it:
function electron(): ElectronBits | null {
try {
return require('electron') as ElectronBits;
} catch {
return null;
}
}
and every caller is built around the possibility of null, because a missing notification may never fail a poll. This is the right answer when the Electron feature is optional. It would be the wrong answer for paths.ts, where there is no sensible fallback for "we do not know where to write your data".
Answer three: import it at the top and guard the first call
import { safeStorage } from 'electron';
// ...
export function canEncrypt(): boolean {
try {
return safeStorage.isEncryptionAvailable();
} catch {
// Not running under Electron, or the OS keychain is unavailable. Fall back to
// plaintext rather than losing the data.
return false;
}
}
This one reads like a mistake and is not. The top-level import is inert, because the failure only comes at property access, and the single property access in the file is inside that try. Outside Electron safeStorage is undefined, undefined.isEncryptionAvailable() throws, the catch returns false, and the data gets written in the plaintext branch of the envelope that records how it was written. One try turns an import-time crash into a documented fallback.
It still breaks the rule as stated, though, which is the useful thing to notice. settings.ts imports electron and is perfectly safe to import outside Electron. finds.ts imports no Electron at all and is not, because it imports paths.ts. The rule people repeat and the rule the code obeys are two different rules.
What I would change
Not the bridges' existence: the inversion is right, and a singleton with registered handlers is the smallest thing that works for two processes' worth of concerns inside one process.
Three smaller things. Drop extends EventEmitter from both, since neither uses it. Give the login bridge the recorder bridge's available getter, so a route can answer a question instead of catching an exception. And make paths.ts lazy, functions rather than module-scope constants, which would turn the one unavoidable Electron importer into a harmless one and widen the testable surface from eight modules to nearly all of them.
The app those modules add up to is on the download page for Mac and Windows as a one-time licence, the window and its tabs are walked through in the help pages, and the per-site setup, including which sites need you to sign in, is on the supported sites pages.
Top comments (0)