skillgym exports a root assert object that combines:
- Node's
node:assert/strictAPI - grouped helpers for normalized session reports
assert.soft.*for Jest/Vitest-style sync soft assertionsassert.classify(...)for attaching structured failure classes to assertion failures
import { assert } from "skillgym";
assert.ok(true);
assert.equal(1, 1);
assert.match("skillgym ready", /ready/);
assert.soft.match("skillgym ready", /ready/);
assert.classify("missing-flag", () => {
assert.match("--json", /--yaml/);
});Use assert.classify(...) when you want an assertion failure to carry a stable structured class that reporters can group across runs.
assert.classify({ id: "wrong-cli-alias", label: "Wrong CLI alias" }, () => {
assert.doesNotMatch(ctx.finalOutput(), /\bcursr\b/i, "wrong Cursor CLI alias in final output");
});Rules:
idis the stable machine-readable key used for groupinglabelis optional and gives reporters a human-friendly display name- passing a string such as
assert.classify("wrong-cli-alias", ...)sets only theid - if the callback does not throw, no failure class is recorded
- if the callback throws, the thrown error keeps the attached failure class through the runner and reporter pipeline
assert.soft.*assert.skills.*assert.commands.*assert.fileReads.*assert.toolCalls.*assert.output.*
assert.soft mirrors the sync assertion methods on the root assert export and the grouped SkillGym helpers.
- soft failures are collected in execution order
- the runner throws a single
AssertionErroraftertestCase.assert(report, ctx)completes - if a hard
AssertionErroris thrown after soft failures were collected, the final failure includes both assert.soft.rejects(...)andassert.soft.doesNotReject(...)remain hard assertions in the first implementation
Example:
assert.soft.match(report.finalOutput, /ready/i);
assert.soft.commands.includes(report, "pnpm test");
assert.soft.output.notEmpty(report);Commands, file reads, and output use:
type Matcher = string | RegExp;Tool calls use:
interface ToolCallMatcher {
tool?: string | RegExp;
where?: (args: unknown, event: ToolCallEvent) => boolean;
}Common grouped assertion options:
interface AssertionOptions {
message?: string;
}Skill assertions also accept:
type SkillConfidence = "weak" | "medium" | "strong" | "explicit";
interface SkillAssertionOptions {
minConfidence?: SkillConfidence;
message?: string;
}Skill assertions operate on report.detectedSkills.
Available methods:
assert.skills.has(report, skill, options?)assert.skills.notHas(report, skill, options?)assert.skills.includes(report, skills, options?)assert.skills.count(report, skill, expected, options?)assert.skills.exactlyOne(report, skill, options?)assert.skills.only(report, skills, options?)
Descriptions:
has: requires the named skill to be detectednotHas: requires the named skill not to be detectedincludes: requires all listed skills to be detectedcount: requires the named skill to appear exactlyexpectedtimesexactlyOne: alias forcount(..., 1)only: requires every detected skill to be in the allowed list
Confidence behavior:
minConfidencefilters matches to detections at or above that confidence- confidence order is
weak < medium < strong < explicit
Example:
assert.skills.has(report, "find-skills");
assert.skills.has(report, "find-skills", { minConfidence: "strong" });
assert.skills.notHas(report, "upgrading-expo");
assert.skills.includes(report, ["find-skills", "upgrading-expo"]);
assert.skills.only(report, ["find-skills", "upgrading-expo"]);Command assertions operate on observed command events in execution order.
Use raw string or RegExp matchers when you only care about the emitted command text. Use commandMatcher(...) when you want stable checks against the executable, positional arguments, options, repeated flags, or -- handling.
Available methods:
assert.commands.includes(report, matcher, options?)assert.commands.notIncludes(report, matcher, options?)assert.commands.count(report, matcher, expected, options?)assert.commands.atLeast(report, matcher, min, options?)assert.commands.atMost(report, matcher, max, options?)assert.commands.before(report, firstMatcher, secondMatcher, options?)assert.commands.only(report, matchers, options?)assert.commands.size(report, expected, options?)assert.commands.exactlyOne(report, matcher, options?)assert.commands.first(report, matcher, options?)assert.commands.last(report, matcher, options?)
Descriptions:
includes: requires at least one command matching the matchernotIncludes: requires no matching commandcount: requires exactlyexpectedmatching commandsatLeast: requires at leastminmatching commandsatMost: requires at mostmaxmatching commandsbefore: requires the first match offirstMatcherto appear before the first match ofsecondMatcheronly: requires every observed command to match one of the allowed matcherssize: checks the total number of observed commandsexactlyOne: alias forcount(..., 1)first: checks the first observed commandlast: checks the last observed command
Example:
import { assert, commandMatcher } from "skillgym";
assert.commands.includes(report, "npx skills find");
assert.commands.notIncludes(report, "npm install");
assert.commands.count(report, /pnpm test/, 2);
assert.commands.before(report, /skills find/, /pnpm install/);
assert.commands.includes(
report,
commandMatcher("pnpm").arg("test").option("--filter", "unit").flag("--watch"),
);
assert.commands.first(report, /rozenite --help/);
assert.commands.last(report, /agent session stop/);Structured command matcher semantics:
- string and
RegExpcommand matchers keep the current raw-text behavior executablematches the leading command token- positional arguments preserve order
- option order is ignored
- grouped short flags such as
-abcare normalized as-a,-b, and-c - option values from
--name value,--name=value, and short attached forms such as-p80are normalized to the same matcher model - bare
--ends option parsing, and later tokens are treated as positional arguments includes,count,before,first, andlastallow extra options and extra positionals unless the matcher usesstrict: true,exact: true, or.strict()/.exact()on the builder
Normalization is best-effort. Some runners emit direct command strings, while others reconstruct commands from shell-wrapped output. Assertion failures show both the raw command and the parsed interpretation used for matching.
File read assertions operate on observed file-read paths in execution order.
Available methods:
assert.fileReads.includes(report, matcher, options?)assert.fileReads.notIncludes(report, matcher, options?)assert.fileReads.count(report, matcher, expected, options?)assert.fileReads.atLeast(report, matcher, min, options?)assert.fileReads.atMost(report, matcher, max, options?)assert.fileReads.before(report, firstMatcher, secondMatcher, options?)assert.fileReads.only(report, matchers, options?)assert.fileReads.size(report, expected, options?)assert.fileReads.exactlyOne(report, matcher, options?)assert.fileReads.first(report, matcher, options?)assert.fileReads.last(report, matcher, options?)
Descriptions:
includes: requires at least one matching file readnotIncludes: requires no matching file readcount: requires exactlyexpectedmatchesatLeast: requires at leastminmatchesatMost: requires at mostmaxmatchesbefore: requires the first match offirstMatcherto appear before the first match ofsecondMatcheronly: requires every observed file read to match one of the allowed matcherssize: checks the total number of observed file readsexactlyOne: alias forcount(..., 1)first: checks the first observed file readlast: checks the last observed file read
If file-read events are absent, these assertions can fall back to report.files.observedReads.
Example:
assert.fileReads.includes(report, /find-skills\/SKILL\.md$/);
assert.fileReads.notIncludes(report, /upgrading-expo\/SKILL\.md$/);
assert.fileReads.before(report, /find-skills\/SKILL\.md$/, /upgrading-expo\/SKILL\.md$/);
assert.fileReads.only(report, [/find-skills\/SKILL\.md$/, /upgrading-expo\/SKILL\.md$/]);Tool call assertions operate on observed tool-call events in execution order.
Available methods:
assert.toolCalls.has(report, matcher, options?)assert.toolCalls.count(report, matcher, expected, options?)assert.toolCalls.atLeast(report, matcher, min, options?)assert.toolCalls.atMost(report, matcher, max, options?)assert.toolCalls.before(report, firstMatcher, secondMatcher, options?)assert.toolCalls.sequence(report, matchers, options?)assert.toolCalls.only(report, matchers, options?)
Descriptions:
has: requires at least one matching tool callcount: requires exactlyexpectedmatching tool callsatLeast: requires at leastminmatching tool callsatMost: requires at mostmaxmatching tool callsbefore: requires the first match offirstMatcherto appear before the first match ofsecondMatchersequence: requires each matcher to appear after the previous oneonly: requires every observed tool call to match one of the allowed matchers
Example:
assert.toolCalls.has(report, {
tool: "skill",
where: (args) => (args as { name?: string })?.name === "rozenite-agent",
});
assert.toolCalls.sequence(report, [
{ tool: "skill" },
{
tool: "read",
where: (args) => /mmkv\.md$/.test((args as { filePath?: string })?.filePath ?? ""),
},
{
tool: "bash",
where: (args) => /session create/.test((args as { command?: string })?.command ?? ""),
},
]);Output assertions operate on report.finalOutput.
Available methods:
assert.output.includes(report, matcher, options?)assert.output.notEmpty(report, options?)
Descriptions:
includes: requires the final output to match a string or regex matchernotEmpty: requires non-empty final output
Example:
assert.output.includes(report, /MMKV storages/);
assert.output.notEmpty(report);- if an assertion completes normally, it passes
- if it throws, it fails the current execution
- grouped assertion failures include observed values to help debug mismatches
test-cases.mdsession-report.md