NotusBase

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.

OptionDefaultWhat for
projectrequiredFolder with convex/ and node_modules
namedefaultFolder 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
portfreeServer port
notusbaseNOTUSBASE or the one on the PATHBinary
serverVariables of notusbase serve, like NOTUSBASE_UPLOAD_MB. null removes an inherited one
envProject variables, like notusbase env set
userrandom emailEmail 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
skipTypecheckfalseDeploys 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 backDoes
clientConvexClient 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, dirWhere 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.

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.