src/state_machines/testing.ts
Rama main · réplica de lectura del repositorio original.
import type { IgnoreReason, TransitionResult } from "./types";
import type { Clock, ClockHandle, IdSource } from "./clock";
import { KeyedControllerHost } from "./keyed_host";
import type { ReplaySerialization, ReplayTrace } from "./trace";
import {
describeTransitionValue as describe,
transitionValuesAreEqual as valuesAreEqual,
validateTransitionResult,
} from "./transition_validation";
export { validateTransitionResult } from "./transition_validation";
export interface FakeClock extends Clock {
advanceBy(delayMs: number): void;
pendingTimerCount(): number;
}
/** Deterministic scheduler that runs due callbacks in deadline/creation order. */
export function createFakeClock(startAt = 0): FakeClock {
let currentTime = startAt;
let nextHandle = 1;
const timers = new Map<
number,
{ callback: () => void; deadline: number; order: number }
>();
return {
now: () => currentTime,
schedule(callback, delayMs) {
const handle = nextHandle++;
timers.set(handle, {
callback,
deadline: currentTime + Math.max(0, delayMs),
order: handle,
});
return handle as unknown as ClockHandle;
},
cancel(handle) {
timers.delete(handle as unknown as number);
},
advanceBy(delayMs) {
if (delayMs < 0) throw new Error("Fake clock cannot move backwards");
const target = currentTime + delayMs;
while (true) {
const due = [...timers.entries()]
.filter(([, timer]) => timer.deadline <= target)
.sort(
([, left], [, right]) =>
left.deadline - right.deadline || left.order - right.order,
)[0];
if (!due) break;
const [handle, timer] = due;
timers.delete(handle);
currentTime = timer.deadline;
timer.callback();
}
currentTime = target;
},
pendingTimerCount: () => timers.size,
};
}
export function createSequentialIdSource(startAt = 1): IdSource {
let nextId = startAt;
return {
next(prefix) {
return `${prefix}:${nextId++}`;
},
};
}
export interface CapabilityRepresentativeEvents<Event, Reason> {
/** Valid payloads that must apply whenever the capability is enabled. */
readonly valid: readonly Event[];
/**
* Invalid payloads whose rejection is independent of whether the control is
* enabled. Omit them for states where another guard takes precedence.
*/
readonly invalid?: readonly {
readonly event: Event;
readonly reason: Reason;
}[];
}
export interface CapabilityConsistencyCase<State, Event, Reason> {
readonly representativeEvents: (
state: State,
) => CapabilityRepresentativeEvents<Event, Reason>;
/**
* When supplied, valid payloads for disabled states must be ignored for this
* reason. Return undefined when the domain does not promise a reason for a
* particular disabled state.
*/
readonly disabledReason?: Reason | ((state: State) => Reason | undefined);
}
/**
* Checks that explicit UI capability policy stays consistent with the real
* transition function. Capability policy remains domain-owned: this helper
* never probes synthetic events to derive it.
*/
export function assertCapabilityTransitionConsistency<
State,
Event,
Command,
Reason extends IgnoreReason,
Capabilities extends {
readonly [Capability in keyof Capabilities]: boolean;
},
>(options: {
states: readonly State[];
selectCapabilities: (state: State) => Capabilities;
transition: (
state: State,
event: Event,
) => TransitionResult<State, Command, Reason>;
cases: {
readonly [Capability in keyof Capabilities]: CapabilityConsistencyCase<
State,
Event,
Reason
>;
};
}): void {
for (const state of options.states) {
const capabilities = options.selectCapabilities(state);
for (const capability of Object.keys(
capabilities,
) as (keyof Capabilities)[]) {
const capabilityCase = options.cases[capability];
const representatives = capabilityCase.representativeEvents(state);
if (capabilities[capability] && representatives.valid.length === 0) {
capabilityFailure(
capability,
"enabled capability must supply at least one representative valid event",
state,
undefined,
undefined,
);
}
for (const event of representatives.valid) {
const result = options.transition(state, event);
if (capabilities[capability]) {
if (result.kind !== "applied") {
capabilityFailure(
capability,
"enabled capability must apply its representative valid event",
state,
event,
result,
);
}
continue;
}
const disabledReason =
typeof capabilityCase.disabledReason === "function"
? capabilityCase.disabledReason(state)
: capabilityCase.disabledReason;
if (disabledReason !== undefined) {
if (result.kind !== "ignored" || result.reason !== disabledReason) {
capabilityFailure(
capability,
`disabled capability must be ignored with reason ${describe(disabledReason)}`,
state,
event,
result,
);
}
}
}
for (const invalid of representatives.invalid ?? []) {
const result = options.transition(state, invalid.event);
if (result.kind !== "ignored" || result.reason !== invalid.reason) {
capabilityFailure(
capability,
`invalid representative payload must be ignored with reason ${describe(invalid.reason)}`,
state,
invalid.event,
result,
);
}
}
}
}
}
function capabilityFailure(
capability: PropertyKey,
message: string,
state: unknown,
event: unknown,
result: unknown,
): never {
throw new Error(
`Capability ${String(capability)}: ${message}\nSource state: ${describe(state)}\nEvent: ${describe(event)}\nResult: ${describe(result)}`,
);
}
export function driveTransitionMatrix<
State,
Event,
Command,
Reason extends IgnoreReason = IgnoreReason,
>(options: {
states: readonly State[];
events: readonly Event[];
transition: (
state: State,
event: Event,
) => TransitionResult<State, Command, Reason>;
}): TransitionResult<State, Command, Reason>[] {
const results: TransitionResult<State, Command, Reason>[] = [];
for (const state of options.states) {
for (const event of options.events) {
const result = options.transition(state, event);
validateTransitionResult(state, event, result, []);
results.push(result);
}
}
return results;
}
export interface ReachableStateNode<State, Event> {
readonly key: string;
readonly state: State;
readonly path: readonly Event[];
}
export interface ReachableStateEdge<State, Event, Command, Reason> {
readonly source: ReachableStateNode<State, Event>;
readonly target: ReachableStateNode<State, Event>;
readonly event: Event;
readonly result: TransitionResult<State, Command, Reason & IgnoreReason>;
}
export interface ReachableStateGraph<State, Event, Command, Reason> {
readonly nodes: readonly ReachableStateNode<State, Event>[];
readonly edges: readonly ReachableStateEdge<State, Event, Command, Reason>[];
readonly predecessors: ReadonlyMap<
string,
ReachableStateEdge<State, Event, Command, Reason>
>;
}
/**
* Breadth-first exploration for machines whose reachable states can be
* generated from a finite event set.
*/
export function exploreReachableStates<
State,
Event,
Command,
Reason extends IgnoreReason = IgnoreReason,
>(options: {
initialState: State;
events: readonly Event[] | ((state: State) => readonly Event[]);
transition: (
state: State,
event: Event,
) => TransitionResult<State, Command, Reason>;
stateKey: (state: State) => string;
maxStates?: number;
}): ReachableStateGraph<State, Event, Command, Reason> {
const maxStates = options.maxStates ?? 1_000;
const initial: ReachableStateNode<State, Event> = {
key: options.stateKey(options.initialState),
state: options.initialState,
path: [],
};
const nodes: ReachableStateNode<State, Event>[] = [initial];
const byKey = new Map([[initial.key, initial]]);
const edges: ReachableStateEdge<State, Event, Command, Reason>[] = [];
const predecessors = new Map<
string,
ReachableStateEdge<State, Event, Command, Reason>
>();
for (let index = 0; index < nodes.length; index += 1) {
const source = nodes[index];
const events =
typeof options.events === "function"
? options.events(source.state)
: options.events;
for (const event of events) {
const result = options.transition(source.state, event);
validateTransitionResult(source.state, event, result, source.path);
const key = options.stateKey(result.state);
let target = byKey.get(key);
if (target === undefined) {
if (nodes.length >= maxStates) {
throw new Error(
`Reachable-state exploration exceeded maxStates (${maxStates}); source state: ${describe(source.state)}; event: ${describe(event)}; result: ${describe(result)}; explored path: ${describe(source.path)}`,
);
}
target = {
key,
state: result.state,
path: [...source.path, event],
};
byKey.set(key, target);
nodes.push(target);
}
const edge: ReachableStateEdge<State, Event, Command, Reason> = {
source,
target,
event,
result,
};
edges.push(edge);
if (target !== initial && !predecessors.has(target.key)) {
predecessors.set(target.key, edge);
}
}
}
return { nodes, edges, predecessors };
}
export interface InventoryExclusion<Kind extends string> {
readonly kind: Kind;
readonly reason: string;
}
function assertInventoryCovered<Kind extends string>(
label: string,
inventory: readonly Kind[],
produced: ReadonlySet<Kind>,
exclusions: readonly InventoryExclusion<Kind>[],
): void {
const excluded = new Map(
exclusions.map(({ kind, reason }) => [kind, reason]),
);
for (const kind of inventory) {
if (produced.has(kind)) continue;
const reason = excluded.get(kind);
if (reason === undefined || reason.trim() === "") {
throw new Error(`${label} "${kind}" was not reached or excluded`);
}
}
}
type ExplorationOptions<State, Event, Command, Reason extends IgnoreReason> = {
initialState: State;
events: readonly Event[] | ((state: State) => readonly Event[]);
transition: (
state: State,
event: Event,
) => TransitionResult<State, Command, Reason>;
stateKey: (state: State) => string;
maxStates?: number;
};
export function assertAllStatesReachable<
State,
Event,
Command,
Reason extends IgnoreReason,
Kind extends string,
>(
options: ExplorationOptions<State, Event, Command, Reason> & {
inventory: readonly Kind[];
stateKind: (state: State) => Kind;
exclusions?: readonly InventoryExclusion<Kind>[];
},
): ReachableStateGraph<State, Event, Command, Reason> {
const graph = exploreReachableStates(options);
assertInventoryCovered(
"State",
options.inventory,
new Set(graph.nodes.map(({ state }) => options.stateKind(state))),
options.exclusions ?? [],
);
return graph;
}
export function assertAllCommandsProducible<
State,
Event,
Command,
Reason extends IgnoreReason,
Kind extends string,
>(
options: ExplorationOptions<State, Event, Command, Reason> & {
inventory: readonly Kind[];
commandKind: (command: Command) => Kind;
exclusions?: readonly InventoryExclusion<Kind>[];
},
): ReachableStateGraph<State, Event, Command, Reason> {
const graph = exploreReachableStates(options);
const produced = new Set<Kind>();
for (const edge of graph.edges) {
if (edge.result.kind !== "applied") continue;
for (const command of edge.result.commands) {
produced.add(options.commandKind(command));
}
}
assertInventoryCovered(
"Command",
options.inventory,
produced,
options.exclusions ?? [],
);
return graph;
}
export function assertReferenceStability<
State,
Command,
Reason extends IgnoreReason,
>(
previous: State,
result: TransitionResult<State, Command, Reason>,
areEqual: (left: State, right: State) => boolean,
): void {
if (result.kind === "ignored") {
if (result.state !== previous) {
throw new Error(
"Ignored transitions must retain the state reference and emit no commands",
);
}
return;
}
if (areEqual(previous, result.state) && previous !== result.state) {
throw new Error(
"A value-equal transition must not return a new state reference",
);
}
}
/** Test-only projection for assertions that apply to both result variants. */
export function commandsOf<State, Command, Reason extends IgnoreReason>(
result: TransitionResult<State, Command, Reason>,
): readonly Command[] {
return result.kind === "applied" ? result.commands : [];
}
/** Test-only projection for concise ignored-reason assertions. */
export function ignoreReasonOf<State, Command, Reason extends IgnoreReason>(
result: TransitionResult<State, Command, Reason>,
): Reason | undefined {
return result.kind === "ignored" ? result.reason : undefined;
}
export interface ConformanceController<State, Event> {
getSnapshot(): State;
subscribe(listener: () => void): () => void;
send(event: Event): void;
dispose(): void;
}
export interface ControllerConformanceAdapter<
State,
Event,
Command,
Reason extends IgnoreReason,
> {
initialState: State;
transition(
state: State,
event: Event,
): TransitionResult<State, Command, Reason>;
create(options: {
runCommand(
command: Command,
emit: (event: Event) => void,
): void | Promise<void>;
observer?: import("./types").TransitionObserver<
State,
Event,
Command,
Reason
>;
beforeCommit?(previous: State, next: State): void;
project?(state: State): void;
reportError?(error: unknown): void;
disposeCommands?(state: State): readonly Command[];
cleanupProjection?(): void;
releaseWriter?(): void;
onDisposed?(): void;
}): ConformanceController<State, Event>;
events: {
enterA: Event;
enterB: Event;
finish: Event;
command(command: Command): Event;
};
errorStage(error: unknown): string | undefined;
commands: {
emit(event: Event): Command;
syncThrow: Command;
asyncReject: Command;
awaitThen(event: Event): {
command: Command;
resolve(): void;
};
cleanup(state: State): readonly Command[];
};
/** One representative event for every reachable non-terminal state. */
nonTerminalEvents: readonly { name: string; event: Event }[];
stateKey(state: State): string;
}
function conformanceAssert(
condition: unknown,
scenario: string,
detail: string,
): asserts condition {
if (!condition) {
throw new Error(`Controller conformance failed: ${scenario}: ${detail}`);
}
}
async function flushConformancePromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferredLifecycleDriver<
Controller extends { dispose(): void },
>() {
const generations = new Map<Controller, number>();
return {
mount(controller: Controller): () => void {
const generation = (generations.get(controller) ?? 0) + 1;
generations.set(controller, generation);
return () => {
queueMicrotask(() => {
if (generations.get(controller) !== generation) return;
generations.delete(controller);
controller.dispose();
});
};
},
};
}
/**
* Runs the shared adversarial controller contract.
*
* Domains provide representative states/events/commands, keeping concurrency,
* cleanup, and staleness policy outside the harness. Failures name the exact
* scenario so deliberately broken reference implementations fail usefully.
*/
export async function runControllerConformanceSuite<
State,
Event,
Command,
Reason extends IgnoreReason,
>(
adapter: ControllerConformanceAdapter<State, Event, Command, Reason>,
): Promise<void> {
{
const order: string[] = [];
let controller: ConformanceController<State, Event>;
controller = adapter.create({
runCommand: () => undefined,
observer: {
onTransitionApplied({ event }) {
order.push(`observe:${describe(event)}`);
if (order.length === 1) controller.send(adapter.events.enterB);
},
},
});
controller.send(adapter.events.enterA);
conformanceAssert(
adapter.stateKey(controller.getSnapshot()) ===
adapter.stateKey(
adapter.transition(
adapter.transition(adapter.initialState, adapter.events.enterA)
.state,
adapter.events.enterB,
).state,
),
"re-entrant observer dispatch",
"the observer's event did not run after the committed outer event",
);
}
{
const seen: string[] = [];
const controller = adapter.create({ runCommand: () => undefined });
controller.subscribe(() => {
seen.push(adapter.stateKey(controller.getSnapshot()));
if (seen.length === 1) controller.send(adapter.events.enterB);
});
controller.send(adapter.events.enterA);
conformanceAssert(
seen.length >= 2,
"re-entrant subscriber dispatch",
"the subscriber's event was lost",
);
}
{
const command = adapter.commands.emit(adapter.events.enterB);
const commandEvent = adapter.events.command(command);
const controller = adapter.create({
runCommand(current, emit) {
if (Object.is(current, command)) emit(adapter.events.enterB);
},
});
controller.send(commandEvent);
const commandState = adapter.transition(
adapter.initialState,
commandEvent,
).state;
conformanceAssert(
adapter.stateKey(controller.getSnapshot()) ===
adapter.stateKey(
adapter.transition(commandState, adapter.events.enterB).state,
),
"synchronous command emission",
"the emitted event was not drained",
);
}
{
const errors: unknown[] = [];
const command = adapter.commands.emit(adapter.events.enterB);
const commandEvent = adapter.events.command(command);
const controller = adapter.create({
beforeCommit() {
throw new Error("conformance before-commit throw");
},
runCommand: () => undefined,
reportError: (error) => errors.push(error),
});
controller.send(commandEvent);
const commandState = adapter.transition(
adapter.initialState,
commandEvent,
).state;
conformanceAssert(
errors.length === 2 &&
errors.every(
(error) => adapter.errorStage(error) === "before-commit",
) &&
adapter.stateKey(controller.getSnapshot()) ===
adapter.stateKey(
adapter.transition(commandState, adapter.events.enterB).state,
),
"before-commit failure preserves progress",
"the failure was not reported, commit was vetoed, or synchronous follow-up work stopped",
);
}
{
const errors: unknown[] = [];
const controller = adapter.create({
runCommand(command) {
if (Object.is(command, adapter.commands.syncThrow)) {
throw new Error("conformance sync throw");
}
},
reportError: (error) => errors.push(error),
});
controller.send(adapter.events.command(adapter.commands.syncThrow));
controller.send(adapter.events.enterA);
conformanceAssert(
errors.length === 1 &&
adapter.stateKey(controller.getSnapshot()) !==
adapter.stateKey(adapter.initialState),
"synchronous runner throw",
"the error was not reported or the queue wedged",
);
}
{
const errors: unknown[] = [];
const controller = adapter.create({
runCommand(command) {
if (Object.is(command, adapter.commands.asyncReject)) {
return Promise.reject(new Error("conformance async rejection"));
}
},
reportError: (error) => errors.push(error),
});
controller.send(adapter.events.command(adapter.commands.asyncReject));
controller.send(adapter.events.enterA);
await flushConformancePromises();
conformanceAssert(
errors.length === 1 &&
adapter.stateKey(controller.getSnapshot()) !==
adapter.stateKey(adapter.initialState),
"asynchronous runner rejection",
"the rejection was not reported or later events stopped",
);
}
{
const deferred = adapter.commands.awaitThen(adapter.events.enterB);
const commandEvent = adapter.events.command(deferred.command);
const controller = adapter.create({
runCommand(command, emit) {
if (!Object.is(command, deferred.command)) return;
return new Promise<void>((resolve) => {
const originalResolve = deferred.resolve;
deferred.resolve = () => {
originalResolve();
emit(adapter.events.enterB);
resolve();
};
});
},
});
controller.send(commandEvent);
const snapshotAtDispose = controller.getSnapshot();
controller.dispose();
deferred.resolve();
await flushConformancePromises();
conformanceAssert(
adapter.stateKey(controller.getSnapshot()) ===
adapter.stateKey(snapshotAtDispose),
"dispose during await",
"a completion changed state after disposal",
);
}
{
const controller = adapter.create({ runCommand: () => undefined });
controller.dispose();
controller.send(adapter.events.enterA);
conformanceAssert(
adapter.stateKey(controller.getSnapshot()) ===
adapter.stateKey(adapter.initialState),
"post-dispose emit",
"an event was admitted after disposal",
);
}
{
const stale = adapter.commands.awaitThen(adapter.events.enterB);
let generation = 0;
const host = new KeyedControllerHost<
string,
ConformanceController<State, Event>
>(() => {
generation += 1;
return adapter.create({
runCommand(command, emit) {
if (generation !== 1 || !Object.is(command, stale.command)) return;
const originalResolve = stale.resolve;
stale.resolve = () => {
originalResolve();
emit(adapter.events.enterB);
};
},
});
});
const old = host.ensure("entity");
old.send(adapter.events.command(stale.command));
host.disposeKey("entity");
const replacement = host.ensure("entity");
replacement.send(adapter.events.enterA);
stale.resolve();
conformanceAssert(
adapter.stateKey(replacement.getSnapshot()) ===
adapter.stateKey(
adapter.transition(adapter.initialState, adapter.events.enterA).state,
),
"key dispose/recreate with stale events",
"an old controller affected its replacement",
);
host.dispose();
}
{
const disposal = { count: 0 };
const disposalCount = () => disposal.count;
const replayed = adapter.create({
runCommand: () => undefined,
onDisposed: () => {
disposal.count += 1;
},
});
const lifecycle = createDeferredLifecycleDriver();
const cleanupFirstMount = lifecycle.mount(replayed);
cleanupFirstMount();
const cleanupReplay = lifecycle.mount(replayed);
await flushConformancePromises();
replayed.send(adapter.events.enterA);
conformanceAssert(
disposalCount() === 0 &&
adapter.stateKey(replayed.getSnapshot()) !==
adapter.stateKey(adapter.initialState),
"StrictMode replay",
"the replayed setup did not cancel deferred disposal",
);
cleanupReplay();
await flushConformancePromises();
conformanceAssert(
disposalCount() === 1,
"StrictMode replay",
"final cleanup did not dispose exactly once",
);
}
{
const disposals = { a: 0, b: 0 };
const aDisposeCount = () => disposals.a;
const bDisposeCount = () => disposals.b;
const a = adapter.create({
runCommand: () => undefined,
onDisposed: () => {
disposals.a += 1;
},
});
const b = adapter.create({
runCommand: () => undefined,
onDisposed: () => {
disposals.b += 1;
},
});
const lifecycle = createDeferredLifecycleDriver();
lifecycle.mount(a)();
lifecycle.mount(b)();
lifecycle.mount(a)();
const cleanupFinalB = lifecycle.mount(b);
await flushConformancePromises();
b.send(adapter.events.enterB);
conformanceAssert(
aDisposeCount() === 1 &&
bDisposeCount() === 0 &&
adapter.stateKey(b.getSnapshot()) !==
adapter.stateKey(adapter.initialState),
"A to B to A to B replacement",
"a prior generation disposed the final replacement",
);
cleanupFinalB();
await flushConformancePromises();
conformanceAssert(
bDisposeCount() === 1,
"A to B to A to B replacement",
"the final replacement did not dispose exactly once",
);
}
{
const order: string[] = [];
const controller = adapter.create({
runCommand: () => undefined,
cleanupProjection: () => order.push("projection-cleanup"),
releaseWriter: () => order.push("writer-release"),
});
controller.dispose();
conformanceAssert(
order.join(",") === "projection-cleanup,writer-release",
"projection cleanup before writer release",
`observed ${order.join(",")}`,
);
}
for (const { name, event } of adapter.nonTerminalEvents) {
const finalizers: Command[] = [];
let disposeCount = 0;
let projectionCleanupCount = 0;
let writerReleaseCount = 0;
const controller = adapter.create({
runCommand(command) {
finalizers.push(command);
},
disposeCommands: (state) => adapter.commands.cleanup(state),
cleanupProjection() {
projectionCleanupCount += 1;
},
releaseWriter() {
writerReleaseCount += 1;
},
onDisposed() {
disposeCount += 1;
},
});
controller.send(event);
controller.dispose();
controller.dispose();
await flushConformancePromises();
conformanceAssert(
disposeCount === 1 &&
projectionCleanupCount === 1 &&
writerReleaseCount === 1 &&
finalizers.length > 0,
"dispose from every reachable non-terminal state",
`${name} (${adapter.stateKey(controller.getSnapshot())}) did not clean projection/resources exactly once`,
);
}
}
export function replayTrace<State, Event, Command, SerializedEvent>(options: {
initialState: State;
trace: ReplayTrace<SerializedEvent>;
serialization: ReplaySerialization<State, Event, Command, SerializedEvent>;
transition: (state: State, event: Event) => TransitionResult<State, Command>;
}): State {
if (options.trace.schemaVersion !== options.serialization.schemaVersion) {
throw new Error(
`Unsupported replay trace schema version ${options.trace.schemaVersion}; expected ${options.serialization.schemaVersion}`,
);
}
let state = options.initialState;
const replayedEvents: Event[] = [];
for (let index = 0; index < options.trace.entries.length; index += 1) {
const entry = options.trace.entries[index];
try {
const event = options.serialization.deserializeEvent(entry.event);
const result = options.transition(state, event);
validateTransitionResult(state, event, result, replayedEvents);
const actual =
result.kind === "ignored"
? {
kind: result.kind,
reason: result.reason,
stateKey: options.serialization.stateKey(result.state),
}
: {
kind: result.kind,
stateKey: options.serialization.stateKey(result.state),
commands: result.commands.map((command) =>
options.serialization.describeCommand(command),
),
};
if (!valuesAreEqual(actual, entry.outcome)) {
throw new Error(
`expected ${describe(entry.outcome)}, received ${describe(actual)}`,
);
}
state = result.state;
replayedEvents.push(event);
} catch (error) {
const detail =
error instanceof Error ? error.message : `threw ${describe(error)}`;
throw new Error(`Replay diverged at prefix ${index + 1}: ${detail}`);
}
}
return state;
}
export function createRecordingCommandRunner<Command, Event>(
implementation?: (
command: Command,
emit: (event: Event) => void,
) => void | Promise<void>,
) {
const commands: Command[] = [];
const events: Event[] = [];
const run = async (
command: Command,
emit: (event: Event) => void,
): Promise<void> => {
commands.push(command);
await implementation?.(command, (event) => {
events.push(event);
emit(event);
});
};
return { commands, events, run };
}