@firfi/quint-connect
Version:
Model-based testing framework connecting Quint specifications to TypeScript implementations
200 lines • 9.38 kB
JavaScript
import { Array as Arr, Effect, Predicate, Schema } from "effect";
import { generateTraces } from "../cli/quint.js";
import { defaultConfig } from "../driver/types.js";
import { ItfOption, MbtMeta as MbtMetaSchema } from "../itf/schema.js";
export class StateMismatchError extends Schema.TaggedError()("StateMismatchError", {
message: Schema.String,
traceIndex: Schema.Number,
stepIndex: Schema.Number,
expected: Schema.Unknown,
actual: Schema.Unknown,
showDiff: Schema.optionalWith(Schema.Boolean, { default: () => true })
}) {
}
export class TraceReplayError extends Schema.TaggedError()("TraceReplayError", {
message: Schema.String,
traceIndex: Schema.Number,
stepIndex: Schema.Number,
action: Schema.String,
cause: Schema.optional(Schema.Unknown)
}) {
}
export class NoTracesError extends Schema.TaggedError()("NoTracesError", {
message: Schema.String
}) {
}
const extractMbtMeta = (state, traceIndex, stepIndex) => Effect.mapError(Schema.decodeUnknown(MbtMetaSchema)(state), (e) => new TraceReplayError({
message: `Failed to extract MBT metadata: ${e}`,
traceIndex,
stepIndex,
action: "unknown"
}));
/** @internal */
export const stripMetadata = (state) => Object.fromEntries(Object.entries(state).filter(([k]) => k !== "#meta" && !k.startsWith("mbt::")));
/** @internal */
export const jsonReplacer = (_, v) => typeof v === "bigint" ? `${v}n` : v;
const resolveNestedValue = (obj, path) => {
let current = obj;
for (const key of path) {
if (!Predicate.isRecord(current) || !(key in current)) {
return undefined;
}
current = current[key];
}
return current;
};
// Extract action + nondet picks from a sum type at a custom path
// (Choreo-style specs where action is encoded as { tag: "ActionName", value: { picks... } })
const extractFromNondetPath = (state, nondetPath, traceIndex, stepIndex) => {
const raw = resolveNestedValue(state, nondetPath);
if (!Predicate.isRecord(raw) || typeof raw["tag"] !== "string") {
return Effect.fail(new TraceReplayError({
message: `Expected sum type {tag, value} at path ${nondetPath.join(".")}, got: ${JSON.stringify(raw)}`,
traceIndex,
stepIndex,
action: "unknown"
}));
}
const action = raw["tag"];
const value = raw["value"];
const picks = Predicate.isRecord(value) ? new Map(Object.entries(value)) : new Map();
return Effect.succeed({ action, nondetPicks: picks });
};
const buildPicksDecoder = (picksShape) => {
const wrappedFields = Object.fromEntries(Object.entries(picksShape.fields).map(([key, fieldSchema]) => [
key,
Schema.UndefinedOr(ItfOption(Schema.asSchema(fieldSchema)))
]));
return Schema.decodeUnknown(Schema.Struct(wrappedFields));
};
const resolveRawState = (rawState, statePath) => statePath.length > 0
? resolveNestedValue(rawState, statePath)
: stripMetadata(rawState);
/** @internal */
export const replayTrace = (trace, traceIndex, driver, config, stateCheck, seed) => Effect.gen(function* () {
const picksDecoders = driver.step === undefined
? new Map(Object.entries(driver.actions)
.filter((entry) => entry[1] !== undefined)
.map(([name, def]) => [name, buildPicksDecoder(def.picks)]))
: undefined;
const statePath = config.statePath ?? [];
const nondetPath = config.nondetPath ?? [];
for (const [stepIndex, rawState] of trace.states.entries()) {
const { action, nondetPicks } = nondetPath.length > 0
? yield* extractFromNondetPath(rawState, nondetPath, traceIndex, stepIndex)
: yield* Effect.map(extractMbtMeta(rawState, traceIndex, stepIndex), (meta) => ({
action: meta["mbt::actionTaken"],
nondetPicks: new Map(Object.entries(meta["mbt::nondetPicks"]))
}));
// Defensive: skip step 0 if actionTaken is empty (both backends normally produce "init").
if (action === "") {
if (stepIndex === 0)
continue;
return yield* new TraceReplayError({
message: `Anonymous action at trace ${traceIndex}, step ${stepIndex}`,
traceIndex,
stepIndex,
action: ""
});
}
if (driver.step !== undefined) {
yield* driver.step(action, nondetPicks).pipe(Effect.mapError((e) => new TraceReplayError({
message: `step failed: ${String(e)}`,
traceIndex,
stepIndex,
action,
cause: e
})), Effect.catchAllDefect((defect) => Effect.fail(new TraceReplayError({
message: `step failed: ${String(defect)}`,
traceIndex,
stepIndex,
action,
cause: defect
}))));
}
else {
const actionDef = driver.actions[action];
if (actionDef === undefined) {
// At step 0, action is "init" — skip if no handler defined (convenience so users
// don't need an explicit init handler when their constructor already sets up state).
if (stepIndex === 0)
continue;
// Rust backend emits "step" when the spec's step action body is a direct no-op
// (e.g. state' = state for a dead character). Skip since there's nothing to dispatch.
if (action === "step")
continue;
return yield* new TraceReplayError({
message: action === "init"
? `Unknown action: init. This is likely the known Quint typescript backend bug (https://github.com/informalsystems/quint/pull/1929) where non-disjunctive step actions report "init" instead of the actual action name. Wrap your step action body in \`any { YourAction, }\` as a workaround, or use \`--backend rust\`.`
: `Unknown action: ${action}`,
traceIndex,
stepIndex,
action
});
}
const decode = picksDecoders?.get(action) ?? buildPicksDecoder(actionDef.picks);
const decodedPicks = yield* Effect.mapError(decode(Object.fromEntries(nondetPicks)), (cause) => new TraceReplayError({
message: `Failed to decode action picks: ${String(cause)}`,
traceIndex,
stepIndex,
action,
cause
}));
yield* actionDef.handler(decodedPicks).pipe(Effect.mapError((e) => new TraceReplayError({
message: `Action handler failed: ${String(e)}`,
traceIndex,
stepIndex,
action,
cause: e
})), Effect.catchAllDefect((defect) => Effect.fail(new TraceReplayError({
message: `Action handler failed: ${String(defect)}`,
traceIndex,
stepIndex,
action,
cause: defect
}))));
}
if (stateCheck !== undefined) {
if (driver.getState === undefined) {
return yield* new TraceReplayError({
message: "stateCheck is provided but driver.getState is not defined; getState is required when stateCheck is provided",
traceIndex,
stepIndex,
action
});
}
const specState = yield* stateCheck.deserializeState(resolveRawState(rawState, statePath));
const implState = yield* driver.getState();
if (!stateCheck.compareState(specState, implState)) {
return yield* new StateMismatchError({
message: `State mismatch at trace ${traceIndex}, step ${stepIndex}, action "${action}" (seed: ${seed})\nExpected: ${JSON.stringify(specState, jsonReplacer)}\nActual: ${JSON.stringify(implState, jsonReplacer)}`,
traceIndex,
stepIndex,
expected: specState,
actual: implState
});
}
}
}
});
/** Resolve the seed. Always generates a real seed so failures are reproducible. */
const resolveSeed = (opts) => {
return opts.seed ?? process.env["QUINT_SEED"] ?? `0x${Math.floor(Math.random() * 0xFFFFFFFF).toString(16).padStart(8, "0")}`;
};
export const quintRun = (opts) => Effect.gen(function* () {
const seed = resolveSeed(opts);
const traceOpts = { ...opts, seed };
const traces = yield* generateTraces(traceOpts);
if (traces.length === 0) {
return yield* new NoTracesError({
message: "quint run produced no traces"
});
}
const results = yield* Effect.forEach(Arr.map(traces, (trace, traceIndex) => ({ trace, traceIndex })), ({ trace, traceIndex }) => Effect.gen(function* () {
const driver = yield* opts.driverFactory.create();
const config = { ...defaultConfig, ...driver.config?.() };
yield* replayTrace(trace, traceIndex, driver, config, opts.stateCheck, seed);
}), { concurrency: opts.concurrency ?? 1 });
return { tracesReplayed: results.length, seed };
});
//# sourceMappingURL=runner.js.map