@mmapp/player-core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend Expression Context Tests — verifies evaluator resolves
|
|
3
|
+
* event, local, viewport, and $domain() context fields.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { createEvaluator, WEB_FAILURE_POLICIES, clearExpressionCache } from '../expression';
|
|
8
|
+
import type { Evaluator, ExpressionContext } from '../expression';
|
|
9
|
+
|
|
10
|
+
describe('Frontend Expression Context', () => {
|
|
11
|
+
let evaluator: Evaluator;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
clearExpressionCache();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves event payload fields', () => {
|
|
18
|
+
evaluator = createEvaluator({
|
|
19
|
+
functions: [],
|
|
20
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ctx: ExpressionContext = {
|
|
24
|
+
state_data: {},
|
|
25
|
+
memory: {},
|
|
26
|
+
current_state: 'listening',
|
|
27
|
+
status: 'ACTIVE',
|
|
28
|
+
event: {
|
|
29
|
+
instance_id: 'i-123',
|
|
30
|
+
from_state: 'pending',
|
|
31
|
+
to_state: 'completed',
|
|
32
|
+
changed_fields: ['status', 'result'],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
expect(evaluator.evaluate('event.to_state', ctx).value).toBe('completed');
|
|
37
|
+
expect(evaluator.evaluate('event.instance_id', ctx).value).toBe('i-123');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resolves local state fields', () => {
|
|
41
|
+
evaluator = createEvaluator({
|
|
42
|
+
functions: [],
|
|
43
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ctx: ExpressionContext = {
|
|
47
|
+
state_data: {},
|
|
48
|
+
memory: {},
|
|
49
|
+
current_state: 'active',
|
|
50
|
+
status: 'ACTIVE',
|
|
51
|
+
local: {
|
|
52
|
+
selected_tab: 'details',
|
|
53
|
+
filter_active: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(evaluator.evaluate('local.selected_tab', ctx).value).toBe('details');
|
|
58
|
+
expect(evaluator.evaluate('local.filter_active', ctx).value).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('resolves viewport dimensions via custom functions', () => {
|
|
62
|
+
evaluator = createEvaluator({
|
|
63
|
+
functions: [
|
|
64
|
+
{ name: 'viewport_width', fn: () => 1920, arity: 0 },
|
|
65
|
+
{ name: 'viewport_height', fn: () => 1080, arity: 0 },
|
|
66
|
+
{ name: 'is_online', fn: () => true, arity: 0 },
|
|
67
|
+
],
|
|
68
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const ctx: ExpressionContext = {
|
|
72
|
+
state_data: {},
|
|
73
|
+
memory: {},
|
|
74
|
+
current_state: 'active',
|
|
75
|
+
status: 'ACTIVE',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
expect(evaluator.evaluate('viewport_width()', ctx).value).toBe(1920);
|
|
79
|
+
expect(evaluator.evaluate('viewport_height()', ctx).value).toBe(1080);
|
|
80
|
+
expect(evaluator.evaluate('is_online()', ctx).value).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves $domain() via custom function', () => {
|
|
84
|
+
evaluator = createEvaluator({
|
|
85
|
+
functions: [
|
|
86
|
+
{
|
|
87
|
+
name: '$domain',
|
|
88
|
+
fn: (slug: unknown, field: unknown) => {
|
|
89
|
+
if (slug === 'user-stats' && field === 'points') return 42;
|
|
90
|
+
if (slug === 'user-stats' && field === 'level') return 5;
|
|
91
|
+
return undefined;
|
|
92
|
+
},
|
|
93
|
+
arity: 2,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const ctx: ExpressionContext = {
|
|
100
|
+
state_data: {},
|
|
101
|
+
memory: {},
|
|
102
|
+
current_state: 'dashboard',
|
|
103
|
+
status: 'ACTIVE',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect(evaluator.evaluate("$domain('user-stats', 'points')", ctx).value).toBe(42);
|
|
107
|
+
expect(evaluator.evaluate("$domain('user-stats', 'level')", ctx).value).toBe(5);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('resolves $fe_ref() via custom function', () => {
|
|
111
|
+
evaluator = createEvaluator({
|
|
112
|
+
functions: [
|
|
113
|
+
{
|
|
114
|
+
name: '$fe_ref',
|
|
115
|
+
fn: (instanceId: unknown, path: unknown) => {
|
|
116
|
+
if (instanceId === 'nav-1' && path === 'active_page') return '/dashboard';
|
|
117
|
+
return undefined;
|
|
118
|
+
},
|
|
119
|
+
arity: 2,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const ctx: ExpressionContext = {
|
|
126
|
+
state_data: {},
|
|
127
|
+
memory: {},
|
|
128
|
+
current_state: 'nav',
|
|
129
|
+
status: 'ACTIVE',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
expect(evaluator.evaluate("$fe_ref('nav-1', 'active_page')", ctx).value).toBe('/dashboard');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('resolves $local() via custom function', () => {
|
|
136
|
+
evaluator = createEvaluator({
|
|
137
|
+
functions: [
|
|
138
|
+
{
|
|
139
|
+
name: '$local',
|
|
140
|
+
fn: (key: unknown) => {
|
|
141
|
+
const store: Record<string, unknown> = { theme: 'dark', lang: 'en' };
|
|
142
|
+
return store[key as string];
|
|
143
|
+
},
|
|
144
|
+
arity: 1,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const ctx: ExpressionContext = {
|
|
151
|
+
state_data: {},
|
|
152
|
+
memory: {},
|
|
153
|
+
current_state: 'settings',
|
|
154
|
+
status: 'ACTIVE',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
expect(evaluator.evaluate("$local('theme')", ctx).value).toBe('dark');
|
|
158
|
+
expect(evaluator.evaluate("$local('lang')", ctx).value).toBe('en');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('combines event + state_data + functions in one context', () => {
|
|
162
|
+
evaluator = createEvaluator({
|
|
163
|
+
functions: [
|
|
164
|
+
{ name: 'viewport_width', fn: () => 800, arity: 0 },
|
|
165
|
+
],
|
|
166
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const ctx: ExpressionContext = {
|
|
170
|
+
state_data: { total: 100 },
|
|
171
|
+
memory: { last_seen: 'yesterday' },
|
|
172
|
+
current_state: 'dashboard',
|
|
173
|
+
status: 'ACTIVE',
|
|
174
|
+
total: 100,
|
|
175
|
+
event: { new_points: 10 },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Expression using state_data field + event field + function
|
|
179
|
+
expect(evaluator.evaluate('add(total, event.new_points)', ctx).value).toBe(110);
|
|
180
|
+
expect(evaluator.evaluate('gt(viewport_width(), 768)', ctx).value).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests — full chain from domain event through browser engine to action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Tests the complete pipeline:
|
|
5
|
+
* 1. Create state machine with on_event subscription
|
|
6
|
+
* 2. Wire event bus to action handler
|
|
7
|
+
* 3. Publish domain event matching the pattern
|
|
8
|
+
* 4. Verify action fires with correct context
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
import { StateMachine } from '../state-machine/interpreter';
|
|
13
|
+
import { EventBus } from '../events/event-bus';
|
|
14
|
+
import { ActionDispatcher } from '../actions/dispatcher';
|
|
15
|
+
import { createEvaluator, WEB_FAILURE_POLICIES, clearExpressionCache } from '../expression';
|
|
16
|
+
import type { PlayerWorkflowDefinition, PlayerAction } from '../state-machine/types';
|
|
17
|
+
import type { Evaluator, ExpressionContext } from '../expression/types';
|
|
18
|
+
|
|
19
|
+
describe('Integration: Domain Event → Engine → Action', () => {
|
|
20
|
+
let evaluator: Evaluator;
|
|
21
|
+
let eventBus: EventBus;
|
|
22
|
+
let dispatcher: ActionDispatcher;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
clearExpressionCache();
|
|
26
|
+
evaluator = createEvaluator({
|
|
27
|
+
functions: [],
|
|
28
|
+
failurePolicy: WEB_FAILURE_POLICIES.EVENT_REACTION,
|
|
29
|
+
});
|
|
30
|
+
eventBus = new EventBus();
|
|
31
|
+
dispatcher = new ActionDispatcher();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('full chain: domain event → pattern match → toast action fires', async () => {
|
|
35
|
+
// 1. Define workflow with on_event subscription
|
|
36
|
+
const definition: PlayerWorkflowDefinition = {
|
|
37
|
+
id: 'dashboard-experience',
|
|
38
|
+
slug: 'dashboard',
|
|
39
|
+
states: [
|
|
40
|
+
{
|
|
41
|
+
name: 'active',
|
|
42
|
+
type: 'START',
|
|
43
|
+
on_event: [
|
|
44
|
+
{
|
|
45
|
+
match: 'project:*:*:complete_task.transitioned',
|
|
46
|
+
actions: [
|
|
47
|
+
{
|
|
48
|
+
type: 'toast',
|
|
49
|
+
config: { variant: 'success', message: 'Task completed!' },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
transitions: [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// 2. Create state machine
|
|
60
|
+
const sm = new StateMachine(definition, {}, {
|
|
61
|
+
evaluator,
|
|
62
|
+
actionHandlers: new Map(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 3. Register toast handler
|
|
66
|
+
const toastHandler = vi.fn();
|
|
67
|
+
dispatcher.register('toast', toastHandler);
|
|
68
|
+
|
|
69
|
+
// 4. Wire on_event subscriptions to event bus → action dispatcher
|
|
70
|
+
const stateDef = sm.getCurrentStateDefinition();
|
|
71
|
+
if (stateDef?.on_event) {
|
|
72
|
+
for (const sub of stateDef.on_event) {
|
|
73
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
74
|
+
const ctx: ExpressionContext = {
|
|
75
|
+
...sm.stateData,
|
|
76
|
+
state_data: sm.stateData,
|
|
77
|
+
memory: sm.getSnapshot().memory,
|
|
78
|
+
current_state: sm.currentState,
|
|
79
|
+
status: sm.status,
|
|
80
|
+
event: event.payload,
|
|
81
|
+
};
|
|
82
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 5. Publish domain event (simulating WebSocket delivery)
|
|
88
|
+
await eventBus.publish('project:stakeholder:s1:complete_task.transitioned', {
|
|
89
|
+
instance_id: 'i-999',
|
|
90
|
+
from_state: 'in_progress',
|
|
91
|
+
to_state: 'done',
|
|
92
|
+
trigger: 'user',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 6. Verify toast action was called
|
|
96
|
+
expect(toastHandler).toHaveBeenCalledOnce();
|
|
97
|
+
expect(toastHandler).toHaveBeenCalledWith(
|
|
98
|
+
{ variant: 'success', message: 'Task completed!' },
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
current_state: 'active',
|
|
101
|
+
status: 'ACTIVE',
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('full chain: domain event with conditions — action fires only when condition met', async () => {
|
|
107
|
+
const definition: PlayerWorkflowDefinition = {
|
|
108
|
+
id: 'vote-watcher',
|
|
109
|
+
slug: 'vote-experience',
|
|
110
|
+
states: [
|
|
111
|
+
{
|
|
112
|
+
name: 'watching',
|
|
113
|
+
type: 'START',
|
|
114
|
+
on_event: [
|
|
115
|
+
{
|
|
116
|
+
match: 'investment:*:*:vote.transitioned',
|
|
117
|
+
conditions: ['gte(vote_count, threshold)'],
|
|
118
|
+
actions: [
|
|
119
|
+
{
|
|
120
|
+
type: 'toast',
|
|
121
|
+
config: { variant: 'info', message: 'Funding threshold reached!' },
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
transitions: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const sm = new StateMachine(definition, { vote_count: 5, threshold: 10 }, {
|
|
132
|
+
evaluator,
|
|
133
|
+
actionHandlers: new Map(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const toastHandler = vi.fn();
|
|
137
|
+
dispatcher.register('toast', toastHandler);
|
|
138
|
+
|
|
139
|
+
// Wire subscriptions
|
|
140
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
141
|
+
for (const sub of stateDef.on_event!) {
|
|
142
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
143
|
+
const ctx: ExpressionContext = {
|
|
144
|
+
...sm.stateData,
|
|
145
|
+
state_data: sm.stateData,
|
|
146
|
+
memory: sm.getSnapshot().memory,
|
|
147
|
+
current_state: sm.currentState,
|
|
148
|
+
status: sm.status,
|
|
149
|
+
event: event.payload,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Check conditions
|
|
153
|
+
if (sub.conditions) {
|
|
154
|
+
for (const condition of sub.conditions) {
|
|
155
|
+
const result = evaluator.evaluate<boolean>(condition, ctx);
|
|
156
|
+
if (!result.value) return; // Skip
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Publish event — vote_count (5) < threshold (10), should NOT fire
|
|
165
|
+
await eventBus.publish('investment:stakeholder:s1:vote.transitioned', {});
|
|
166
|
+
expect(toastHandler).not.toHaveBeenCalled();
|
|
167
|
+
|
|
168
|
+
// Update vote_count past threshold
|
|
169
|
+
sm.setField('vote_count', 15);
|
|
170
|
+
|
|
171
|
+
// Publish again — now vote_count (15) >= threshold (10), should fire
|
|
172
|
+
await eventBus.publish('investment:stakeholder:s1:vote.transitioned', {});
|
|
173
|
+
expect(toastHandler).toHaveBeenCalledOnce();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('full chain: multiple subscriptions, only matching patterns fire', async () => {
|
|
177
|
+
const definition: PlayerWorkflowDefinition = {
|
|
178
|
+
id: 'multi-sub',
|
|
179
|
+
slug: 'multi',
|
|
180
|
+
states: [
|
|
181
|
+
{
|
|
182
|
+
name: 'active',
|
|
183
|
+
type: 'START',
|
|
184
|
+
on_event: [
|
|
185
|
+
{
|
|
186
|
+
match: 'project:*:*:created.transitioned',
|
|
187
|
+
actions: [{ type: 'action_a', config: {} }],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
match: 'task:*:*:completed.transitioned',
|
|
191
|
+
actions: [{ type: 'action_b', config: {} }],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
transitions: [],
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const sm = new StateMachine(definition, {}, { evaluator, actionHandlers: new Map() });
|
|
200
|
+
const handlerA = vi.fn();
|
|
201
|
+
const handlerB = vi.fn();
|
|
202
|
+
dispatcher.register('action_a', handlerA);
|
|
203
|
+
dispatcher.register('action_b', handlerB);
|
|
204
|
+
|
|
205
|
+
const stateDef = sm.getCurrentStateDefinition()!;
|
|
206
|
+
for (const sub of stateDef.on_event!) {
|
|
207
|
+
eventBus.subscribe(sub.match, async (event) => {
|
|
208
|
+
const ctx: ExpressionContext = {
|
|
209
|
+
...sm.stateData, state_data: sm.stateData, memory: {},
|
|
210
|
+
current_state: sm.currentState, status: sm.status,
|
|
211
|
+
};
|
|
212
|
+
await dispatcher.execute(sub.actions, ctx, evaluator);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Only task event should fire action_b
|
|
217
|
+
await eventBus.publish('task:s:1:completed.transitioned', {});
|
|
218
|
+
expect(handlerA).not.toHaveBeenCalled();
|
|
219
|
+
expect(handlerB).toHaveBeenCalledOnce();
|
|
220
|
+
|
|
221
|
+
// Only project event should fire action_a
|
|
222
|
+
await eventBus.publish('project:s:1:created.transitioned', {});
|
|
223
|
+
expect(handlerA).toHaveBeenCalledOnce();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('full chain: auto-transition + on_enter actions', async () => {
|
|
227
|
+
const onEnterHandler = vi.fn();
|
|
228
|
+
const definition: PlayerWorkflowDefinition = {
|
|
229
|
+
id: 'auto-chain',
|
|
230
|
+
slug: 'auto',
|
|
231
|
+
states: [
|
|
232
|
+
{ name: 'init', type: 'START' },
|
|
233
|
+
{ name: 'processing', type: 'REGULAR', on_enter: [{ type: 'notify', config: { step: 'processing' } }] },
|
|
234
|
+
{ name: 'done', type: 'END', on_enter: [{ type: 'notify', config: { step: 'done' } }] },
|
|
235
|
+
],
|
|
236
|
+
transitions: [
|
|
237
|
+
{ name: 'start', from: ['init'], to: 'processing' },
|
|
238
|
+
{ name: 'auto_complete', from: ['processing'], to: 'done', auto: true },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const sm = new StateMachine(definition, {}, {
|
|
243
|
+
evaluator,
|
|
244
|
+
actionHandlers: new Map([['notify', onEnterHandler]]),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Should auto-chain: init → processing (on_enter fires) → done (on_enter fires)
|
|
248
|
+
const result = await sm.transition('start');
|
|
249
|
+
expect(result.success).toBe(true);
|
|
250
|
+
expect(sm.currentState).toBe('done');
|
|
251
|
+
expect(sm.status).toBe('COMPLETED');
|
|
252
|
+
expect(onEnterHandler).toHaveBeenCalledTimes(2);
|
|
253
|
+
expect(onEnterHandler.mock.calls[0][0]).toEqual({ type: 'notify', config: { step: 'processing' } });
|
|
254
|
+
expect(onEnterHandler.mock.calls[1][0]).toEqual({ type: 'notify', config: { step: 'done' } });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Tests — Verify that the recursive descent parser
|
|
3
|
+
* blocks all known sandbox escape vectors.
|
|
4
|
+
*
|
|
5
|
+
* These tests confirm that the old new Function() + with() RCE
|
|
6
|
+
* vulnerabilities are no longer exploitable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
createEvaluator,
|
|
12
|
+
WEB_FAILURE_POLICIES,
|
|
13
|
+
clearExpressionCache,
|
|
14
|
+
} from '../expression';
|
|
15
|
+
import type { Evaluator, ExpressionContext } from '../expression';
|
|
16
|
+
|
|
17
|
+
describe('Security: Expression Evaluator RCE Prevention', () => {
|
|
18
|
+
let evaluator: Evaluator;
|
|
19
|
+
let ctx: ExpressionContext;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
clearExpressionCache();
|
|
23
|
+
evaluator = createEvaluator({
|
|
24
|
+
functions: [],
|
|
25
|
+
failurePolicy: WEB_FAILURE_POLICIES.VIEW_BINDING,
|
|
26
|
+
});
|
|
27
|
+
ctx = {
|
|
28
|
+
state_data: { title: 'Test' },
|
|
29
|
+
memory: {},
|
|
30
|
+
current_state: 'active',
|
|
31
|
+
status: 'ACTIVE',
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('Prototype chain escape prevention', () => {
|
|
36
|
+
it('constructor.constructor cannot access global scope', () => {
|
|
37
|
+
// Old vulnerability: constructor.constructor("return this")() gave globalThis
|
|
38
|
+
const result = evaluator.evaluate('constructor.constructor("return this")()', ctx);
|
|
39
|
+
// Should NOT return global/window — should be undefined or error
|
|
40
|
+
expect(result.value).not.toBe(globalThis);
|
|
41
|
+
// In the safe parser, "constructor" is just an identifier lookup
|
|
42
|
+
// that resolves from context (undefined), so member access fails
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('__proto__ resolves from context but cannot execute code', () => {
|
|
46
|
+
const result = evaluator.evaluate('__proto__', ctx);
|
|
47
|
+
// __proto__ may resolve from the context object's prototype,
|
|
48
|
+
// but it's safe — there's no way to call constructors or execute code
|
|
49
|
+
expect(result.status).toBe('ok');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('prototype chain traversal is blocked', () => {
|
|
53
|
+
const result = evaluator.evaluate('state_data.__proto__.constructor', ctx);
|
|
54
|
+
// The parser resolves this as member access, but __proto__ should
|
|
55
|
+
// NOT give access to Object constructor in a meaningful way
|
|
56
|
+
expect(result.status).toBe('ok');
|
|
57
|
+
// Even if it resolves, it cannot execute arbitrary code
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Code injection prevention', () => {
|
|
62
|
+
it('eval() is not available (returns undefined)', () => {
|
|
63
|
+
const result = evaluator.evaluate('eval("1+1")', ctx);
|
|
64
|
+
// eval is just an unknown function — returns undefined
|
|
65
|
+
expect(result.value).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('Function() constructor is not available', () => {
|
|
69
|
+
// In old code: Function("return process")() could access Node globals
|
|
70
|
+
// Now: Function("return 42") is a function call on "Function" (an identifier)
|
|
71
|
+
// that resolves from context. The result of that call cannot be invoked
|
|
72
|
+
// because the parser sees Function(...)() as a double-call which fails
|
|
73
|
+
const result = evaluator.evaluate('Function("return 42")()', ctx);
|
|
74
|
+
// Hits "Cannot call non-function" → fallback ('' for VIEW_BINDING)
|
|
75
|
+
expect(result.status).toBe('fallback');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('import() is not executable', () => {
|
|
79
|
+
// import("malicious-module") should fail to parse or be inert
|
|
80
|
+
const result = evaluator.evaluate('import("fs")', ctx);
|
|
81
|
+
expect(result.value).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('require() is not available', () => {
|
|
85
|
+
const result = evaluator.evaluate('require("child_process")', ctx);
|
|
86
|
+
expect(result.value).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('globalThis access returns undefined', () => {
|
|
90
|
+
const result = evaluator.evaluate('globalThis', ctx);
|
|
91
|
+
expect(result.value).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('window access returns undefined', () => {
|
|
95
|
+
const result = evaluator.evaluate('window', ctx);
|
|
96
|
+
expect(result.value).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('document access returns undefined', () => {
|
|
100
|
+
const result = evaluator.evaluate('document', ctx);
|
|
101
|
+
expect(result.value).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('process access returns undefined', () => {
|
|
105
|
+
const result = evaluator.evaluate('process', ctx);
|
|
106
|
+
expect(result.value).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('No arbitrary JavaScript execution', () => {
|
|
111
|
+
it('assignment operators are not supported (parse error)', () => {
|
|
112
|
+
const result = evaluator.evaluate('x = 42', ctx);
|
|
113
|
+
// This should either fail to parse or return the identifier value
|
|
114
|
+
// It should NOT actually assign anything
|
|
115
|
+
expect(ctx['x']).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('semicolons cannot chain statements', () => {
|
|
119
|
+
// Semicolons should cause a parse error
|
|
120
|
+
const result = evaluator.validate('a; b');
|
|
121
|
+
expect(result.valid).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('template literals (backticks) are not supported', () => {
|
|
125
|
+
const result = evaluator.validate('`${process.env}`');
|
|
126
|
+
expect(result.valid).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('arrow functions cannot be created', () => {
|
|
130
|
+
const result = evaluator.validate('() => 42');
|
|
131
|
+
expect(result.valid).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('for/while/if statements cannot be injected', () => {
|
|
135
|
+
expect(evaluator.validate('for(;;){}').valid).toBe(false);
|
|
136
|
+
expect(evaluator.validate('while(1){}').valid).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Depth limit protection', () => {
|
|
141
|
+
it('deeply nested expressions are rejected', () => {
|
|
142
|
+
// Create an expression nested 60 levels deep
|
|
143
|
+
const nested = '(' .repeat(60) + '1' + ')'.repeat(60);
|
|
144
|
+
const result = evaluator.evaluate(nested, ctx);
|
|
145
|
+
// Should fail with depth limit
|
|
146
|
+
expect(result.status).toBe('fallback');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Legitimate expressions still work', () => {
|
|
151
|
+
it('function calls work normally', () => {
|
|
152
|
+
expect(evaluator.evaluate('add(1, 2)', ctx).value).toBe(3);
|
|
153
|
+
expect(evaluator.evaluate('multiply(3, 4)', ctx).value).toBe(12);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('comparisons work normally', () => {
|
|
157
|
+
expect(evaluator.evaluate('5 > 3', ctx).value).toBe(true);
|
|
158
|
+
expect(evaluator.evaluate('1 == 1', ctx).value).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('ternary works normally', () => {
|
|
162
|
+
expect(evaluator.evaluate("true ? 'yes' : 'no'", ctx).value).toBe('yes');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('nested function calls work', () => {
|
|
166
|
+
expect(evaluator.evaluate('add(multiply(2, 3), 4)', ctx).value).toBe(10);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('logical operators work', () => {
|
|
170
|
+
expect(evaluator.evaluate('true && false', ctx).value).toBe(false);
|
|
171
|
+
expect(evaluator.evaluate('true || false', ctx).value).toBe(true);
|
|
172
|
+
expect(evaluator.evaluate('!false', ctx).value).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('path resolution works', () => {
|
|
176
|
+
expect(evaluator.evaluate('state_data.title', ctx).value).toBe('Test');
|
|
177
|
+
expect(evaluator.evaluate('current_state', ctx).value).toBe('active');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('string literals work', () => {
|
|
181
|
+
expect(evaluator.evaluate("'hello'", ctx).value).toBe('hello');
|
|
182
|
+
expect(evaluator.evaluate('"world"', ctx).value).toBe('world');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('templates still work', () => {
|
|
186
|
+
const r = evaluator.evaluateTemplate('State: {{current_state}}', ctx);
|
|
187
|
+
expect(r.value).toBe('State: active');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|