Blame Raw
· · · 975 lines (36.2 KB)
0 contributors
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4/** @typedef {import('chevrotain').CstNode} CstNode */
5/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
6
7/** @typedef {{[tokenName: string]: (string|string[]|TestableCstNode|TestableCstNode[])}} TestableCstNode */
8/** @typedef {{name: string, message: string}} TestableRecognitionException */
9
10const DEFAULT_FLATTEN_KEYS = [
11 'arguments.Args.DoubleColon',
12];
13const DEFAULT_IGNORE_KEYS = [
14
15];
16
17test.describe('MacroParser', () => {
18 // Currently this test suits runs without ST context. Enable, if ever needed
19 test.beforeEach(testSetup.goST);
20
21 test.describe('General Macro', () => {
22 // {{user}}
23 test('should parse a simple macro', async ({ page }) => {
24 const input = '{{user}}';
25 const macroCst = await runParser(page, input);
26
27 const expectedCst = {
28 'Macro.Start': '{{',
29 'Macro.identifier': 'user',
30 'Macro.End': '}}',
31 };
32
33 expect(macroCst).toEqual(expectedCst);
34 });
35 // {{ user }}
36 test('should generally handle whitespaces', async ({ page }) => {
37 const input = '{{ user }}';
38 const macroCst = await runParser(page, input);
39
40 const expectedCst = {
41 'Macro.Start': '{{',
42 'Macro.identifier': 'user',
43 'Macro.End': '}}',
44 };
45
46 expect(macroCst).toEqual(expectedCst);
47 });
48
49 test.describe('Error Cases (General Macro)', () => {
50 // {{}}
51 test('[Error] should throw an error for empty macro', async ({ page }) => {
52 const input = '{{}}';
53 const { macroCst, errors } = await runParserAndGetErrors(page, input);
54
55 const expectedErrors = [
56 { name: 'NoViableAltException' },
57 ];
58 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '}}'/gs;
59
60 expect(macroCst).toBeUndefined();
61 expect(errors).toMatchObject(expectedErrors);
62 expect(errors[0].message).toMatch(expectedMessage);
63 });
64 // {{§%€blah}}
65 test('[Error] should throw an error for invalid identifier', async ({ page }) => {
66 const input = '{{§%€blah}}';
67 const { macroCst, errors } = await runParserAndGetErrors(page, input);
68
69 const expectedErrors = [
70 { name: 'NoViableAltException' },
71 ];
72 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '§%€blah}}'/gs;
73
74 expect(macroCst).toBeUndefined();
75 expect(errors).toMatchObject(expectedErrors);
76 expect(errors[0].message).toMatch(expectedMessage);
77 });
78 // {{user
79 test('[Error] should throw an error for incomplete macro', async ({ page }) => {
80 const input = '{{user';
81 const { macroCst, errors } = await runParserAndGetErrors(page, input);
82
83 const expectedErrors = [
84 { name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.End <-- but found --> \'\' <--' },
85 ];
86
87 expect(macroCst).toBeUndefined();
88 expect(errors).toEqual(expectedErrors);
89 });
90
91 // something{{user}}
92 test('[Error] for testing purposes, macros need to start at the beginning of the string', async ({ page }) => {
93 const input = 'something{{user}}';
94 const { macroCst, errors } = await runParserAndGetErrors(page, input);
95
96 const expectedErrors = [
97 { name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.Start <-- but found --> \'something\' <--' },
98 ];
99
100 expect(macroCst).toBeUndefined();
101 expect(errors).toEqual(expectedErrors);
102 });
103 });
104 });
105
106 test.describe('Arguments Handling', () => {
107 // {{getvar::myvar}}
108 test('should parse macros with double-colon argument', async ({ page }) => {
109 const input = '{{getvar::myvar}}';
110 const macroCst = await runParser(page, input, {
111 flattenKeys: ['arguments.argument'],
112 });
113 expect(macroCst).toEqual({
114 'Macro.Start': '{{',
115 'Macro.identifier': 'getvar',
116 'arguments': {
117 'separator': '::',
118 'argument': 'myvar',
119 },
120 'Macro.End': '}}',
121 });
122 });
123
124 // {{roll:3d20}}
125 test('should parse macros with single colon argument', async ({ page }) => {
126 const input = '{{roll:3d20}}';
127 const macroCst = await runParser(page, input, {
128 flattenKeys: ['arguments.argument'],
129 });
130 expect(macroCst).toEqual({
131 'Macro.Start': '{{',
132 'Macro.identifier': 'roll',
133 'arguments': {
134 'separator': ':',
135 'argument': '3d20',
136 },
137 'Macro.End': '}}',
138 });
139 });
140
141 // {{setvar::myvar::value}}
142 test('should parse macros with multiple double-colon arguments', async ({ page }) => {
143 const input = '{{setvar::myvar::value}}';
144 const macroCst = await runParser(page, input, {
145 flattenKeys: ['arguments.argument'],
146 ignoreKeys: ['arguments.Args.DoubleColon'],
147 });
148 expect(macroCst).toEqual({
149 'Macro.Start': '{{',
150 'Macro.identifier': 'setvar',
151 'arguments': {
152 'separator': '::',
153 'argument': ['myvar', 'value'],
154 },
155 'Macro.End': '}}',
156 });
157 });
158
159 // {{something:: spaced }}
160 test('should strip spaces around arguments', async ({ page }) => {
161 const input = '{{something:: spaced }}';
162 const macroCst = await runParser(page, input, {
163 flattenKeys: ['arguments.argument'],
164 ignoreKeys: ['arguments.separator', 'arguments.Args.DoubleColon'],
165 });
166 expect(macroCst).toEqual({
167 'Macro.Start': '{{',
168 'Macro.identifier': 'something',
169 'arguments': { 'argument': 'spaced' },
170 'Macro.End': '}}',
171 });
172 });
173
174 // {{something::with:single:colons}}
175 test('should treat single colons as part of the argument with double-colon separator', async ({ page }) => {
176 const input = '{{something::with:single:colons}}';
177 const macroCst = await runParser(page, input, {
178 flattenKeys: ['arguments.argument'],
179 ignoreKeys: ['arguments.Args.DoubleColon'],
180 });
181 expect(macroCst).toEqual({
182 'Macro.Start': '{{',
183 'Macro.identifier': 'something',
184 'arguments': {
185 'separator': '::',
186 'argument': 'with:single:colons',
187 },
188 'Macro.End': '}}',
189 });
190 });
191
192 // {{legacy:something:else}}
193 test('should treat single colons as part of the argument even with colon separator', async ({ page }) => {
194 const input = '{{legacy:something:else}}';
195 const macroCst = await runParser(page, input, {
196 flattenKeys: ['arguments.argument'],
197 ignoreKeys: ['arguments.separator', 'arguments.Args.Colon'],
198 });
199 expect(macroCst).toEqual({
200 'Macro.Start': '{{',
201 'Macro.identifier': 'legacy',
202 'arguments': { 'argument': 'something:else' },
203 'Macro.End': '}}',
204 });
205 });
206
207 // {{something::}}
208 test('should parse double-colon with an empty argument value', async ({ page }) => {
209 const input = '{{something::}}';
210 const macroCst = await runParser(page, input, {
211 flattenKeys: ['arguments.argument'],
212 });
213
214 expect(macroCst).toEqual({
215 'Macro.Start': '{{',
216 'Macro.identifier': 'something',
217 'arguments': {
218 'separator': '::',
219 'argument': '',
220 },
221 'Macro.End': '}}',
222 });
223 });
224
225 });
226
227 test.describe('Legacy Macros', () => {
228 // {{roll 1d5}}
229 test('should parse legacy roll macro with whitespace separator', async ({ page }) => {
230 const input = '{{roll 1d5}}';
231 const macroCst = await runParser(page, input, {
232 flattenKeys: ['arguments.argument'],
233 });
234
235 expect(macroCst).toEqual({
236 'Macro.Start': '{{',
237 'Macro.identifier': 'roll',
238 'arguments': { 'argument': '1d5' },
239 'Macro.End': '}}',
240 });
241 });
242
243 // {{roll:2d20}}
244 test('should parse legacy roll macro with explicit colon separator', async ({ page }) => {
245 const input = '{{roll:2d20}}';
246 const macroCst = await runParser(page, input, {
247 flattenKeys: ['arguments.argument'],
248 });
249
250 expect(macroCst).toEqual({
251 'Macro.Start': '{{',
252 'Macro.identifier': 'roll',
253 'arguments': {
254 'separator': ':',
255 'argument': '2d20',
256 },
257 'Macro.End': '}}',
258 });
259 });
260
261 // {{roll 20}}
262 test('should parse legacy roll macro with numeric argument', async ({ page }) => {
263 const input = '{{roll 20}}';
264 const macroCst = await runParser(page, input, {
265 flattenKeys: ['arguments.argument'],
266 });
267
268 expect(macroCst).toEqual({
269 'Macro.Start': '{{',
270 'Macro.identifier': 'roll',
271 'arguments': { 'argument': '20' },
272 'Macro.End': '}}',
273 });
274 });
275
276 // {{reverse:something}}
277 test('should parse reverse legacy macro with colon argument', async ({ page }) => {
278 const input = '{{reverse:something}}';
279 const macroCst = await runParser(page, input, {
280 flattenKeys: ['arguments.argument'],
281 });
282
283 expect(macroCst).toEqual({
284 'Macro.Start': '{{',
285 'Macro.identifier': 'reverse',
286 'arguments': {
287 'separator': ':',
288 'argument': 'something',
289 },
290 'Macro.End': '}}',
291 });
292 });
293
294 // {{reverse:this contains::double::colons}}
295 test('should parse legacy single colon argument that allows double colons inside the argument', async ({ page }) => {
296 const input = '{{reverse:this contains::double::colons}}';
297 const macroCst = await runParser(page, input, {
298 flattenKeys: ['arguments.argument'],
299 });
300
301 expect(macroCst).toEqual({
302 'Macro.Start': '{{',
303 'Macro.identifier': 'reverse',
304 'arguments': {
305 'separator': ':',
306 'argument': 'this contains::double::colons',
307 },
308 'Macro.End': '}}',
309 });
310 });
311
312 // {{//comment-style macro}}
313 // TODO: Comment like // is not a valid identifier, needs to be an exception (until we maybe add flags)
314 test('should parse legacy comment macro', async ({ page }) => {
315 const input = '{{//comment-style macro}}';
316 const macroCst = await runParser(page, input, {
317 flattenKeys: ['arguments.argument'],
318 });
319
320 expect(macroCst).toEqual({
321 'Macro.Start': '{{',
322 'Macro.identifier': '//',
323 'arguments': { 'argument': 'comment-style macro' },
324 'Macro.End': '}}',
325 });
326 });
327
328 // {{datetimeformat HH:mm}}
329 test('should parse legacy datetime format macro', async ({ page }) => {
330 const input = '{{datetimeformat HH:mm}}';
331 const macroCst = await runParser(page, input, {
332 flattenKeys: ['arguments.argument'],
333 });
334
335 expect(macroCst).toEqual({
336 'Macro.Start': '{{',
337 'Macro.identifier': 'datetimeformat',
338 'arguments': { 'argument': 'HH:mm' },
339 'Macro.End': '}}',
340 });
341 });
342
343 // Note: Legacy time macros like {{time_UTC+2}} are now handled by the MacroEngine
344 // pre-processing pipeline instead of the parser. See MacroEngine.e2e tests for coverage.
345
346 // {{banned "abannedword"}}
347 test('should parse legacy banned macro with quoted argument', async ({ page }) => {
348 const input = '{{banned "abannedword"}}';
349 const macroCst = await runParser(page, input, {
350 flattenKeys: ['arguments.argument'],
351 });
352
353 expect(macroCst).toEqual({
354 'Macro.Start': '{{',
355 'Macro.identifier': 'banned',
356 'arguments': { 'argument': '"abannedword"' },
357 'Macro.End': '}}',
358 });
359 });
360
361 // {{banned ""}}
362 test('should parse legacy macro with empty quoted argument', async ({ page }) => {
363 const input = '{{banned ""}}';
364 const macroCst = await runParser(page, input, {
365 flattenKeys: ['arguments.argument'],
366 });
367
368 expect(macroCst).toEqual({
369 'Macro.Start': '{{',
370 'Macro.identifier': 'banned',
371 'arguments': { 'argument': '""' },
372 'Macro.End': '}}',
373 });
374 });
375
376 // {{setvar::myvar::}}
377 test('should allow legacy setvar with empty value argument', async ({ page }) => {
378 const input = '{{setvar::myvar::}}';
379 const macroCst = await runParser(page, input, {
380 flattenKeys: ['arguments.argument'],
381 });
382
383 expect(macroCst).toEqual({
384 'Macro.Start': '{{',
385 'Macro.identifier': 'setvar',
386 'arguments': {
387 'separator': '::',
388 'argument': ['myvar', ''],
389 },
390 'Macro.End': '}}',
391 });
392 });
393
394 });
395
396 test.describe('Comment Macros', () => {
397 // {{//comment}}
398 test('should parse comment macro without whitespace', async ({ page }) => {
399 const input = '{{//comment}}';
400 const macroCst = await runParser(page, input, {
401 flattenKeys: ['arguments.argument'],
402 });
403 expect(macroCst).toEqual({
404 'Macro.Start': '{{',
405 'Macro.identifier': '//',
406 'Macro.End': '}}',
407 'arguments': {
408 'argument': 'comment',
409 },
410 });
411 });
412
413 // {{// comment}}
414 test('should parse comment macro with whitespace', async ({ page }) => {
415 const input = '{{// comment}}';
416 const macroCst = await runParser(page, input, {
417 flattenKeys: ['arguments.argument'],
418 });
419 expect(macroCst).toEqual({
420 'Macro.Start': '{{',
421 'Macro.identifier': '//',
422 'Macro.End': '}}',
423 'arguments': {
424 'argument': 'comment',
425 },
426 });
427 });
428
429
430 // {{//!@#$%^&*()_+}}
431 test('should parse comment macro with special characters', async ({ page }) => {
432 const input = '{{//!@#$%^&*()_+}}';
433 const macroCst = await runParser(page, input, {
434 flattenKeys: ['arguments.argument'],
435 });
436 expect(macroCst).toEqual({
437 'Macro.Start': '{{',
438 'Macro.identifier': '//',
439 'Macro.End': '}}',
440 'arguments': {
441 'argument': '!@#$%^&*()_+',
442 },
443 });
444 });
445
446
447 // {{//!@flags}}
448 test('should parse comment macro starting with flags', async ({ page }) => {
449 const input = '{{//!@flags}}';
450 const macroCst = await runParser(page, input, {
451 flattenKeys: ['arguments.argument'],
452 });
453 expect(macroCst).toEqual({
454 'Macro.Start': '{{',
455 'Macro.identifier': '//',
456 'Macro.End': '}}',
457 'arguments': {
458 'argument': '!@flags',
459 },
460 });
461 });
462
463 // {{// This is a multiline comment.
464 // This is the second line
465 // }}
466 test('should parse multiline comments', async ({ page }) => {
467 const input = `{{// This is a multiline comment.
468This is the second line
469}}`;
470 const macroCst = await runParser(page, input, {
471 flattenKeys: ['arguments.argument'],
472 });
473 expect(macroCst).toEqual({
474 'Macro.Start': '{{',
475 'Macro.identifier': '//',
476 'Macro.End': '}}',
477 'arguments': {
478 'argument': 'This is a multiline comment.\nThis is the second line',
479 },
480 });
481 });
482
483
484 });
485
486 test.describe('Nested Macros', () => {
487 // {{outer::word {{inner}}}}
488 test('should parse nested macros inside arguments', async ({ page }) => {
489 const input = '{{outer::word {{inner}}}}';
490 const macroCst = await runParser(page, input, {});
491 expect(macroCst).toEqual({
492 'Macro.Start': '{{',
493 'Macro.identifier': 'outer',
494 'arguments': {
495 'argument': {
496 'Identifier': 'word',
497 'macro': {
498 'Macro.Start': '{{',
499 'Macro.identifier': 'inner',
500 'Macro.End': '}}',
501 },
502 },
503 'separator': '::',
504 },
505 'Macro.End': '}}',
506 });
507 });
508
509 // {{outer::word {{inner1}}{{inner2}}}}
510 test('should parse two nested macros next to each other inside an argument', async ({ page }) => {
511 const input = '{{outer::word {{inner1}}{{inner2}}}}';
512 const macroCst = await runParser(page, input, {});
513 expect(macroCst).toEqual({
514 'Macro.Start': '{{',
515 'Macro.identifier': 'outer',
516 'arguments': {
517 'argument': {
518 'Identifier': 'word',
519 'macro': [
520 {
521 'Macro.Start': '{{',
522 'Macro.identifier': 'inner1',
523 'Macro.End': '}}',
524 },
525 {
526 'Macro.Start': '{{',
527 'Macro.identifier': 'inner2',
528 'Macro.End': '}}',
529 },
530 ],
531 },
532 'separator': '::',
533 },
534 'Macro.End': '}}',
535 });
536 });
537
538 test.describe('Error Cases (Nested Macros)', () => {
539 // {{{{macroindentifier}}::value}}
540 test('[Error] should throw when there is a nested macro instead of an identifier', async ({ page }) => {
541 const input = '{{{{macroindentifier}}::value}}';
542 const { macroCst, errors } = await runParserAndGetErrors(page, input);
543
544 expect(macroCst).toBeUndefined();
545 expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
546 });
547
548 // {{inside{{macro}}me}}
549 test('[Error] should throw when there is a macro inside an identifier', async ({ page }) => {
550 const input = '{{inside{{macro}}me}}';
551 const { macroCst, errors } = await runParserAndGetErrors(page, input);
552
553 expect(macroCst).toBeUndefined();
554 expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
555 });
556
557 });
558 });
559
560 test.describe('Macro Flags', () => {
561 // {{!user}}
562 test('should parse macro with single flag', async ({ page }) => {
563 const input = '{{!user}}';
564 const macroCst = await runParser(page, input);
565
566 expect(macroCst).toEqual({
567 'Macro.Start': '{{',
568 'flags': '!',
569 'Macro.identifier': 'user',
570 'Macro.End': '}}',
571 });
572 });
573
574 // {{?delayed}}
575 test('should parse macro with delayed flag', async ({ page }) => {
576 const input = '{{?delayed}}';
577 const macroCst = await runParser(page, input);
578
579 expect(macroCst).toEqual({
580 'Macro.Start': '{{',
581 'flags': '?',
582 'Macro.identifier': 'delayed',
583 'Macro.End': '}}',
584 });
585 });
586
587 // {{/closing}}
588 test('should parse macro with closing block flag', async ({ page }) => {
589 const input = '{{/closing}}';
590 const macroCst = await runParser(page, input);
591
592 expect(macroCst).toEqual({
593 'Macro.Start': '{{',
594 'flags': '/',
595 'Macro.identifier': 'closing',
596 'Macro.End': '}}',
597 });
598 });
599
600 // {{>filtered}}
601 test('should parse macro with filter flag', async ({ page }) => {
602 const input = '{{>filtered}}';
603 const macroCst = await runParser(page, input);
604
605 expect(macroCst).toEqual({
606 'Macro.Start': '{{',
607 'flags': '>',
608 'Macro.identifier': 'filtered',
609 'Macro.End': '}}',
610 });
611 });
612
613 // {{!?user}}
614 test('should parse macro with multiple flags', async ({ page }) => {
615 const input = '{{!?user}}';
616 const macroCst = await runParser(page, input);
617
618 expect(macroCst).toEqual({
619 'Macro.Start': '{{',
620 'flags': ['!', '?'],
621 'Macro.identifier': 'user',
622 'Macro.End': '}}',
623 });
624 });
625
626 // {{ ! > macro }}
627 test('should parse macro with flags and whitespace', async ({ page }) => {
628 const input = '{{ ! > macro }}';
629 const macroCst = await runParser(page, input);
630
631 expect(macroCst).toEqual({
632 'Macro.Start': '{{',
633 'flags': ['!', '>'],
634 'Macro.identifier': 'macro',
635 'Macro.End': '}}',
636 });
637 });
638
639 // {{#legacy}}
640 test('should parse macro with legacy hash flag', async ({ page }) => {
641 const input = '{{#legacy}}';
642 const macroCst = await runParser(page, input);
643
644 expect(macroCst).toEqual({
645 'Macro.Start': '{{',
646 'flags': '#',
647 'Macro.identifier': 'legacy',
648 'Macro.End': '}}',
649 });
650 });
651
652 // {{!setvar::value::test}}
653 test('should parse macro with flag and arguments', async ({ page }) => {
654 const input = '{{!setvar::value::test}}';
655 const macroCst = await runParser(page, input, {
656 flattenKeys: ['arguments.argument'],
657 });
658
659 expect(macroCst).toEqual({
660 'Macro.Start': '{{',
661 'flags': '!',
662 'Macro.identifier': 'setvar',
663 'arguments': {
664 'separator': '::',
665 'argument': ['value', 'test'],
666 },
667 'Macro.End': '}}',
668 });
669 });
670 });
671
672 test.describe('Variable Shorthand Syntax', () => {
673 // {{.myvar}} - local variable get
674 test('should parse local variable shorthand', async ({ page }) => {
675 const input = '{{.myvar}}';
676 const macroCst = await runParser(page, input);
677
678 expect(macroCst).toEqual({
679 'Macro.Start': '{{',
680 'variableExpr': {
681 'Var.scope': '.',
682 'Var.identifier': 'myvar',
683 },
684 'Macro.End': '}}',
685 });
686 });
687
688 // {{$myvar}} - global variable get
689 test('should parse global variable shorthand', async ({ page }) => {
690 const input = '{{$myvar}}';
691 const macroCst = await runParser(page, input);
692
693 expect(macroCst).toEqual({
694 'Macro.Start': '{{',
695 'variableExpr': {
696 'Var.scope': '$',
697 'Var.identifier': 'myvar',
698 },
699 'Macro.End': '}}',
700 });
701 });
702
703 // {{.my-var}} - variable with hyphen in name
704 test('should parse variable with hyphen in name', async ({ page }) => {
705 const input = '{{.my-var}}';
706 const macroCst = await runParser(page, input);
707
708 expect(macroCst).toEqual({
709 'Macro.Start': '{{',
710 'variableExpr': {
711 'Var.scope': '.',
712 'Var.identifier': 'my-var',
713 },
714 'Macro.End': '}}',
715 });
716 });
717
718 // {{.myvar = value}} - set operator
719 test('should parse variable set shorthand', async ({ page }) => {
720 const input = '{{.myvar = hello}}';
721 const macroCst = await runParser(page, input);
722
723 expect(macroCst).toEqual({
724 'Macro.Start': '{{',
725 'variableExpr': {
726 'Var.scope': '.',
727 'Var.identifier': 'myvar',
728 'variableOperator': {
729 'Var.operator': '=',
730 'Var.value': {
731 'Identifier': 'hello',
732 },
733 },
734 },
735 'Macro.End': '}}',
736 });
737 });
738
739 // {{.counter++}} - increment operator
740 test('should parse variable increment shorthand', async ({ page }) => {
741 const input = '{{.counter++}}';
742 const macroCst = await runParser(page, input);
743
744 expect(macroCst).toEqual({
745 'Macro.Start': '{{',
746 'variableExpr': {
747 'Var.scope': '.',
748 'Var.identifier': 'counter',
749 'variableOperator': {
750 'Var.operator': '++',
751 },
752 },
753 'Macro.End': '}}',
754 });
755 });
756
757 // {{$counter--}} - decrement operator
758 test('should parse global variable decrement shorthand', async ({ page }) => {
759 const input = '{{$counter--}}';
760 const macroCst = await runParser(page, input);
761
762 expect(macroCst).toEqual({
763 'Macro.Start': '{{',
764 'variableExpr': {
765 'Var.scope': '$',
766 'Var.identifier': 'counter',
767 'variableOperator': {
768 'Var.operator': '--',
769 },
770 },
771 'Macro.End': '}}',
772 });
773 });
774
775 // {{.myvar += 5}} - add operator
776 test('should parse variable add shorthand', async ({ page }) => {
777 const input = '{{.myvar += 5}}';
778 const macroCst = await runParser(page, input);
779
780 expect(macroCst).toEqual({
781 'Macro.Start': '{{',
782 'variableExpr': {
783 'Var.scope': '.',
784 'Var.identifier': 'myvar',
785 'variableOperator': {
786 'Var.operator': '+=',
787 'Var.value': {
788 'Unknown': '5',
789 },
790 },
791 },
792 'Macro.End': '}}',
793 });
794 });
795
796 // {{.myvar = Hello {{user}}}} - nested macro in value
797 test('should parse nested macro in variable value', async ({ page }) => {
798 const input = '{{.myvar = Hello {{user}}}}';
799 const macroCst = await runParser(page, input);
800
801 expect(macroCst).toEqual({
802 'Macro.Start': '{{',
803 'variableExpr': {
804 'Var.scope': '.',
805 'Var.identifier': 'myvar',
806 'variableOperator': {
807 'Var.operator': '=',
808 'Var.value': {
809 'Identifier': 'Hello',
810 'macro': {
811 'Macro.Start': '{{',
812 'Macro.identifier': 'user',
813 'Macro.End': '}}',
814 },
815 },
816 },
817 },
818 'Macro.End': '}}',
819 });
820 });
821
822 // {{ .myvar = spaced }} - whitespace handling
823 test('should parse variable shorthand with whitespace', async ({ page }) => {
824 const input = '{{ .myvar = spaced }}';
825 const macroCst = await runParser(page, input);
826
827 expect(macroCst).toEqual({
828 'Macro.Start': '{{',
829 'variableExpr': {
830 'Var.scope': '.',
831 'Var.identifier': 'myvar',
832 'variableOperator': {
833 'Var.operator': '=',
834 'Var.value': {
835 'Identifier': 'spaced',
836 },
837 },
838 },
839 'Macro.End': '}}',
840 });
841 });
842 });
843});
844
845/**
846 * Runs the input through the MacroParser and returns the result.
847 *
848 * @param {import('@playwright/test').Page} page - The Playwright page object.
849 * @param {string} input - The input string to be parsed.
850 * @param {Object} [options={}] Optional arguments
851 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
852 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
853 * @returns {Promise<TestableCstNode>} A promise that resolves to the result of the MacroParser.
854 */
855async function runParser(page, input, options = {}) {
856 const { cst, errors } = await runParserAndGetErrors(page, input, options);
857
858 // Make sure that parser errors get correctly marked as errors during testing, even if the resulting structure might work.
859 // If we don't test for errors, the test should fail.
860 if (errors.length > 0) {
861 throw new Error('Parser errors found\n' + errors.map(x => x.message).join('\n'));
862 }
863
864 return cst;
865}
866
867/**
868 * Runs the input through the MacroParser and returns the syntax tree result and any parser errors.
869 *
870 * Use `runParser` if you don't want to explicitly test against parser errors.
871 *
872 * @param {import('@playwright/test').Page} page - The Playwright page object.
873 * @param {string} input - The input string to be parsed.
874 * @param {Object} [options={}] Optional arguments
875 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
876 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
877 * @returns {Promise<{cst: TestableCstNode, errors: TestableRecognitionException[]}>} A promise that resolves to the result of the MacroParser and error list.
878 */
879async function runParserAndGetErrors(page, input, options = {}) {
880 const params = { input, options };
881 const { result } = await page.evaluate(async ({ input, options }) => {
882 /** @type {import('../../public/scripts/macros/engine/MacroParser.js')} */
883 const { MacroParser } = await import('./scripts/macros/engine/MacroParser.js');
884 const result = MacroParser.test(input);
885 return { result };
886 }, params);
887 return { cst: simplifyCstNode(result.cst, input, options), errors: simplifyErrors(result.errors) };
888}
889
890/**
891 * Simplify the parser syntax tree result into an easily testable format.
892 *
893 * @param {CstNode} result The result from the parser
894 * @param {Object} [options={}] Optional arguments
895 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
896 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
897 * @returns {TestableCstNode} The testable syntax tree
898 */
899function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignoreDefaultFlattenKeys = false, ignoreDefaultIgnoreKeys = false } = {}) {
900 if (!ignoreDefaultFlattenKeys) flattenKeys = [...flattenKeys, ...DEFAULT_FLATTEN_KEYS];
901 if (!ignoreDefaultIgnoreKeys) ignoreKeys = [...ignoreKeys, ...DEFAULT_IGNORE_KEYS];
902
903 /** @returns {TestableCstNode} @param {CstNode} node @param {string[]} path */
904 function simplifyNode(node, path = []) {
905 if (!node) return node;
906 if (Array.isArray(node)) {
907 // Single-element arrays are converted to a single string
908 if (node.length === 1) {
909 return node[0].image || simplifyNode(node[0], path.concat('[]'));
910 }
911 // For multiple elements, return an array of simplified nodes
912 return node.map(child => simplifyNode(child, path.concat('[]')));
913 }
914 if (node.children) {
915 const simplifiedChildren = {};
916
917 // Special handling: merge macroBody children into parent (flatten the structure)
918 // This preserves backward compatibility with existing tests after parser refactor
919 if (node.children.macroBody && Array.isArray(node.children.macroBody) && node.children.macroBody.length === 1) {
920 const macroBody = node.children.macroBody[0];
921 if (macroBody.children) {
922 for (const bodyKey in macroBody.children) {
923 node.children[bodyKey] = macroBody.children[bodyKey];
924 }
925 }
926 delete node.children.macroBody;
927 }
928
929 for (const key in node.children) {
930 function simplifyChildNode(childNode, path) {
931 if (Array.isArray(childNode)) {
932 // Single-element arrays are converted to a single string
933 if (childNode.length === 1) {
934 return simplifyChildNode(childNode[0], path.concat('[]'));
935 }
936 return childNode.map(child => simplifyChildNode(child, path.concat('[]')));
937 }
938
939 const flattenKey = path.filter(x => x !== '[]').join('.');
940 if (ignoreKeys.includes(flattenKey)) {
941 return null;
942 } else if (flattenKeys.includes(flattenKey)) {
943 if (!childNode.location) return null;
944 const startOffset = childNode.location.startOffset;
945 const endOffset = childNode.location.endOffset;
946 return input.slice(startOffset, endOffset + 1);
947 } else {
948 return simplifyNode(childNode, path);
949 }
950 }
951
952 const simplifiedValue = simplifyChildNode(node.children[key], path.concat(key));
953 if (simplifiedValue !== null) simplifiedChildren[key] = simplifiedValue;
954 }
955 if (Object.values(simplifiedChildren).length === 0) return null;
956 return simplifiedChildren;
957 }
958 return node.image;
959 }
960
961 return simplifyNode(cst);
962}
963
964/**
965 * Simplifies a recognition exceptions into an easily testable format.
966 *
967 * @param {IRecognitionException[]} errors - The error list containing exceptions to be simplified.
968 * @return {TestableRecognitionException[]} - The simplified error list
969 */
970function simplifyErrors(errors) {
971 return errors.map(exception => ({
972 name: exception.name,
973 message: exception.message,
974 }));
975}