UNPKG

@firfi/quint-connect

Version:

Model-based testing framework connecting Quint specifications to TypeScript implementations

200 lines 9.38 kB
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