@object-ui/core 3.1.5 → 3.3.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/adapters/ValueDataSource.d.ts +5 -1
- package/dist/adapters/ValueDataSource.js +27 -0
- package/dist/errors/index.js +2 -3
- package/dist/evaluator/ExpressionCache.d.ts +9 -10
- package/dist/evaluator/ExpressionCache.js +29 -8
- package/dist/evaluator/SafeExpressionParser.d.ts +131 -0
- package/dist/evaluator/SafeExpressionParser.js +851 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/protocols/DndProtocol.js +2 -14
- package/dist/protocols/KeyboardProtocol.js +1 -4
- package/dist/protocols/NotificationProtocol.js +3 -13
- package/dist/utils/debug.js +2 -1
- package/package.json +6 -6
- package/src/__tests__/protocols/DndProtocol.test.ts +4 -4
- package/src/__tests__/protocols/KeyboardProtocol.test.ts +2 -2
- package/src/__tests__/protocols/NotificationProtocol.test.ts +3 -3
- package/src/adapters/ValueDataSource.ts +21 -0
- package/src/adapters/__tests__/ValueDataSource.test.ts +99 -0
- package/src/errors/index.ts +4 -5
- package/src/evaluator/ExpressionCache.ts +24 -10
- package/src/evaluator/SafeExpressionParser.ts +893 -0
- package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +427 -0
- package/src/evaluator/index.ts +1 -0
- package/src/protocols/DndProtocol.ts +2 -18
- package/src/protocols/KeyboardProtocol.ts +1 -5
- package/src/protocols/NotificationProtocol.ts +3 -12
- package/src/utils/debug.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
10
|
import { ExpressionEvaluator, evaluateExpression, evaluateCondition, evaluatePlainCondition } from '../ExpressionEvaluator';
|
|
11
11
|
import { ExpressionContext } from '../ExpressionContext';
|
|
12
|
+
import { SafeExpressionParser } from '../SafeExpressionParser';
|
|
12
13
|
|
|
13
14
|
describe('ExpressionContext', () => {
|
|
14
15
|
it('should create context with initial data', () => {
|
|
@@ -129,3 +130,429 @@ describe('evaluatePlainCondition', () => {
|
|
|
129
130
|
expect(evaluatePlainCondition('status', { status: 'active' })).toBe(false);
|
|
130
131
|
});
|
|
131
132
|
});
|
|
133
|
+
|
|
134
|
+
// ─── CSP Safety Tests ──────────────────────────────────────────────────────
|
|
135
|
+
//
|
|
136
|
+
// These tests verify that expression evaluation does NOT rely on eval() or
|
|
137
|
+
// new Function() and therefore works under strict Content Security Policy
|
|
138
|
+
// headers that forbid 'unsafe-eval'.
|
|
139
|
+
//
|
|
140
|
+
// The SafeExpressionParser is the CSP-safe backend used by ExpressionCache.
|
|
141
|
+
// All tests below exercise it directly as well as through ExpressionEvaluator.
|
|
142
|
+
|
|
143
|
+
describe('SafeExpressionParser — CSP-safe evaluation', () => {
|
|
144
|
+
const parser = new SafeExpressionParser();
|
|
145
|
+
|
|
146
|
+
describe('does not use eval() or new Function()', () => {
|
|
147
|
+
it('evaluates comparison operators without dynamic code execution', () => {
|
|
148
|
+
// This is the exact expression from the bug report.
|
|
149
|
+
expect(
|
|
150
|
+
parser.evaluate(
|
|
151
|
+
"stage !== 'closed_won' && stage !== 'closed_lost'",
|
|
152
|
+
{ stage: 'open' }
|
|
153
|
+
)
|
|
154
|
+
).toBe(true);
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
parser.evaluate(
|
|
158
|
+
"stage !== 'closed_won' && stage !== 'closed_lost'",
|
|
159
|
+
{ stage: 'closed_won' }
|
|
160
|
+
)
|
|
161
|
+
).toBe(false);
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
parser.evaluate(
|
|
165
|
+
"stage !== 'closed_won' && stage !== 'closed_lost'",
|
|
166
|
+
{ stage: 'closed_lost' }
|
|
167
|
+
)
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('evaluates strict equality operators (===, !==)', () => {
|
|
172
|
+
expect(parser.evaluate("status === 'active'", { status: 'active' })).toBe(true);
|
|
173
|
+
expect(parser.evaluate("status === 'active'", { status: 'inactive' })).toBe(false);
|
|
174
|
+
expect(parser.evaluate("status !== 'active'", { status: 'inactive' })).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('evaluates loose equality operators (==, !=)', () => {
|
|
178
|
+
expect(parser.evaluate('count == 0', { count: 0 })).toBe(true);
|
|
179
|
+
expect(parser.evaluate('count != 0', { count: 1 })).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('evaluates relational operators (>, <, >=, <=)', () => {
|
|
183
|
+
expect(parser.evaluate('score >= 90', { score: 95 })).toBe(true);
|
|
184
|
+
expect(parser.evaluate('score >= 90', { score: 85 })).toBe(false);
|
|
185
|
+
expect(parser.evaluate('score <= 90', { score: 85 })).toBe(true);
|
|
186
|
+
expect(parser.evaluate('score > 50 && score < 100', { score: 75 })).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('evaluates logical operators (&&, ||, !)', () => {
|
|
190
|
+
expect(parser.evaluate('isAdmin && isActive', { isAdmin: true, isActive: true })).toBe(true);
|
|
191
|
+
expect(parser.evaluate('isAdmin && isActive', { isAdmin: true, isActive: false })).toBe(false);
|
|
192
|
+
expect(parser.evaluate('isAdmin || isGuest', { isAdmin: false, isGuest: true })).toBe(true);
|
|
193
|
+
expect(parser.evaluate('!isSuspended', { isSuspended: false })).toBe(true);
|
|
194
|
+
expect(parser.evaluate('!isSuspended', { isSuspended: true })).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('evaluates ternary expressions', () => {
|
|
198
|
+
expect(parser.evaluate("score >= 90 ? 'A' : 'B'", { score: 95 })).toBe('A');
|
|
199
|
+
expect(parser.evaluate("score >= 90 ? 'A' : 'B'", { score: 75 })).toBe('B');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('evaluates nested ternary expressions', () => {
|
|
203
|
+
const expr = "status === 'active' ? 'success' : status === 'pending' ? 'warning' : 'default'";
|
|
204
|
+
expect(parser.evaluate(expr, { status: 'active' })).toBe('success');
|
|
205
|
+
expect(parser.evaluate(expr, { status: 'pending' })).toBe('warning');
|
|
206
|
+
expect(parser.evaluate(expr, { status: 'closed' })).toBe('default');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('evaluates arithmetic operators', () => {
|
|
210
|
+
expect(parser.evaluate('price * quantity', { price: 10, quantity: 5 })).toBe(50);
|
|
211
|
+
expect(parser.evaluate('total - discount', { total: 100, discount: 15 })).toBe(85);
|
|
212
|
+
expect(parser.evaluate('total / count', { total: 60, count: 3 })).toBe(20);
|
|
213
|
+
expect(parser.evaluate('value % 3', { value: 10 })).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('evaluates parenthesized expressions', () => {
|
|
217
|
+
expect(parser.evaluate('(a + b) * c', { a: 2, b: 3, c: 4 })).toBe(20);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('evaluates unary operators', () => {
|
|
221
|
+
expect(parser.evaluate('-amount', { amount: 5 })).toBe(-5);
|
|
222
|
+
expect(parser.evaluate('+amount', { amount: '42' })).toBe(42);
|
|
223
|
+
expect(parser.evaluate('!flag', { flag: false })).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('evaluates typeof operator', () => {
|
|
227
|
+
expect(parser.evaluate('typeof name', { name: 'Alice' })).toBe('string');
|
|
228
|
+
expect(parser.evaluate('typeof count', { count: 42 })).toBe('number');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('property and method access', () => {
|
|
233
|
+
it('evaluates dot notation property access', () => {
|
|
234
|
+
expect(parser.evaluate('user.name', { user: { name: 'Alice' } })).toBe('Alice');
|
|
235
|
+
expect(parser.evaluate('user.address.city', {
|
|
236
|
+
user: { address: { city: 'London' } }
|
|
237
|
+
})).toBe('London');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('evaluates bracket notation property access', () => {
|
|
241
|
+
expect(parser.evaluate("record['status']", { record: { status: 'active' } })).toBe('active');
|
|
242
|
+
expect(parser.evaluate('arr[0]', { arr: ['first', 'second'] })).toBe('first');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('evaluates optional chaining (?.) gracefully', () => {
|
|
246
|
+
expect(parser.evaluate('user?.address?.city', { user: null })).toBeUndefined();
|
|
247
|
+
expect(parser.evaluate('user?.address?.city', { user: { address: { city: 'NYC' } } })).toBe('NYC');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('evaluates string method calls', () => {
|
|
251
|
+
expect(parser.evaluate('name.toUpperCase()', { name: 'hello' })).toBe('HELLO');
|
|
252
|
+
expect(parser.evaluate('name.toLowerCase()', { name: 'WORLD' })).toBe('world');
|
|
253
|
+
expect(parser.evaluate('name.trim()', { name: ' hi ' })).toBe('hi');
|
|
254
|
+
expect(parser.evaluate("name.includes('ell')", { name: 'hello' })).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('evaluates array methods with arrow functions', () => {
|
|
258
|
+
const items = [
|
|
259
|
+
{ name: 'apple', price: 1.5, active: true },
|
|
260
|
+
{ name: 'banana', price: 0.75, active: false },
|
|
261
|
+
{ name: 'cherry', price: 3.0, active: true },
|
|
262
|
+
];
|
|
263
|
+
expect(
|
|
264
|
+
parser.evaluate('items.filter(i => i.active).length', { items })
|
|
265
|
+
).toBe(2);
|
|
266
|
+
expect(
|
|
267
|
+
parser.evaluate('items.map(i => i.name).join(", ")', { items })
|
|
268
|
+
).toBe('apple, banana, cherry');
|
|
269
|
+
expect(
|
|
270
|
+
parser.evaluate('items.filter(i => i.price > 1).length', { items })
|
|
271
|
+
).toBe(2);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('evaluates chained array method calls', () => {
|
|
275
|
+
const users = [
|
|
276
|
+
{ name: 'Alice', isActive: true },
|
|
277
|
+
{ name: 'Bob', isActive: false },
|
|
278
|
+
{ name: 'Carol', isActive: true },
|
|
279
|
+
];
|
|
280
|
+
expect(
|
|
281
|
+
parser.evaluate('users.filter(u => u.isActive).map(u => u.name).join(", ")', { users })
|
|
282
|
+
).toBe('Alice, Carol');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('evaluates array .length property', () => {
|
|
286
|
+
expect(parser.evaluate('items.length === 0', { items: [] })).toBe(true);
|
|
287
|
+
expect(parser.evaluate('items.length', { items: [1, 2, 3] })).toBe(3);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('evaluates number method calls', () => {
|
|
291
|
+
expect(parser.evaluate('price.toFixed(2)', { price: 3.14159 })).toBe('3.14');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('evaluates Math global functions', () => {
|
|
295
|
+
expect(parser.evaluate('Math.round(value)', { value: 3.7 })).toBe(4);
|
|
296
|
+
expect(parser.evaluate('Math.floor(value)', { value: 3.9 })).toBe(3);
|
|
297
|
+
expect(parser.evaluate('Math.abs(value)', { value: -5 })).toBe(5);
|
|
298
|
+
expect(parser.evaluate('Math.max(a, b)', { a: 10, b: 7 })).toBe(10);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('literals', () => {
|
|
303
|
+
it('evaluates boolean literals', () => {
|
|
304
|
+
expect(parser.evaluate('true', {})).toBe(true);
|
|
305
|
+
expect(parser.evaluate('false', {})).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('evaluates null and undefined literals', () => {
|
|
309
|
+
expect(parser.evaluate('null', {})).toBeNull();
|
|
310
|
+
expect(parser.evaluate('undefined', {})).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('evaluates numeric literals', () => {
|
|
314
|
+
expect(parser.evaluate('42', {})).toBe(42);
|
|
315
|
+
expect(parser.evaluate('3.14', {})).toBeCloseTo(3.14);
|
|
316
|
+
expect(parser.evaluate('1e3', {})).toBe(1000);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('evaluates string literals with single and double quotes', () => {
|
|
320
|
+
expect(parser.evaluate("'hello'", {})).toBe('hello');
|
|
321
|
+
expect(parser.evaluate('"world"', {})).toBe('world');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('evaluates string escape sequences', () => {
|
|
325
|
+
expect(parser.evaluate("'line1\\nline2'", {})).toBe('line1\nline2');
|
|
326
|
+
expect(parser.evaluate("'tab\\there'", {})).toBe('tab\there');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('evaluates array literals', () => {
|
|
330
|
+
expect(parser.evaluate('[1, 2, 3]', {})).toEqual([1, 2, 3]);
|
|
331
|
+
expect(parser.evaluate("['a', 'b']", {})).toEqual(['a', 'b']);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('evaluates NaN and Infinity literals', () => {
|
|
335
|
+
expect(parser.evaluate('Infinity', {})).toBe(Infinity);
|
|
336
|
+
expect(Number.isNaN(parser.evaluate('NaN', {}) as number)).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('nullish coalescing', () => {
|
|
341
|
+
it('evaluates ?? operator', () => {
|
|
342
|
+
expect(parser.evaluate('value ?? "default"', { value: null })).toBe('default');
|
|
343
|
+
expect(parser.evaluate('value ?? "default"', { value: undefined })).toBe('default');
|
|
344
|
+
expect(parser.evaluate('value ?? "default"', { value: 0 })).toBe(0);
|
|
345
|
+
expect(parser.evaluate('value ?? "default"', { value: '' })).toBe('');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('short-circuit evaluation', () => {
|
|
350
|
+
it('|| does not evaluate RHS when LHS is truthy', () => {
|
|
351
|
+
// 'missingVar' is not in context — would throw ReferenceError without short-circuit.
|
|
352
|
+
expect(parser.evaluate('true || missingVar', {})).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('&& does not evaluate RHS when LHS is falsy', () => {
|
|
356
|
+
expect(parser.evaluate('false && missingVar', {})).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('?? does not evaluate RHS when LHS is not nullish', () => {
|
|
360
|
+
expect(parser.evaluate('"present" ?? missingVar', {})).toBe('present');
|
|
361
|
+
expect(parser.evaluate('0 ?? missingVar', {})).toBe(0);
|
|
362
|
+
expect(parser.evaluate('"" ?? missingVar', {})).toBe('');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('?? DOES evaluate RHS when LHS is null/undefined', () => {
|
|
366
|
+
expect(parser.evaluate('null ?? "fallback"', {})).toBe('fallback');
|
|
367
|
+
expect(parser.evaluate('undefined ?? "fallback"', {})).toBe('fallback');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('ternary true branch: does not evaluate false branch', () => {
|
|
371
|
+
expect(parser.evaluate("true ? 'yes' : missingVar", {})).toBe('yes');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('ternary false branch: does not evaluate true branch', () => {
|
|
375
|
+
expect(parser.evaluate("false ? missingVar : 'no'", {})).toBe('no');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('nested ternary short-circuits correctly', () => {
|
|
379
|
+
const expr = "status === 'a' ? 'alpha' : status === 'b' ? 'beta' : 'other'";
|
|
380
|
+
expect(parser.evaluate(expr, { status: 'a' })).toBe('alpha');
|
|
381
|
+
expect(parser.evaluate(expr, { status: 'b' })).toBe('beta');
|
|
382
|
+
expect(parser.evaluate(expr, { status: 'c' })).toBe('other');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('sandbox security', () => {
|
|
387
|
+
it('blocks constructor property access via dot notation', () => {
|
|
388
|
+
expect(() =>
|
|
389
|
+
parser.evaluate('name.constructor', { name: 'hello' })
|
|
390
|
+
).toThrow(TypeError);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('blocks constructor property access via bracket notation', () => {
|
|
394
|
+
expect(() =>
|
|
395
|
+
parser.evaluate("name['constructor']", { name: 'hello' })
|
|
396
|
+
).toThrow(TypeError);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('blocks __proto__ access', () => {
|
|
400
|
+
expect(() =>
|
|
401
|
+
parser.evaluate('obj.__proto__', { obj: {} })
|
|
402
|
+
).toThrow(TypeError);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('blocks prototype access', () => {
|
|
406
|
+
expect(() =>
|
|
407
|
+
parser.evaluate('fn.prototype', { fn: () => {} })
|
|
408
|
+
).toThrow(TypeError);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('blocks constructor method calls', () => {
|
|
412
|
+
expect(() =>
|
|
413
|
+
parser.evaluate("name['constructor']('return 1')()", { name: 'hello' })
|
|
414
|
+
).toThrow(TypeError);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('does not expose String/Number/Boolean/Array globals (removed to prevent .constructor escape)', () => {
|
|
418
|
+
expect(() => parser.evaluate('String', {})).toThrow(ReferenceError);
|
|
419
|
+
expect(() => parser.evaluate('Number', {})).toThrow(ReferenceError);
|
|
420
|
+
expect(() => parser.evaluate('Boolean', {})).toThrow(ReferenceError);
|
|
421
|
+
expect(() => parser.evaluate('Array', {})).toThrow(ReferenceError);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('error handling', () => {
|
|
426
|
+
it('throws ReferenceError for undefined identifiers', () => {
|
|
427
|
+
expect(() => parser.evaluate('nonExistentVar', {})).toThrow(ReferenceError);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('returns undefined gracefully for missing property access (no throw)', () => {
|
|
431
|
+
expect(parser.evaluate('user.missingProp', { user: {} })).toBeUndefined();
|
|
432
|
+
expect(parser.evaluate('user.address.city', { user: {} })).toBeUndefined();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('throws SyntaxError for unclosed parentheses', () => {
|
|
436
|
+
// Use a valid inner expression so the error is about the missing ')' not the content.
|
|
437
|
+
expect(() => parser.evaluate('(1 + 2', {})).toThrow(SyntaxError);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('throws SyntaxError for unclosed array literal', () => {
|
|
441
|
+
expect(() => parser.evaluate('[1, 2', {})).toThrow(SyntaxError);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('throws SyntaxError for malformed numeric exponent (e.g. 1e)', () => {
|
|
445
|
+
// '1e' has no exponent digits — the stricter parser rejects it.
|
|
446
|
+
expect(() => parser.evaluate('1e', {})).toThrow(SyntaxError);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe('ExpressionEvaluator — CSP safety integration', () => {
|
|
452
|
+
it('evaluates the bug-report expression without CSP violation', () => {
|
|
453
|
+
// Exact expression from the bug report that was blocked by CSP in production.
|
|
454
|
+
const evaluator = new ExpressionEvaluator({ stage: 'open' });
|
|
455
|
+
expect(
|
|
456
|
+
evaluator.evaluate("${stage !== 'closed_won' && stage !== 'closed_lost'}")
|
|
457
|
+
).toBe(true);
|
|
458
|
+
|
|
459
|
+
const evaluator2 = new ExpressionEvaluator({ stage: 'closed_won' });
|
|
460
|
+
expect(
|
|
461
|
+
evaluator2.evaluate("${stage !== 'closed_won' && stage !== 'closed_lost'}")
|
|
462
|
+
).toBe(false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('supports all comparison operators via the safe parser', () => {
|
|
466
|
+
const e = new ExpressionEvaluator({ a: 10, b: 10, c: 5 });
|
|
467
|
+
expect(e.evaluateExpression('a === b')).toBe(true);
|
|
468
|
+
expect(e.evaluateExpression('a !== c')).toBe(true);
|
|
469
|
+
expect(e.evaluateExpression('a > c')).toBe(true);
|
|
470
|
+
expect(e.evaluateExpression('c < a')).toBe(true);
|
|
471
|
+
expect(e.evaluateExpression('a >= b')).toBe(true);
|
|
472
|
+
expect(e.evaluateExpression('c <= a')).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('supports logical operators via the safe parser', () => {
|
|
476
|
+
const e = new ExpressionEvaluator({ x: true, y: false });
|
|
477
|
+
expect(e.evaluateExpression('x && !y')).toBe(true);
|
|
478
|
+
expect(e.evaluateExpression('x || y')).toBe(true);
|
|
479
|
+
expect(e.evaluateExpression('!x || !y')).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('supports ternary expressions via the safe parser', () => {
|
|
483
|
+
const e = new ExpressionEvaluator({ score: 95 });
|
|
484
|
+
expect(e.evaluateExpression("score >= 90 ? 'A' : 'B'")).toBe('A');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('supports property access via the safe parser', () => {
|
|
488
|
+
const e = new ExpressionEvaluator({ user: { role: 'admin', isActive: true } });
|
|
489
|
+
expect(e.evaluateExpression("user.role === 'admin'")).toBe(true);
|
|
490
|
+
expect(e.evaluateExpression('user.isActive')).toBe(true);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('supports arithmetic via the safe parser', () => {
|
|
494
|
+
const e = new ExpressionEvaluator({ price: 10, qty: 5 });
|
|
495
|
+
expect(e.evaluateExpression('price * qty')).toBe(50);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('supports arrow-function array methods via the safe parser', () => {
|
|
499
|
+
const e = new ExpressionEvaluator({
|
|
500
|
+
items: [{ price: 50 }, { price: 150 }, { price: 200 }],
|
|
501
|
+
});
|
|
502
|
+
expect(e.evaluateExpression('items.filter(item => item.price > 100).length')).toBe(2);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('supports formula functions via the safe parser', () => {
|
|
506
|
+
const e = new ExpressionEvaluator({ values: [10, 20, 30] });
|
|
507
|
+
expect(e.evaluateExpression('SUM(values)')).toBe(60);
|
|
508
|
+
expect(e.evaluateExpression('AVG(values)')).toBe(20);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('supports Math global via the safe parser', () => {
|
|
512
|
+
const e = new ExpressionEvaluator({ value: 3.7 });
|
|
513
|
+
expect(e.evaluateExpression('Math.round(value)')).toBe(4);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('does not use eval() or new Function() during evaluation', () => {
|
|
517
|
+
// Spy on both to ensure they are NEVER called (via construct OR apply).
|
|
518
|
+
const originalEval = globalThis.eval;
|
|
519
|
+
const originalFunction = Function;
|
|
520
|
+
const evalCalls: string[] = [];
|
|
521
|
+
const functionCalls: string[] = [];
|
|
522
|
+
|
|
523
|
+
globalThis.eval = (...args: Parameters<typeof eval>) => {
|
|
524
|
+
evalCalls.push(String(args[0]));
|
|
525
|
+
return originalEval(...args);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const FunctionProxy = new Proxy(Function, {
|
|
529
|
+
construct(target, args) {
|
|
530
|
+
functionCalls.push(`new Function(${String(args)})`);
|
|
531
|
+
return Reflect.construct(target, args);
|
|
532
|
+
},
|
|
533
|
+
apply(target, thisArg, args) {
|
|
534
|
+
// Catches indirect calls like: Function('return 1')() or String['constructor']('...')
|
|
535
|
+
functionCalls.push(`Function(${String(args)})`);
|
|
536
|
+
return Reflect.apply(target, thisArg, args);
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
(globalThis as any).Function = FunctionProxy;
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
const e = new ExpressionEvaluator({
|
|
543
|
+
stage: 'open',
|
|
544
|
+
data: { amount: 1500 },
|
|
545
|
+
items: [{ active: true }, { active: false }],
|
|
546
|
+
});
|
|
547
|
+
e.evaluate("${stage !== 'closed_won' && stage !== 'closed_lost'}");
|
|
548
|
+
e.evaluateExpression('data.amount > 1000');
|
|
549
|
+
e.evaluateExpression('items.filter(i => i.active).length');
|
|
550
|
+
|
|
551
|
+
expect(evalCalls).toHaveLength(0);
|
|
552
|
+
expect(functionCalls).toHaveLength(0);
|
|
553
|
+
} finally {
|
|
554
|
+
globalThis.eval = originalEval;
|
|
555
|
+
(globalThis as any).Function = originalFunction;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
package/src/evaluator/index.ts
CHANGED
|
@@ -95,18 +95,10 @@ export function resolveDndConfig(config: DndConfig): ResolvedDndConfig {
|
|
|
95
95
|
* @returns Component props object for a draggable element
|
|
96
96
|
*/
|
|
97
97
|
export function createDragItemProps(item: DragItem): DragItemProps {
|
|
98
|
-
const ariaLabel = typeof item.ariaLabel === 'string'
|
|
99
|
-
? item.ariaLabel
|
|
100
|
-
: item.ariaLabel?.defaultValue;
|
|
101
|
-
|
|
102
|
-
const label = typeof item.label === 'string'
|
|
103
|
-
? item.label
|
|
104
|
-
: item.label?.defaultValue;
|
|
105
|
-
|
|
106
98
|
return {
|
|
107
99
|
draggable: !(item.disabled ?? false),
|
|
108
100
|
'aria-roledescription': 'draggable',
|
|
109
|
-
'aria-label': ariaLabel ?? label,
|
|
101
|
+
'aria-label': item.ariaLabel ?? item.label,
|
|
110
102
|
'aria-describedby': item.ariaDescribedBy,
|
|
111
103
|
role: item.role ?? 'listitem',
|
|
112
104
|
'data-drag-type': item.type,
|
|
@@ -127,17 +119,9 @@ export function createDragItemProps(item: DragItem): DragItemProps {
|
|
|
127
119
|
* @returns Component props object for a droppable area
|
|
128
120
|
*/
|
|
129
121
|
export function createDropZoneProps(zone: DropZone): DropZoneProps {
|
|
130
|
-
const ariaLabel = typeof zone.ariaLabel === 'string'
|
|
131
|
-
? zone.ariaLabel
|
|
132
|
-
: zone.ariaLabel?.defaultValue;
|
|
133
|
-
|
|
134
|
-
const label = typeof zone.label === 'string'
|
|
135
|
-
? zone.label
|
|
136
|
-
: zone.label?.defaultValue;
|
|
137
|
-
|
|
138
122
|
return {
|
|
139
123
|
'aria-dropeffect': zone.dropEffect ?? 'move',
|
|
140
|
-
'aria-label': ariaLabel ?? label,
|
|
124
|
+
'aria-label': zone.ariaLabel ?? zone.label,
|
|
141
125
|
'aria-describedby': zone.ariaDescribedBy,
|
|
142
126
|
role: zone.role ?? 'list',
|
|
143
127
|
'data-drop-accept': zone.accept.join(','),
|
|
@@ -83,15 +83,11 @@ export interface KeyboardEventLike {
|
|
|
83
83
|
* @returns Fully resolved keyboard navigation configuration
|
|
84
84
|
*/
|
|
85
85
|
export function resolveKeyboardConfig(config: KeyboardNavigationConfig): ResolvedKeyboardConfig {
|
|
86
|
-
const ariaLabel = typeof config.ariaLabel === 'string'
|
|
87
|
-
? config.ariaLabel
|
|
88
|
-
: config.ariaLabel?.defaultValue;
|
|
89
|
-
|
|
90
86
|
return {
|
|
91
87
|
shortcuts: config.shortcuts ?? [],
|
|
92
88
|
focusManagement: resolveFocusManagement(config.focusManagement),
|
|
93
89
|
rovingTabindex: config.rovingTabindex ?? false,
|
|
94
|
-
ariaLabel,
|
|
90
|
+
ariaLabel: config.ariaLabel,
|
|
95
91
|
ariaDescribedBy: config.ariaDescribedBy,
|
|
96
92
|
role: config.role,
|
|
97
93
|
};
|
|
@@ -125,15 +125,6 @@ export function resolveNotificationConfig(config: NotificationConfig): ResolvedN
|
|
|
125
125
|
// Spec Notification → Toast
|
|
126
126
|
// ============================================================================
|
|
127
127
|
|
|
128
|
-
/**
|
|
129
|
-
* Extract the display string from a translatable value (string or Translation object).
|
|
130
|
-
*/
|
|
131
|
-
function resolveTranslatableString(value: string | { key: string; defaultValue?: string } | undefined): string | undefined {
|
|
132
|
-
if (value === undefined) return undefined;
|
|
133
|
-
if (typeof value === 'string') return value;
|
|
134
|
-
return value.defaultValue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
128
|
/**
|
|
138
129
|
* Convert a spec Notification to a toast-compatible object.
|
|
139
130
|
*
|
|
@@ -142,14 +133,14 @@ function resolveTranslatableString(value: string | { key: string; defaultValue?:
|
|
|
142
133
|
*/
|
|
143
134
|
export function specNotificationToToast(notification: SpecNotification): ToastNotification {
|
|
144
135
|
const actions: ToastAction[] = (notification.actions ?? []).map((a: NotificationAction) => ({
|
|
145
|
-
label:
|
|
136
|
+
label: a.label,
|
|
146
137
|
action: a.action,
|
|
147
138
|
variant: a.variant ?? 'primary',
|
|
148
139
|
}));
|
|
149
140
|
|
|
150
141
|
return {
|
|
151
|
-
title:
|
|
152
|
-
description:
|
|
142
|
+
title: notification.title,
|
|
143
|
+
description: notification.message ?? '',
|
|
153
144
|
variant: mapSeverityToVariant(notification.severity ?? 'info'),
|
|
154
145
|
position: mapPosition(notification.position ?? 'top_right'),
|
|
155
146
|
duration: notification.duration ?? 5000,
|
package/src/utils/debug.ts
CHANGED
|
@@ -93,7 +93,8 @@ export function isDebugEnabled(): boolean {
|
|
|
93
93
|
if (g === true || g === 'true') return true;
|
|
94
94
|
|
|
95
95
|
// 3. process.env
|
|
96
|
-
|
|
96
|
+
const proc = (globalThis as any).process;
|
|
97
|
+
if (proc?.env?.OBJECTUI_DEBUG === 'true') return true;
|
|
97
98
|
|
|
98
99
|
return false;
|
|
99
100
|
} catch {
|