Writing principles
Testing
A test builds the event a detector receives and checks what the detector reports. Tests are ordinary vitest files, and the registry runs the same files again before it accepts a revision.
A test#
import { expect, test } from "vitest";
import { detect, edit, prompt, source, write } from "@wellactually/sdk/v4/test";
const session = source`
class Session {
late User user;
int retries = 3;
}
`;
test("fires on a late field the agent writes", () => {
expect(detect(write("lib/session.dart", session))).toEqual([{ line: 2 }]);
});
test("stays quiet on a late field that was already there", () => {
expect(detect(edit("lib/session.dart", session, { written: " int retries = 3;" }))).toEqual([]);
});
test("stays quiet when late is about time", () => {
expect(detect(prompt("The release is running late."))).toEqual([]);
});detect(event) runs the real detector of the principle the test file is in, the way a session does.
It checks whether a handler wants the event, runs the detector in the isolate with its limits, and returns the findings.
It throws when the detector fails, reports a line the file does not have, or a match that is not there.
The events#
| Function | The event |
|---|---|
write(path, content) | The agent wrote the file in full. Every line counts as written. |
write(path, content, { isNew: true }) | The same, and the write created the file. |
edit(path, content, { written }) | The agent changed the file. content is the file after the edit, and only written counts as written. |
read(path, content) | The agent read the file. It wrote none of it. |
prompt(text) | You sent this, or the agent briefed a subagent with it. |
command(text) | The agent is about to run this shell command. |
output(command, text) | The command ran and printed text. |
edit is how you show that code which was already there is left alone.
written is the text the agent put in, as one string or a list of pieces, and has to occur in content.
Claude Code and Codex describe an edit differently. The plugin turns both into the same event, so a test is written once.
Inline source#
source strips the indentation of a template string, so the code in a test can be indented like the test around it.
Inside it, ${ and a backtick need a backslash, as in any template string.
const code = source`
class _\$Session {
late User user;
}
`;A project for the detector to read#
Without a project, the only file that exists is the file of the event.
A detector that reads other files needs a small project in the principle's fixtures folder:
use-clock/
detector.ts
detector.test.ts
fixtures/
dart_app/
pubspec.yamldetect(prompt("Why does my fakeAsync test still see the real time?"), { project: "fixtures/dart_app" });The project sits at a made-up root, /project, so do not assert on absolute paths.
Files in fixtures are never built or run as code.
What the registry asks of your tests#
A revision is only accepted when its tests:
- pass,
- make the detector fire at least once,
- and run it at least once on something it stays quiet on.
A detector nobody has seen stay quiet is the one that fires on every file. For every false positive someone reports, add a test that stays quiet on it.
Inside the isolate#
The registry runs your test files again inside the isolate, with a small copy of vitest.
It has test, it, describe, test.each and expect(...) with:
toEqual, toStrictEqual, toBe, toHaveLength, toContain, toContainEqual, toMatchObject, toBeTruthy, toBeFalsy, toBeNull, toBeUndefined, toBeDefined, toThrow and .not.
Anything else passes on your machine and fails there, with a message that names it. Tests are not async.
Run them#
In the browser editor, Run tests builds the principle and runs its tests in the isolate, with the same checks as the registry.
Your agent does the same with the MCP tool run_tests, see Write with an agent.
Run them with vitest#
To run the tests with npm test, in a repository of your own, install the SDK from GitHub.
It is not on npm while its API is still changing.
npm install --save-dev github:passsy/wellactually#sdk vitest{
"name": "my-principles",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run"
}
}npm test then runs every principle below the repository, and npx vitest run principles/use-clock runs one.
A principle's tests do not import its detector, detect() builds it.
So tell vitest to run them again when a detector changes while it watches:
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
// A detector gets 100 ms, and every test file builds its principle in a child process.
maxWorkers: "50%",
forceRerunTriggers: [...configDefaults.forceRerunTriggers, "**/detector.ts", "**/principle.md", "**/fixtures/**"],
},
});passsy/principles is a whole repository set up this way, with 81 principles.