EkoLite, our small real time backend, can do two things that have never been introduced to each other. A method can run: a named function on the server, reachable over the socket, that mints a reply for whoever called. A script can run: ScriptRunnerWrapper takes a command and some arguments, runs a process, and hands back what it printed, its stdout, its stderr, its exit code. Both are tested to the hilt on nullables. And until now, no method has ever run a script. A registry that can dispatch and a runner that can shell out, and not a single line where one reaches the other. A wrapper with no caller is a promise nobody has cashed.
This post is that first introduction, and it is smaller than you might expect, on purpose.
The smallest possible join
An app built on EkoLite defines the method itself, because the framework wires infrastructure and stops there. It comes back with empty registries, and whatever you define is all that is on it. So the join is a method the app registers, one that resolves a script, asks the runner to run it, and returns what the script printed:
app.methods.define('runCountC', async () => {
const result = await app.scriptRunner.exec('python3', [app.asset('countC.py')]);
return result.stdout;
});
That is the whole method. It is meant to be almost nothing, because the interesting part is not what it computes. It is that it joins two things that used to live apart. A real analysis can grow on this seam later, but the seam is the point today, so we keep the method too small to hide anything.
The question that shapes the test
Here is the awkward thing about proving this. A method that runs a script is exactly the kind of code that is tempting to test by running the script. Spin up python3, point it at a real file, read what comes back. And then your test needs Python on the machine, and a file on disk, and it is slow, and it is really testing three things at once while pretending to test one.
So we ask the question we always end up asking here. How do you prove a join without doing the real thing on either side of it?
The answer is the off switch we already trust everywhere else. ScriptRunnerWrapper has a null version, and its whole job is to behave like the real runner without ever touching a process. So we prime it with the answer a script would have printed, wire it into the app, and call the method the way the wire would. App.createNull takes those primed answers, keyed by command, and hands the same assembled app back with its runner switched off.
One real decision, made honestly
There was a genuine fork in the road, and it is worth pulling out because the test cannot be written until it is settled.
When the method calls exec, what does it pass? It could hand exec the script path as the command, as if the script were an executable you could run on its own. Or it could hand exec a runtime, python3, with the path as an argument. Those are two different shapes, and the null has to be keyed to whichever one you pick, because the null answers on the command it was given.
We went with python3 and the path as an argument. Partly because it is honest for a Python file that has no execute bit and no shebang you can rely on, which is how these scripts will actually arrive. Partly because it makes the null key on something the test can predict, python3, rather than on a path that only exists once it has been resolved. The rule is simple and it kept us straight: whatever the real run will do, the null has to be keyed the same way, or the test is rehearsing a different play than the one that ships.
There is a second test worth keeping next to the first, and it exists only to stop a shortcut. It hands the method a deliberately different path and watches, through the runner's own tracking, that the runner was asked to run that path and not some favourite baked into the method. Without it, the method could ignore its argument entirely, run a hardcoded file, and the first test would smile and pass. The second test makes the method prove it runs the path it was given.
The path is configuration, not a secret
That second test is really about one habit, so let us name it. The script path does not live as a literal inside the method. It arrives.
EkoLite reads its configuration once, at boot, and injects it. The database URI, the upload directory, the port, all of them are read in one place and handed to the pieces that need them. The script path joins that list through the app's assets directory, so app.asset('countC.py') resolves against configuration rather than a literal buried in the method. A different script, a different location, a different deployment, and nothing inside the method changes.
One small detail underneath that resolution is worth thirty seconds, because it is the kind of thing that works on your laptop and breaks in a container. When you turn a repository path like scripts/countC.py into a full path, you have to anchor it on something. The lazy anchor is the current working directory, the folder the process happened to be started from. That is a property of how someone launched the server, not of where the code lives, and it quietly changes when the server boots from a service manager, a container, or a test runner that sets its own. So the app anchors on a configured assets directory instead, which is fixed no matter who started the process. The asset resolves to the same place whether you run it from the repository root or from anywhere else.
What this story is honest about not doing
The teaching moment is not finished, and pretending otherwise would be the dishonest move.
EkoLite does not ship countC.py, and it never will, because the framework carries no analysis of its own. The script is the app's to bring. The wiring is proven against a runner that never shells out, which is exactly the right shape for this slice and exactly the wrong shape to call it done. The real script, an integration test that runs it for real and reads its actual output, and the socket path a browser would call the method through, are all held for the next slices. Register the method today against a real runner and an app whose script is not there yet, and a live call resolves the path, runs python3 against a file that is not there, and comes back with an empty string. Not an error, an empty string, because the method hands back what the script printed to stdout, and the complaint about the missing file went to stderr, which this method does not read yet. That is the smell of a call that resolves successfully and quietly returns nothing. It is the honest state. The join exists and is tested, the thing it joins to is the app's to supply, and the day it does the same seam carries its output back untouched.
The move you can reproduce
You do not have to take our word for the shape of it. In a Node session or a test, build a null app, prime the runner with the answer a script would have printed, define the join, and call it:
import { App } from 'ekolite';
const app = App.createNull({ scriptResponses: { python3: 'count: 42' } });
app.methods.define('runCountC', async () => {
const result = await app.scriptRunner.exec('python3', ['scripts/countC.py']);
return result.stdout;
});
await app.methods.call('runCountC', []); // 'count: 42'
Then do the thing that makes it click. Change the answer you prime the runner with, from count: 42 to anything you like, and watch the method hand your new string straight back. Nothing ran. No Python, no file, no process. What you are watching is a defined method carry a script's output back to its caller, proven before the script it will one day run has been written at all. That gap between proven and finished is not a gap we are hiding. It is where the next slice goes.


