The requirement held.
The assertion ran and the output satisfied it.
Exit code 0.
v0.1.1 · offline, provider-independent
m2aitestkit never calls a model. Point it at what your app produced and it tells you, per assertion, whether the output passed, failed or could not be evaluated reliably, and what changed since your last known-good run.
npm install --save-dev m2aitestkitis-jsonstructuralhas-fields(invoiceNumber,vendor,total)pass → failmissing required field(s): vendor
field-type(total:number)pass → failfield total has type string, expected number
forbid(no-markdown)ruleReal output from runSuite and diffReports. No model called, no key needed.
Three outcomes, not two
Most test tools collapse “couldn’t tell” into green. m2aitestkit keeps it separate and fails CI on it by default.
The assertion ran and the output satisfied it.
Exit code 0.
The assertion ran and the output violated it. The message names the rule, never the offending text.
Exit code 1. pass → fail blocks the build.
Malformed JSON, a crashing custom check, a dead evaluator, or a stale or missing recording. Never silently a pass.
Exit code 3, unless you opt out.
What you get
Redacted, key-sorted, timestamp-free JSON that diffs cleanly in code review. Raw inputs are never stored, only a hash.
pass → fail and pass → unevaluable are regressions. Removed cases and assertions are always listed, so a suite can't be weakened silently.
Every assertion is exact, structural, rule, heuristic, semantic or custom, so lexical overlap never passes for correctness.
Valid JSON, JSON Schema or any Standard Schema validator, required fields, types, ranges and tool-call rules.
No network, no telemetry. Emails, tokens, keys and card-like numbers are redacted before anything is evaluated or saved.
Deterministic JSON and JUnit reports, and exit codes 0, 1, 2 and 3. Two runs of the same recording are byte-identical.
Workflow
One workflow, aimed at one problem: outputs your code depends on drifting quietly when a prompt, model or retrieval step changes.
Run your app once and commit the redacted recording. This is the only step that needs a model.
m2aitestkit record .CI re-evaluates the committed outputs offline, so parsing and assertion changes are caught without a key.
m2aitestkit run .Each assertion is compared with its baseline status. Regressions block the build.
m2aitestkit run . --update-baselineA green replay says your assertions and parsing still hold on the recorded output. To detect model drift, re-record or run --live. Read the limitations →
How it works
Your app runs behind run(), with any provider. Assertions are plain functions.
import { defineSuite, fieldConstraint, forbid, hasFields, isJson, toolCalls } from "m2aitestkit";
declare function supportBot(input: { question: string }): Promise<string>;
export default defineSuite({
name: "support-bot",
run: (input) => supportBot(input as { question: string }), // your code, any provider
cases: [
{
id: "refund-request",
input: { question: "Can I get a refund?" },
metadata: { model: "gpt-x", promptVersion: "v3" },
assertions: [
isJson(),
hasFields(["answer", "refundAmount"]),
fieldConstraint("refundAmount", { min: 0, max: 100 }),
forbid({ "competitor-mention": /acme/i }),
toolCalls({ allow: ["lookup_order"], forbid: ["issue_refund"] }),
],
},
],
});Commit .m2aitestkit. Later runs replay it offline and diff against the baseline.
npx m2aitestkit record . # call run() once, write a redacted recording
npx m2aitestkit run . --update-baseline # evaluate it and save the baseline
git add .m2aitestkit # commit both
npx m2aitestkit run . # later: offline, no API keyNo API key in CI. A JUnit file feeds your usual test reporting.
- run: npm ci
- run: npx m2aitestkit run . --format junit --output m2aitestkit.xmlimport { assertOutput, hasFields, isJson } from "m2aitestkit";
declare function summarize(doc: string): Promise<string>;
// Works under node:test, vitest, jest or mocha: it only throws.
await assertOutput(await summarize("…"), [isJson(), hasFields(["summary"])]);Playground
This runs the m2aitestkit source from this repository in your browser: the same assertions, the same replay and the same baseline diff as the CLI. Nothing is mocked and nothing is uploaded.
is-jsonstructuralhas-fields(invoiceNumber,vendor,total)structuralregressedfield-type(total:number)structuralregressedforbid(no-markdown)ruleThe new output is still valid JSON, but vendor is gone and total became a string. Two assertions that passed in the baseline now fail, so CI blocks. How the outcomes work →
import { diffReports, formatPretty, runSuite, suiteFromSpec, type AssertionSpec, type Diff, type RunReport } from "m2aitestkit";
export type Input = {
/** What was sent to the model. Only a hash of it is ever stored. */
input: string;
/** The known-good output you recorded and committed. */
baseline: string;
/** What the application produces now, after a prompt, model or retrieval change. */
current: string;
assertions: AssertionSpec[];
};
export type Evaluation = { baseline: RunReport; current: RunReport; diff: Diff; text: string };
/** The same functions the CLI uses: replay each output against the assertions, then diff the two runs. */
export async function evaluate({ input, baseline, current, assertions }: Input): Promise<Evaluation> {
const replay = async (output: string): Promise<RunReport> => {
const suite = suiteFromSpec({ name: "demo", cases: [{ id: "reply", input, output, assertions }] });
return (await runSuite(suite, { mode: "replay" })).report;
};
const before = await replay(baseline);
const after = await replay(current);
const diff = diffReports(before, after);
return { baseline: before, current: after, diff, text: formatPretty(after, diff) };
}Assertion kinds
Reports label each assertion, so a heuristic is never read as proof.
exactDeep equality against a stored expectation.structuralJSON shape, schema, fields, types and ranges.ruleForbidden content, length caps, tool-call constraints.heuristicDeterministic but approximate: refusal phrasing, lexical similarity.semanticJudged by an evaluator you supply. Flagged when it uses an external service.customYour own function. A throw means unevaluable, never fail.Documentation
More from M2Toolkit