Files
Wolfsblvt 42155ecebf Macros 2.0 (v0.7.3) - Variable Shorthand: Comparison Operators & Autocomplete Improvements (#5050)
* feat: Add numeric comparison operators (>, >=, <, <=) to variable shorthand syntax

- Added four new comparison operators for local and global variables
- Implemented greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual operations in MacroCstWalker
- Updated lexer to recognize >=, >, <=, < operators (longer patterns before shorter to avoid conflicts)
- Simplified parser by consolidating operator alternatives into single OR block
- Enhanced autocomplete with operator definitions, examples, and usage

* Improve autocomplete for variable shorthand operators with cursor-aware filtering

- Track `variableNameEnd` and `variableOperatorEnd` positions in parsed macro context for accurate cursor position checks
- Add `isShortOperatorPrefix()` helper to detect operators that could be prefixes of longer ones (e.g., `>` → `>=`)
- Fix operator autocomplete to show longer variants when typing short operators (typing `>` now shows both `>` and `>=`)
- Show operator suggestions immediately when variable
2026-01-23 01:05:00 +02:00

3412 lines
172 KiB
JavaScript

import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
test.describe('MacroEngine', () => {
test.beforeEach(testSetup.awaitST);
test.describe('Basic evaluation', () => {
test('should return input unchanged when there are no macros', async ({ page }) => {
const input = 'Hello world, no macros here.';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(input);
});
test('should evaluate a simple macro without arguments', async ({ page }) => {
const input = 'Start {{newline}} end.';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Start \n end.');
});
test('should evaluate multiple macros in order', async ({ page }) => {
const input = 'A {{setvar::test::4}}{{getvar::test}} B {{setvar::test::2}}{{getvar::test}} C';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('A 4 B 2 C');
});
});
test.describe('Unnamed arguments', () => {
test('should handle normal double-colon separated unnamed argument', async ({ page }) => {
const input = 'Reversed: {{reverse::abc}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: cba!');
});
test('should handle (legacy) colon separated unnamed argument', async ({ page }) => {
const input = 'Reversed: {{reverse:abc}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: cba!');
});
test('should handle (legacy) colon separated argument as only one, even with more separators (double colon)', async ({ page }) => {
const input = 'Reversed: {{reverse:abc::def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: fed::cba!');
});
test('should handle (legacy) colon separated argument as only one, even with more separators (single colon)', async ({ page }) => {
const input = 'Reversed: {{reverse:abc:def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: fed:cba!');
});
test('should handle (legacy) whitespace separated unnamed argument', async ({ page }) => {
const input = 'Values: {{roll 1d1}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Values: 1!');
});
test('should handle (legacy) whitespace separated unnamed argument as only one, even with more separators (space)', async ({ page }) => {
const input = 'Values: {{reverse abc def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Values: fed cba!');
});
test('should support multi-line arguments for macros', async ({ page }) => {
const input = 'Result: {{reverse::first line\nsecond line}}'; // "\n" becomes a real newline in the macro argument
const output = await evaluateWithEngine(page, input);
const original = 'first line\nsecond line';
const expectedReversed = Array.from(original).reverse().join('');
expect(output).toBe(`Result: ${expectedReversed}`);
});
});
test.describe('Nested macros', () => {
test('should resolve nested macros inside arguments inside-out', async ({ page }) => {
const input = 'Result: {{setvar::test::0}}{{reverse::{{addvar::test::100}}{{getvar::test}}}}{{setvar::test::0}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: 001');
});
// {{wrap::{{upper::x}}::[::]}} -> '[X]'
test('should resolve nested macros across multiple arguments', async ({ page }) => {
const input = 'Result: {{setvar::addvname::test}}{{addvar::{{getvar::addvname}}::{{setvar::test::5}}{{getvar::test}}}}{{getvar::test}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: 10');
});
});
test.describe('Unknown macros', () => {
test('should keep unknown macro syntax but resolve nested macros inside it', async ({ page }) => {
const input = 'Test: {{unknown::{{newline}}}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Test: {{unknown::\n}}');
});
test('should keep surrounding text inside unknown macros intact', async ({ page }) => {
const input = 'Test: {{unknown::my {{newline}} example}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Test: {{unknown::my \n example}}');
});
});
test.describe('Comment macro', () => {
test('should remove single-line comments with simple body', async ({ page }) => {
const input = 'Hello{{// comment}}World';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('HelloWorld');
});
test('should accept non-word characters immediately after //', async ({ page }) => {
const input = 'A{{//!@#$%^&*()_+}}B';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('AB');
});
test('should ignore additional // sequences inside the comment body', async ({ page }) => {
const input = 'X{{//comment with // extra // slashes}}Y';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('XY');
});
test('should support multi-line comment bodies', async ({ page }) => {
const input = 'Start{{// line one\nline two\nline three}}End';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('StartEnd');
});
});
test.describe('Trim macro', () => {
test('should trim content inside scoped trim macro', async ({ page }) => {
const input = '{{trim}} hello world {{/trim}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('hello world');
});
test('should trim leading whitespace in scoped trim', async ({ page }) => {
const input = '{{trim}}\n\n content{{/trim}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('content');
});
test('should trim trailing whitespace in scoped trim', async ({ page }) => {
const input = '{{trim}}content \n\n{{/trim}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('content');
});
test('should handle scoped trim with macros inside', async ({ page }) => {
const input = '{{trim}} Hello {{user}} {{/trim}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User');
});
test('should handle nested scoped trim', async ({ page }) => {
const input = '{{trim}} outer {{trim}} inner {{/trim}} outer {{/trim}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('outer inner outer');
});
});
test.describe('Legacy compatibility', () => {
test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
const input = 'foo\n\n{{trim}}\n\nbar';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('foobar');
});
test('should handle multiple trim macros in a single string', async ({ page }) => {
const input = 'A\n\n{{trim}}\n\nB\n\n{{trim}}\n\nC';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('ABC');
});
test('should support legacy time macro with positive offset via pre-processing', async ({ page }) => {
const input = 'Time: {{time_UTC+2}}';
const output = await evaluateWithEngine(page, input);
// After pre-processing, this should behave like {{time::UTC+2}} and be resolved by the time macro.
// We only assert that the placeholder was consumed and some non-empty value was produced.
expect(output).not.toBe(input);
expect(output.startsWith('Time: ')).toBeTruthy();
expect(output.length).toBeGreaterThan('Time: '.length);
});
test('should support legacy time macro with negative offset via pre-processing', async ({ page }) => {
const input = 'Time: {{time_UTC-10}}';
const output = await evaluateWithEngine(page, input);
expect(output).not.toBe(input);
expect(output.startsWith('Time: ')).toBeTruthy();
expect(output.length).toBeGreaterThan('Time: '.length);
});
test('should support legacy <USER> marker via pre-processing', async ({ page }) => {
const input = 'Hello <USER>!';
const output = await evaluateWithEngine(page, input);
// In the default test env, name1Override is "User".
expect(output).toBe('Hello User!');
});
test('should support legacy <BOT> and <CHAR> markers via pre-processing', async ({ page }) => {
const input = 'Bot: <BOT>, Char: <CHAR>.';
const output = await evaluateWithEngine(page, input);
// In the default test env, name2Override is "Character".
expect(output).toBe('Bot: Character, Char: Character.');
});
test('should support legacy <GROUP> and <CHARIFNOTGROUP> markers via pre-processing (non-group fallback)', async ({ page }) => {
const input = 'Group: <GROUP>, CharIfNotGroup: <CHARIFNOTGROUP>.';
const output = await evaluateWithEngine(page, input);
// Without an active group, both markers fall back to the current character name.
expect(output).toBe('Group: Character, CharIfNotGroup: Character.');
});
});
test.describe('Bracket handling around macros', () => {
test('should allow single opening brace inside macro arguments', async ({ page }) => {
const input = 'Test§ {{reverse::my { test}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// "my { test" reversed becomes "tset { ym"
expect(output).toBe('Test§ tset { ym');
const EXPECT_WARNINGS = false;
const EXPECT_ERRORS = false;
expect(hasMacroWarnings).toBe(EXPECT_WARNINGS);
expect(hasMacroErrors).toBe(EXPECT_ERRORS);
});
test('should allow single closing brace inside macro arguments', async ({ page }) => {
const input = 'Test§ {{reverse::my } test}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// "my } test" reversed becomes "tset } ym"
expect(output).toBe('Test§ tset } ym');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should treat unterminated macro with identifier at end of input as plain text', async ({ page }) => {
const input = 'Test {{ hehe';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should treat invalid macro start as plain text when followed by non-identifier characters', async ({ page }) => {
const input = 'Test {{§§ hehe';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
expect(hasMacroErrors).toBe(false);
});
test('should treat unterminated macro in the middle of the string as plain text', async ({ page }) => {
const input = 'Before {{ hehe After';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should treat dangling macro start as text and still evaluate subsequent macro', async ({ page }) => {
const input = 'Test {{ hehe {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Default test env uses name1Override = "User" and name2Override = "Character".
expect(output).toBe('Test {{ hehe User');
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should ignore invalid macro start but still evaluate following valid macro', async ({ page }) => {
const input = 'Test {{&& hehe {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Default test env uses name1Override = "User" and name2Override = "Character".
expect(output).toBe('Test {{&& hehe User');
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
expect(hasMacroErrors).toBe(false);
});
test('should allow single opening brace immediately before a macro', async ({ page }) => {
const input = '{{{char}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// One literal '{' plus the resolved character name.
expect(output).toBe('{Character');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow single closing brace immediately after a macro', async ({ page }) => {
const input = '{{char}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow single braces around a macro', async ({ page }) => {
const input = '{{{char}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{Character}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double opening braces immediately before a macro', async ({ page }) => {
const input = '{{{{char}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{{Character');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double closing braces immediately after a macro', async ({ page }) => {
const input = '{{char}}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character}}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double braces around a macro', async ({ page }) => {
const input = '{{{{char}}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{{Character}}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should resolve nested macro inside argument with surrounding braces', async ({ page }) => {
const input = 'Result: {{reverse::pre-{ {{user}} }-post}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Argument "pre-{ User }-post" reversed becomes "tsop-} resU {-erp".
expect(output).toBe('Result: tsop-} resU {-erp');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle adjacent macros with no separator', async ({ page }) => {
const input = '{{char}}{{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('CharacterUser');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle macros separated only by surrounding braces', async ({ page }) => {
const input = '{{char}}{ {{user}} }';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character{ User }');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle Windows newlines with braces near macros', async ({ page }) => {
const input = 'Line1 {{char}}\r\n{Line2}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Line1 Character\r\n{Line2}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should treat stray closing braces outside macros as plain text', async ({ page }) => {
const input = 'Foo }} bar';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should keep stray closing braces and still evaluate following macro', async ({ page }) => {
const input = 'Foo }} {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Foo }} User');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle stray closing braces before macros as plain text', async ({ page }) => {
const input = 'Foo {{user}} }}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Foo User }}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
});
test.describe('Arity errors', () => {
test('should not resolve macro without arguments when called with arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Start {{char::extra}} end.';
const output = await evaluateWithEngine(page, input);
// Macro text should remain unchanged
expect(output).toBe(input);
// Should have logged an arity warning for char
expect(warnings.some(w => w.includes('Macro "char"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve reverse when called without arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Result: {{reverse}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve reverse when called with too many arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Result: {{reverse::a::b}}';
const output = await evaluateWithEngine(page, input);
// Macro text should remain unchanged when extra unnamed args are provided
expect(output).toBe(input);
// Should have logged an arity warning for reverse
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve list-bounded macro when called outside list bounds', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
// Register a temporary macro with explicit list bounds: exactly 1 required + 1-2 list args
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-list-bounds');
MacroRegistry.registerMacro('test-list-bounds', {
unnamedArgs: 1,
list: { min: 1, max: 2 },
description: 'Test macro for list bounds.',
handler: ({ unnamedArgs, list }) => {
const all = [...unnamedArgs, ...(list ?? [])];
return all.join('|');
},
});
});
// First macro: too few list args (only required arg)
// Second macro: too many list args (required arg + 3 list entries)
const input = 'A {{test-list-bounds::base}} B {{test-list-bounds::base::x::y::z}}';
const output = await evaluateWithEngine(page, input);
// Both macros should remain unchanged in the output
expect(output).toBe(input);
const testWarnings = warnings.filter(w => w.includes('Macro "test-list-bounds"') && w.includes('unnamed arguments'));
// We expect one warning for each invalid invocation (too few and too many list args)
expect(testWarnings.length).toBe(2);
});
test('should resolve nested macros in arguments, even though the outer macro has wrong number of arguments', async ({ page }) => {
// Macro {{user ....}} will fail, because it has no args, but {{char}} should still resolve
const input = 'Result: {{user Something {{char}}}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: {{user Something Character}}');
});
});
test.describe('Type validation', () => {
test('should not resolve strict typed macro when argument type is invalid', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-int-strict');
MacroRegistry.registerMacro('test-int-strict', {
unnamedArgs: [
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
],
strictArgs: true,
description: 'Strict integer macro for testing type validation.',
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
});
});
const input = 'Value: {{test-int-strict::abc}}';
const output = await evaluateWithEngine(page, input);
// Strict typed macro should leave the text unchanged when the argument is invalid
expect(output).toBe(input);
// A runtime type validation warning should be logged
expect(warnings.some(w => w.includes('Macro "test-int-strict"') && w.includes('expected type integer'))).toBeTruthy();
});
test('should resolve non-strict typed macro when argument type is invalid but still log warning', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-int-nonstrict');
MacroRegistry.registerMacro('test-int-nonstrict', {
unnamedArgs: [
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
],
strictArgs: false,
description: 'Non-strict integer macro for testing type validation.',
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
});
});
const input = 'Value: {{test-int-nonstrict::abc}}';
const output = await evaluateWithEngine(page, input);
// Non-strict typed macro should still execute, even with invalid type
expect(output).toBe('Value: #abc#');
// A runtime type validation warning should still be logged
expect(warnings.some(w => w.includes('Macro "test-int-nonstrict"') && w.includes('expected type integer'))).toBeTruthy();
});
});
test.describe('Environment', () => {
test('should expose original content as env.content to macro handlers', async ({ page }) => {
const input = '{{env-content}}';
const originalContent = 'This is the full original input string.';
const output = await page.evaluate(async ({ input, originalContent }) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('env-content');
MacroRegistry.registerMacro('env-content', {
description: 'Test macro that returns env.content.',
handler: ({ env }) => env.content,
});
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const rawEnv = {
content: originalContent,
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, { input, originalContent });
expect(output).toBe(originalContent);
});
});
test.describe('Deterministic pick macro', () => {
/** Fixed chat ID hash used across all pick tests for deterministic behavior */
const TEST_CHAT_ID_HASH = 123456;
/**
* Registers a testable pick macro that returns the seed string instead of the picked value.
* This allows tests to verify that different macro positions produce different seeds.
*
* @param {import('@playwright/test').Page} page
*/
async function registerTestablePick(page) {
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry, MacroCategory } = await import('./scripts/macros/engine/MacroRegistry.js');
/** @type {import('../../public/scripts/utils.js')} */
const { getStringHash } = await import('./scripts/utils.js');
/** @type {import('../../public/script.js')} */
const { chat_metadata } = await import('./script.js');
/** @type {import('../../public/lib.js')} */
const { seedrandom } = await import('./lib.js');
// Only register once
if (MacroRegistry.getMacro('testablePick')) return;
MacroRegistry.registerMacro('testablePick', {
category: MacroCategory.RANDOM,
list: true,
description: 'Test version of pick that returns the seed string for verification.',
handler: ({ list, globalOffset, env }) => {
const chatIdHash = chat_metadata.chat_id_hash ?? 0;
const rawContentHash = env.contentHash;
const offset = globalOffset;
const rerollSeed = chat_metadata.pick_reroll_seed || null;
const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-');
// Return both the seed and what would be picked for validation
const finalSeed = getStringHash(combinedSeedString);
const rng = seedrandom(String(finalSeed));
const randomIndex = Math.floor(rng() * list.length);
return `seed:${combinedSeedString}|pick:${list[randomIndex]}`;
},
});
});
}
test.beforeEach(async ({ page }) => {
// Set consistent chat ID hash for all tests
await page.evaluate(async (hash) => {
/** @type {import('../../public/script.js')} */
const { chat_metadata } = await import('./script.js');
chat_metadata.chat_id_hash = hash;
}, TEST_CHAT_ID_HASH);
});
test('should return stable results for the same chat and content', async ({ page }) => {
const input = 'Choices: {{pick::red::green::blue}}, {{pick::red::green::blue}}.';
const output1 = await evaluateWithEngine(page, input);
const output2 = await evaluateWithEngine(page, input);
// Deterministic: same chat and same content should yield identical output
expect(output1).toBe(output2);
// Sanity check: both picks should resolve to one of the provided options
const match = output1.match(/Choices: ([^,]+), ([^.]+)\./);
expect(match).not.toBeNull();
if (!match) return;
const first = match[1].trim();
const second = match[2].trim();
const options = ['red', 'green', 'blue'];
expect(options.includes(first)).toBeTruthy();
expect(options.includes(second)).toBeTruthy();
});
test('should use different seeds for identical picks at different positions', async ({ page }) => {
await registerTestablePick(page);
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const input = '{{testablePick::A::B::C}}###{{testablePick::A::B::C}}';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
});
const parts = output.split('###');
expect(parts.length).toBe(2);
// Extract seeds from both results
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
expect(seed1).toBeTruthy();
expect(seed2).toBeTruthy();
// Seeds must be different because the macros are at different positions
expect(seed1).not.toBe(seed2);
// Verify picked values are valid options
const pick1 = parts[0].match(/pick:(\w+)/)?.[1];
const pick2 = parts[1].match(/pick:(\w+)/)?.[1];
const options = ['A', 'B', 'C'];
expect(options.includes(pick1 ?? '')).toBeTruthy();
expect(options.includes(pick2 ?? '')).toBeTruthy();
});
test('should use different seeds for identical picks inside different scoped macros at the same offset', async ({ page }) => {
await registerTestablePick(page);
// Key regression test: picks inside scoped content must use global offsets
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Two identical pick macros inside different setvar scopes
// Before the fix, both would get startOffset=0 relative to their argument
// After the fix, they get different globalOffset values
const input = '{{setvar::first}}{{testablePick::A::B::C}}{{/setvar}}{{setvar::second}}{{testablePick::A::B::C}}{{/setvar}}{{.first}}###{{.second}}';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
});
const parts = output.split('###');
expect(parts.length).toBe(2);
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
expect(seed1).toBeTruthy();
expect(seed2).toBeTruthy();
// Seeds must be different - this is the key assertion for the fix
expect(seed1).not.toBe(seed2);
});
test('should use different seeds for identical picks in inline arguments', async ({ page }) => {
await registerTestablePick(page);
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Two identical pick macros inside different setvar inline arguments
const input = '{{setvar::first::{{testablePick::A::B::C}}}}{{setvar::second::{{testablePick::A::B::C}}}}{{.first}}###{{.second}}';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
});
const parts = output.split('###');
expect(parts.length).toBe(2);
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
expect(seed1).toBeTruthy();
expect(seed2).toBeTruthy();
// Seeds must be different due to different global offsets
expect(seed1).not.toBe(seed2);
});
test('should maintain stability across evaluations for picks in scoped content', async ({ page }) => {
// Picks inside scoped content should still be deterministic (same result each time)
const outputs = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const input = '{{setvar::val}}{{pick::X::Y::Z}}{{/setvar}}{{.val}}';
const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
const result1 = MacroEngine.evaluate(input, env1);
const result2 = MacroEngine.evaluate(input, env2);
return [result1, result2];
});
// Same input should produce same output (deterministic)
expect(outputs[0]).toBe(outputs[1]);
// Should be one of the valid options
expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
});
test('should use different seeds for identical picks inside different if blocks (delayArgResolution)', async ({ page }) => {
await registerTestablePick(page);
// Key regression test: picks inside {{if}} blocks use resolve() which must preserve globalOffset
// This tests the fix for macros with delayArgResolution that call resolve() internally
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Two identical pick macros inside different if blocks
// Before the fix, both would get contextOffset=0 when resolve() was called
// After the fix, resolve() passes the caller's globalOffset as contextOffset
const input = '{{if true}}{{testablePick::A::B::C}}{{/if}}###{{if true}}{{testablePick::A::B::C}}{{/if}}';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
});
const parts = output.split('###');
expect(parts.length).toBe(2);
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
expect(seed1).toBeTruthy();
expect(seed2).toBeTruthy();
// Seeds must be different because the {{if}} blocks are at different positions
expect(seed1).not.toBe(seed2);
});
test('should maintain stability for picks inside if blocks across evaluations', async ({ page }) => {
// Picks inside if blocks should still be deterministic
const outputs = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const input = '{{if true}}{{pick::X::Y::Z}}{{/if}}';
const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
const result1 = MacroEngine.evaluate(input, env1);
const result2 = MacroEngine.evaluate(input, env2);
return [result1, result2];
});
// Same input should produce same output (deterministic)
expect(outputs[0]).toBe(outputs[1]);
// Should be one of the valid options
expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
});
});
test.describe('Dynamic macros', () => {
test.describe('String value dynamic macros', () => {
test('should resolve dynamic macro with string value', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: 'Test: {{myvalue}}',
dynamicMacros: {
myvalue: 'hello world',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('Test: {{myvalue}}', env);
});
expect(output).toBe('Test: hello world');
});
test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
num: 42,
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('Value: {{num}}', env);
});
expect(output).toBe('Value: 42');
});
test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = 'Dyn: {{myvalue::extra}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: input,
dynamicMacros: { myvalue: 'hello' },
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('Macro "myvalue"') && w.includes('unnamed arguments'))).toBeTruthy();
});
});
test.describe('Handler function dynamic macros', () => {
test('should resolve dynamic macro with handler function', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
dyn: () => 'handler result',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('Result: {{dyn}}', env);
});
expect(output).toBe('Result: handler result');
});
test('should pass execution context to handler function', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: 'full content here',
dynamicMacros: {
dyn: (ctx) => `name=${ctx.name}, content=${ctx.env.content}`,
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{dyn}}', env);
});
expect(output).toBe('name=dyn, content=full content here');
});
test('should not resolve handler dynamic macro when called with arguments due to strict arity', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = 'Dyn: {{dyn::extra}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: input,
dynamicMacros: {
dyn: () => 'OK',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
});
});
test.describe('MacroDefinitionOptions dynamic macros', () => {
test('should resolve dynamic macro with MacroDefinitionOptions', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
greet: {
description: 'A greeting macro',
handler: () => 'Hello from options!',
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{greet}}', env);
});
expect(output).toBe('Hello from options!');
});
test('should support unnamed arguments in dynamic macro with options', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
greet: {
unnamedArgs: [{ name: 'name' }],
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{greet::World}}', env);
});
expect(output).toBe('Hello, World!');
});
test('should support multiple unnamed arguments in dynamic macro', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
wrap: {
unnamedArgs: [
{ name: 'content' },
{ name: 'prefix' },
{ name: 'suffix' },
],
handler: ({ unnamedArgs: [content, prefix, suffix] }) => `${prefix}${content}${suffix}`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{wrap::hello::[::]}}', env);
});
expect(output).toBe('[hello]');
});
test('should support optional arguments in dynamic macro', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
greet: {
unnamedArgs: [
{ name: 'name' },
{ name: 'greeting', optional: true, defaultValue: 'Hello' },
],
handler: ({ unnamedArgs: [name, greeting] }) => `${greeting || 'Hello'}, ${name}!`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const result1 = MacroEngine.evaluate('{{greet::World}}', env);
const result2 = MacroEngine.evaluate('{{greet::World::Hi}}', env);
return { result1, result2 };
});
expect(output.result1).toBe('Hello, World!');
expect(output.result2).toBe('Hi, World!');
});
test('should support list arguments in dynamic macro', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
join: {
unnamedArgs: [{ name: 'separator' }],
list: true,
handler: ({ unnamedArgs: [sep], list }) => list.join(sep),
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{join::-::a::b::c}}', env);
});
expect(output).toBe('a-b-c');
});
test('should enforce type validation in dynamic macro with options', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = '{{calc::abc}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: input,
dynamicMacros: {
calc: {
unnamedArgs: [{ name: 'value', type: 'integer' }],
strictArgs: true,
handler: ({ unnamedArgs: [val] }) => `#${val}#`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
});
test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
calc: {
unnamedArgs: [{ name: 'value', type: 'integer' }],
strictArgs: false,
handler: ({ unnamedArgs: [val] }) => `#${val}#`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{calc::abc}}', env);
});
expect(output).toBe('#abc#');
expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
});
test('should fail arity check in dynamic macro with options when too few args', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = '{{greet}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: input,
dynamicMacros: {
greet: {
unnamedArgs: [{ name: 'name' }],
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should fail arity check in dynamic macro with options when too many args', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = '{{greet::one::two}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: input,
dynamicMacros: {
greet: {
unnamedArgs: [{ name: 'name' }],
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should handle invalid MacroDefinitionOptions gracefully', async ({ page }) => {
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
const input = '{{bad}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const rawEnv = {
content: input,
dynamicMacros: {
bad: {
// Missing handler - should fail validation
unnamedArgs: 1,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
// Should remain unresolved since options are invalid
expect(output).toBe(input);
expect(warnings.some(w => w.includes('bad') && w.includes('is not defined correctly'))).toBeTruthy();
});
});
test.describe('Dynamic macro priority and case sensitivity', () => {
test('should override registered macro with dynamic macro of same name', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
name1Override: 'User',
dynamicMacros: {
user: 'DynamicUser',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{user}}', env);
});
expect(output).toBe('DynamicUser');
});
test('should match dynamic macro names case-insensitively', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
MyMacro: 'value',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const r1 = MacroEngine.evaluate('{{MyMacro}}', env);
const r2 = MacroEngine.evaluate('{{mymacro}}', env);
const r3 = MacroEngine.evaluate('{{MYMACRO}}', env);
return { r1, r2, r3 };
});
expect(output.r1).toBe('value');
expect(output.r2).toBe('value');
expect(output.r3).toBe('value');
});
test('should resolve multiple different dynamic macros in same evaluation', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const rawEnv = {
content: '',
dynamicMacros: {
a: 'alpha',
b: () => 'beta',
c: {
handler: () => 'gamma',
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{a}}-{{b}}-{{c}}', env);
});
expect(output).toBe('alpha-beta-gamma');
});
});
});
test.describe('Macro flags', () => {
test('should resolve macro with legacy hash flag (no effect)', async ({ page }) => {
// Legacy hash flag should be parsed but have no effect
const input = 'Hello {{#user}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User!');
});
test('should keep unmatched closing block macro as raw text', async ({ page }) => {
// Closing block without matching opening should be kept as raw
const input = '{{/unknown}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{/unknown}}');
});
test('should keep unmatched closing block macro for existing macro as raw text', async ({ page }) => {
// Closing block for a known macro (user) without matching opening should stay raw
const input = '{{/user}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{/user}}');
});
test('should keep unmatched closing block macro with arguments as raw text', async ({ page }) => {
// Closing block with arguments should stay raw (closing macros don't take args anyway)
const input = '{{/getvar::test}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{/getvar::test}}');
});
test('should keep closing macro raw when surrounded by other content', async ({ page }) => {
// Closing macro in middle of text should stay raw, other macros should resolve
const input = 'Hello {{user}}, this {{/char}} is raw, bye {{char}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User, this {{/char}} is raw, bye Character!');
});
test('should resolve scoped macro while keeping unrelated closing raw', async ({ page }) => {
// Scoped macro resolves normally, unrelated closing stays raw
const input = '{{setvar::x}}value{{/setvar}}{{/user}}{{getvar::x}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{/user}}value');
});
test('should pass flags to macro handler', async ({ page }) => {
// Register a test macro that returns its flags
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-flags');
MacroRegistry.registerMacro('test-flags', {
description: 'Test macro that returns its flags.',
handler: ({ flags }) => {
const activeFlags = flags.raw.join(',') || 'none';
return `[${activeFlags}]`;
},
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{test-flags}} / {{!test-flags}} / {{!?test-flags}}', env);
});
expect(output).toBe('[none] / [!] / [!,?]');
});
test('should correctly identify individual flags in handler', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-flag-check');
MacroRegistry.registerMacro('test-flag-check', {
description: 'Test macro that checks specific flags.',
handler: ({ flags }) => {
const parts = [];
if (flags.immediate) parts.push('immediate');
if (flags.delayed) parts.push('delayed');
if (flags.filter) parts.push('filter');
if (flags.closingBlock) parts.push('closingBlock');
if (flags.preserveWhitespace) parts.push('preserveWhitespace');
return parts.join('+') || 'noflags';
},
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const results = [
MacroEngine.evaluate('{{test-flag-check}}', env),
MacroEngine.evaluate('{{!test-flag-check}}', env),
MacroEngine.evaluate('{{?test-flag-check}}', env),
MacroEngine.evaluate('{{>test-flag-check}}', env),
// Note: {{/test-flag-check}} would stay raw (unmatched closing macro)
MacroEngine.evaluate('{{#test-flag-check}}', env),
MacroEngine.evaluate('{{!?>test-flag-check}}', env),
];
return results.join(' | ');
});
// Closing flag (/) is not tested here as standalone closing macros stay raw
expect(output).toBe('noflags | immediate | delayed | filter | preserveWhitespace | immediate+delayed+filter');
});
test('should handle flags with arguments correctly', async ({ page }) => {
const input = '{{!reverse::hello}}';
const output = await evaluateWithEngine(page, input);
// The flag should not affect the macro resolution
expect(output).toBe('olleh');
});
test('should handle multiple flags with whitespace', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-flags-ws');
MacroRegistry.registerMacro('test-flags-ws', {
description: 'Test macro for flags with whitespace.',
handler: ({ flags }) => flags.raw.length.toString(),
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{ ! ? > test-flags-ws }}', env);
});
expect(output).toBe('3');
});
});
test.describe('Scoped macros', () => {
test('should merge scoped content as last unnamed argument', async ({ page }) => {
const input = '{{setvar::myvar}}Hello World{{/setvar}}{{getvar::myvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello World');
});
test('should be equivalent to inline argument syntax', async ({ page }) => {
const input1 = '{{setvar::myvar::test value}}{{getvar::myvar}}';
const input2 = '{{setvar::myvar}}test value{{/setvar}}{{getvar::myvar}}';
const output1 = await evaluateWithEngine(page, input1);
const output2 = await evaluateWithEngine(page, input2);
expect(output1).toBe(output2);
});
test('should resolve nested macros inside scoped content', async ({ page }) => {
const input = '{{setvar::myvar}}Hello {{user}}!{{/setvar}}{{getvar::myvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User!');
});
test('should handle nested scoped macros with same name', async ({ page }) => {
// Outer scope sets 'outer', inner scope sets 'inner'
// Since setvar returns '', the inner macro contributes nothing to outer's content
const input = '{{setvar::outer}}before {{setvar::inner}}nested{{/setvar}} after{{/setvar}}{{getvar::outer}} | {{getvar::inner}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('before after | nested'); // Note: double space where inner setvar was
});
test('should handle multiple independent scoped macros', async ({ page }) => {
const input = '{{setvar::a}}first{{/setvar}}{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[first][second]');
});
test('should keep unmatched closing tag as raw text', async ({ page }) => {
const input = 'Before {{/setvar}} After';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Before {{/setvar}} After');
});
test('should keep second closing tag as raw when already closed', async ({ page }) => {
const input = '{{setvar::myvar}}content{{/setvar}}{{/setvar}}{{getvar::myvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{/setvar}}content');
});
test('should work with empty scoped content', async ({ page }) => {
const input = '{{setvar::empty}}{{/setvar}}[{{getvar::empty}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[]');
});
test('should work with multi-line scoped content', async ({ page }) => {
const input = '{{setvar::multi}}Line 1\nLine 2\nLine 3{{/setvar}}{{getvar::multi}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Line 1\nLine 2\nLine 3');
});
test('should preserve plaintext around scoped macros', async ({ page }) => {
const input = 'Before {{setvar::x}}value{{/setvar}} After {{getvar::x}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Before After value');
});
test('should handle deeply nested scoped macros', async ({ page }) => {
// Since setvar returns '', nested setvars contribute nothing to parent content
// l3 = "C", l2 = "B" + "" + "B" = "BB", l1 = "A" + "" + "A" = "AA"
const input = '{{setvar::l1}}A{{setvar::l2}}B{{setvar::l3}}C{{/setvar}}B{{/setvar}}A{{/setvar}}{{getvar::l1}}|{{getvar::l2}}|{{getvar::l3}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('AA|BB|C');
});
test('should handle scoped macro with existing arguments', async ({ page }) => {
// reverse takes 1 arg; scoped content becomes the only arg
const input = '{{reverse}}hello{{/reverse}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('olleh');
});
test('should not match closing tag for different macro name', async ({ page }) => {
// Opening setvar, closing getvar - should not match
const input = '{{setvar::x}}content{{/getvar}}{{getvar::x}}';
const output = await evaluateWithEngine(page, input);
// setvar without proper closing keeps looking, finds none, so it stays as is
// getvar closing has no opener, stays as raw
expect(output).toBe('{{setvar::x}}content{{/getvar}}');
});
test('should handle scoped content with special characters', async ({ page }) => {
const input = '{{setvar::special}}Hello { world } :: test{{/setvar}}{{getvar::special}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello { world } :: test');
});
test('should set isScoped to true for scoped macro invocation', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-isscoped');
MacroRegistry.registerMacro('test-isscoped', {
description: 'Test macro that reports isScoped value.',
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
handler: ({ isScoped }) => `isScoped:${isScoped}`,
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{test-isscoped}}content{{/test-isscoped}}', env);
});
expect(output).toBe('isScoped:true');
});
test('should set isScoped to false for inline argument syntax', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-isscoped');
MacroRegistry.registerMacro('test-isscoped', {
description: 'Test macro that reports isScoped value.',
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
handler: ({ isScoped }) => `isScoped:${isScoped}`,
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate('{{test-isscoped::content}}', env);
});
expect(output).toBe('isScoped:false');
});
test('should keep scoped macro raw when macro accepts no arguments', async ({ page }) => {
// {{user}} takes no arguments, so {{user}}content{{/user}} should stay raw
// But content inside should still resolve
const input = '{{user}}Hello {{char}}!{{/user}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{user}}Hello Character!{{/user}}');
});
test('should keep scoped macro raw when argument count exceeds maximum', async ({ page }) => {
// setvar takes 2 args (name, value). With scoped content as 3rd arg, it exceeds max.
// When already at max args, scoped content would be extra - should stay raw
const input = '{{setvar::myvar::existing}}extra{{/setvar}}{{getvar::myvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('{{setvar::myvar::existing}}extra{{/setvar}}');
});
test('should keep scoped macro raw when argument count is below minimum', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// Register a macro that requires exactly 3 arguments
MacroRegistry.unregisterMacro('test-3args');
MacroRegistry.registerMacro('test-3args', {
description: 'Test macro requiring 3 arguments.',
unnamedArgs: [
{ name: 'a', type: 'string', description: 'First' },
{ name: 'b', type: 'string', description: 'Second' },
{ name: 'c', type: 'string', description: 'Third' },
],
handler: ({ unnamedArgs: [a, b, c] }) => `${a}-${b}-${c}`,
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
// Only 2 args (1 inline + 1 scoped), but needs 3 - should stay raw
return MacroEngine.evaluate('{{test-3args::first}}second{{/test-3args}}', env);
});
expect(output).toBe('{{test-3args::first}}second{{/test-3args}}');
});
test('should evaluate inner macros before outer macro in scoped content', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// Track evaluation order
const evalOrder = [];
MacroRegistry.unregisterMacro('test-outer');
MacroRegistry.registerMacro('test-outer', {
description: 'Outer test macro.',
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
handler: ({ unnamedArgs: [content] }) => {
evalOrder.push('outer');
return `[outer:${content}]`;
},
});
MacroRegistry.unregisterMacro('test-inner');
MacroRegistry.registerMacro('test-inner', {
description: 'Inner test macro.',
handler: () => {
evalOrder.push('inner');
return 'INNER';
},
});
const rawEnv = { content: '' };
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const result = MacroEngine.evaluate('{{test-outer}}before {{test-inner}} after{{/test-outer}}', env);
return { result, order: evalOrder.join(',') };
});
expect(output.result).toBe('[outer:before INNER after]');
expect(output.order).toBe('inner,outer');
});
test('should handle scoped macro inside another scoped macro content', async ({ page }) => {
// Both scoped macros should resolve, inner first
const input = '{{setvar::outer}}A{{setvar::inner}}B{{/setvar}}C{{/setvar}}{{getvar::outer}}|{{getvar::inner}}';
const output = await evaluateWithEngine(page, input);
// inner = "B", outer = "A" + "" + "C" = "AC" (setvar returns empty string)
expect(output).toBe('AC|B');
});
test('should auto-trim whitespace-only scoped content to empty', async ({ page }) => {
const input = '{{setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[]');
});
test('should preserve whitespace-only scoped content with # flag', async ({ page }) => {
const input = '{{#setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[ ]');
});
test('should handle scoped macro at start of input', async ({ page }) => {
const input = '{{setvar::x}}value{{/setvar}}result:{{getvar::x}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('result:value');
});
test('should handle scoped macro at end of input', async ({ page }) => {
const input = 'prefix {{setvar::x}}value{{/setvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('prefix ');
});
test('should handle consecutive scoped macros', async ({ page }) => {
const input = '{{setvar::a}}1{{/setvar}}{{setvar::b}}2{{/setvar}}{{setvar::c}}3{{/setvar}}{{getvar::a}}{{getvar::b}}{{getvar::c}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('123');
});
test('should handle scoped macro with only macro content (no plaintext)', async ({ page }) => {
const input = '{{setvar::x}}{{user}}{{/setvar}}{{getvar::x}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('User');
});
test('should not match closing tag across different macro instances', async ({ page }) => {
// Two separate setvar macros - second closing should not match first opening
const input = '{{setvar::a}}first{{/setvar}}middle{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('middle[first][second]');
});
test.describe('scoped macros nested inside arguments', () => {
test('should resolve scoped macro inside another macro argument', async ({ page }) => {
// {{reverse}}hello{{/reverse}} inside setvar's value argument should resolve first
const input = '{{setvar::testvar::{{reverse}}hello{{/reverse}}}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' olleh');
});
test('should resolve scoped if macro inside setvar argument', async ({ page }) => {
// {{if true}}true branch{{/if}} inside setvar should resolve to "true branch"
const input = '{{setvar::testvar::{{if true}}true branch{{/if}}}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' true branch');
});
test('should resolve scoped if/else macro inside setvar argument', async ({ page }) => {
const input = '{{setvar::testvar::{{if 0}}wrong{{else}}correct{{/if}}}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' correct');
});
test('should resolve multiple scoped macros inside single argument', async ({ page }) => {
// Two scoped macros in the same argument
const input = '{{setvar::testvar::{{reverse}}ab{{/reverse}}-{{reverse}}cd{{/reverse}}}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' ba-dc');
});
test('should resolve deeply nested scoped macros in arguments', async ({ page }) => {
// Scoped macro inside scoped macro inside argument
const input = '{{setvar::outer::{{setvar::inner::{{reverse}}xyz{{/reverse}}}}{{getvar::inner}}}} {{getvar::outer}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' zyx');
});
test('should resolve scoped macro with text before and after in argument', async ({ page }) => {
const input = '{{setvar::testvar::before {{reverse}}mid{{/reverse}} after}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' before dim after');
});
test('should handle scoped macro inside first argument when macro has multiple args', async ({ page }) => {
// setvar has two args: name and value. Test scoped in value position.
const input = '{{setvar::myvar::prefix-{{reverse}}abc{{/reverse}}-suffix}}{{getvar::myvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('prefix-cba-suffix');
});
test('should handle multiline scoped content inside argument', async ({ page }) => {
const input = '{{setvar::testvar::{{if true}}\ntrue\nbranch\n{{/if}}}} {{getvar::testvar}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(' true\nbranch');
});
});
});
test.describe('{{if}} conditional macro', () => {
test.describe('with literal values', () => {
test('should return content when condition is truthy string', async ({ page }) => {
const input = '{{if::hello::shown}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
test('should return empty when condition is empty string', async ({ page }) => {
const input = '{{if::::hidden}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('');
});
test('should return empty when condition is "false"', async ({ page }) => {
const input = '{{if::false::hidden}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('');
});
test('should return empty when condition is "off"', async ({ page }) => {
const input = '{{if::off::hidden}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('');
});
test('should return empty when condition is "0"', async ({ page }) => {
const input = '{{if::0::hidden}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('');
});
test('should return content when condition is "true"', async ({ page }) => {
const input = '{{if::true::shown}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
test('should return content when condition is "1"', async ({ page }) => {
const input = '{{if::1::shown}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
});
test.describe('with macro name resolution', () => {
test('should resolve macro name and return content when macro returns truthy', async ({ page }) => {
// {{char}} returns "Character" (set in test env)
const input = '{{if char}}Name: {{char}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Name: Character');
});
test('should resolve macro name and return empty when macro returns empty', async ({ page }) => {
// {{noop}} is a registered macro that always returns empty string
const input = '{{if noop}}should not show{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
test('should not resolve non-existent macro names (treat as literal)', async ({ page }) => {
// "notamacro" is not registered, so it's truthy as a literal string
const input = '{{if::notamacro::shown}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
test('should resolve user macro and show content', async ({ page }) => {
// {{user}} returns "User" (set in test env)
const input = '{{if user}}Hello {{user}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User');
});
});
test.describe('with nested macros in condition', () => {
test('should evaluate nested macro in condition (truthy)', async ({ page }) => {
const input = '{{setvar::flag::yes}}{{if {{getvar::flag}}}}shown{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
test('should evaluate nested macro in condition (falsy)', async ({ page }) => {
const input = '{{setvar::flag::}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
test('should evaluate nested macro in condition (false string)', async ({ page }) => {
const input = '{{setvar::flag::false}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
});
test.describe('scoped usage', () => {
test('should work with scoped content (truthy)', async ({ page }) => {
const input = '{{if yes}}This is the content{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('This is the content');
});
test('should work with scoped content (falsy)', async ({ page }) => {
const input = '{{if::}}This should not show{{/if}}[after]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[after]');
});
test('should handle macros inside scoped content', async ({ page }) => {
const input = '{{if yes}}Hello {{user}}!{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User!');
});
test('should handle nested if macros', async ({ page }) => {
const input = '{{if yes}}outer{{if yes}}inner{{/if}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('outerinner');
});
test('should handle nested if with outer false', async ({ page }) => {
const input = '{{if::}}outer{{if yes}}inner{{/if}}{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
test('should handle nested if with inner false', async ({ page }) => {
const input = '{{if yes}}outer{{if::}}inner{{/if}}end{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('outerend');
});
});
test.describe('with space-separated condition', () => {
test('should work with space-separated condition (truthy)', async ({ page }) => {
const input = '{{if something}}content{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('content');
});
test('should resolve macro name with space-separated syntax', async ({ page }) => {
const input = '{{if char}}{{char}} exists{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Character exists');
});
});
test.describe('with {{else}} branch', () => {
test('should return then-branch when condition is truthy', async ({ page }) => {
const input = '{{if yes}}then{{else}}else{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('then');
});
test('should return else-branch when condition is falsy', async ({ page }) => {
const input = '{{if::}}then{{else}}else{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('else');
});
test('should return else-branch when condition is "false"', async ({ page }) => {
const input = '{{if::false}}yes{{else}}no{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('no');
});
test('should handle macros in both branches', async ({ page }) => {
const input = '{{if yes}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Hello User');
});
test('should handle macros in else branch when falsy', async ({ page }) => {
const input = '{{if::}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Goodbye Character');
});
test('should handle nested if-else in then-branch', async ({ page }) => {
const input = '{{if yes}}outer-then{{if yes}}inner-then{{else}}inner-else{{/if}}{{else}}outer-else{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('outer-theninner-then');
});
test('should handle nested if-else in else-branch', async ({ page }) => {
const input = '{{if::}}outer-then{{else}}outer-else{{if yes}}inner-then{{else}}inner-else{{/if}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('outer-elseinner-then');
});
test('should handle deeply nested if-else', async ({ page }) => {
const input = '{{if::}}A{{else}}B{{if::}}C{{else}}D{{/if}}{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('BD');
});
test('should return empty else-branch if not provided', async ({ page }) => {
const input = '{{if::}}content{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
test('should trim whitespace from branches', async ({ page }) => {
const input = '{{if yes}} then {{else}} else {{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('then');
});
test('should trim newlines from branches', async ({ page }) => {
const input = '{{if yes}}\n then\n{{else}}\n else\n{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('then');
});
test('should trim else branch when selected', async ({ page }) => {
const input = '{{if::}}\n then\n{{else}}\n else\n{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('else');
});
test('should resolve macro name in condition with else branch', async ({ page }) => {
const input = '{{if char}}Has char{{else}}No char{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Has char');
});
test('should handle empty macro returning else branch', async ({ page }) => {
const input = '{{if noop}}Has value{{else}}Empty{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Empty');
});
});
test.describe('with inverted condition (!)', () => {
test('should invert truthy condition to falsy', async ({ page }) => {
const input = '{{if !yes}}shown{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
test('should invert falsy condition to truthy', async ({ page }) => {
const input = '{{if !false}}shown{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('shown');
});
test('should invert empty string to truthy', async ({ page }) => {
const input = '{{if::!}}not shown{{else}}shown{{/if}}';
const output = await evaluateWithEngine(page, input);
// Note: "!" is not empty, so it's truthy - but this tests literal ! as value
expect(output).toBe('not shown');
});
test('should work with ! prefix and macro name', async ({ page }) => {
// noop returns empty string, so !noop should be truthy
const input = '{{if !noop}}No value{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('No value');
});
test('should work with ! prefix and truthy macro', async ({ page }) => {
// char returns "Character", so !char should be falsy
const input = '{{if !char}}No char{{else}}Has char{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Has char');
});
test('should work with ! prefix and nested macro', async ({ page }) => {
// Set a variable to empty, then check !{{getvar}}
const input = '{{setvar::emptyVar::}}{{if !{{getvar::emptyVar}}}}Empty var{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Empty var');
});
test('should NOT invert when ! comes from resolved value', async ({ page }) => {
// Set a variable starting with !, then check without ! prefix
// The ! in the value should NOT cause inversion
const input = '{{setvar::bangVar::!hello}}{{if {{getvar::bangVar}}}}Has value{{else}}No value{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Has value');
});
test('should work with else branch on inverted condition', async ({ page }) => {
const input = '{{if !yes}}then{{else}}else{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('else');
});
test('should work with separator syntax', async ({ page }) => {
const input = '{{if::!something}}shown{{/if}}[end]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[end]');
});
});
});
test.describe('scoped content auto-trim', () => {
test('should auto-trim scoped content by default', async ({ page }) => {
const input = '{{setvar::myvar}}\n content with whitespace \n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[content with whitespace]');
});
test('should auto-trim leading newlines in scoped content', async ({ page }) => {
const input = '{{setvar::myvar}}\n\n\ntext{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[text]');
});
test('should auto-trim trailing newlines in scoped content', async ({ page }) => {
const input = '{{setvar::myvar}}text\n\n\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[text]');
});
test('should dedent consistent indentation when auto-trimming', async ({ page }) => {
// Both lines have 2-space indent, so dedent removes it from both
const input = '{{setvar::myvar}}\n line1\n line2 \n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[line1\nline2]');
});
test('should preserve whitespace with # flag', async ({ page }) => {
const input = '{{#setvar::myvar}}\n content \n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[\n content \n]');
});
test('should preserve leading newlines with # flag', async ({ page }) => {
const input = '{{#setvar::myvar}}\n\ntext{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[\n\ntext]');
});
test('should preserve trailing newlines with # flag', async ({ page }) => {
const input = '{{#setvar::myvar}}text\n\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[text\n\n]');
});
test('should work with # flag and nested macros', async ({ page }) => {
const input = '{{#setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[\n Character \n]');
});
test('should auto-trim with nested macros by default', async ({ page }) => {
const input = '{{setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[Character]');
});
test('should auto-trim {{if}} scoped content', async ({ page }) => {
const input = '{{if yes}}\n trimmed \n{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('trimmed');
});
test('should preserve {{if}} whitespace with # flag', async ({ page }) => {
const input = '{{#if yes}}\n preserved \n{{/if}}';
const output = await evaluateWithEngine(page, input);
// With # flag, both outer content AND branch trimming is skipped
expect(output).toBe('\n preserved \n');
});
test('should auto-trim {{reverse}} scoped content', async ({ page }) => {
const input = '{{reverse}}\n abc \n{{/reverse}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('cba');
});
test('should preserve {{reverse}} whitespace with # flag', async ({ page }) => {
const input = '{{#reverse}}\n abc \n{{/reverse}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('\n cba \n');
});
test('should dedent consistent indentation from multiline content', async ({ page }) => {
const input = '{{setvar::myvar}}\n # Heading\n Content here\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[# Heading\nContent here]');
});
test('should dedent based on first non-empty line indentation', async ({ page }) => {
const input = '{{setvar::myvar}}\n line1\n line2\n line3\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[line1\nline2\nline3]');
});
test('should preserve relative indentation when dedenting', async ({ page }) => {
const input = '{{setvar::myvar}}\n parent\n child\n sibling\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[parent\n child\nsibling]');
});
test('should handle mixed indentation levels correctly', async ({ page }) => {
const input = '{{setvar::myvar}}\n # Header\n - item1\n - item2\n Paragraph\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[# Header\n - item1\n - item2\nParagraph]');
});
test('should dedent {{if}} branches with indentation', async ({ page }) => {
const input = '{{if yes}}\n # Title\n Body text\n{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('# Title\nBody text');
});
test('should dedent {{if}} else branch with indentation', async ({ page }) => {
const input = '{{if false}}\n Then branch\n{{else}}\n # Else Title\n Else body\n{{/if}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('# Else Title\nElse body');
});
test('should not dedent when # flag is set', async ({ page }) => {
const input = '{{#setvar::myvar}}\n # Heading\n Content\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[\n # Heading\n Content\n]');
});
test('should handle single line content without dedent issues', async ({ page }) => {
const input = '{{setvar::myvar}}\n single line\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[single line]');
});
test('should handle empty lines in multiline content', async ({ page }) => {
const input = '{{setvar::myvar}}\n line1\n\n line2\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[line1\n\nline2]');
});
test('should dedent based on first non-empty line and preserve relative indentation', async ({ page }) => {
// First non-empty line has 2-space indent, subsequent lines have varying indentation
// The 2-space base indent should be removed, preserving relative indentation
const input = '{{setvar::myvar}}\n First Line\n Second Line, more indented\n Third line\n Fourth line, also more indented\n{{/setvar}}[{{getvar::myvar}}]';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('[First Line\n Second Line, more indented\nThird line\n Fourth line, also more indented]');
});
});
test.describe('Pre/Post Processor Registration', () => {
test('should run custom pre-processor before macro evaluation', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Add a pre-processor that replaces [[USER]] with {{user}}
const handler = (text) => text.replace(/\[\[USER\]\]/g, '{{user}}');
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:custom-user-marker' });
try {
const input = 'Hello [[USER]]!';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePreProcessor(handler);
}
});
expect(output).toBe('Hello TestUser!');
});
test('should run custom post-processor after macro evaluation', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Add a post-processor that wraps output in brackets
const handler = (text) => `[${text}]`;
MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:bracket-wrapper' });
try {
const input = 'Hello {{user}}!';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePostProcessor(handler);
}
});
expect(output).toBe('[Hello TestUser!]');
});
test('should execute pre-processors in priority order (lower first)', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// First handler (priority 200) appends 'B'
const handlerB = (text) => text + 'B';
// Second handler (priority 100) appends 'A' - should run first despite being registered second
const handlerA = (text) => text + 'A';
MacroEngine.addPreProcessor(handlerB, { priority: 200, source: 'test:append-b' });
MacroEngine.addPreProcessor(handlerA, { priority: 100, source: 'test:append-a' });
try {
const input = 'X';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePreProcessor(handlerA);
MacroEngine.removePreProcessor(handlerB);
}
});
// Priority 100 (A) runs before priority 200 (B), so: X -> XA -> XAB
expect(output).toBe('XAB');
});
test('should execute post-processors in priority order (lower first)', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// First handler (priority 200) wraps with ()
const handlerParen = (text) => `(${text})`;
// Second handler (priority 100) wraps with [] - should run first
const handlerBracket = (text) => `[${text}]`;
MacroEngine.addPostProcessor(handlerParen, { priority: 200, source: 'test:wrap-paren' });
MacroEngine.addPostProcessor(handlerBracket, { priority: 100, source: 'test:wrap-bracket' });
try {
const input = 'X';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePostProcessor(handlerBracket);
MacroEngine.removePostProcessor(handlerParen);
}
});
// Priority 100 ([]) runs before priority 200 (()), so: X -> [X] -> ([X])
expect(output).toBe('([X])');
});
test('should successfully remove a registered pre-processor', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const handler = (text) => text + '-ADDED';
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:to-remove' });
// Remove it immediately
const removed = MacroEngine.removePreProcessor(handler);
const input = 'Test';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
const result = MacroEngine.evaluate(input, env);
return { result, removed };
});
expect(output.removed).toBe(true);
expect(output.result).toBe('Test'); // No '-ADDED' suffix
});
test('should return false when removing non-existent processor', async ({ page }) => {
const removed = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const handler = () => 'never registered';
return MacroEngine.removePreProcessor(handler);
});
expect(removed).toBe(false);
});
test('should pass env to pre-processor handlers', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Pre-processor that uses env to get the user name
/** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
const handler = (text, env) => text.replace('__NAME__', env.names.user);
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:env-access' });
try {
const input = 'Hello __NAME__!';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'EnvUser' });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePreProcessor(handler);
}
});
expect(output).toBe('Hello EnvUser!');
});
test('should pass env to post-processor handlers', async ({ page }) => {
const output = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Post-processor that appends the character name from env
/** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
const handler = (text, env) => `${text} (by ${env.names.char})`;
MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:env-access-post' });
try {
const input = 'Message';
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name2Override: 'EnvChar' });
return MacroEngine.evaluate(input, env);
} finally {
MacroEngine.removePostProcessor(handler);
}
});
expect(output).toBe('Message (by EnvChar)');
});
});
test.describe('Variable Shorthand Syntax', () => {
// {{.myvar}} - get local variable
test('should get local variable with . shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } });
expect(output).toBe('hello');
});
// {{$myvar}} - get global variable
test('should get global variable with $ shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } });
expect(output).toBe('world');
});
// {{.myvar = value}} - set local variable (setvar returns empty string)
test('should set local variable with = shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} });
// setvar returns '', then "Value: ", then getvar returns "test"
expect(output).toBe('Value: test');
});
// {{.counter++}} - increment local variable (incvar returns new value)
test('should increment local variable with ++ shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } });
expect(output).toBe('6');
});
// {{$counter--}} - decrement global variable (decvar returns new value)
test('should decrement global variable with -- shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } });
expect(output).toBe('9');
});
// {{.myvar += 5}} - add to local variable (addvar returns empty string)
test('should add to local variable with += shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } });
// addvar returns '', then "Then: ", then getvar returns "10"
expect(output).toBe('Then: 10');
});
// Nested macro in value: {{.myvar = {{user}}}}
test('should support nested macro in variable value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} });
// setvar returns '', then getvar returns "Hello User"
expect(output).toBe('Hello User');
});
// Whitespace handling: {{ .myvar = value }}
test('should handle whitespace in variable shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} });
// setvar returns '', then getvar returns "spaced"
expect(output).toBe('spaced');
});
// Variable with hyphen in name: {{.my-var}}
test('should handle variable name with hyphens', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } });
expect(output).toBe('hyphenated');
});
// Variable with underscore: {{.my_var}}
test('should handle variable name with underscores', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } });
expect(output).toBe('underscored');
});
// Non-existent variable returns empty string
test('should return empty string for non-existent variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} });
expect(output).toBe('Value:[]');
});
// Increment non-existent variable (should start from 0)
test('should increment non-existent variable starting from 0', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} });
expect(output).toBe('1');
});
// Chain multiple operations
test('should handle multiple variable operations in sequence', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} });
// setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
expect(output).toBe('616');
});
// {{.myvar -= 5}} - subtract from local variable
test('should subtract from local variable with -= shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= 3}}Then: {{.myvar}}', { local: { myvar: '10' } });
// subvar returns '', then "Then: ", then getvar returns "7"
expect(output).toBe('Then: 7');
});
// {{$myvar -= 5}} - subtract from global variable
test('should subtract from global variable with -= shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar -= 5}}{{$myvar}}', { global: { myvar: '20' } });
expect(output).toBe('15');
});
// {{.myvar || default}} - returns default when falsy
test('should return default value with || when variable is falsy (empty)', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '' } });
expect(output).toBe('fallback');
});
test('should return default value with || when variable is falsy (zero)', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '0' } });
expect(output).toBe('fallback');
});
test('should return variable value with || when truthy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: 'existing' } });
expect(output).toBe('existing');
});
test('should return default value with || when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.nonexistent || default}}', { local: {} });
expect(output).toBe('default');
});
// {{.myvar ?? default}} - returns default only when undefined
test('should return default value with ?? when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: {} });
expect(output).toBe('fallback');
});
test('should return empty string with ?? when variable exists but is empty', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? fallback}}]', { local: { myvar: '' } });
expect(output).toBe('[]');
});
test('should return zero with ?? when variable exists and is zero', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: '0' } });
expect(output).toBe('0');
});
test('should return variable value with ?? when it exists', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: 'value' } });
expect(output).toBe('value');
});
// {{.myvar ||= default}} - sets and returns default when falsy
test('should set and return default with ||= when variable is falsy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: '' } });
// ||= returns 'newval', then getvar also returns 'newval'
expect(output).toBe('newvalnewval');
});
test('should not set and return current with ||= when variable is truthy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: 'existing' } });
// ||= returns 'existing', then getvar returns 'existing'
expect(output).toBe('existingexisting');
});
test('should set and return default with ||= when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= created}}{{.myvar}}', { local: {} });
expect(output).toBe('createdcreated');
});
// {{.myvar ??= default}} - sets and returns default only when undefined
test('should set and return default with ??= when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= created}}{{.myvar}}', { local: {} });
expect(output).toBe('createdcreated');
});
test('should not set and return current with ??= when variable exists but is empty', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ??= newval}}][{{.myvar}}]', { local: { myvar: '' } });
// ??= returns '' (current value), then getvar returns '' (unchanged)
expect(output).toBe('[][]');
});
test('should not set and return current with ??= when variable exists and is zero', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= newval}}{{.myvar}}', { local: { myvar: '0' } });
// ??= returns '0', then getvar returns '0'
expect(output).toBe('00');
});
// {{.myvar == value}} - equality comparison
test('should return true when variable equals value with ==', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == hello}}', { local: { myvar: 'hello' } });
expect(output).toBe('true');
});
test('should return false when variable does not equal value with ==', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == world}}', { local: { myvar: 'hello' } });
expect(output).toBe('false');
});
test('should compare empty variable correctly with ==', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ==}}', { local: { myvar: '' } });
expect(output).toBe('true');
});
test('should compare numeric value correctly with ==', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == 42}}', { local: { myvar: '42' } });
expect(output).toBe('true');
});
// {{.myvar != value}} - inequality comparison
test('should return true when variable does not equal value with !=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != world}}', { local: { myvar: 'hello' } });
expect(output).toBe('true');
});
test('should return false when variable equals value with !=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != hello}}', { local: { myvar: 'hello' } });
expect(output).toBe('false');
});
test('should compare empty variable correctly with !=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar !=}}', { local: { myvar: '' } });
expect(output).toBe('false');
});
test('should compare non-empty to empty with !=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != }}', { local: { myvar: 'value' } });
expect(output).toBe('true');
});
test('should compare numeric value correctly with !=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != 99}}', { local: { myvar: '42' } });
expect(output).toBe('true');
});
// {{.myvar > value}} - greater than comparison (numeric)
test('should return true when variable is greater than value with >', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: '10' } });
expect(output).toBe('true');
});
test('should return false when variable is not greater than value with >', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '5' } });
expect(output).toBe('false');
});
test('should return false when variable equals value with >', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '10' } });
expect(output).toBe('false');
});
test('should return false for non-numeric values with >', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: 'abc' } });
expect(output).toBe('false');
});
// {{.myvar >= value}} - greater than or equal comparison (numeric)
test('should return true when variable is greater than value with >=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 5}}', { local: { myvar: '10' } });
expect(output).toBe('true');
});
test('should return true when variable equals value with >=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '10' } });
expect(output).toBe('true');
});
test('should return false when variable is less than value with >=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '5' } });
expect(output).toBe('false');
});
// {{.myvar < value}} - less than comparison (numeric)
test('should return true when variable is less than value with <', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '5' } });
expect(output).toBe('true');
});
test('should return false when variable is not less than value with <', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: '10' } });
expect(output).toBe('false');
});
test('should return false when variable equals value with <', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '10' } });
expect(output).toBe('false');
});
test('should return false for non-numeric values with <', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: 'abc' } });
expect(output).toBe('false');
});
// {{.myvar <= value}} - less than or equal comparison (numeric)
test('should return true when variable is less than value with <=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '5' } });
expect(output).toBe('true');
});
test('should return true when variable equals value with <=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '10' } });
expect(output).toBe('true');
});
test('should return false when variable is greater than value with <=', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 5}}', { local: { myvar: '10' } });
expect(output).toBe('false');
});
// Negative numbers with comparison operators
test('should handle negative numbers with > operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > -5}}', { local: { myvar: '0' } });
expect(output).toBe('true');
});
test('should handle negative numbers with < operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 0}}', { local: { myvar: '-5' } });
expect(output).toBe('true');
});
// Decimal numbers with comparison operators
test('should handle decimal numbers with >= operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 3.14}}', { local: { myvar: '3.14' } });
expect(output).toBe('true');
});
test('should handle decimal numbers with <= operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 2.5}}', { local: { myvar: '2.49' } });
expect(output).toBe('true');
});
// Global variable versions of new operators
test('should use || with global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });
expect(output).toBe('globaldefault');
});
test('should use ?? with global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ?? globaldefault}}', { global: {} });
expect(output).toBe('globaldefault');
});
test('should use ||= with global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ||= gset}}{{$myvar}}', { global: { myvar: '' } });
expect(output).toBe('gsetgset');
});
test('should use ??= with global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ??= gcreated}}{{$myvar}}', { global: {} });
expect(output).toBe('gcreatedgcreated');
});
test('should use == with global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{$myvar == test}}', { global: { myvar: 'test' } });
expect(output).toBe('true');
});
// Nested macro in fallback value
test('should support nested macro in || fallback value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || Hello {{user}}}}', { local: {} });
expect(output).toBe('Hello User');
});
test('should support nested macro in ?? fallback value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? Hello {{user}}}}', { local: {} });
expect(output).toBe('Hello User');
});
// Whitespace handling with new operators
test('should handle whitespace with || operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar || spaced }}', { local: {} });
expect(output).toBe('spaced');
});
test('should handle whitespace with ?? operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar ?? spaced }}', { local: {} });
expect(output).toBe('spaced');
});
});
test.describe('Variable Shorthand Lazy Evaluation', () => {
// Tests to verify that fallback value expressions are only evaluated when needed.
// This is important for performance and because some macros are stateful.
// ?? should NOT evaluate fallback when variable exists
test('should NOT evaluate ?? fallback when variable exists', async ({ page }) => {
// Use setvar in the fallback - if lazy evaluation works, tracker should remain unset
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'exists' } });
// myvar exists, so ?? returns 'exists' and the fallback (which would set tracker) is NOT evaluated
expect(output).toBe('exists[]');
});
test('should evaluate ?? fallback when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: {} });
// myvar doesn't exist, so ?? evaluates and returns the fallback, setting tracker
expect(output).toBe('fallback[evaluated]');
});
// || should NOT evaluate fallback when variable is truthy
test('should NOT evaluate || fallback when variable is truthy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
// myvar is truthy, so || returns 'truthy' and the fallback is NOT evaluated
expect(output).toBe('truthy[]');
});
test('should evaluate || fallback when variable is falsy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: '' } });
// myvar is falsy, so || evaluates and returns the fallback, setting tracker
expect(output).toBe('fallback[evaluated]');
});
// ??= should NOT evaluate value when variable exists
test('should NOT evaluate ??= value when variable exists', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'exists' } });
// myvar exists, so ??= returns current value and the value expression is NOT evaluated
expect(output).toBe('exists[]');
});
test('should evaluate ??= value when variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: {} });
// myvar doesn't exist, so ??= evaluates value, sets myvar, and returns it
expect(output).toBe('newval[evaluated]');
});
// ||= should NOT evaluate value when variable is truthy
test('should NOT evaluate ||= value when variable is truthy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
// myvar is truthy, so ||= returns current value and the value expression is NOT evaluated
expect(output).toBe('truthy[]');
});
test('should evaluate ||= value when variable is falsy', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: '' } });
// myvar is falsy, so ||= evaluates value, sets myvar, and returns it
expect(output).toBe('newval[evaluated]');
});
// Operators that ALWAYS evaluate value should still work
test('should always evaluate = value expression', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = {{.tracker = evaluated}}value}}[{{.tracker}}]', { local: {} });
expect(output).toBe('[evaluated]');
});
test('should always evaluate += value expression', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += {{.tracker = evaluated}}5}}[{{.tracker}}]', { local: { myvar: '10' } });
expect(output).toBe('[evaluated]');
});
test('should always evaluate == value expression', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == {{.tracker = evaluated}}test}}[{{.tracker}}]', { local: { myvar: 'test' } });
expect(output).toBe('true[evaluated]');
});
// Value should only be evaluated once (caching test)
test('should only evaluate value expression once when needed', async ({ page }) => {
// Use addvar to track how many times the value is evaluated (addvar returns empty string)
const output = await evaluateWithEngineAndVariables(page, '{{.counter = 0}}{{.myvar ??= {{.counter += 1}}value}}{{.counter}}', { local: {} });
// counter should be 1 (value evaluated exactly once)
expect(output).toBe('value1');
});
});
test.describe('Variable Shorthand Edge Cases', () => {
// Operators requiring a value but value is empty
test('should handle = operator with empty value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = }}[{{.myvar}}]', { local: {} });
// Empty value after = should set the variable to empty string
expect(output).toBe('[]');
});
test('should handle += operator with empty value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += }}[{{.myvar}}]', { local: { myvar: 'existing' } });
// Empty value after += should add nothing
expect(output).toBe('[existing]');
});
test('should handle -= operator with empty value (non-numeric)', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= }}[{{.myvar}}]', { local: { myvar: '10' } });
// Empty value is NaN, so subtraction fails silently and returns empty
expect(output).toBe('[10]');
});
test('should handle || operator with empty fallback', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar || }}]', { local: { myvar: '' } });
// Falsy myvar, empty fallback - returns empty string
expect(output).toBe('[]');
});
test('should handle ?? operator with empty fallback', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? }}]', { local: {} });
// Undefined myvar, empty fallback - returns empty string
expect(output).toBe('[]');
});
test('should handle == operator with empty comparison value', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: '' } });
// Empty var equals empty value - should be true
expect(output).toBe('true');
});
test('should handle == operator comparing non-empty to empty', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: 'value' } });
// Non-empty var vs empty value - should be false
expect(output).toBe('false');
});
// Operators that don't take values - should return raw if invalid
test('should return raw with trailing content after ++ operator', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.myvar++5}}', { local: { myvar: '5' } });
expect(output).toBe('{{.myvar++5}}');
});
test('should return empty with trailing content after -- operator', async ({ page }) => {
// This is a weird case. The "--" operator does not accept value expression, but writing it like this,
// makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
// This is intended, so this resolving to null, as the variable does not exist, is also intended.
const output = await evaluateWithEngineAndVariables(page, '{{.myvar--5}}', { local: { myvar: '10' } });
expect(output).toBe('');
});
test('should return raw with trailing content after -- operator separated by spaces', async ({ page }) => {
// This is a weird case. The "--" operator does not accept value expression, but writing it like this,
// makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
// This is intended, so this resolving to null, as the variable does not exist, is also intended.
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -- 5}}', { local: { myvar: '10' } });
expect(output).toBe('{{.myvar -- 5}}');
});
});
test.describe('Variable Shorthand in {{if}} Macro', () => {
// {{if .myvar}}...{{/if}} - truthy local variable
test('should evaluate truthy local variable in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } });
expect(output).toBe('Yes');
});
// {{if .myvar}}...{{/if}} - falsy local variable
test('should evaluate falsy local variable in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } });
expect(output).toBe('');
});
// {{if $globalvar}}...{{/if}} - truthy global variable
test('should evaluate truthy global variable in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } });
expect(output).toBe('Active');
});
// {{if !.myvar}}...{{/if}} - inverted condition
test('should evaluate inverted variable condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } });
expect(output).toBe('Not set');
});
// {{if !$globalvar}}...{{/if}} - inverted global
test('should evaluate inverted global variable condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } });
expect(output).toBe('Enabled');
});
// {{if ! .myvar}}...{{/if}} - inverted with whitespace
test('should evaluate inverted condition with whitespace after !', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } });
expect(output).toBe('Empty');
});
// Non-existent variable is falsy
test('should treat non-existent variable as falsy in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} });
expect(output).toBe('No');
});
// {{if .myvar}}...{{else}}...{{/if}} - with else branch
test('should handle else branch with variable shorthand', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } });
expect(output).toBe('On');
});
// Variable with hyphen in if condition
test('should handle variable with hyphen in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } });
expect(output).toBe('Valid');
});
// Combine set and if
test('should work with variable set before if check', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} });
expect(output).toBe('Ready!');
});
// Zero is falsy
test('should treat zero as falsy in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } });
expect(output).toBe('No count');
});
// Non-zero number is truthy
test('should treat non-zero number as truthy in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } });
expect(output).toBe('Count: 42');
});
});
const getUniqueVariableId = () => `dt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
test.describe('Delayed Argument Resolution ({{if}} branch isolation)', () => {
// Core feature: setvar in non-chosen branch should NOT execute
test('should NOT execute setvar in false branch', async ({ page }) => {
const id = getUniqueVariableId();
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(id);
const input = `{{if 0}}{{setvar::${id}::should-not-set}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
// Variable should NOT be set because the branch was not taken
const result = ctx.variables.local.get(id);
ctx.variables.local.del(id);
return result;
}, id);
expect(output).toBe('');
});
// setvar in true branch SHOULD execute
test('should execute setvar in true branch', async ({ page }) => {
const id = getUniqueVariableId();
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(id);
const input = `{{if 1}}{{setvar::${id}::was-set}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const result = ctx.variables.local.get(id);
ctx.variables.local.del(id);
return result;
}, id);
expect(output).toBe('was-set');
});
// With else branch: only the chosen branch's setvar should execute
test('should only execute setvar in chosen else branch', async ({ page }) => {
const id = getUniqueVariableId();
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(id);
const input = `{{if 0}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const result = ctx.variables.local.get(id);
ctx.variables.local.del(id);
return result;
}, id);
expect(output).toBe('else-branch');
});
// Verify then branch setvar executes, not else branch
test('should only execute setvar in chosen then branch', async ({ page }) => {
const id = getUniqueVariableId();
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(id);
const input = `{{if 1}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const result = ctx.variables.local.get(id);
ctx.variables.local.del(id);
return result;
}, id);
expect(output).toBe('then-branch');
});
// Multiple setvars in branches - only chosen branch's setvars execute
test('should execute multiple setvars only in chosen branch', async ({ page }) => {
const id = getUniqueVariableId();
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(`${id}_a`);
ctx.variables.local.del(`${id}_b`);
const input = `{{if 0}}{{setvar::${id}_a::wrong}}{{setvar::${id}_b::wrong}}{{else}}{{setvar::${id}_a::right}}{{setvar::${id}_b::right}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const a = ctx.variables.local.get(`${id}_a`);
const b = ctx.variables.local.get(`${id}_b`);
ctx.variables.local.del(`${id}_a`);
ctx.variables.local.del(`${id}_b`);
return `a=${a},b=${b}`;
}, id);
expect(output).toBe('a=right,b=right');
});
// Nested if with delayed resolution - inner if should also work correctly
test('should handle nested if with delayed resolution', async ({ page }) => {
const id = `dt_nested_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.del(`${id}_outer`);
ctx.variables.local.del(`${id}_inner`);
// Outer if is true, inner if is false
const input = `{{if 1}}{{setvar::${id}_outer::yes}}{{if 0}}{{setvar::${id}_inner::wrong}}{{else}}{{setvar::${id}_inner::correct}}{{/if}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const outer = ctx.variables.local.get(`${id}_outer`);
const inner = ctx.variables.local.get(`${id}_inner`);
ctx.variables.local.del(`${id}_outer`);
ctx.variables.local.del(`${id}_inner`);
return `outer=${outer},inner=${inner}`;
}, id);
expect(output).toBe('outer=yes,inner=correct');
});
// Variable-based condition with delayed resolution
test('should work with variable shorthand condition and delayed resolution', async ({ page }) => {
const id = `dt_varsh_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const output = await page.evaluate(async (id) => {
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = SillyTavern.getContext();
ctx.variables.local.set(`${id}_flag`, '');
ctx.variables.local.del(`${id}_result`);
const input = `{{if .${id}_flag}}{{setvar::${id}_result::truthy}}{{else}}{{setvar::${id}_result::falsy}}{{/if}}`;
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
MacroEngine.evaluate(input, env);
const result = ctx.variables.local.get(`${id}_result`);
ctx.variables.local.del(`${id}_flag`);
ctx.variables.local.del(`${id}_result`);
return result;
}, id);
expect(output).toBe('falsy');
});
// Inline {{if}} should not break outer {{else}} detection
test('should handle inline if inside scoped if with else', async ({ page }) => {
const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::inner}}{{else}}outer-else{{/if}}');
expect(output).toBe('outer-else');
});
// Another inline if scenario - inner inline if should not affect outer else
test('should correctly find outer else with multiple inline ifs', async ({ page }) => {
const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::a}}{{if::1::b}}{{else}}found{{/if}}');
expect(output).toBe('found');
});
});
test.describe('Variable Macros (hasvar, deletevar)', () => {
// {{hasvar::name}} - check if local variable exists
test('should return true when local variable exists', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: 'value' } });
expect(output).toBe('true');
});
test('should return false when local variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::nonexistent}}', { local: {} });
expect(output).toBe('false');
});
test('should return true when local variable exists but is empty', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: '' } });
expect(output).toBe('true');
});
// {{hasglobalvar::name}} - check if global variable exists
test('should return true when global variable exists', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
expect(output).toBe('true');
});
test('should return false when global variable does not exist', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::nonexistent}}', { global: {} });
expect(output).toBe('false');
});
// {{deletevar::name}} - delete local variable
test('should delete local variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}{{deletevar::myvar}}{{hasvar::myvar}}', { local: { myvar: 'value' } });
expect(output).toBe('truefalse');
});
// {{deleteglobalvar::name}} - delete global variable
test('should delete global variable', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}{{deleteglobalvar::myvar}}{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
expect(output).toBe('truefalse');
});
// Combining hasvar with if
test('should use hasvar in if condition', async ({ page }) => {
const output = await evaluateWithEngineAndVariables(page, '{{if {{hasvar::myvar}} == true}}exists{{else}}missing{{/if}}', { local: { myvar: '' } });
expect(output).toBe('exists');
});
});
});
/**
* Evaluates the given input string using the MacroEngine inside the browser
* context, ensuring that the core macros are registered.
*
* @param {import('@playwright/test').Page} page
* @param {string} input
* @returns {Promise<string>}
*/
async function evaluateWithEngine(page, input) {
const result = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const rawEnv = {
content: input,
name1Override: 'User',
name2Override: 'Character',
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const output = await MacroEngine.evaluate(input, env);
return output;
}, input);
return result;
}
/**
* Evaluates the given input string while capturing whether any macro-related
* warnings or errors were logged to the browser console.
*
* This is useful for tests that want to assert both the resolved output and
* whether the lexer/parser/engine reported issues (e.g. unterminated macros).
*
* @param {import('@playwright/test').Page} page
* @param {string} input
* @returns {Promise<{ output: string, hasMacroWarnings: boolean, hasMacroErrors: boolean }>}
*/
async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
/** @type {boolean} */
let hasMacroWarnings = false;
/** @type {boolean} */
let hasMacroErrors = false;
/** @param {import('playwright').ConsoleMessage} msg */
const handler = (msg) => {
const text = msg.text();
if (text.includes('[Macro] Warning:')) {
hasMacroWarnings = true;
}
if (text.includes('[Macro] Error:')) {
hasMacroErrors = true;
}
};
page.on('console', handler);
try {
const output = await evaluateWithEngine(page, input);
return { output, hasMacroWarnings, hasMacroErrors };
} finally {
page.off('console', handler);
}
}
/**
* Evaluates the given input string with pre-set variables.
* Variables are set via SillyTavern.getContext().variables which is where
* the variable macros read/write their data.
*
* @param {import('@playwright/test').Page} page
* @param {string} input
* @param {{ local?: Record<string, string>, global?: Record<string, string> }} variables
* @returns {Promise<string>}
*/
async function evaluateWithEngineAndVariables(page, input, variables) {
const result = await page.evaluate(async ({ input, variables }) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
// Get the SillyTavern context for variable access
const ctx = SillyTavern.getContext();
// Pre-set local variables
if (variables.local) {
for (const [key, value] of Object.entries(variables.local)) {
ctx.variables.local.set(key, value);
}
}
// Pre-set global variables
if (variables.global) {
for (const [key, value] of Object.entries(variables.global)) {
ctx.variables.global.set(key, value);
}
}
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const rawEnv = {
content: input,
name1Override: 'User',
name2Override: 'Character',
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const output = await MacroEngine.evaluate(input, env);
return output;
}, { input, variables });
return result;
}