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, +1189 -221Showing whitespace changes
public/script.js+1 -1
@@ -2839,7 +2839,7 @@ export function substituteParamsLegacy(content, _name1, _name2, _original, _grou
2839 * @param {string} [options.original] - The original message for {{original}} substitution.2839 * @param {string} [options.original] - The original message for {{original}} substitution.
2840 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.2840 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
2841 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.2841 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
2842 * @param {Record<string,string|MacroHandler>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.2842 * @param {Record<string, import('./scripts/macros/engine/MacroEnv.types.js').DynamicMacroValue>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
2843 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.2843 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
2844 * @returns {string} The string with substituted parameters.2844 * @returns {string} The string with substituted parameters.
2845 */2845 */
public/scripts/macros/engine/MacroEngine.js+34 -20
@@ -1,11 +1,12 @@
1import { MacroParser } from './MacroParser.js';1import { MacroParser } from './MacroParser.js';
2import { MacroCstWalker } from './MacroCstWalker.js';2import { MacroCstWalker } from './MacroCstWalker.js';
3import { MacroRegistry } from './MacroRegistry.js';3import { MacroRegistry, MacroValueType } from './MacroRegistry.js';
4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
5import { ELSE_MARKER } from '../definitions/core-macros.js';5import { ELSE_MARKER } from '../definitions/core-macros.js';
66
7/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */7/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
8/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */8/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
9/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
9/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */10/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
1011
11/**12/**
@@ -166,31 +167,44 @@ class MacroEngine {
166167
167 // 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.168 // 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.
168 // Dynamic macro keys are normalized to lowercase for case-insensitive matching.169 // Dynamic macro keys are normalized to lowercase for case-insensitive matching.
169 /** @type {MacroDefinition?} */170 /** @type {MacroDefinition|null} */
170 let defOverride = null;171 let defOverride = null;
171 const nameLower = name.toLowerCase();172 const nameLower = name.toLowerCase();
172 if (Object.hasOwn(env.dynamicMacros, nameLower)) {173 if (Object.hasOwn(env.dynamicMacros, nameLower)) {
173 const impl = env.dynamicMacros[nameLower];174 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
178 // 2. function - handler function, no args allowed (legacy behavior)
179 // 3. MacroDefinitionOptions object - full definition with handler, args, type validation, etc.
180
181 // Check if this looks like a MacroDefinitionOptions object (has handler property)
182 const looksLikeOptions = impl && typeof impl === 'object' &&
183 'handler' in impl && typeof impl.handler === 'function';
184
185 if (looksLikeOptions) {
186 // Case 3: MacroDefinitionOptions - use the full definition builder
187 try {
188 const options = /** @type {MacroDefinitionOptions} */ (impl);
189 defOverride = MacroRegistry.buildMacroDefFromOptions(name, options);
190 } catch (error) {
191 // If building fails, log warning and fall through to check registered macros
192 logMacroRuntimeWarning({ message: `Dynamic macro "${name}" has invalid options: ${error.message}`, call });
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 ?? ''),
177 category: 'dynamic',201 category: 'dynamic',
178 description: 'Dynamic macro',202 description: 'Dynamic macro',
179 minArgs: 0,203 returnType: MacroValueType.STRING,
180 maxArgs: 0,204 });
181 unnamedArgDefs: [],205 } else {
182 list: null,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 });
183 strictArgs: true, // Fail dynamic macros if they are called with arguments207 }
184 returns: null,
185 returnType: 'string',
186 displayOverride: null,
187 exampleUsage: [],
188 source: { name: 'dynamic', isExtension: false, isThirdParty: false },
189 aliasOf: null,
190 aliasVisible: null,
191 delayArgResolution: false,
192 handler: typeof impl === 'function' ? impl : () => impl,
193 };
194 }208 }
195209
196 // If not, check if the macro exists and is registered210 // If not, check if the macro exists and is registered
public/scripts/macros/engine/MacroEnv.types.js+10 -1
@@ -7,6 +7,15 @@
7 */7 */
88
9/** @typedef {import('./MacroRegistry.js').MacroHandler} MacroHandler */9/** @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
11/**20/**
12 * @typedef {Object} MacroEnvNames21 * @typedef {Object} MacroEnvNames
@@ -50,7 +59,7 @@
50 * @property {MacroEnvCharacter} character59 * @property {MacroEnvCharacter} character
51 * @property {MacroEnvSystem} system60 * @property {MacroEnvSystem} system
52 * @property {MacroEnvFunctions} functions61 * @property {MacroEnvFunctions} functions
53 * @property {Object<string, string|MacroHandler>} dynamicMacros62 * @property {Object<string, DynamicMacroValue>} dynamicMacros
54 * @property {Record<string, unknown>} extra63 * @property {Record<string, unknown>} extra
55 */64 */
5665
public/scripts/macros/engine/MacroEnvBuilder.js+1 -1
@@ -22,7 +22,7 @@ import { getStringHash } from '/scripts/utils.js';
22 * @property {string|null} [original]22 * @property {string|null} [original]
23 * @property {string|null} [groupOverride]23 * @property {string|null} [groupOverride]
24 * @property {boolean} [replaceCharacterCard]24 * @property {boolean} [replaceCharacterCard]
25 * @property {Record<string, any>|null} [dynamicMacros]25 * @property {Record<string, import('./MacroEnv.types.js').DynamicMacroValue>|null} [dynamicMacros]
26 * @property {(value: string) => string} [postProcessFn]26 * @property {(value: string) => string} [postProcessFn]
27 */27 */
2828
public/scripts/macros/engine/MacroRegistry.js+207 -185
@@ -192,6 +192,210 @@ class MacroRegistry {
192 name = typeof name === 'string' ? name.trim() : String(name);192 name = typeof name === 'string' ? name.trim() : String(name);
193193
194 try {194 try {
195 const nameKey = name.toLowerCase();
196 if (this.#macros.has(nameKey)) {
197 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
198 }
199
200 // Detect extension/third-party status from call stack
201 const { isExtension, isThirdParty, source } = detectMacroSource();
202
203 // Build the definition using the shared helper
204 const definition = this.buildMacroDefFromOptions(name, options, {
205 source: { name: source, isExtension, isThirdParty },
206 });
207
208 this.#macros.set(nameKey, definition);
209
210 // Register alias entries pointing to the same definition
211 for (const { alias, visible } of definition.aliases) {
212 const aliasKey = alias.toLowerCase();
213 if (this.#macros.has(aliasKey)) {
214 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
215 }
216 /** @type {MacroDefinition} */
217 const aliasEntry = {
218 ...definition,
219 name: alias, // The lookup name is the alias (preserves original casing for display)
220 aliasOf: name,
221 aliasVisible: visible,
222 };
223 this.#macros.set(aliasKey, aliasEntry);
224 }
225
226 return definition;
227 } catch (error) {
228 logMacroRegisterError({
229 message: `Failed to register macro "${name}". The macro will not be available.`,
230 macroName: name,
231 error,
232 });
233 return null;
234 }
235 }
236
237 /**
238 * Unregisters a macro.
239 *
240 * @param {string} name - Macro name (identifier).
241 * @returns {boolean} True if a macro was removed.
242 */
243 unregisterMacro(name) {
244 if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
245 name = name.trim();
246 return this.#macros.delete(name.toLowerCase());
247 }
248
249 /**
250 * Checks whether a macro with the given name is registered.
251 *
252 * @param {string} name - Macro name (identifier).
253 * @returns {boolean}
254 */
255 hasMacro(name) {
256 if (typeof name !== 'string' || !name.trim()) return false;
257 name = name.trim();
258 return this.#macros.has(name.toLowerCase());
259 }
260
261 /**
262 * Returns the macro definition for a given name.
263 *
264 * @param {string} name - Macro name (identifier).
265 * @returns {MacroDefinition|undefined}
266 */
267 getMacro(name) {
268 if (typeof name !== 'string' || !name.trim()) return undefined;
269 name = name.trim();
270 return this.#macros.get(name.toLowerCase());
271 }
272
273 /**
274 * Returns the primary (non-alias) definition for a macro.
275 * If given an alias name, returns the primary definition it points to.
276 *
277 * @param {string} name - Macro name or alias.
278 * @returns {MacroDefinition|undefined}
279 */
280 getPrimaryMacro(name) {
281 const def = this.getMacro(name);
282 if (!def) return undefined;
283 return def.aliasOf ? this.getMacro(def.aliasOf) : def;
284 }
285
286 /**
287 * Returns an array of all registered macros.
288 *
289 * @param {Object} [options] - Filter options.
290 * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
291 * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
292 * @returns {MacroDefinition[]}
293 */
294 getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
295 let macros = Array.from(this.#macros.values());
296 if (excludeAliases) {
297 macros = macros.filter(m => !m.aliasOf);
298 } else if (excludeHiddenAliases) {
299 macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
300 }
301 return macros;
302 }
303
304 /**
305 * Executes a macro for a given call.
306 *
307 * @param {MacroCall} call - Macro call information.
308 * @param {Object} [options] - Additional options.
309 * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
310 * @returns {string}
311 */
312 executeMacro(call, { defOverride } = {}) {
313 const name = call.name;
314 const def = defOverride || this.getMacro(name);
315 if (!def) {
316 throw new Error(`Macro "${name}" is not registered`);
317 }
318
319 const args = Array.isArray(call.args) ? call.args : [];
320
321 if (!isArgsValid(def, args)) {
322 const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
323 const expectedMax = def.list && def.list.max !== null
324 ? def.maxArgs + def.list.max
325 : (def.list ? null : def.maxArgs);
326
327 const expectation = (() => {
328 if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
329 if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
330 return `at least ${expectedMin}`;
331 })();
332
333 const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
334 if (def.strictArgs) {
335 throw createMacroRuntimeError({ message, call, def });
336 }
337 logMacroRuntimeWarning({ message, call, def });
338 }
339
340 // Compute unnamed args (required + optional, up to maxArgs)
341 const unnamedArgsCount = Math.min(args.length, def.maxArgs);
342 const unnamedArgsValues = args.slice(0, unnamedArgsCount);
343 const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
344
345 // Perform best-effort type validation for documented positional arguments.
346 // This can throw an error if the arguments are invalid.
347 validateArgTypes(call, def, unnamedArgsValues);
348
349 const namedArgs = null;
350
351 /** @type {MacroExecutionContext} */
352 const executionContext = {
353 name: def.name,
354 args,
355 unnamedArgs: unnamedArgsValues,
356 list: listValues,
357 namedArgs,
358 flags: call.flags,
359 isScoped: call.isScoped,
360 raw: call.rawInner,
361 rawOriginal: call.rawWithBraces,
362 rawArgs: call.rawArgs,
363 env: call.env,
364 cstNode: call.cstNode,
365 range: call.range,
366 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
367 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
368 resolve: (text) => MacroEngine.evaluate(text, call.env),
369 };
370
371 const result = def.handler(executionContext);
372 return executionContext.normalize(result);
373 }
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
195 if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);399 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.`);400 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
197401
@@ -222,7 +426,7 @@ class MacroRegistry {
222 const aliasName = aliasDef.alias.trim();426 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.`);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.`);
224 if (aliasName.toLowerCase() === name.toLowerCase()) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name (insensitive).`);428 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 true429 const visible = aliasDef.visible !== false;
226 aliases.push({ alias: aliasName, visible });430 aliases.push({ alias: aliasName, visible });
227 }431 }
228 }432 }
@@ -239,13 +443,11 @@ class MacroRegistry {
239 let unnamedArgDefs = [];443 let unnamedArgDefs = [];
240 if (rawUnnamedArgs !== undefined) {444 if (rawUnnamedArgs !== undefined) {
241 if (Array.isArray(rawUnnamedArgs)) {445 if (Array.isArray(rawUnnamedArgs)) {
242 // Parse array of argument definitions with optional support
243 let foundOptional = false;446 let foundOptional = false;
244 unnamedArgDefs = rawUnnamedArgs.map((def, index) => {447 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.`);448 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.`);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.`);
247450
248 // Validate: no required args after optional
249 if (foundOptional && !def.optional) {451 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.`);452 throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
251 }453 }
@@ -270,10 +472,9 @@ class MacroRegistry {
270 return normalized;472 return normalized;
271 });473 });
272474
273 // Compute minArgs (required count) and maxArgs (total count)
274 maxArgs = unnamedArgDefs.length;475 maxArgs = unnamedArgDefs.length;
275 minArgs = unnamedArgDefs.findIndex(d => d.optional);476 minArgs = unnamedArgDefs.findIndex(d => d.optional);
276 if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required477 if (minArgs === -1) minArgs = maxArgs;
277 } else if (typeof rawUnnamedArgs === 'number') {478 } else if (typeof rawUnnamedArgs === 'number') {
278 if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {479 if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
279 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);480 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
@@ -324,13 +525,10 @@ class MacroRegistry {
324 returns = rawReturns || '<empty string>';525 returns = rawReturns || '<empty string>';
325 }526 }
326527
327 // Process and validate returnType (defaults to 'string')
328 const validTypes = ['string', 'integer', 'number', 'boolean'];528 const validTypes = ['string', 'integer', 'number', 'boolean'];
329 let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');529 let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
330 if (rawReturnType !== undefined && rawReturnType !== null) {530 if (rawReturnType !== undefined && rawReturnType !== null) {
331 // Normalize to non-empty value or default
332 returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;531 returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
333 // Validate all types
334 const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];532 const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
335 if (typesToValidate.some(t => !validTypes.includes(t))) {533 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.`);534 throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
@@ -368,14 +566,6 @@ class MacroRegistry {
368 delayArgResolution = rawDelayArgResolution;566 delayArgResolution = rawDelayArgResolution;
369 }567 }
370568
371 const nameKey = name.toLowerCase();
372 if (this.#macros.has(nameKey)) {
373 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
374 }
375
376 // Detect extension/third-party status from call stack
377 const { isExtension, isThirdParty, source } = detectMacroSource();
378
379 /** @type {MacroDefinition} */569 /** @type {MacroDefinition} */
380 const definition = {570 const definition = {
381 name: name,571 name: name,
@@ -393,180 +583,12 @@ class MacroRegistry {
393 exampleUsage,583 exampleUsage,
394 delayArgResolution,584 delayArgResolution,
395 handler,585 handler,
396 source: {586 source: source ?? { name: 'dynamic', isExtension: false, isThirdParty: false },
397 name: source,
398 isExtension,
399 isThirdParty,
400 },
401 aliasOf: null,587 aliasOf: null,
402 aliasVisible: null,588 aliasVisible: null,
403 };589 };
404590
405 this.#macros.set(nameKey, definition);
406
407 // Register alias entries pointing to the same definition
408 for (const { alias, visible } of aliases) {
409 const aliasKey = alias.toLowerCase();
410 if (this.#macros.has(aliasKey)) {
411 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
412 }
413 /** @type {MacroDefinition} */
414 const aliasEntry = {
415 ...definition,
416 name: alias, // The lookup name is the alias (preserves original casing for display)
417 aliasOf: name,
418 aliasVisible: visible,
419 };
420 this.#macros.set(aliasKey, aliasEntry);
421 }
422
423 return definition;591 return definition;
424 } catch (error) {
425 logMacroRegisterError({
426 message: `Failed to register macro "${name}". The macro will not be available.`,
427 macroName: name,
428 error,
429 });
430 return null;
431 }
432 }
433
434 /**
435 * Unregisters a macro.
436 *
437 * @param {string} name - Macro name (identifier).
438 * @returns {boolean} True if a macro was removed.
439 */
440 unregisterMacro(name) {
441 if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
442 name = name.trim();
443 return this.#macros.delete(name.toLowerCase());
444 }
445
446 /**
447 * Checks whether a macro with the given name is registered.
448 *
449 * @param {string} name - Macro name (identifier).
450 * @returns {boolean}
451 */
452 hasMacro(name) {
453 if (typeof name !== 'string' || !name.trim()) return false;
454 name = name.trim();
455 return this.#macros.has(name.toLowerCase());
456 }
457
458 /**
459 * Returns the macro definition for a given name.
460 *
461 * @param {string} name - Macro name (identifier).
462 * @returns {MacroDefinition|undefined}
463 */
464 getMacro(name) {
465 if (typeof name !== 'string' || !name.trim()) return undefined;
466 name = name.trim();
467 return this.#macros.get(name.toLowerCase());
468 }
469
470 /**
471 * Returns the primary (non-alias) definition for a macro.
472 * If given an alias name, returns the primary definition it points to.
473 *
474 * @param {string} name - Macro name or alias.
475 * @returns {MacroDefinition|undefined}
476 */
477 getPrimaryMacro(name) {
478 const def = this.getMacro(name);
479 if (!def) return undefined;
480 return def.aliasOf ? this.getMacro(def.aliasOf) : def;
481 }
482
483 /**
484 * Returns an array of all registered macros.
485 *
486 * @param {Object} [options] - Filter options.
487 * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
488 * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
489 * @returns {MacroDefinition[]}
490 */
491 getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
492 let macros = Array.from(this.#macros.values());
493 if (excludeAliases) {
494 macros = macros.filter(m => !m.aliasOf);
495 } else if (excludeHiddenAliases) {
496 macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
497 }
498 return macros;
499 }
500
501 /**
502 * Executes a macro for a given call.
503 *
504 * @param {MacroCall} call - Macro call information.
505 * @param {Object} [options] - Additional options.
506 * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
507 * @returns {string}
508 */
509 executeMacro(call, { defOverride } = {}) {
510 const name = call.name;
511 const def = defOverride || this.getMacro(name);
512 if (!def) {
513 throw new Error(`Macro "${name}" is not registered`);
514 }
515
516 const args = Array.isArray(call.args) ? call.args : [];
517
518 if (!isArgsValid(def, args)) {
519 const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
520 const expectedMax = def.list && def.list.max !== null
521 ? def.maxArgs + def.list.max
522 : (def.list ? null : def.maxArgs);
523
524 const expectation = (() => {
525 if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
526 if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
527 return `at least ${expectedMin}`;
528 })();
529
530 const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
531 if (def.strictArgs) {
532 throw createMacroRuntimeError({ message, call, def });
533 }
534 logMacroRuntimeWarning({ message, call, def });
535 }
536
537 // Compute unnamed args (required + optional, up to maxArgs)
538 const unnamedArgsCount = Math.min(args.length, def.maxArgs);
539 const unnamedArgsValues = args.slice(0, unnamedArgsCount);
540 const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
541
542 // Perform best-effort type validation for documented positional arguments.
543 // This can throw an error if the arguments are invalid.
544 validateArgTypes(call, def, unnamedArgsValues);
545
546 const namedArgs = null;
547
548 /** @type {MacroExecutionContext} */
549 const executionContext = {
550 name: def.name,
551 args,
552 unnamedArgs: unnamedArgsValues,
553 list: listValues,
554 namedArgs,
555 flags: call.flags,
556 isScoped: call.isScoped,
557 raw: call.rawInner,
558 rawOriginal: call.rawWithBraces,
559 rawArgs: call.rawArgs,
560 env: call.env,
561 cstNode: call.cstNode,
562 range: call.range,
563 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
564 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
565 resolve: (text) => MacroEngine.evaluate(text, call.env),
566 };
567
568 const result = def.handler(executionContext);
569 return executionContext.normalize(result);
570 }592 }
571}593}
572594
public/scripts/slash-commands/SlashCommandClosure.js+102 -0
@@ -1,4 +1,5 @@
1import { substituteParams } from '../../script.js';1import { substituteParams } from '../../script.js';
2import { power_user } from '../power-user.js';
2import { delay, escapeRegex, uuidv4 } from '../utils.js';3import { delay, escapeRegex, uuidv4 } from '../utils.js';
3import { SlashCommand } from './SlashCommand.js';4import { SlashCommand } from './SlashCommand.js';
4import { SlashCommandAbortController } from './SlashCommandAbortController.js';5import { SlashCommandAbortController } from './SlashCommandAbortController.js';
@@ -48,6 +49,104 @@ export class SlashCommandClosure {
48 }49 }
4950
50 /**51 /**
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 /**
51 *150 *
52 * @param {string} text151 * @param {string} text
53 * @param {SlashCommandScope} scope152 * @param {SlashCommandScope} scope
@@ -72,6 +171,9 @@ export class SlashCommandClosure {
72 if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');171 if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
73 return 0;172 return 0;
74 });173 });
174 if (power_user.experimental_macro_engine) {
175 return this.substituteWithMacroEngine(text, scope, macroList);
176 }
75 const macros = macroList.map(it=>escapeMacro(it)).join('|');177 const macros = macroList.map(it=>escapeMacro(it)).join('|');
76 const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);178 const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
77 let done = '';179 let done = '';
public/scripts/slash-commands/SlashCommandParser.js+4 -0
@@ -1392,6 +1392,10 @@ export class SlashCommandParser {
1392 }1392 }
13931393
1394 replaceGetvar(value) {1394 replaceGetvar(value) {
1395 // Not needed with the new parser.
1396 if (power_user.experimental_macro_engine) {
1397 return value;
1398 }
1395 return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {1399 return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {
1396 name = name.trim();1400 name = name.trim();
1397 cmd = cmd.toLowerCase();1401 cmd = cmd.toLowerCase();
tests/frontend/MacroEngine.e2e.js+465 -10
@@ -674,13 +674,118 @@ test.describe('MacroEngine', () => {
674 });674 });
675675
676 test.describe('Dynamic macros', () => {676 test.describe('Dynamic macros', () => {
677 test('should not resolve dynamic macro when called with arguments due to strict arity', async ({ page }) => {677 test.describe('String value dynamic macros', () => {
678 /** @type {string[]} */678 test('should resolve dynamic macro with string value', async ({ page }) => {
679 const output = await page.evaluate(async () => {
680 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
681 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
682 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
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');
696 });
697
698 test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
699 const output = await page.evaluate(async () => {
700 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
701 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
702 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
703 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
704
705 const rawEnv = {
706 content: '',
707 dynamicMacros: {
708 num: 42,
709 },
710 };
711 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
712 return MacroEngine.evaluate('Value: {{num}}', env);
713 });
714
715 expect(output).toBe('Value: 42');
716 });
717
718 test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
679 const warnings = [];719 const warnings = [];
680 page.on('console', msg => {720 page.on('console', msg => {
681 if (msg.type() === 'warning') {721 if (msg.type() === 'warning') warnings.push(msg.text());
682 warnings.push(msg.text());722 });
683 }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());
684 });789 });
685790
686 const input = 'Dyn: {{dyn::extra}}';791 const input = 'Dyn: {{dyn::extra}}';
@@ -690,7 +795,6 @@ test.describe('MacroEngine', () => {
690 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */795 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
691 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');796 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
692797
693 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
694 const rawEnv = {798 const rawEnv = {
695 content: input,799 content: input,
696 dynamicMacros: {800 dynamicMacros: {
@@ -698,16 +802,367 @@ test.describe('MacroEngine', () => {
698 },802 },
699 };803 };
700 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);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 });
701946
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);
702 return MacroEngine.evaluate(input, env);965 return MacroEngine.evaluate(input, env);
703 }, input);966 }, input);
704967
705 // Dynamic macro with arguments should not resolve because the
706 // temporary definition is strictArgs: true and minArgs/maxArgs: 0.
707 expect(output).toBe(input);968 expect(output).toBe(input);
969 expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
970 });
708971
709 // A runtime arity warning for the dynamic macro should be logged972 test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
710 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();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);
1027
1028 expect(output).toBe(input);
1029 expect(warnings.some(w => w.includes('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 });
711 });1166 });
712 });1167 });
7131168
tests/frontend/MacroEnvBuilder.e2e.js+188 -3
@@ -205,18 +205,17 @@ test.describe('MacroEnvBuilder', () => {
205 });205 });
206 });206 });
207207
208 test.describe('Dynamic macros in env', () => {
208 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {209 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {
209 const dynamicMacros = await page.evaluate(async () => {210 const dynamicMacros = await page.evaluate(async () => {
210 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */211 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
211 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');212 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
212213
213 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
214 const ctx = {214 const ctx = {
215 content: '',215 content: '',
216 dynamicMacros: {216 dynamicMacros: {
217 simple: 'value',217 simple: 'value',
218 number: 42,218 number: 42,
219 nested: { foo: 'bar' },
220 },219 },
221 };220 };
222221
@@ -226,7 +225,193 @@ test.describe('MacroEnvBuilder', () => {
226225
227 expect(dynamicMacros.simple).toBe('value');226 expect(dynamicMacros.simple).toBe('value');
228 expect(dynamicMacros.number).toBe(42);227 expect(dynamicMacros.number).toBe(42);
229 expect(dynamicMacros.nested).toEqual({ foo: 'bar' });228 });
229
230 test('normalizes dynamic macro keys to lowercase', async ({ page }) => {
231 const result = await page.evaluate(async () => {
232 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
233 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
234
235 const ctx = {
236 content: '',
237 dynamicMacros: {
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 });
262
263 test('accepts string values for dynamic macros', async ({ page }) => {
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');
284 });
285
286 test('accepts handler functions for dynamic macros', async ({ page }) => {
287 const result = await page.evaluate(async () => {
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 });
230 });415 });
231416
232 test('sets system.model field from getGeneratingModel helper', async ({ page }) => {417 test('sets system.model field from getGeneratingModel helper', async ({ page }) => {
tests/frontend/MacroSlashCommands.e2e.js+177 -0
@@ -0,0 +1,177 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4test.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});