Test and start kit
A single file, kit.mjs, in Node 22 with no dependencies. The convex client comes from the project's node_modules. It takes out of every system what all of them repeated: starting NotusBase, waiting for it to respond, deploying, setting variables, creating a user and stopping.
The kit ships inside the binary and follows it. notusbase init writes it to the project as kit.mjs, stamped with the binary's version and the sum of the file, and notusbase codegen, dev and deploy rewrite it when the binary changes. They rewrite only a file that is still as a binary wrote it, and never one that a newer binary wrote. An edited kit.mjs is left as it is, with a warning: put helpers of your own in another file. A kit.mjs that does not start with the kit's first line is the project's own and is left alone; an empty kit.mjs asks for the kit. One from a binary before the sum is rewritten once, and the file before stays beside it as kit.mjs.bak, which a project from notusbase init already ignores in .gitignore. A kit.mjs that is a link to outside the project is never written through; that is said once for the link and the binary, not at every command. The kit keeps its test data in tmp/kit: when the project's .gitignore does not ignore tmp/, codegen says so once, and leaves the .gitignore as it is. When the binary a test runs is another version than the one that wrote the file, the kit says so in one line. Every export carries its description and types in the file, for the editor.
Who uses it: the test.mjs of every project from notusbase init, the example project and NotusBook.
Test
import assert from "node:assert/strict";
import { setup } from "./kit.mjs";
import { api } from "./convex/_generated/api.js";
const t = await setup({ project: import.meta.dirname, env: { SECRET: "x" } });
try {
await t.client.mutation(api.teams.create, { name: "Team" }); // already signed in as the test user
const other = await t.addUser("[email protected]"); // one more user, with their own client
assert.equal(await t.run("tasks:deleteDone", { ... }), 0); // internal functions too
await t.waitJobs(); // the due jobs have finished
} catch (error) {
console.error(t.log()); // the end of the server log
throw error;
} finally {
await t.close();
}
setup deletes and creates <project>/tmp/kit/default, starts notusbase serve on a free port, deploys convex/ with tsc, sets the variables and creates a test user with a random password.
| Option | Default | What for |
|---|---|---|
project | required | Folder with convex/ and node_modules |
name | default | Folder under the project's tmp/kit/, for two environments of the same project |
dir | <project>/tmp/kit/<name> | Another throwaway folder. It is deleted at the start |
port | free | Server port |
notusbase | NOTUSBASE or the one on the PATH | Binary |
server | Variables of notusbase serve, like NOTUSBASE_UPLOAD_MB. null removes an inherited one | |
env | Project variables, like notusbase env set | |
user | random email | Email of the test user, or false to skip creating one. With login in the project (below), there is no test user and passing user is an error |
skipTypecheck | false | Deploys without tsc |
The server starts with NOTUSBASE_AUTH_COLLECTIONS=users, PB_WAL_SYNC_MS=1000, NOTUSBASE_ENV=development with NOTUSBASE_FETCH_HOSTS=127.0.0.1,localhost,::1 (email captured, and an action's fetch reaches only this machine: a fake API in the test does, a real one does not; server: { NOTUSBASE_FETCH_HOSTS: "..." } adds names) and, in tests only, NOTUSBASE_TEST_LOGIN_MAX=1000, because every login comes from the same IP and the normal limit is 2 per 3 s. Signup stays as notusbase serve leaves it, closed: the kit creates users through a test superuser.
Login in the project. When convex/schema.ts declares a users table, the login is the project's own code: the kit starts the server without NOTUSBASE_AUTH_COLLECTIONS, creates no user, and client and user come back undefined. The test takes a client from anonymous(), creates its accounts through its own functions (auth:signUp), passes the token to setAuth, and reads the links from emails(). startApp does the same for npm start.
| What comes back | Does |
|---|---|
client | ConvexClient signed in as the test user. It does not print to the console: a refused call arrives as the exception |
user | { email, password, token, id }; id is the subject in the functions |
addUser(email?) | Creates another user and returns the same shape, plus client |
anonymous() | ConvexClient with no login |
run(fn, args) | Runs a function, internal ones included, and returns the value. An error becomes an exception, with data when it was a ConvexError |
waitJobs({ function?, ms? }) | Waits for the jobs that are already due to finish (runAfter(0), past runAt and the crons' own). A job set for later does not count. Returns how many there are in each state |
runCron(name, { ms? }) | Fires the cron now, without touching its next occurrence, and waits for its job to finish; returns the job. Jobs the function schedules are left for waitJobs. It needs the binary to have POST /api/notusbase/schedule/run-cron: on an older one, the error comes with noRoute |
advance(time, { wait?, ms? }) | Advances the server clock and waits for what came due to finish: runAfter/runAt and crons. time is ms or a text ("2h", "1h30m", "500ms", "3d"; units ms, s, m, h, d, w). See "Test clock" below |
now() | The server's "now" in ms: the real one plus what advance added |
emails({ status?, ms? }) | With the email module: waits for the jobs and returns the captured emails (status changes the status, null returns all), oldest first. One sent by template carries its name in template; t.run("email:preview", { name }) renders a template without sending. The kit sets EMAIL_PROVIDER=capture when the project imports notusbase-modules/email, so nothing is sent |
envSet(name, value) | Sets a variable; null deletes it |
cli(...args) | Runs the binary with the same environment; returns the output |
deploy(project) | Deploys again |
log(n) | The last n lines of the server log, in <dir>/notusbase.log |
url, port, dataDir, dir | Where everything is |
close() | Closes the clients and stops the server. The folder stays, to look at later |
A business error is checked on e.data.code, as on the client. Whoever needs raw HTTP uses t.url.
After waitJobs, the data is already in the database, but the client only sees it when the server pushes the change, about 10 ms later on a Mac. A client.query right after can answer with the result the client still holds from a subscription to the same query. Check through the subscription (onUpdate) or retry with waitUntil.
Test clock
t.advance("2h") adds 2 h to the test server's clock and makes due jobs and crons run right away, without waiting. It serves flows with deadlines: appointment reminder, overdue charge, 7-day cleanup.
- Tests only.
setupstarts the server withNOTUSBASE_TEST_CLOCK=1, which turns onPOST /api/notusbase/test/advance(with the deploy key). A normalnotusbase servedoes not have the route. With the variable, the binary refuses to start outside loopback or with--behind-proxy, and warns in the log when it turns on.server: { NOTUSBASE_TEST_CLOCK: null }insetupremoves it. - What moves: the scheduler (
runAfter,runAt, crons) andDate.now()/new Date()inside functions, in queries, mutations and actions, andscheduledTime. All on the same clock: a mutation that callsrunAfter(1h)afteradvance("2h")marks 3 h ahead of the start. The clock only moves forward, and only when the test says so: real time keeps running on top. - What does not move:
"use node"functions (the Node process sees the real clock), login tokens, file links, the request limit, logs and the documents'_creationTime. Whoever tests login expiry does not use this. - Choice: there is no fake clock that freezes and runs the queue at every step, like
convex-test. The server is the real one, andadvanceadds the time, wakes the scheduler and waits: the test checks what the product does, with no mocks. The cost is that real time also passes, so do not compare times to the millisecond. - A cron that missed several occurrences runs once per
advance(as after the server was stopped), and the next one counts from the new clock. To see each occurrence, advance in steps (for (...) await t.advance("1h")). A cron that has not finished gets no second copy. advanceonly waits for what is already due on the new clock. A job scheduled for after it stays pending; advance more.
From the terminal and the dashboard
Without the kit, on a real server: notusbase cron run <name> runs the cron now and shows the job's id; notusbase job run <id> brings a pending job to now. Both use the deploy key (--key, NOTUSBASE_DEPLOY_KEY or pb_data/.notusbase_secret) and --url/--dir like the other commands. The cron follows the schedule: the job created is the one the schedule would create, next does not change and one that has not finished gets no second copy (409). The pending job changes its at to now only if it was still in the future, and scheduledTime becomes that time. A job already started, finished or cancelled returns 409. The dashboard, in Schedule, has "Run now" on the cron rows and on pending future job rows.
Development start
startApp starts the server, functions, variables and the Vite screen, and stops everything on Ctrl-C. With dev: true, the server is notusbase dev, which redeploys on every saved file and writes to the terminal; without it, notusbase serve with the screen's --origins. NotusBook's subir.mjs is an example, with file copying and the first user through hooks (onStart, afterDeploy, onStop).
Loose pieces
start({ dataDir, port, env, origins, log }) starts only the server; serverAt({ url, dataDir }) gives run, envSet, signIn, cli and waitJobs for a server another script started; freePort(), waitUntil(check, what, ms) and sleep(ms) are the rest.
Temporary files go to <repository>/tmp/tmpdir, never to the system disk, unless TMPDIR already points inside the repository.