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
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+36 -22
@@ -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
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 arguments184
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 }
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+224 -202
@@ -192,182 +192,6 @@ 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 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
371 const nameKey = name.toLowerCase();195 const nameKey = name.toLowerCase();
372 if (this.#macros.has(nameKey)) {196 if (this.#macros.has(nameKey)) {
373 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });197 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
@@ -376,36 +200,15 @@ class MacroRegistry {
376 // Detect extension/third-party status from call stack200 // Detect extension/third-party status from call stack
377 const { isExtension, isThirdParty, source } = detectMacroSource();201 const { isExtension, isThirdParty, source } = detectMacroSource();
378202
379 /** @type {MacroDefinition} */203 // Build the definition using the shared helper
380 const definition = {204 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
405 this.#macros.set(nameKey, definition);208 this.#macros.set(nameKey, definition);
406209
407 // Register alias entries pointing to the same definition210 // Register alias entries pointing to the same definition
408 for (const { alias, visible } of aliases) {211 for (const { alias, visible } of definition.aliases) {
409 const aliasKey = alias.toLowerCase();212 const aliasKey = alias.toLowerCase();
410 if (this.#macros.has(aliasKey)) {213 if (this.#macros.has(aliasKey)) {
411 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });214 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
@@ -568,6 +371,225 @@ class MacroRegistry {
568 const result = def.handler(executionContext);371 const result = def.handler(executionContext);
569 return executionContext.normalize(result);372 return executionContext.normalize(result);
570 }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
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 }
571}593}
572594
573instance = MacroRegistry.instance;595instance = MacroRegistry.instance;
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+483 -28
@@ -674,40 +674,495 @@ 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 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');
684 });696 });
685697
686 const input = 'Dyn: {{dyn::extra}}';698 test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
687 const output = await page.evaluate(async (input) => {699 const output = await page.evaluate(async () => {
688 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */700 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
689 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');701 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
690 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */702 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
691 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');703 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 },
698 },710 };
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');
703 }, input);716 });
704717
705 // Dynamic macro with arguments should not resolve because the718 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 logged1028 expect(output).toBe(input);
710 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();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+203 -18
@@ -205,28 +205,213 @@ test.describe('MacroEnvBuilder', () => {
205 });205 });
206 });206 });
207207
208 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {208 test.describe('Dynamic macros in env', () => {
209 const dynamicMacros = await page.evaluate(async () => {209 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {
210 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */210 const dynamicMacros = await page.evaluate(async () => {
211 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');211 /** @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 = {
219 nested: { foo: 'bar' },236 content: '',
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');
225 });284 });
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 });
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});