Macros 2.0 (v0.6.6) - STscript compatibility (#4957) * Bypass REPLACE_GETVAR with experimental parser enabled * (wip) macro-2.0 replacer in STscript parser * Add support for MacroDefinitionOptions format in dynamic macros - Add DynamicMacroValue typedef supporting string, function, or MacroDefinitionOptions object - Extract macro definition building logic into MacroRegistry.buildMacroDefFromOptions() method - Update MacroEngine to detect and handle three dynamic macro formats: 1. string - direct value, no args allowed 2. function - handler function, no args allowed (legacy) 3. MacroDefinitionOptions object - full definition with handler, args, type * Implement dynamic macro replacers * Remove global typedef * Use unique closure boundary * Add e2e tests for MacroSlashCommands * Update public/scripts/macros/engine/MacroEngine.js Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com> * Use strict args array match * Update public/scripts/macros/engine/MacroEngine.js --------- Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com>

7331dba056583f4fd1118686ed2fe6fbea5785e6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
10 files changed, +1241 -273Ignore whitespace
public/script.js+1 -1
@@ -2839,7 +2839,7 @@ export function substituteParamsLegacy(content, _name1, _name2, _original, _grou
28392839 * @param {string} [options.original] - The original message for {{original}} substitution.
28402840 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
28412841 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
28422842 * @param {Record<string,string|MacroHandler import('./scripts/macros/engine/MacroEnv.types.js').DynamicMacroValue>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
28432843 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
28442844 * @returns {string} The string with substituted parameters.
28452845 */
public/scripts/macros/engine/MacroEngine.js+36 -22
@@ -1,11 +1,12 @@
11import { MacroParser } from './MacroParser.js';
22import { MacroCstWalker } from './MacroCstWalker.js';
33import { MacroRegistry, MacroValueType } from './MacroRegistry.js';
44import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
55import { ELSE_MARKER } from '../definitions/core-macros.js';
66
77/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
88/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
9+/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
910/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
1011
1112/**
@@ -166,31 +167,44 @@ class MacroEngine {
166167
167168 // First check if this is a dynamic macro to use. If so, we will create a temporary macro definition for it and use that over any registered macro.
168169 // Dynamic macro keys are normalized to lowercase for case-insensitive matching.
169170 /** @type {MacroDefinition?|null} */
170171 let defOverride = null;
171172 const nameLower = name.toLowerCase();
172173 if (Object.hasOwn(env.dynamicMacros, nameLower)) {
173174 const impl = env.dynamicMacros[nameLower];
174- defOverride = {
175+
175- name,
176+ // Dynamic macros support three formats:
176- aliases: [],
177+ // 1. string - direct value, no args allowed
177- category: 'dynamic',
178+ // 2. function - handler function, no args allowed (legacy behavior)
178- description: 'Dynamic macro',
179+ // 3. MacroDefinitionOptions object - full definition with handler, args, type validation, etc.
179- minArgs: 0,
180+
180- maxArgs: 0,
181+ // Check if this looks like a MacroDefinitionOptions object (has handler property)
181- unnamedArgDefs: [],
182+ const looksLikeOptions = impl && typeof impl === 'object' &&
182- list: null,
183+ 'handler' in impl && typeof impl.handler === 'function';
183- strictArgs: true, // Fail dynamic macros if they are called with arguments
184+
184- returns: null,
185+ if (looksLikeOptions) {
185- returnType: 'string',
186+ // Case 3: MacroDefinitionOptions - use the full definition builder
186- displayOverride: null,
187+ try {
187- exampleUsage: [],
188+ const options = /** @type {MacroDefinitionOptions} */ (impl);
188- source: { name: 'dynamic', isExtension: false, isThirdParty: false },
189+ defOverride = MacroRegistry.buildMacroDefFromOptions(name, options);
189- aliasOf: null,
190+ } catch (error) {
190- aliasVisible: null,
191+ // If building fails, log warning and fall through to check registered macros
191- delayArgResolution: false,
192+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" has invalid options: ${error.message}`, call });
192- handler: typeof impl === 'function' ? impl : () => impl,
193+ }
193- };
194+ } else if (['string', 'number', 'boolean', 'function'].includes((typeof impl))) {
195+ // Case 1 & 2: string or handler function
196+ if (['number', 'boolean'].includes(typeof impl)) {
197+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" uses unsupported number/boolean format.`, call });
198+ }
199+ defOverride = MacroRegistry.buildMacroDefFromOptions(name, {
200+ handler: typeof impl === 'function' ? impl : () => String(impl ?? ''),
201+ category: 'dynamic',
202+ description: 'Dynamic macro',
203+ returnType: MacroValueType.STRING,
204+ });
205+ } else {
206+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" is not defined correctly (must be string, a handler function, or a macro def options object with handler property).`, call });
207+ }
194208 }
195209
196210 // If not, check if the macro exists and is registered
public/scripts/macros/engine/MacroEnv.types.js+10 -1
@@ -7,6 +7,15 @@
77 */
88
99/** @typedef {import('./MacroRegistry.js').MacroHandler} MacroHandler */
10+/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
11+
12+/**
13+ * A dynamic macro value can be:
14+ * - A string (direct value)
15+ * - A MacroHandler function (resolved at runtime)
16+ * - A MacroDefinitionOptions object (full macro definition with handler, args, etc.)
17+ * @typedef {string | MacroHandler | MacroDefinitionOptions} DynamicMacroValue
18+ */
1019
1120/**
1221 * @typedef {Object} MacroEnvNames
@@ -50,7 +59,7 @@
5059 * @property {MacroEnvCharacter} character
5160 * @property {MacroEnvSystem} system
5261 * @property {MacroEnvFunctions} functions
5362 * @property {Object<string, string|MacroHandlerDynamicMacroValue>} dynamicMacros
5463 * @property {Record<string, unknown>} extra
5564 */
5665
public/scripts/macros/engine/MacroEnvBuilder.js+1 -1
@@ -22,7 +22,7 @@ import { getStringHash } from '/scripts/utils.js';
2222 * @property {string|null} [original]
2323 * @property {string|null} [groupOverride]
2424 * @property {boolean} [replaceCharacterCard]
2525 * @property {Record<string, anyimport('./MacroEnv.types.js').DynamicMacroValue>|null} [dynamicMacros]
2626 * @property {(value: string) => string} [postProcessFn]
2727 */
2828
public/scripts/macros/engine/MacroRegistry.js+224 -202
@@ -192,182 +192,6 @@ class MacroRegistry {
192192 name = typeof name === 'string' ? name.trim() : String(name);
193193
194194 try {
195- if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
196- if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
197-
198- const {
199- aliases: rawAliases,
200- category: rawCategory,
201- unnamedArgs: rawUnnamedArgs,
202- list: rawList,
203- strictArgs: rawStrictArgs,
204- description: rawDescription,
205- returns: rawReturns,
206- returnType: rawReturnType,
207- displayOverride: rawDisplayOverride,
208- exampleUsage: rawExampleUsage,
209- delayArgResolution: rawDelayArgResolution,
210- handler,
211- } = options;
212-
213- if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
214-
215- /** @type {MacroResolvedAlias[]} */
216- const aliases = [];
217- if (rawAliases !== undefined && rawAliases !== null) {
218- if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
219- for (const [i, aliasDef] of rawAliases.entries()) {
220- if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
221- if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
222- const aliasName = aliasDef.alias.trim();
223- if (!isIdentifierValid(aliasName)) throw new Error(`Macro "${name}" options.aliases[${i}].alias "${aliasName}" is invalid. Must start with a letter, followed by word chars or hyphens.`);
224- if (aliasName.toLowerCase() === name.toLowerCase()) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name (insensitive).`);
225- const visible = aliasDef.visible !== false; // Default to true
226- aliases.push({ alias: aliasName, visible });
227- }
228- }
229-
230- /** @type {MacroCategory|string} */
231- let category = MacroCategory.UNCATEGORIZED;
232- if (typeof rawCategory === 'string' && rawCategory.trim()) {
233- category = rawCategory.trim();
234- }
235-
236- let minArgs = 0;
237- let maxArgs = 0;
238- /** @type {MacroUnnamedArgDef[]} */
239- let unnamedArgDefs = [];
240- if (rawUnnamedArgs !== undefined) {
241- if (Array.isArray(rawUnnamedArgs)) {
242- // Parse array of argument definitions with optional support
243- let foundOptional = false;
244- unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
245- if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
246- if (typeof def.name !== 'string' || !def.name.trim()) throw new Error(`Macro "${name}" options.unnamedArgs[${index}].name must be a non-empty string when using argument definitions.`);
247-
248- // Validate: no required args after optional
249- if (foundOptional && !def.optional) {
250- throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
251- }
252- if (def.optional) foundOptional = true;
253-
254- /** @type {MacroUnnamedArgDef} */
255- const normalized = {
256- name: def.name.trim(),
257- optional: def.optional || false,
258- defaultValue: def.defaultValue?.trim(),
259- type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
260- sampleValue: def.sampleValue?.trim(),
261- description: typeof def.description === 'string' ? def.description : undefined,
262- };
263-
264- const validTypes = ['string', 'integer', 'number', 'boolean'];
265- const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
266- if (type.some(t => !validTypes.includes(t))) {
267- throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
268- }
269-
270- return normalized;
271- });
272-
273- // Compute minArgs (required count) and maxArgs (total count)
274- maxArgs = unnamedArgDefs.length;
275- minArgs = unnamedArgDefs.findIndex(d => d.optional);
276- if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required
277- } else if (typeof rawUnnamedArgs === 'number') {
278- if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
279- throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
280- }
281- minArgs = rawUnnamedArgs;
282- maxArgs = rawUnnamedArgs;
283- unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
284- name: `arg${i + 1}`,
285- optional: false,
286- type: 'string',
287- sampleValue: `arg${i + 1}`,
288- }));
289- } else {
290- throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
291- }
292- }
293-
294- /** @type {{ min: number, max: (number|null) }|null} */
295- let list = null;
296- if (rawList !== undefined) {
297- if (typeof rawList === 'boolean') {
298- list = rawList ? { min: 0, max: null } : null;
299- } else if (typeof rawList === 'object' && rawList !== null) {
300- if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
301- if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
302- if (rawList.max !== undefined && rawList.max < rawList.min) throw new Error(`Macro "${name}" options.list.max must be greater than or equal to options.list.min.`);
303- list = { min: rawList.min, max: rawList.max ?? null };
304- } else {
305- throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
306- }
307- }
308-
309- let strictArgs = true;
310- if (rawStrictArgs !== undefined) {
311- if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
312- strictArgs = rawStrictArgs;
313- }
314-
315- let description = '<no description>';
316- if (rawDescription !== undefined) {
317- if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
318- description = rawDescription;
319- }
320-
321- let returns = null;
322- if (rawReturns !== undefined && rawReturns !== null) {
323- if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
324- returns = rawReturns || '<empty string>';
325- }
326-
327- // Process and validate returnType (defaults to 'string')
328- const validTypes = ['string', 'integer', 'number', 'boolean'];
329- let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
330- if (rawReturnType !== undefined && rawReturnType !== null) {
331- // Normalize to non-empty value or default
332- returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
333- // Validate all types
334- const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
335- if (typesToValidate.some(t => !validTypes.includes(t))) {
336- throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
337- }
338- }
339-
340- let displayOverride = null;
341- if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
342- if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
343- displayOverride = rawDisplayOverride.trim();
344- if (displayOverride && !displayOverride.startsWith('{{')) {
345- logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
346- displayOverride = `{{${displayOverride}}}`;
347- }
348- }
349-
350- /** @type {string[]} */
351- let exampleUsage = [];
352- if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
353- const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
354- for (const [i, ex] of examples.entries()) {
355- if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
356- let trimmed = ex.trim();
357- if (trimmed && !trimmed.startsWith('{{')) {
358- logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
359- trimmed = `{{${trimmed}}}`;
360- }
361- if (trimmed) exampleUsage.push(trimmed);
362- }
363- }
364-
365- let delayArgResolution = false;
366- if (rawDelayArgResolution !== undefined) {
367- if (typeof rawDelayArgResolution !== 'boolean') throw new Error(`Macro "${name}" options.delayArgResolution must be a boolean when provided.`);
368- delayArgResolution = rawDelayArgResolution;
369- }
370-
371195 const nameKey = name.toLowerCase();
372196 if (this.#macros.has(nameKey)) {
373197 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
@@ -376,36 +200,15 @@ class MacroRegistry {
376200 // Detect extension/third-party status from call stack
377201 const { isExtension, isThirdParty, source } = detectMacroSource();
378202
379- /** @type {MacroDefinition} */
203+ // Build the definition using the shared helper
380204 const definition = this.buildMacroDefFromOptions(name, options, {
381- name: name,
205+ source: { name: source, isExtension, isThirdParty },
382- aliases,
206+ });
383- category,
384- minArgs,
385- maxArgs,
386- unnamedArgDefs,
387- list,
388- strictArgs,
389- description,
390- returns,
391- returnType,
392- displayOverride,
393- exampleUsage,
394- delayArgResolution,
395- handler,
396- source: {
397- name: source,
398- isExtension,
399- isThirdParty,
400- },
401- aliasOf: null,
402- aliasVisible: null,
403- };
404207
405208 this.#macros.set(nameKey, definition);
406209
407210 // Register alias entries pointing to the same definition
408211 for (const { alias, visible } of definition.aliases) {
409212 const aliasKey = alias.toLowerCase();
410213 if (this.#macros.has(aliasKey)) {
411214 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
@@ -568,6 +371,225 @@ class MacroRegistry {
568371 const result = def.handler(executionContext);
569372 return executionContext.normalize(result);
570373 }
374+
375+ /**
376+ * Builds a MacroDefinition from MacroDefinitionOptions.
377+ *
378+ * This is the core logic building the actual registered macro from an options object
379+ * that has nearly everything as optional args.
380+ *
381+ * The options object is highly flexible and allows defining all aspects of a macro
382+ * through optional properties. This method processes and validates the options to
383+ * create a proper MacroDefinition that can be registered with the engine.
384+ *
385+ * Validation includes checking for required fields, validating argument definitions,
386+ * and ensuring the handler function is callable.
387+ * It throws errors for invalid configurations.
388+ *
389+ * @param {string} name - Macro name (identifier).
390+ * @param {MacroDefinitionOptions} options - Macro definition options.
391+ * @param {Object} [buildOptions] - Additional options for building.
392+ * @param {MacroSource} [buildOptions.source] - Source information. Defaults to dynamic source.
393+ * @returns {MacroDefinition} The built macro definition.
394+ * @throws {Error} If validation fails.
395+ */
396+ buildMacroDefFromOptions(name, options, { source } = {}) {
397+ name = typeof name === 'string' ? name.trim() : String(name);
398+
399+ if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
400+ if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
401+
402+ const {
403+ aliases: rawAliases,
404+ category: rawCategory,
405+ unnamedArgs: rawUnnamedArgs,
406+ list: rawList,
407+ strictArgs: rawStrictArgs,
408+ description: rawDescription,
409+ returns: rawReturns,
410+ returnType: rawReturnType,
411+ displayOverride: rawDisplayOverride,
412+ exampleUsage: rawExampleUsage,
413+ delayArgResolution: rawDelayArgResolution,
414+ handler,
415+ } = options;
416+
417+ if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
418+
419+ /** @type {MacroResolvedAlias[]} */
420+ const aliases = [];
421+ if (rawAliases !== undefined && rawAliases !== null) {
422+ if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
423+ for (const [i, aliasDef] of rawAliases.entries()) {
424+ if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
425+ if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
426+ const aliasName = aliasDef.alias.trim();
427+ if (!isIdentifierValid(aliasName)) throw new Error(`Macro "${name}" options.aliases[${i}].alias "${aliasName}" is invalid. Must start with a letter, followed by word chars or hyphens.`);
428+ if (aliasName.toLowerCase() === name.toLowerCase()) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name (insensitive).`);
429+ const visible = aliasDef.visible !== false;
430+ aliases.push({ alias: aliasName, visible });
431+ }
432+ }
433+
434+ /** @type {MacroCategory|string} */
435+ let category = MacroCategory.UNCATEGORIZED;
436+ if (typeof rawCategory === 'string' && rawCategory.trim()) {
437+ category = rawCategory.trim();
438+ }
439+
440+ let minArgs = 0;
441+ let maxArgs = 0;
442+ /** @type {MacroUnnamedArgDef[]} */
443+ let unnamedArgDefs = [];
444+ if (rawUnnamedArgs !== undefined) {
445+ if (Array.isArray(rawUnnamedArgs)) {
446+ let foundOptional = false;
447+ unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
448+ if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
449+ if (typeof def.name !== 'string' || !def.name.trim()) throw new Error(`Macro "${name}" options.unnamedArgs[${index}].name must be a non-empty string when using argument definitions.`);
450+
451+ if (foundOptional && !def.optional) {
452+ throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
453+ }
454+ if (def.optional) foundOptional = true;
455+
456+ /** @type {MacroUnnamedArgDef} */
457+ const normalized = {
458+ name: def.name.trim(),
459+ optional: def.optional || false,
460+ defaultValue: def.defaultValue?.trim(),
461+ type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
462+ sampleValue: def.sampleValue?.trim(),
463+ description: typeof def.description === 'string' ? def.description : undefined,
464+ };
465+
466+ const validTypes = ['string', 'integer', 'number', 'boolean'];
467+ const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
468+ if (type.some(t => !validTypes.includes(t))) {
469+ throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
470+ }
471+
472+ return normalized;
473+ });
474+
475+ maxArgs = unnamedArgDefs.length;
476+ minArgs = unnamedArgDefs.findIndex(d => d.optional);
477+ if (minArgs === -1) minArgs = maxArgs;
478+ } else if (typeof rawUnnamedArgs === 'number') {
479+ if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
480+ throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
481+ }
482+ minArgs = rawUnnamedArgs;
483+ maxArgs = rawUnnamedArgs;
484+ unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
485+ name: `arg${i + 1}`,
486+ optional: false,
487+ type: 'string',
488+ sampleValue: `arg${i + 1}`,
489+ }));
490+ } else {
491+ throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
492+ }
493+ }
494+
495+ /** @type {{ min: number, max: (number|null) }|null} */
496+ let list = null;
497+ if (rawList !== undefined) {
498+ if (typeof rawList === 'boolean') {
499+ list = rawList ? { min: 0, max: null } : null;
500+ } else if (typeof rawList === 'object' && rawList !== null) {
501+ if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
502+ if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
503+ if (rawList.max !== undefined && rawList.max < rawList.min) throw new Error(`Macro "${name}" options.list.max must be greater than or equal to options.list.min.`);
504+ list = { min: rawList.min, max: rawList.max ?? null };
505+ } else {
506+ throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
507+ }
508+ }
509+
510+ let strictArgs = true;
511+ if (rawStrictArgs !== undefined) {
512+ if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
513+ strictArgs = rawStrictArgs;
514+ }
515+
516+ let description = '<no description>';
517+ if (rawDescription !== undefined) {
518+ if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
519+ description = rawDescription;
520+ }
521+
522+ let returns = null;
523+ if (rawReturns !== undefined && rawReturns !== null) {
524+ if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
525+ returns = rawReturns || '<empty string>';
526+ }
527+
528+ const validTypes = ['string', 'integer', 'number', 'boolean'];
529+ let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
530+ if (rawReturnType !== undefined && rawReturnType !== null) {
531+ returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
532+ const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
533+ if (typesToValidate.some(t => !validTypes.includes(t))) {
534+ throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
535+ }
536+ }
537+
538+ let displayOverride = null;
539+ if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
540+ if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
541+ displayOverride = rawDisplayOverride.trim();
542+ if (displayOverride && !displayOverride.startsWith('{{')) {
543+ logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
544+ displayOverride = `{{${displayOverride}}}`;
545+ }
546+ }
547+
548+ /** @type {string[]} */
549+ let exampleUsage = [];
550+ if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
551+ const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
552+ for (const [i, ex] of examples.entries()) {
553+ if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
554+ let trimmed = ex.trim();
555+ if (trimmed && !trimmed.startsWith('{{')) {
556+ logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
557+ trimmed = `{{${trimmed}}}`;
558+ }
559+ if (trimmed) exampleUsage.push(trimmed);
560+ }
561+ }
562+
563+ let delayArgResolution = false;
564+ if (rawDelayArgResolution !== undefined) {
565+ if (typeof rawDelayArgResolution !== 'boolean') throw new Error(`Macro "${name}" options.delayArgResolution must be a boolean when provided.`);
566+ delayArgResolution = rawDelayArgResolution;
567+ }
568+
569+ /** @type {MacroDefinition} */
570+ const definition = {
571+ name: name,
572+ aliases,
573+ category,
574+ minArgs,
575+ maxArgs,
576+ unnamedArgDefs,
577+ list,
578+ strictArgs,
579+ description,
580+ returns,
581+ returnType,
582+ displayOverride,
583+ exampleUsage,
584+ delayArgResolution,
585+ handler,
586+ source: source ?? { name: 'dynamic', isExtension: false, isThirdParty: false },
587+ aliasOf: null,
588+ aliasVisible: null,
589+ };
590+
591+ return definition;
592+ }
571593}
572594
573595instance = MacroRegistry.instance;
public/scripts/slash-commands/SlashCommandClosure.js+102 -0
@@ -1,4 +1,5 @@
11import { substituteParams } from '../../script.js';
2+import { power_user } from '../power-user.js';
23import { delay, escapeRegex, uuidv4 } from '../utils.js';
34import { SlashCommand } from './SlashCommand.js';
45import { SlashCommandAbortController } from './SlashCommandAbortController.js';
@@ -48,6 +49,104 @@ export class SlashCommandClosure {
4849 }
4950
5051 /**
52+ * Performs parameter substitution using the macro engine.
53+ * @param {string} text Text to substitute
54+ * @param {SlashCommandScope} scope Script scope
55+ * @param {{key:string, value:string|SlashCommandClosure}[]} macroList Custom scope macros
56+ * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]} Substituted text or list of strings/closures
57+ */
58+ substituteWithMacroEngine(text, scope, macroList) {
59+ /** @type {Record<string, import('./../macros/engine/MacroEnv.types.js').DynamicMacroValue>} */
60+ const dynamicMacros = {
61+ 'pipe': () => scope.pipe,
62+ 'var': {
63+ strictArgs: false,
64+ list: { min: 1, max: 2 },
65+ handler: (context) => {
66+ try {
67+ // NB: Legacy replacer halted the script execution on unknown variables
68+ return scope.getVariable(context.list[0], context.list[1]);
69+ } catch (error) {
70+ console.warn('{{var}} dynamic macro execution error:', error);
71+ return '';
72+ }
73+ },
74+ },
75+ };
76+
77+ // Special marker to denote closures in the substituted text
78+ const CLOSURE_BOUNDARY = '\uFFF0~CLOSURE~\uFFF0';
79+ /** @type {Map<string, SlashCommandClosure>} */
80+ const closures = new Map();
81+ /** @type {Record<string, { args: string[], value: string|SlashCommandClosure }[]>} */
82+ const customMacros = {};
83+
84+ for (const macro of macroList) {
85+ const [name, ...rest] = macro.key.split('::');
86+ if (!Object.hasOwn(customMacros, name)) {
87+ customMacros[name] = [];
88+ }
89+ customMacros[name].push({ args: rest, value: macro.value });
90+ }
91+
92+ for (const [macroName, macroArguments] of Object.entries(customMacros)) {
93+ dynamicMacros[macroName] = {
94+ strictArgs: false,
95+ list: { min: 0, max: Number.MAX_SAFE_INTEGER },
96+ handler: (context) => {
97+ // Sort to prefer exact matches over wildcard matches
98+ const sortedMacroArgs = macroArguments.toSorted((a, b) => {
99+ const aHasWildcard = a.args.includes('*');
100+ const bHasWildcard = b.args.includes('*');
101+ if (aHasWildcard && !bHasWildcard) return 1;
102+ if (!aHasWildcard && bHasWildcard) return -1;
103+ return 0;
104+ });
105+
106+ const findMacroMatch = (/** @type {{args: string[]}} */ i) => {
107+ // Exact match
108+ if (i.args.length === context.list.length && i.args.every((arg, index) => arg === context.list[index])) {
109+ return true;
110+ }
111+ // Wildcard match - if any definition arg is '*', it matches any value at that position
112+ if (i.args.length === context.list.length) {
113+ return i.args.every((arg, index) => arg === '*' || arg === context.list[index]);
114+ }
115+ return false;
116+ };
117+
118+ const replacer = sortedMacroArgs.find(findMacroMatch)?.value;
119+ if (replacer instanceof SlashCommandClosure) {
120+ replacer.abortController = this.abortController;
121+ replacer.breakController = this.breakController;
122+ replacer.scope.parent = this.scope;
123+ if (this.debugController && !replacer.debugController) {
124+ replacer.debugController = this.debugController;
125+ }
126+
127+ const closureKey = uuidv4();
128+ closures.set(closureKey, replacer);
129+ return `${CLOSURE_BOUNDARY}${closureKey}${CLOSURE_BOUNDARY}`;
130+ }
131+
132+ return String(replacer ?? '');
133+ },
134+ };
135+ }
136+
137+ const substitutedText = substituteParams(text, { dynamicMacros });
138+
139+ // If any closures were inserted, split the text accordingly
140+ if (closures.size > 0) {
141+ const parts = substitutedText.split(CLOSURE_BOUNDARY).map(part => closures.has(part) ? closures.get(part) : part).filter(Boolean);
142+ return parts.length === 1 ? parts[0] : parts;
143+ }
144+
145+ // No closures, return substituted text as-is
146+ return substitutedText;
147+ }
148+
149+ /**
51150 *
52151 * @param {string} text
53152 * @param {SlashCommandScope} scope
@@ -72,6 +171,9 @@ export class SlashCommandClosure {
72171 if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
73172 return 0;
74173 });
174+ if (power_user.experimental_macro_engine) {
175+ return this.substituteWithMacroEngine(text, scope, macroList);
176+ }
75177 const macros = macroList.map(it=>escapeMacro(it)).join('|');
76178 const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
77179 let done = '';
public/scripts/slash-commands/SlashCommandParser.js+4 -0
@@ -1392,6 +1392,10 @@ export class SlashCommandParser {
13921392 }
13931393
13941394 replaceGetvar(value) {
1395+ // Not needed with the new parser.
1396+ if (power_user.experimental_macro_engine) {
1397+ return value;
1398+ }
13951399 return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {
13961400 name = name.trim();
13971401 cmd = cmd.toLowerCase();
tests/frontend/MacroEngine.e2e.js+483 -28
@@ -674,40 +674,495 @@ test.describe('MacroEngine', () => {
674674 });
675675
676676 test.describe('Dynamic macros', () => {
677677 test.describe('should notString resolvevalue dynamic macro when called with arguments due to strict aritymacros', async ({ page }) => {
678- /** @type {string[]} */
678+ test('should resolve dynamic macro with string value', async ({ page }) => {
679- const warnings = [];
679+ const output = await page.evaluate(async () => {
680- page.on('console', msg => {
680+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
681- if (msg.type() === 'warning') {
681+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
682- warnings.push(msg.text());
682+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
683- }
683+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
684+
685+ const rawEnv = {
686+ content: 'Test: {{myvalue}}',
687+ dynamicMacros: {
688+ myvalue: 'hello world',
689+ },
690+ };
691+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
692+ return MacroEngine.evaluate('Test: {{myvalue}}', env);
693+ });
694+
695+ expect(output).toBe('Test: hello world');
684696 });
685697
686- const input = 'Dyn: {{dyn::extra}}';
698+ test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
687699 const output = await page.evaluate(async (input) => {
688700 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
689701 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
690702 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
691703 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
692704
693- /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
705+ const rawEnv = {
694- const rawEnv = {
706+ content: '',
695- content: input,
707+ dynamicMacros: {
696- dynamicMacros: {
708+ num: 42,
697- dyn: () => 'OK',
709+ },
698710 },;
699- };
711+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
700- const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
712+ return MacroEngine.evaluate('Value: {{num}}', env);
713+ });
701714
702- return MacroEngine.evaluate(input, env);
715+ expect(output).toBe('Value: 42');
703716 }, input);
704717
705- // Dynamic macro with arguments should not resolve because the
718+ test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
706- // temporary definition is strictArgs: true and minArgs/maxArgs: 0.
719+ const warnings = [];
707- expect(output).toBe(input);
720+ page.on('console', msg => {
721+ if (msg.type() === 'warning') warnings.push(msg.text());
722+ });
723+
724+ const input = 'Dyn: {{myvalue::extra}}';
725+ const output = await page.evaluate(async (input) => {
726+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
727+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
728+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
729+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
730+
731+ const rawEnv = {
732+ content: input,
733+ dynamicMacros: { myvalue: 'hello' },
734+ };
735+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
736+ return MacroEngine.evaluate(input, env);
737+ }, input);
738+
739+ expect(output).toBe(input);
740+ expect(warnings.some(w => w.includes('Macro "myvalue"') && w.includes('unnamed arguments'))).toBeTruthy();
741+ });
742+ });
743+
744+ test.describe('Handler function dynamic macros', () => {
745+ test('should resolve dynamic macro with handler function', async ({ page }) => {
746+ const output = await page.evaluate(async () => {
747+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
748+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
749+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
750+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
751+
752+ const rawEnv = {
753+ content: '',
754+ dynamicMacros: {
755+ dyn: () => 'handler result',
756+ },
757+ };
758+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
759+ return MacroEngine.evaluate('Result: {{dyn}}', env);
760+ });
761+
762+ expect(output).toBe('Result: handler result');
763+ });
764+
765+ test('should pass execution context to handler function', async ({ page }) => {
766+ const output = await page.evaluate(async () => {
767+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
768+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
769+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
770+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
771+
772+ const rawEnv = {
773+ content: 'full content here',
774+ dynamicMacros: {
775+ dyn: (ctx) => `name=${ctx.name}, content=${ctx.env.content}`,
776+ },
777+ };
778+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
779+ return MacroEngine.evaluate('{{dyn}}', env);
780+ });
781+
782+ expect(output).toBe('name=dyn, content=full content here');
783+ });
784+
785+ test('should not resolve handler dynamic macro when called with arguments due to strict arity', async ({ page }) => {
786+ const warnings = [];
787+ page.on('console', msg => {
788+ if (msg.type() === 'warning') warnings.push(msg.text());
789+ });
790+
791+ const input = 'Dyn: {{dyn::extra}}';
792+ const output = await page.evaluate(async (input) => {
793+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
794+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
795+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
796+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
797+
798+ const rawEnv = {
799+ content: input,
800+ dynamicMacros: {
801+ dyn: () => 'OK',
802+ },
803+ };
804+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
805+ return MacroEngine.evaluate(input, env);
806+ }, input);
807+
808+ expect(output).toBe(input);
809+ expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
810+ });
811+ });
812+
813+ test.describe('MacroDefinitionOptions dynamic macros', () => {
814+ test('should resolve dynamic macro with MacroDefinitionOptions', async ({ page }) => {
815+ const output = await page.evaluate(async () => {
816+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
817+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
818+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
819+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
820+
821+ const rawEnv = {
822+ content: '',
823+ dynamicMacros: {
824+ greet: {
825+ description: 'A greeting macro',
826+ handler: () => 'Hello from options!',
827+ },
828+ },
829+ };
830+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
831+ return MacroEngine.evaluate('{{greet}}', env);
832+ });
833+
834+ expect(output).toBe('Hello from options!');
835+ });
836+
837+ test('should support unnamed arguments in dynamic macro with options', async ({ page }) => {
838+ const output = await page.evaluate(async () => {
839+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
840+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
841+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
842+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
843+
844+ const rawEnv = {
845+ content: '',
846+ dynamicMacros: {
847+ greet: {
848+ unnamedArgs: [{ name: 'name' }],
849+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
850+ },
851+ },
852+ };
853+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
854+ return MacroEngine.evaluate('{{greet::World}}', env);
855+ });
856+
857+ expect(output).toBe('Hello, World!');
858+ });
859+
860+ test('should support multiple unnamed arguments in dynamic macro', async ({ page }) => {
861+ const output = await page.evaluate(async () => {
862+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
863+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
864+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
865+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
866+
867+ const rawEnv = {
868+ content: '',
869+ dynamicMacros: {
870+ wrap: {
871+ unnamedArgs: [
872+ { name: 'content' },
873+ { name: 'prefix' },
874+ { name: 'suffix' },
875+ ],
876+ handler: ({ unnamedArgs: [content, prefix, suffix] }) => `${prefix}${content}${suffix}`,
877+ },
878+ },
879+ };
880+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
881+ return MacroEngine.evaluate('{{wrap::hello::[::]}}', env);
882+ });
883+
884+ expect(output).toBe('[hello]');
885+ });
886+
887+ test('should support optional arguments in dynamic macro', async ({ page }) => {
888+ const output = await page.evaluate(async () => {
889+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
890+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
891+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
892+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
893+
894+ const rawEnv = {
895+ content: '',
896+ dynamicMacros: {
897+ greet: {
898+ unnamedArgs: [
899+ { name: 'name' },
900+ { name: 'greeting', optional: true, defaultValue: 'Hello' },
901+ ],
902+ handler: ({ unnamedArgs: [name, greeting] }) => `${greeting || 'Hello'}, ${name}!`,
903+ },
904+ },
905+ };
906+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
907+
908+ const result1 = MacroEngine.evaluate('{{greet::World}}', env);
909+ const result2 = MacroEngine.evaluate('{{greet::World::Hi}}', env);
910+ return { result1, result2 };
911+ });
912+
913+ expect(output.result1).toBe('Hello, World!');
914+ expect(output.result2).toBe('Hi, World!');
915+ });
916+
917+ test('should support list arguments in dynamic macro', async ({ page }) => {
918+ const output = await page.evaluate(async () => {
919+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
920+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
921+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
922+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
923+
924+ const rawEnv = {
925+ content: '',
926+ dynamicMacros: {
927+ join: {
928+ unnamedArgs: [{ name: 'separator' }],
929+ list: true,
930+ handler: ({ unnamedArgs: [sep], list }) => list.join(sep),
931+ },
932+ },
933+ };
934+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
935+ return MacroEngine.evaluate('{{join::-::a::b::c}}', env);
936+ });
937+
938+ expect(output).toBe('a-b-c');
939+ });
940+
941+ test('should enforce type validation in dynamic macro with options', async ({ page }) => {
942+ const warnings = [];
943+ page.on('console', msg => {
944+ if (msg.type() === 'warning') warnings.push(msg.text());
945+ });
946+
947+ const input = '{{calc::abc}}';
948+ const output = await page.evaluate(async (input) => {
949+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
950+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
951+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
952+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
953+
954+ const rawEnv = {
955+ content: input,
956+ dynamicMacros: {
957+ calc: {
958+ unnamedArgs: [{ name: 'value', type: 'integer' }],
959+ strictArgs: true,
960+ handler: ({ unnamedArgs: [val] }) => `#${val}#`,
961+ },
962+ },
963+ };
964+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
965+ return MacroEngine.evaluate(input, env);
966+ }, input);
967+
968+ expect(output).toBe(input);
969+ expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
970+ });
971+
972+ test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
973+ const warnings = [];
974+ page.on('console', msg => {
975+ if (msg.type() === 'warning') warnings.push(msg.text());
976+ });
977+
978+ const output = await page.evaluate(async () => {
979+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
980+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
981+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
982+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
983+
984+ const rawEnv = {
985+ content: '',
986+ dynamicMacros: {
987+ calc: {
988+ unnamedArgs: [{ name: 'value', type: 'integer' }],
989+ strictArgs: false,
990+ handler: ({ unnamedArgs: [val] }) => `#${val}#`,
991+ },
992+ },
993+ };
994+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
995+ return MacroEngine.evaluate('{{calc::abc}}', env);
996+ });
997+
998+ expect(output).toBe('#abc#');
999+ expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
1000+ });
1001+
1002+ test('should fail arity check in dynamic macro with options when too few args', async ({ page }) => {
1003+ const warnings = [];
1004+ page.on('console', msg => {
1005+ if (msg.type() === 'warning') warnings.push(msg.text());
1006+ });
1007+
1008+ const input = '{{greet}}';
1009+ const output = await page.evaluate(async (input) => {
1010+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1011+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1012+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1013+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1014+
1015+ const rawEnv = {
1016+ content: input,
1017+ dynamicMacros: {
1018+ greet: {
1019+ unnamedArgs: [{ name: 'name' }],
1020+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1021+ },
1022+ },
1023+ };
1024+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1025+ return MacroEngine.evaluate(input, env);
1026+ }, input);
7081027
709- // A runtime arity warning for the dynamic macro should be logged
1028+ expect(output).toBe(input);
7101029 expect(warnings.some(w => w.includes('Macro "dyn"greet') && w.includes('unnamed arguments'))).toBeTruthy();
1030+ });
1031+
1032+ test('should fail arity check in dynamic macro with options when too many args', async ({ page }) => {
1033+ const warnings = [];
1034+ page.on('console', msg => {
1035+ if (msg.type() === 'warning') warnings.push(msg.text());
1036+ });
1037+
1038+ const input = '{{greet::one::two}}';
1039+ const output = await page.evaluate(async (input) => {
1040+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1041+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1042+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1043+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1044+
1045+ const rawEnv = {
1046+ content: input,
1047+ dynamicMacros: {
1048+ greet: {
1049+ unnamedArgs: [{ name: 'name' }],
1050+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1051+ },
1052+ },
1053+ };
1054+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1055+ return MacroEngine.evaluate(input, env);
1056+ }, input);
1057+
1058+ expect(output).toBe(input);
1059+ expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
1060+ });
1061+
1062+ test('should handle invalid MacroDefinitionOptions gracefully', async ({ page }) => {
1063+ const warnings = [];
1064+ page.on('console', msg => {
1065+ if (msg.type() === 'warning') warnings.push(msg.text());
1066+ });
1067+
1068+ const input = '{{bad}}';
1069+ const output = await page.evaluate(async (input) => {
1070+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1071+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1072+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1073+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1074+
1075+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
1076+ const rawEnv = {
1077+ content: input,
1078+ dynamicMacros: {
1079+ bad: {
1080+ // Missing handler - should fail validation
1081+ unnamedArgs: 1,
1082+ },
1083+ },
1084+ };
1085+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1086+ return MacroEngine.evaluate(input, env);
1087+ }, input);
1088+
1089+ // Should remain unresolved since options are invalid
1090+ expect(output).toBe(input);
1091+ expect(warnings.some(w => w.includes('bad') && w.includes('is not defined correctly'))).toBeTruthy();
1092+ });
1093+ });
1094+
1095+ test.describe('Dynamic macro priority and case sensitivity', () => {
1096+ test('should override registered macro with dynamic macro of same name', async ({ page }) => {
1097+ const output = await page.evaluate(async () => {
1098+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1099+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1100+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1101+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1102+
1103+ const rawEnv = {
1104+ content: '',
1105+ name1Override: 'User',
1106+ dynamicMacros: {
1107+ user: 'DynamicUser',
1108+ },
1109+ };
1110+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1111+ return MacroEngine.evaluate('{{user}}', env);
1112+ });
1113+
1114+ expect(output).toBe('DynamicUser');
1115+ });
1116+
1117+ test('should match dynamic macro names case-insensitively', async ({ page }) => {
1118+ const output = await page.evaluate(async () => {
1119+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1120+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1121+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1122+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1123+
1124+ const rawEnv = {
1125+ content: '',
1126+ dynamicMacros: {
1127+ MyMacro: 'value',
1128+ },
1129+ };
1130+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1131+
1132+ const r1 = MacroEngine.evaluate('{{MyMacro}}', env);
1133+ const r2 = MacroEngine.evaluate('{{mymacro}}', env);
1134+ const r3 = MacroEngine.evaluate('{{MYMACRO}}', env);
1135+ return { r1, r2, r3 };
1136+ });
1137+
1138+ expect(output.r1).toBe('value');
1139+ expect(output.r2).toBe('value');
1140+ expect(output.r3).toBe('value');
1141+ });
1142+
1143+ test('should resolve multiple different dynamic macros in same evaluation', async ({ page }) => {
1144+ const output = await page.evaluate(async () => {
1145+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1146+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1147+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1148+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1149+
1150+ const rawEnv = {
1151+ content: '',
1152+ dynamicMacros: {
1153+ a: 'alpha',
1154+ b: () => 'beta',
1155+ c: {
1156+ handler: () => 'gamma',
1157+ },
1158+ },
1159+ };
1160+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1161+ return MacroEngine.evaluate('{{a}}-{{b}}-{{c}}', env);
1162+ });
1163+
1164+ expect(output).toBe('alpha-beta-gamma');
1165+ });
7111166 });
7121167 });
7131168
tests/frontend/MacroEnvBuilder.e2e.js+203 -18
@@ -205,28 +205,213 @@ test.describe('MacroEnvBuilder', () => {
205205 });
206206 });
207207
208208 test.describe('merges dynamicMacrosDynamic propertiesmacros intoin env.dynamicMacros', async ({ page }) => {
209209 consttest('merges dynamicMacros =properties awaitinto pageenv.evaluate(dynamicMacros', async ({ page }) => {
210- /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
210+ const dynamicMacros = await page.evaluate(async () => {
211211 const { MacroEnvBuilder } = /** await@type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js');} */
212+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
213+
214+ const ctx = {
215+ content: '',
216+ dynamicMacros: {
217+ simple: 'value',
218+ number: 42,
219+ },
220+ };
221+
222+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
223+ return env.dynamicMacros;
224+ });
225+
226+ expect(dynamicMacros.simple).toBe('value');
227+ expect(dynamicMacros.number).toBe(42);
228+ });
212229
213- /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
230+ test('normalizes dynamic macro keys to lowercase', async ({ page }) => {
214- const ctx = {
231+ const result = await page.evaluate(async () => {
215- content: '',
232+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
216- dynamicMacros: {
233+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
217- simple: 'value',
234+
218- number: 42,
235+ const ctx = {
219236 nested: { foocontent: 'bar' },
220- },
237+ dynamicMacros: {
221- };
238+ MyMacro: 'value1',
239+ UPPERCASE: 'value2',
240+ lowercase: 'value3',
241+ },
242+ };
243+
244+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
245+ return {
246+ hasLower: 'mymacro' in env.dynamicMacros,
247+ hasUpper: 'uppercase' in env.dynamicMacros,
248+ hasLowercase: 'lowercase' in env.dynamicMacros,
249+ value1: env.dynamicMacros['mymacro'],
250+ value2: env.dynamicMacros['uppercase'],
251+ value3: env.dynamicMacros['lowercase'],
252+ };
253+ });
254+
255+ expect(result.hasLower).toBe(true);
256+ expect(result.hasUpper).toBe(true);
257+ expect(result.hasLowercase).toBe(true);
258+ expect(result.value1).toBe('value1');
259+ expect(result.value2).toBe('value2');
260+ expect(result.value3).toBe('value3');
261+ });
222262
223- const env = MacroEnvBuilder.buildFromRawEnv(ctx);
263+ test('accepts string values for dynamic macros', async ({ page }) => {
224- return env.dynamicMacros;
264+ const result = await page.evaluate(async () => {
265+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
266+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
267+
268+ const ctx = {
269+ content: '',
270+ dynamicMacros: {
271+ greeting: 'Hello, World!',
272+ },
273+ };
274+
275+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
276+ return {
277+ value: env.dynamicMacros['greeting'],
278+ type: typeof env.dynamicMacros['greeting'],
279+ };
280+ });
281+
282+ expect(result.value).toBe('Hello, World!');
283+ expect(result.type).toBe('string');
225284 });
226285
227- expect(dynamicMacros.simple).toBe('value');
286+ test('accepts handler functions for dynamic macros', async ({ page }) => {
228- expect(dynamicMacros.number).toBe(42);
287+ const result = await page.evaluate(async () => {
229- expect(dynamicMacros.nested).toEqual({ foo: 'bar' });
288+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
289+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
290+
291+ const ctx = {
292+ content: '',
293+ dynamicMacros: {
294+ dyn: () => 'handler result',
295+ },
296+ };
297+
298+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
299+ const storedValue = env.dynamicMacros['dyn'];
300+ return {
301+ isFunction: typeof storedValue === 'function',
302+ callResult: typeof storedValue === 'function' ? storedValue() : null,
303+ };
304+ });
305+
306+ expect(result.isFunction).toBe(true);
307+ expect(result.callResult).toBe('handler result');
308+ });
309+
310+ test('accepts MacroDefinitionOptions objects for dynamic macros', async ({ page }) => {
311+ const result = await page.evaluate(async () => {
312+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
313+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
314+
315+ const ctx = {
316+ content: '',
317+ dynamicMacros: {
318+ greet: {
319+ description: 'A greeting macro',
320+ unnamedArgs: [{ name: 'name' }],
321+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
322+ },
323+ },
324+ };
325+
326+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
327+ const storedValue = env.dynamicMacros['greet'];
328+ return {
329+ isObject: typeof storedValue === 'object' && storedValue !== null,
330+ hasHandler: typeof storedValue?.handler === 'function',
331+ hasDescription: typeof storedValue?.description === 'string',
332+ hasUnnamedArgs: Array.isArray(storedValue?.unnamedArgs),
333+ };
334+ });
335+
336+ expect(result.isObject).toBe(true);
337+ expect(result.hasHandler).toBe(true);
338+ expect(result.hasDescription).toBe(true);
339+ expect(result.hasUnnamedArgs).toBe(true);
340+ });
341+
342+ test('supports mixed dynamic macro value types', async ({ page }) => {
343+ const result = await page.evaluate(async () => {
344+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
345+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
346+
347+ const ctx = {
348+ content: '',
349+ dynamicMacros: {
350+ stringVal: 'direct string',
351+ funcVal: () => 'from function',
352+ optionsVal: {
353+ handler: () => 'from options',
354+ unnamedArgs: 1,
355+ },
356+ },
357+ };
358+
359+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
360+ return {
361+ stringType: typeof env.dynamicMacros['stringval'],
362+ funcType: typeof env.dynamicMacros['funcval'],
363+ optionsType: typeof env.dynamicMacros['optionsval'],
364+ optionsHasHandler: typeof env.dynamicMacros['optionsval']?.handler === 'function',
365+ };
366+ });
367+
368+ expect(result.stringType).toBe('string');
369+ expect(result.funcType).toBe('function');
370+ expect(result.optionsType).toBe('object');
371+ expect(result.optionsHasHandler).toBe(true);
372+ });
373+
374+ test('handles null dynamicMacros gracefully', async ({ page }) => {
375+ const result = await page.evaluate(async () => {
376+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
377+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
378+
379+ const ctx = {
380+ content: '',
381+ dynamicMacros: null,
382+ };
383+
384+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
385+ return {
386+ hasDynamicMacros: 'dynamicMacros' in env,
387+ isEmpty: Object.keys(env.dynamicMacros).length === 0,
388+ };
389+ });
390+
391+ expect(result.hasDynamicMacros).toBe(true);
392+ expect(result.isEmpty).toBe(true);
393+ });
394+
395+ test('handles undefined dynamicMacros gracefully', async ({ page }) => {
396+ const result = await page.evaluate(async () => {
397+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
398+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
399+
400+ const ctx = {
401+ content: '',
402+ // dynamicMacros not provided
403+ };
404+
405+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
406+ return {
407+ hasDynamicMacros: 'dynamicMacros' in env,
408+ isEmpty: Object.keys(env.dynamicMacros).length === 0,
409+ };
410+ });
411+
412+ expect(result.hasDynamicMacros).toBe(true);
413+ expect(result.isEmpty).toBe(true);
414+ });
230415 });
231416
232417 test('sets system.model field from getGeneratingModel helper', async ({ page }) => {
tests/frontend/MacroSlashCommands.e2e.js+177 -0
@@ -0,0 +1,177 @@
1+import { test, expect } from '@playwright/test';
2+import { testSetup } from './frontent-test-utils.js';
3+
4+test.describe('MacroSlashCommands', () => {
5+ test.beforeEach(testSetup.awaitST);
6+
7+ test.describe('Parser Flags', () => {
8+ test('should bypass REPLACE_GETVAR', async ({ page }) => {
9+ const output = await page.evaluate(async () => {
10+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
11+ const { power_user } = await import('./scripts/power-user.js');
12+
13+ power_user.experimental_macro_engine = true;
14+
15+ return (await executeSlashCommandsWithOptions('/parser-flag REPLACE_GETVAR || /setvar key=x \\{\\{lastMessageId}} || /pass {{getvar::x}}')).pipe;
16+ });
17+
18+ expect(output).toBe('{{lastMessageId}}');
19+ });
20+ });
21+
22+ test.describe('{{pipe}} Macro', () => {
23+ test('should support {{pipe}} macro', async ({ page }) => {
24+ const output = await page.evaluate(async () => {
25+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
26+ const { power_user } = await import('./scripts/power-user.js');
27+
28+ power_user.experimental_macro_engine = true;
29+
30+ return (await executeSlashCommandsWithOptions('/pass Hello World || /pass {{pipe}}')).pipe;
31+ });
32+
33+ expect(output).toBe('Hello World');
34+ });
35+ });
36+
37+ test.describe('{{var}} Macro', () => {
38+ test('should support {{var::key}} macro', async ({ page }) => {
39+ const output = await page.evaluate(async () => {
40+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
41+ const { power_user } = await import('./scripts/power-user.js');
42+
43+ power_user.experimental_macro_engine = true;
44+
45+ return (await executeSlashCommandsWithOptions('/let key=greeting Hello || /pass {{var::greeting}}')).pipe;
46+ });
47+
48+ expect(output).toBe('Hello');
49+ });
50+
51+ test('should support {{var::key::index}} macro', async ({ page }) => {
52+ const output = await page.evaluate(async () => {
53+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
54+ const { power_user } = await import('./scripts/power-user.js');
55+
56+ power_user.experimental_macro_engine = true;
57+
58+ return (await executeSlashCommandsWithOptions('/let key=list ["item1","item2","item3"] || /pass {{var::list::1}}')).pipe;
59+ });
60+
61+ expect(output).toBe('item2');
62+ });
63+
64+ test('should not fail on unknown variable keys', async ({ page }) => {
65+ const output = await page.evaluate(async () => {
66+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
67+ const { power_user } = await import('./scripts/power-user.js');
68+
69+ power_user.experimental_macro_engine = true;
70+ return (await executeSlashCommandsWithOptions('/pass {{var::unknownKey}}')).pipe;
71+ });
72+
73+ expect(output).toBe('');
74+ });
75+
76+ test('should not fail on out-of-bounds variable index', async ({ page }) => {
77+ const output = await page.evaluate(async () => {
78+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
79+ const { power_user } = await import('./scripts/power-user.js');
80+
81+ power_user.experimental_macro_engine = true;
82+
83+ return (await executeSlashCommandsWithOptions('/let key=test {"key":"value"} || /pass {{var::test::error}}')).pipe;
84+ });
85+
86+ expect(output).toBe('');
87+ });
88+ });
89+
90+ test.describe('{{arg}} Macro', () => {
91+ test('should support {{arg}} macro with plain value', async ({ page }) => {
92+ const output = await page.evaluate(async () => {
93+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
94+ const { power_user } = await import('./scripts/power-user.js');
95+
96+ power_user.experimental_macro_engine = true;
97+
98+ return (await executeSlashCommandsWithOptions('/qr-arg hello world || /pass {{arg::hello}}')).pipe;
99+ });
100+
101+ expect(output).toBe('world');
102+ });
103+
104+ test('should support {{arg}} macro with closure value', async ({ page }) => {
105+ const output = await page.evaluate(async () => {
106+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
107+ const { power_user } = await import('./scripts/power-user.js');
108+
109+ power_user.experimental_macro_engine = true;
110+
111+ return (await executeSlashCommandsWithOptions('/qr-arg x {: /echo test :} || /echo {{arg::x}}')).pipe;
112+ });
113+
114+ expect(output).toBe('[Closure]');
115+ });
116+
117+ test('should support mixed type {{arg}} macro values', async ({ page }) => {
118+ const output = await page.evaluate(async () => {
119+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
120+ const { power_user } = await import('./scripts/power-user.js');
121+
122+ power_user.experimental_macro_engine = true;
123+
124+ return (await executeSlashCommandsWithOptions('/qr-arg a simple || /qr-arg b {: /echo closure :} || /echo {{arg::a}} and {{arg::b}}')).pipe;
125+ });
126+
127+ expect(output).toBe('simple and ,[Closure]');
128+ });
129+
130+ test('should support wildcard {{arg}} macro', async ({ page }) => {
131+ const output = await page.evaluate(async () => {
132+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
133+ const { power_user } = await import('./scripts/power-user.js');
134+
135+ power_user.experimental_macro_engine = true;
136+
137+ return (await executeSlashCommandsWithOptions('/qr-arg * wildcard || /pass {{arg::any}}')).pipe;
138+ });
139+
140+ expect(output).toBe('wildcard');
141+ });
142+ });
143+
144+ test.describe('Custom Scope Macros', () => {
145+ test('should support {{timesIndex}} in /times command', async ({ page }) => {
146+ const output = await page.evaluate(async () => {
147+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
148+ const { power_user } = await import('./scripts/power-user.js');
149+
150+ power_user.experimental_macro_engine = true;
151+
152+ return (await executeSlashCommandsWithOptions('/times 1 {: /pass {{timesIndex}} :}')).pipe;
153+ });
154+
155+ expect(output).toBe('0');
156+ });
157+
158+ test('should support custom SlashCommandScope macros', async ({ page }) => {
159+ const output = await page.evaluate(async () => {
160+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
161+ const { SlashCommandScope } = await import('./scripts/slash-commands/SlashCommandScope.js');
162+ const { power_user } = await import('./scripts/power-user.js');
163+
164+ power_user.experimental_macro_engine = true;
165+
166+ // Create a custom scope with a macro
167+ const customScope = new SlashCommandScope(null);
168+ customScope.setMacro('uno::dos::tres', 'CUSTOM_VALUE');
169+ customScope.setMacro('uno::dos::quatro', 'SHOULD_NOT_BE_USED');
170+ customScope.setMacro('uno::dos::*', 'WILDCARD_VALUE');
171+ return (await executeSlashCommandsWithOptions('/pass {{uno::dos::tres}}', { scope: customScope })).pipe;
172+ });
173+
174+ expect(output).toBe('CUSTOM_VALUE');
175+ });
176+ });
177+});