Skip to content

Commit 255329a

Browse files
committed
WIP SCXML
1 parent 953acdf commit 255329a

File tree

4 files changed

+144
-29
lines changed

4 files changed

+144
-29
lines changed

packages/core/src/createMachineFromConfig.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ export interface ScxmlRaiseJSON {
6060
delay?: number;
6161
/** Expression for delay */
6262
delayexpr?: string;
63+
/** Expression for target */
64+
targetexpr?: string;
65+
}
66+
67+
export interface ScxmlScriptJSON {
68+
type: 'scxml.script';
69+
/** The script code to execute */
70+
code: string;
6371
}
6472

6573
export type BuiltInActionJSON =
@@ -82,7 +90,8 @@ export type ActionJSON =
8290
| EmitJSON
8391
| AssignJSON
8492
| ScxmlAssignJSON
85-
| ScxmlRaiseJSON;
93+
| ScxmlRaiseJSON
94+
| ScxmlScriptJSON;
8695

8796
export interface GuardJSON {
8897
type: string;
@@ -136,16 +145,59 @@ function evaluateExpr(
136145
expr: string,
137146
event: AnyEventObject | null
138147
): unknown {
139-
const scope = 'const _sessionid = "NOT_IMPLEMENTED";';
148+
const scope =
149+
'const _sessionid = "NOT_IMPLEMENTED"; const _ioprocessors = "NOT_IMPLEMENTED";';
140150
const fnBody = `
141151
${scope}
142152
with (context) {
143153
return (${expr});
144154
}
145-
`;
155+
`.trim();
146156
// eslint-disable-next-line @typescript-eslint/no-implied-eval
147157
const fn = new Function('context', '_event', fnBody);
148-
return fn(context, event ? { name: event.type, data: event } : undefined);
158+
// SCXML _event has: name, data, origin, origintype, etc.
159+
// For self-raised events, origin is #_internal
160+
const result = fn(
161+
context,
162+
event
163+
? {
164+
name: event.type,
165+
data: event,
166+
origin: '#_internal',
167+
origintype: 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor'
168+
}
169+
: undefined
170+
);
171+
172+
return result;
173+
}
174+
175+
/** Executes an SCXML script block and returns updated context values. */
176+
function executeScript(
177+
context: MachineContext,
178+
code: string
179+
): Record<string, unknown> {
180+
// Create a proxy to track which properties are modified
181+
const updates: Record<string, unknown> = {};
182+
const contextKeys = Object.keys(context);
183+
184+
// Build variable declarations and reassignment capture
185+
const varDeclarations = contextKeys
186+
.map((k) => `let ${k} = context.${k};`)
187+
.join('\n');
188+
const captureUpdates = contextKeys
189+
.map((k) => `updates.${k} = ${k};`)
190+
.join('\n');
191+
192+
const fnBody = `
193+
${varDeclarations}
194+
${code}
195+
${captureUpdates}
196+
return updates;
197+
`;
198+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
199+
const fn = new Function('context', 'updates', fnBody);
200+
return fn(context, updates);
149201
}
150202

151203
export function createMachineFromConfig(json: MachineJSON): AnyStateMachine {
@@ -305,19 +357,40 @@ export function createMachineFromConfig(json: MachineJSON): AnyStateMachine {
305357
}
306358
}
307359

308-
// Evaluate delay if expression
360+
// Evaluate target if expression
361+
const target = scxmlAction.targetexpr
362+
? (evaluateExpr(
363+
mergedContext,
364+
scxmlAction.targetexpr,
365+
x.event
366+
) as string)
367+
: undefined;
368+
369+
// If target is #_internal, use internal queue (no delay)
370+
// Otherwise use the specified delay or default external queue delay
371+
const isInternalTarget = target === '#_internal';
309372
const delay = scxmlAction.delayexpr
310373
? (evaluateExpr(
311374
mergedContext,
312375
scxmlAction.delayexpr,
313376
x.event
314377
) as number)
315-
: scxmlAction.delay;
378+
: isInternalTarget
379+
? undefined
380+
: scxmlAction.delay;
316381

317382
enq.raise(eventData as AnyEventObject, {
318383
id: scxmlAction.id,
319384
delay
320385
});
386+
} else if (action.type === 'scxml.script') {
387+
// SCXML script - execute code that can modify context
388+
context ??= {};
389+
const scxmlAction = action as ScxmlScriptJSON;
390+
const mergedContext = { ...x.context, ...context };
391+
// Execute script with context variables in scope
392+
const updatedContext = executeScript(mergedContext, scxmlAction.code);
393+
Object.assign(context, updatedContext);
321394
} else {
322395
enq(x.actions[action.type], (action as CustomActionJSON).params);
323396
}

packages/core/src/scxml.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function mapAction(element: XMLElement): ActionJSON {
108108
};
109109
}
110110
case 'send': {
111-
const { event, eventexpr, target, id, delay, delayexpr } =
111+
const { event, eventexpr, target, targetexpr, id, delay, delayexpr } =
112112
element.attributes!;
113113

114114
// Extract params from child elements
@@ -128,23 +128,22 @@ function mapAction(element: XMLElement): ActionJSON {
128128
}
129129
}
130130

131-
// If no params and no expressions, use simple RaiseJSON
132-
if (!params.length && !eventexpr && !delayexpr) {
133-
if (target === SpecialTargets.Internal) {
134-
const action: RaiseJSON = {
135-
type: '@xstate.raise',
136-
event: { type: event as string },
137-
id: id as string | undefined,
138-
delay: delay ? delayToMs(delay) : undefined
139-
};
140-
return action;
141-
}
131+
const isInternal = target === SpecialTargets.Internal;
132+
// External events (non-internal) go to external queue via delay:0
133+
// This ensures internal events are processed first within a macrostep
134+
const resolvedDelay = delay
135+
? delayToMs(delay)
136+
: isInternal
137+
? undefined
138+
: 0;
142139

140+
// If no params and no expressions, use simple RaiseJSON
141+
if (!params.length && !eventexpr && !delayexpr && !targetexpr) {
143142
const action: RaiseJSON = {
144143
type: '@xstate.raise',
145144
event: { type: (event as string) || 'unknown' },
146145
id: id as string | undefined,
147-
delay: delay ? delayToMs(delay) : undefined
146+
delay: resolvedDelay
148147
};
149148
return action;
150149
}
@@ -156,8 +155,9 @@ function mapAction(element: XMLElement): ActionJSON {
156155
eventexpr: eventexpr as string | undefined,
157156
params: params.length ? params : undefined,
158157
id: id as string | undefined,
159-
delay: delay ? delayToMs(delay) : undefined,
160-
delayexpr: delayexpr as string | undefined
158+
delay: resolvedDelay,
159+
delayexpr: delayexpr as string | undefined,
160+
targetexpr: targetexpr as string | undefined
161161
};
162162
return action;
163163
}
@@ -176,6 +176,12 @@ function mapAction(element: XMLElement): ActionJSON {
176176
// The full implementation would require runtime conditional logic
177177
return { type: 'scxml.if', params: { element } };
178178
}
179+
case 'script': {
180+
// Get the script text content
181+
const textElement = element.elements?.find((el) => el.type === 'text');
182+
const code = (textElement?.text as string) || '';
183+
return { type: 'scxml.script', code: code.trim() };
184+
}
179185
default:
180186
throw new Error(
181187
`Conversion of "${element.name}" elements is not implemented yet.`
@@ -340,13 +346,16 @@ function toStateNodeJSON(
340346
guard = createGuard(value.attributes.cond as string);
341347
}
342348

349+
// Only set reenter:true for external transitions WITH a target
350+
// Targetless transitions should not reenter (they just execute actions)
351+
const hasTarget = targets !== undefined;
343352
const transitionConfig: TransitionJSON = {
344353
target: getTargets(targets),
345354
...(value.elements?.length
346355
? { actions: mapActions(value.elements) }
347356
: undefined),
348357
...(guard ? { guard } : undefined),
349-
...(!internal && { reenter: true })
358+
...(hasTarget && !internal && { reenter: true })
350359
};
351360

352361
if (eventType === NULL_EVENT || eventType === '') {

packages/core/src/stateUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,7 @@ function enterStates(
12411241
* transition.
12421242
*/
12431243
export function getTransitionResult(
1244-
transition: Pick<AnyTransitionDefinition, 'target' | 'fn' | 'source'> & {
1244+
transition: Pick<AnyTransitionDefinition, 'target' | 'to' | 'source'> & {
12451245
reenter?: AnyTransitionDefinition['reenter'];
12461246
},
12471247
snapshot: AnyMachineSnapshot,

packages/core/test/scxml.test.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ const testGroups: Record<string, string[]> = {
164164
// 'test198.txml', // origintype not implemented yet
165165
// 'test199.txml', // send type not checked
166166
'test200.txml',
167-
'test201.txml',
167+
// 'test201.txml', // optional
168168
'test205.txml',
169169
// 'test207.txml', // delayexpr
170170
'test208.txml',
@@ -354,25 +354,46 @@ interface SCIONTest {
354354

355355
async function runW3TestToCompletion(
356356
name: string,
357-
machine: AnyStateMachine
357+
scxmlDefinition: string,
358+
test: SCIONTest
358359
): Promise<void> {
360+
const machine = toMachine(scxmlDefinition);
361+
359362
const { resolve, reject, promise } = Promise.withResolvers<void>();
360363
let nextState: AnyMachineSnapshot;
361364
let prevState: AnyMachineSnapshot;
362365

366+
const transitions: string[] = [];
367+
363368
const actor = createActor(machine, {
364369
logger: () => void 0
365370
});
371+
actor.system.inspect((e) => {
372+
console.log({
373+
type: e.type,
374+
microsteps: e.microsteps.map((m) => ({
375+
type: m.eventType,
376+
state: m.target
377+
}))
378+
});
379+
});
366380
actor.subscribe({
367381
next: (state) => {
368382
prevState = nextState;
369383
nextState = state;
384+
transitions.push(
385+
`${JSON.stringify(state.value)} ${JSON.stringify(state.context)}`
386+
);
370387
},
371388
complete: () => {
372389
// Add 'final' for test230.txml which does not have a 'pass' state
373390
if (['final', 'pass'].includes(nextState.value as string)) {
374391
resolve();
375392
} else {
393+
console.log(transitions.join('\n'));
394+
console.log(scxmlDefinition);
395+
console.log(toMachineJSON(scxmlDefinition));
396+
console.log(JSON.stringify(test, null, 2));
376397
reject(
377398
new Error(
378399
`${name}: Reached "fail" state from state ${JSON.stringify(
@@ -397,7 +418,7 @@ async function runTestToCompletion(
397418
const machine = toMachine(scxmlDefinition);
398419

399420
if (!test.events.length && test.initialConfiguration[0] === 'pass') {
400-
await runW3TestToCompletion(name, machine);
421+
await runW3TestToCompletion(name, scxmlDefinition, test);
401422
return;
402423
}
403424

@@ -440,6 +461,7 @@ async function runTestToCompletion(
440461
});
441462
actor.start();
442463

464+
const transitions: string[] = [];
443465
test.events.forEach(({ event, nextConfiguration, after }) => {
444466
if (done) {
445467
return;
@@ -448,19 +470,29 @@ async function runTestToCompletion(
448470
(actor.clock as SimulatedClock).increment(after);
449471
}
450472
actor.send({ type: event.name });
473+
transitions.push(
474+
`${event.name} -> ${JSON.stringify(actor.getSnapshot().value)} ${JSON.stringify(actor.getSnapshot().context)}`
475+
);
451476

452477
const stateIds = getStateNodes(machine.root, nextState.value).map(
453478
(stateNode) => stateNode.id
454479
);
455480

481+
if (!stateIds.includes(sanitizeStateId(nextConfiguration[0]))) {
482+
console.log(transitions.join('\n'));
483+
console.log(scxmlDefinition);
484+
console.log(JSON.stringify(machineJSON, null, 2));
485+
console.log(JSON.stringify(test, null, 2));
486+
}
487+
456488
expect(stateIds).toContain(sanitizeStateId(nextConfiguration[0]));
457489
});
458490
}
459491

460-
describe('scxml', () => {
492+
describe.skip('scxml', () => {
461493
const onlyTests: string[] = [
462494
// e.g., 'test399.txml'
463-
// 'test560.txml'
495+
'test147.txml'
464496
];
465497
const testGroupKeys = Object.keys(testGroups);
466498

@@ -469,7 +501,8 @@ describe('scxml', () => {
469501

470502
testNames.forEach((testName) => {
471503
const execTest = onlyTests.length
472-
? onlyTests.includes(testName)
504+
? onlyTests.includes(testName) ||
505+
onlyTests.includes(`${testGroupName}/${testName}`)
473506
? it.only
474507
: it.skip
475508
: it;

0 commit comments

Comments
 (0)