Repository navigation
feat(repo): test xstate's fsm as a conformance spec - #75
Merged
Merged
Conversation
The fsm capability replaced main's `as` casts with no-op `asserts` functions, which pass consistent-type-assertions while asserting nothing at runtime. createFSM and setup now publish their generic signature as an overload over one implementation typed at MachineContext, EventObject and string, where the empty default context and the setup object need no bridge. The published API report is unchanged. The fsm fixtures drop exports nothing imports.
…d, not a no-op assertion
…thing The fsm spec compared context by value, so a transition that copied the context for a patch whose values already matched passed unseen. The model now predicts, per step, whether the context object is kept, and the fixture reads that identity from every driver. The planted subjects wrap the FSM's own transition instead of overriding the observation, and a new one copies the context on an unchanged patch. The liveness ledger requires such a patch on every seed.
…n table A transition function that returns null threw on this branch, where main treated it as a no-op. The fsm now resolves a missing result to an empty transition, null included, and a state without an `on` table to an empty table, which deletes the guard that only that state reached. The spec's model now draws a bump transition that returns nothing and a state without an `on` table, and its ledger requires both on every seed. The effects row of the type contract is an exact `never[]` equality again.
A snapshot persisted by a machine version that declares a state, restored into a machine that does not, keeps its value and context when an event arrives, as on main. No generated machine reaches that state, so the optional read of the state's config survived the whole suite. The spec now restores such a document through createActor and requires the snapshot to stay as it was.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@systemfsoftware/xstate's fsm capability (@systemfsoftware/xstate/fsm:createFSM,setup,types) is now judged by a conformance spec over a hand-written model of a flat event table, in place of 20 hand-picked cases across six files.src/fsm.tsjoins the mutated set and the recommended lint.Behaviour change
Clearing the 12
cell-architecture(ban-unknown)findings insrc/fsm.tsnarrowed two published signatures (conductor ruling, cycle 178, item 2:minor, break stated):createFSM, and thecreateFSMthatsetupreturns, take the machine's state names as their state type parameter ('idle' | 'done'), not a record keyed by state name ({ idle: unknown; done: unknown }).FSM.initialTransition()andFSM.getInitialSnapshot()take no arguments. They never read the ones they accepted..changeset/fsm-state-name-type-parameter.md(minor) states the break and the migration step, and its validator exits 0. Inferred states,createActor,initialTransitionandtransitionare unchanged. A transition function that returnsnull, which the published type does not admit but main's runtime absorbed, is still a no-op that keeps the snapshot (probe onbfd7a3c9: valuea, context{ n: 1 }, effects[], same object; the reviewer's main run gave the same).Deleted tests -> new coverage (CONST-T9)
The spec is
tests/fsm.conformance.test.ts. Its model,tests/__fixtures__/fsm.model.ts, imports onlyeffectand derives every expected observation from the generated machine and events alone. The fixturetests/__fixtures__/fsm.tsdrives the published API through four drivers:fsm.transition, theinitialTransition/transitionentry points,createActor, and a JSON-persisted then restored actor. It builds each machine through one of two factories,createFSMorsetup().createFSM. A ledger row requires every transition kind, a transition function that returns nothing, a state without anontable, inherited event names, inherited patch keys, a machine without context, actor emissions, every driver and both factories to occur on each seed.fsm-entrypoint.test.ts(2)createFSM,setup,typescontexttransitions through{}withContext: falsemachines start from{}(sabotage E)fsm.actor.test.ts(4)createActor: final value, context,status: 'active', one subscriber emission per sent event, including a no-opactordriver:status,emissions(sabotage C)ontable (the counter'sstopped: {}) ignores every eventbareStatemachines; the ledger requires events sent in that state (bareStateEvents) on every seed (sabotage I)transition()andfsm.transition()agree; effects are[]entryandpuredrivers: same model;effects: 0initialTransition()returnsinitialStateitself, as doesgetInitialSnapshot()entrydriver:initialIdentitypersisteddriver, including its emissions after restorefsm.differential.test.ts(2)createMachine, including pure context updatesfsm.setup.test.ts(3)setup().createFSMruns: transitions, patches, function transitionssetupfactory, with and without a context schema (sabotage H)typeLevelContract:userMachinerows (loaded narrows touser; refusesstatus satisfies 'loading'; refuses a numericuser)typeLevelContract: four@ts-expect-errorrows (initial context, string target, missinguser, function transition without context)typeLevelContract:draftMachinerows (requestIdanddraftbothstring;skipwithout a draft refused)fsm.test.ts(6)targetChanges); an event in a restored state the machine does not declare leaves the snapshot as it was (scenario, sabotage K)Addslot,functionTransitions); a function that returns nothing is a no-op (Absentslot,absentReturns, sabotage J)contextKept, the model's per-step prediction of whether the context object is kept, read from every driver; the ledger requires a patch that changes nothing (unchangedPatches) on every seed.sentinelKeptIdentitycovers the snapshot on a step with no transition. Planted subjects wrap the FSM's owntransition: one copies the snapshot on a no-op, one copies the context on an unchanged patch. Sabotages B and F1count/tagon the patch prototype); planted subject that reads them; sabotage FtoString,constructor, prototypetoggle); planted subject; sabotage Aoutputanderrorare own snapshot keys, also through JSONsnapshotKeysofinitialState,getInitialSnapshot()and every pure/entry step, plain and JSON round-tripped, equalstatus,value,context,output,error(sabotage G)fsm.types.test.ts(3)typeLevelContractrowsFSMsatisfiesActorLogic;SnapshotFrom/EventFromLogicare exact; an actor refuses an undeclared eventtypeLevelContract:satisfies ActorLogic, two type-levelEqualrows, actorsendrefusalsetupsnapshot unions survive into actorstypeLevelContract:SnapshotFrom<typeof loadedMachine>narrowscontext.idtostringtypeLevelContractis a function that is type-checked and never called, so nocreateFSMruns while test modules load. On main, module-level construction madefsm.tsmutants static, so each one ran the whole suite at the gate.tsc -p tsconfig.test.jsonfails on any@ts-expect-errorrow that stops erroring.Enrolment
packages/xstate/stryker.config.ts):src/assert.ts,src/stateMatching.ts,src/mapState.ts,src/fsm.ts.src/fsm.tsand the spec are in thelintscript, with 0 findings and no disable comments.tsconfig.tsgo.jsonincludessrc/fsm.ts.lint:tsgo:Checked 4 files out of 4 files. 0 errors, 0 warnings and 0 messages.packages/AGENTS.mdnow namessrc/fsm.ts; dprint re-pads that table, so the diff shows every row.src/fsm.tsimports nothing fromeffect, andpackage.jsonhas noinlinedDependencies. The build (withapi:check) exits 0.etc/xstate-fsm.api.mdchanges only the two signatures above.Type bridge (declared, CONST-W3)
Main bridged
createFSMandsetupto their generic types withascasts, and the recommended lint rejects everyas(typescript(consistent-type-assertions)). Each now publishes its generic signature as an overload over one implementation typed atMachineContext,EventObjectandstring. There, the{}default context and thesetupobject type-check with no bridge. TypeScript checks overload compatibility with type parameters erased, so this is still an unchecked bridge. It replaces main's casts and doesn't present itself as a runtime check.docs/solutions/conventions/generic-signature-over-erased-implementation.mdrecords why no-opassertsfunctions are the wrong fix: an earlier commit on this branch used them, they pass the lint, and they assert nothing.Evidence
All from head
44fe5b69, based on main84e602e7.--sequence.shuffleseeds 1-3, with the same case names on each seed.src/fsm.ts, each in a scratch worktree removed afterwards. On the 8-case spec (bfd7a3c9), A-H, F1 and I each give3 failed | 5 passedon seeds 1-3 and J gives7 failed | 1 passed; on the 9-case spec (44fe5b69), K gives1 failed | 8 passedon seeds 1-3. The control gives all passed on both heads:Object.hasOwndropped, so inherited event names match;status: 'done';contextkeepsundefined;outputis not an own key of created snapshots;setup().createFSMdrops the context it is given;e549d691the spec stayed green on it; the spec compared context by value;ontable is not given the empty table, soObject.hasOwnreadsundefined;config.states[value].on), so an event in an undeclared state throws.--sequence.shuffleseeds 1-3: 145 files, 2419 passed, 29 skipped, 3 todo. That is main's 2430, minus the 20 deleted cases, plus the 9 spec cases.tsc -b,tsc -p tsconfig.test.json, lint,lint:tsgoand build exit 0. Hooks ran on every commit. No local stryker run.429 Token Plan usage limit reached (2056), as does every reviewer route, so both ran inline. They found the no-opassertsfunctions (fixed in7abb5c58, above) and five fixture exports nothing imports (removed). An earlier inline pass found 5 deleted behaviours the first spec draft didn't cover: own patch keys, ownoutput/errorkeys, actor emissions,setupruntime, and a machine without context. It also found the setup refusal and narrowing type rows. All are covered and listed above.8114d830): FAIL on CONST-T9. The map claimed the no-op context patch was pinned; it was not (F1 above). Fixed ine549d691.e549d691): PASS, 13/13 clauses, CI green on that head. Its two P2s and one P3 are fixed inbfd7a3c9: a transition function that returnsnullthrew where main treated it as a no-op; theon === undefinedguard was reachable but never drawn, so its mutant would survive the release gate (the guard is now deleted, and the state without anontable is drawn and required); the effects row checkedsatisfies never[]instead of an exact equality.bfd7a3c9): PASS, 10/10 clauses, CI green on that head. Its P3:config.states[value]?.onis reachable (a snapshot restored in a state the machine does not declare) but never drawn, so dropping the?.survived the whole suite and would have failed the release gate at break 100.44fe5b69adds the scenario "A snapshot restored in a state the machine does not declare ignores events": a document persisted by a machine that declares statez, restored throughcreateActorinto one that does not, staysactiveinzwith its context aftergo, as on main. Sabotage K above.Remaining
packages/unguarded-tests.json: 188 -> 182 files.