chore(macros): Allow registration of aliases for existing macros (#5053) - Added `registerMacroAlias()` public method to register aliases for existing macros - Extracted shared registration logic into private `#registerMacroEntry()` helper - Refactored `registerMacro()` to use `#registerMacroEntry()` for both primary macros and aliases - Alias registration validates name format, prevents self-aliasing, and resolves alias chains to primary definition - Aliases inherit handler and metadata from target but has source of the registration caller

ca60ba148c39425f7ab682c7592382cfd0efc715

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +460 -20Ignore whitespace
public/scripts/macros/engine/MacroRegistry.js+94 -18
@@ -200,11 +200,6 @@ class MacroRegistry {
200 name = typeof name === 'string' ? name.trim() : String(name);200 name = typeof name === 'string' ? name.trim() : String(name);
201201
202 try {202 try {
203 const nameKey = name.toLowerCase();
204 if (this.#macros.has(nameKey)) {
205 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
206 }
207
208 // Detect extension/third-party status from call stack203 // Detect extension/third-party status from call stack
209 const { isExtension, isThirdParty, source } = detectMacroSource();204 const { isExtension, isThirdParty, source } = detectMacroSource();
210205
@@ -213,22 +208,12 @@ class MacroRegistry {
213 source: { name: source, isExtension, isThirdParty },208 source: { name: source, isExtension, isThirdParty },
214 });209 });
215210
216 this.#macros.set(nameKey, definition);211 // Register the primary macro
212 this.#registerMacroEntry(name, definition);
217213
218 // Register alias entries pointing to the same definition214 // Register alias entries pointing to the same definition
219 for (const { alias, visible } of definition.aliases) {215 for (const { alias, visible } of definition.aliases) {
220 const aliasKey = alias.toLowerCase();216 this.#registerMacroEntry(alias, definition, { primaryMacroName: name, aliasVisible: visible });
221 if (this.#macros.has(aliasKey)) {
222 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
223 }
224 /** @type {MacroDefinition} */
225 const aliasEntry = {
226 ...definition,
227 name: alias, // The lookup name is the alias (preserves original casing for display)
228 aliasOf: name,
229 aliasVisible: visible,
230 };
231 this.#macros.set(aliasKey, aliasEntry);
232 }217 }
233218
234 return definition;219 return definition;
@@ -243,6 +228,97 @@ class MacroRegistry {
243 }228 }
244229
245 /**230 /**
231 * Registers an alias for an existing macro.
232 * The alias will point to the same handler and metadata as the original macro.
233 * Errors during registration are caught and logged, the alias will not be registered, and the function returns false.
234 *
235 * @param {string} targetMacroName - The name of the existing macro to create an alias for.
236 * @param {string} aliasName - The alias name (identifier).
237 * @param {Object} [options] - Alias registration options.
238 * @param {boolean} [options.visible=true] - Whether this alias appears in documentation/autocomplete.
239 * @returns {boolean} True if the alias was registered successfully, false if registration failed.
240 */
241 registerMacroAlias(targetMacroName, aliasName, { visible = true } = {}) {
242 // Extract names early for error logging
243 targetMacroName = typeof targetMacroName === 'string' ? targetMacroName.trim() : String(targetMacroName);
244 aliasName = typeof aliasName === 'string' ? aliasName.trim() : String(aliasName);
245
246 try {
247 // Validate alias name
248 if (!isIdentifierValid(aliasName)) {
249 throw new Error(`Alias name "${aliasName}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
250 }
251
252 // Check that alias is not the same as target (case insensitive)
253 if (aliasName.toLowerCase() === targetMacroName.toLowerCase()) {
254 throw new Error(`Alias name "${aliasName}" cannot be the same as the target macro name (case insensitive).`);
255 }
256
257 // Check that target macro exists
258 const targetDefinition = this.getMacro(targetMacroName);
259 if (!targetDefinition) {
260 throw new Error(`Target macro "${targetMacroName}" is not registered.`);
261 }
262
263 // Get the primary definition (in case target is itself an alias)
264 const primaryDefinition = targetDefinition.aliasOf ? this.getMacro(targetDefinition.aliasOf) : targetDefinition;
265 if (!primaryDefinition) {
266 throw new Error(`Could not resolve primary definition for target macro "${targetMacroName}".`);
267 }
268
269 // Detect extension/third-party status from call stack
270 const { isExtension, isThirdParty, source } = detectMacroSource();
271
272 // Create alias definition with source detection
273 const aliasDefinition = {
274 ...primaryDefinition,
275 source: { name: source, isExtension, isThirdParty },
276 };
277
278 // Register the alias using the shared utility
279 this.#registerMacroEntry(aliasName, aliasDefinition, { primaryMacroName: primaryDefinition.name, aliasVisible: visible });
280
281 return true;
282 } catch (error) {
283 logMacroRegisterError({
284 message: `Failed to register alias "${aliasName}" for macro "${targetMacroName}". The alias will not be available.`,
285 macroName: aliasName,
286 error,
287 });
288 return false;
289 }
290 }
291
292 /**
293 * Shared utility for registering macro entries (primary or alias).
294 *
295 * @param {string} name - The registration name (primary macro or alias).
296 * @param {MacroDefinition} definition - The definition to register.
297 * @param {Object} [options={}] - Options for alias registration.
298 * @param {string} [options.primaryMacroName=null] - For aliases, the primary macro name.
299 * @param {boolean} [options.aliasVisible=null] - For aliases, visibility flag.
300 */
301 #registerMacroEntry(name, definition, { primaryMacroName = null, aliasVisible = null } = {}) {
302 const nameKey = name.toLowerCase();
303
304 if (this.#macros.has(nameKey)) {
305 const warningType = primaryMacroName ? `Alias "${name}" for macro "${primaryMacroName}"` : `Macro "${name}"`;
306 const warningMessage = primaryMacroName ? 'overwrites an existing macro.' : 'is already registered and will be overwritten.';
307 logMacroRegisterWarning({ macroName: primaryMacroName || name, message: `${warningType} ${warningMessage}` });
308 }
309
310 /** @type {MacroDefinition} */
311 const entry = primaryMacroName ? {
312 ...definition,
313 name: name, // The lookup name is the alias (preserves original casing for display)
314 aliasOf: primaryMacroName,
315 aliasVisible: aliasVisible,
316 } : definition;
317
318 this.#macros.set(nameKey, entry);
319 }
320
321 /**
246 * Unregisters a macro.322 * Unregisters a macro.
247 *323 *
248 * @param {string} name - Macro name (identifier).324 * @param {string} name - Macro name (identifier).
public/scripts/macros/macro-system.js+1 -0
@@ -55,6 +55,7 @@ export const macros = {
5555
56 // shorthand functions56 // shorthand functions
57 register: MacroRegistry.registerMacro.bind(MacroRegistry),57 register: MacroRegistry.registerMacro.bind(MacroRegistry),
58 registerAlias: MacroRegistry.registerMacroAlias.bind(MacroRegistry),
58};59};
5960
60/**61/**
tests/frontend/MacroRegistry.e2e.js+365 -2
@@ -5,7 +5,7 @@ test.describe('MacroRegistry', () => {
5 // Currently this test suits runs without ST context. Enable, if ever needed5 // Currently this test suits runs without ST context. Enable, if ever needed
6 test.beforeEach(testSetup.awaitST);6 test.beforeEach(testSetup.awaitST);
77
8 test.describe('valid', () => {8 test.describe('register valid', () => {
9 test('should register a macro with valid options', async ({ page }) => {9 test('should register a macro with valid options', async ({ page }) => {
10 const result = await page.evaluate(async () => {10 const result = await page.evaluate(async () => {
11 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */11 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
@@ -42,7 +42,7 @@ test.describe('MacroRegistry', () => {
42 });42 });
43 });43 });
4444
45 test.describe('reject', () => {45 test.describe('register reject', () => {
46 test('should reject invalid macro name', async ({ page }) => {46 test('should reject invalid macro name', async ({ page }) => {
47 const result = await registerMacroAndCaptureErrors(page, {47 const result = await registerMacroAndCaptureErrors(page, {
48 macroName: ' ',48 macroName: ' ',
@@ -291,6 +291,322 @@ test.describe('MacroRegistry', () => {
291 expect(registrationError?.errorMessage).toContain('is invalid');291 expect(registrationError?.errorMessage).toContain('is invalid');
292 });292 });
293 });293 });
294
295 test.describe('registerMacroAlias', () => {
296 test.describe('valid', () => {
297 test('should register an alias for an existing macro', async ({ page }) => {
298 const result = await page.evaluate(async () => {
299 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
300 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
301
302 // Clean up any existing registrations
303 MacroRegistry.unregisterMacro('alias-target');
304 MacroRegistry.unregisterMacro('my-alias');
305
306 // Register target macro
307 MacroRegistry.registerMacro('alias-target', {
308 description: 'Target macro for alias test',
309 handler: () => 'target-result',
310 });
311
312 // Register alias
313 const success = MacroRegistry.registerMacroAlias('alias-target', 'my-alias');
314
315 const aliasDef = MacroRegistry.getMacro('my-alias');
316 const targetDef = MacroRegistry.getMacro('alias-target');
317
318 return {
319 success,
320 aliasName: aliasDef?.name,
321 aliasOf: aliasDef?.aliasOf,
322 aliasVisible: aliasDef?.aliasVisible,
323 targetName: targetDef?.name,
324 sameHandler: aliasDef?.handler === targetDef?.handler,
325 };
326 });
327
328 expect(result.success).toBe(true);
329 expect(result.aliasName).toBe('my-alias');
330 expect(result.aliasOf).toBe('alias-target');
331 expect(result.aliasVisible).toBe(true);
332 expect(result.targetName).toBe('alias-target');
333 expect(result.sameHandler).toBe(true);
334 });
335
336 test('should register alias with visible=false option', async ({ page }) => {
337 const result = await page.evaluate(async () => {
338 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
339 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
340
341 MacroRegistry.unregisterMacro('alias-target-hidden');
342 MacroRegistry.unregisterMacro('hidden-alias');
343
344 MacroRegistry.registerMacro('alias-target-hidden', {
345 description: 'Target macro',
346 handler: () => 'result',
347 });
348
349 const success = MacroRegistry.registerMacroAlias('alias-target-hidden', 'hidden-alias', { visible: false });
350 const aliasDef = MacroRegistry.getMacro('hidden-alias');
351
352 return {
353 success,
354 aliasVisible: aliasDef?.aliasVisible,
355 };
356 });
357
358 expect(result.success).toBe(true);
359 expect(result.aliasVisible).toBe(false);
360 });
361
362 test('should resolve alias of alias to primary definition', async ({ page }) => {
363 const result = await page.evaluate(async () => {
364 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
365 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
366
367 MacroRegistry.unregisterMacro('primary-macro');
368 MacroRegistry.unregisterMacro('first-alias');
369 MacroRegistry.unregisterMacro('second-alias');
370
371 // Register primary macro
372 MacroRegistry.registerMacro('primary-macro', {
373 description: 'Primary macro',
374 handler: () => 'primary-result',
375 });
376
377 // Register first alias
378 MacroRegistry.registerMacroAlias('primary-macro', 'first-alias');
379
380 // Register alias of alias (should resolve to primary)
381 const success = MacroRegistry.registerMacroAlias('first-alias', 'second-alias');
382
383 const secondAliasDef = MacroRegistry.getMacro('second-alias');
384
385 return {
386 success,
387 aliasOf: secondAliasDef?.aliasOf,
388 };
389 });
390
391 expect(result.success).toBe(true);
392 // Should point to primary, not to the intermediate alias
393 expect(result.aliasOf).toBe('primary-macro');
394 });
395
396 test('should have independent source for alias', async ({ page }) => {
397 const result = await page.evaluate(async () => {
398 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
399 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
400
401 MacroRegistry.unregisterMacro('source-target');
402 MacroRegistry.unregisterMacro('source-alias');
403
404 MacroRegistry.registerMacro('source-target', {
405 description: 'Target',
406 handler: () => '',
407 });
408
409 MacroRegistry.registerMacroAlias('source-target', 'source-alias');
410
411 const targetDef = MacroRegistry.getMacro('source-target');
412 const aliasDef = MacroRegistry.getMacro('source-alias');
413
414 return {
415 // Both should have source objects
416 targetHasSource: !!targetDef?.source,
417 aliasHasSource: !!aliasDef?.source,
418 // The alias has its own source object (not shared reference)
419 sourcesAreDifferentObjects: targetDef?.source !== aliasDef?.source,
420 };
421 });
422
423 expect(result.targetHasSource).toBe(true);
424 expect(result.aliasHasSource).toBe(true);
425 expect(result.sourcesAreDifferentObjects).toBe(true);
426 });
427
428 test('should be case-insensitive for lookup', async ({ page }) => {
429 const result = await page.evaluate(async () => {
430 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
431 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
432
433 MacroRegistry.unregisterMacro('case-target');
434 MacroRegistry.unregisterMacro('CaseAlias');
435
436 MacroRegistry.registerMacro('case-target', {
437 handler: () => '',
438 });
439
440 MacroRegistry.registerMacroAlias('case-target', 'CaseAlias');
441
442 return {
443 foundLowercase: !!MacroRegistry.getMacro('casealias'),
444 foundUppercase: !!MacroRegistry.getMacro('CASEALIAS'),
445 foundMixed: !!MacroRegistry.getMacro('CaseAlias'),
446 };
447 });
448
449 expect(result.foundLowercase).toBe(true);
450 expect(result.foundUppercase).toBe(true);
451 expect(result.foundMixed).toBe(true);
452 });
453 });
454
455 test.describe('reject', () => {
456 test('should reject invalid alias name', async ({ page }) => {
457 const result = await registerAliasAndCaptureErrors(page, {
458 targetMacroName: 'random',
459 aliasName: '123-invalid',
460 });
461
462 expect(result.success).toBe(false);
463 expect(result.errors.length).toBeGreaterThan(0);
464
465 const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
466 expect(registrationError).toBeTruthy();
467 expect(registrationError?.text).toContain('Failed to register alias "123-invalid"');
468 expect(registrationError?.errorMessage).toContain('is invalid');
469 });
470
471 test('should reject alias same as target name (case insensitive)', async ({ page }) => {
472 const result = await registerAliasAndCaptureErrors(page, {
473 targetMacroName: 'random',
474 aliasName: 'RANDOM',
475 });
476
477 expect(result.success).toBe(false);
478 expect(result.errors.length).toBeGreaterThan(0);
479
480 const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
481 expect(registrationError).toBeTruthy();
482 expect(registrationError?.errorMessage).toContain('cannot be the same as the target macro name');
483 });
484
485 test('should reject alias for non-existent target macro', async ({ page }) => {
486 const result = await registerAliasAndCaptureErrors(page, {
487 targetMacroName: 'non-existent-macro-xyz',
488 aliasName: 'my-alias',
489 });
490
491 expect(result.success).toBe(false);
492 expect(result.errors.length).toBeGreaterThan(0);
493
494 const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
495 expect(registrationError).toBeTruthy();
496 expect(registrationError?.errorMessage).toContain('is not registered');
497 });
498
499 test('should reject alias with special characters', async ({ page }) => {
500 const result = await registerAliasAndCaptureErrors(page, {
501 targetMacroName: 'random',
502 aliasName: 'alias@name',
503 });
504
505 expect(result.success).toBe(false);
506 const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
507 expect(registrationError?.errorMessage).toContain('is invalid');
508 });
509
510 test('should reject alias starting with hyphen', async ({ page }) => {
511 const result = await registerAliasAndCaptureErrors(page, {
512 targetMacroName: 'random',
513 aliasName: '-alias',
514 });
515
516 expect(result.success).toBe(false);
517 const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
518 expect(registrationError?.errorMessage).toContain('is invalid');
519 });
520 });
521
522 test.describe('warnings', () => {
523 test('should warn when alias overwrites existing macro', async ({ page }) => {
524 const result = await page.evaluate(async () => {
525 /** @type {string[]} */
526 const warnings = [];
527 const originalWarn = console.warn;
528
529 console.warn = (...args) => {
530 warnings.push(args.map(a => String(a)).join(' '));
531 };
532
533 try {
534 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
535 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
536
537 MacroRegistry.unregisterMacro('overwrite-target');
538 MacroRegistry.unregisterMacro('overwrite-existing');
539
540 // Register target macro
541 MacroRegistry.registerMacro('overwrite-target', {
542 handler: () => 'target',
543 });
544
545 // Register a macro that will be overwritten
546 MacroRegistry.registerMacro('overwrite-existing', {
547 handler: () => 'existing',
548 });
549
550 // Register alias that overwrites existing macro
551 const success = MacroRegistry.registerMacroAlias('overwrite-target', 'overwrite-existing');
552
553 return { success, warnings };
554 } finally {
555 console.warn = originalWarn;
556 }
557 });
558
559 expect(result.success).toBe(true);
560 const overwriteWarning = result.warnings.find(w =>
561 w.includes('overwrites an existing macro') && w.includes('overwrite-existing'),
562 );
563 expect(overwriteWarning).toBeTruthy();
564 });
565
566 test('should warn when alias overwrites another alias', async ({ page }) => {
567 const result = await page.evaluate(async () => {
568 /** @type {string[]} */
569 const warnings = [];
570 const originalWarn = console.warn;
571
572 console.warn = (...args) => {
573 warnings.push(args.map(a => String(a)).join(' '));
574 };
575
576 try {
577 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
578 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
579
580 MacroRegistry.unregisterMacro('alias-warn-target1');
581 MacroRegistry.unregisterMacro('alias-warn-target2');
582 MacroRegistry.unregisterMacro('shared-alias-name');
583
584 // Register two target macros
585 MacroRegistry.registerMacro('alias-warn-target1', { handler: () => '1' });
586 MacroRegistry.registerMacro('alias-warn-target2', { handler: () => '2' });
587
588 // Register first alias
589 MacroRegistry.registerMacroAlias('alias-warn-target1', 'shared-alias-name');
590
591 // Clear warnings from first registration
592 warnings.length = 0;
593
594 // Register second alias with same name (should warn)
595 MacroRegistry.registerMacroAlias('alias-warn-target2', 'shared-alias-name');
596
597 return { warnings };
598 } finally {
599 console.warn = originalWarn;
600 }
601 });
602
603 const overwriteWarning = result.warnings.find(w =>
604 w.includes('overwrites an existing macro') && w.includes('shared-alias-name'),
605 );
606 expect(overwriteWarning).toBeTruthy();
607 });
608 });
609 });
294});610});
295611
296/**612/**
@@ -354,3 +670,50 @@ async function registerMacroAndCaptureErrors(page, { macroName, options }) {
354670
355 return result;671 return result;
356}672}
673
674/**
675 * @param {import('@playwright/test').Page} page
676 * @param {{ targetMacroName: string, aliasName: string, options?: { visible?: boolean } }} params
677 * @returns {Promise<{ success: boolean, errors: CapturedConsoleError[] }>}
678 */
679async function registerAliasAndCaptureErrors(page, { targetMacroName, aliasName, options = {} }) {
680 const result = await page.evaluate(async ({ targetMacroName, aliasName, options }) => {
681 /** @type {CapturedConsoleError[]} */
682 const errors = [];
683 const originalError = console.error;
684
685 console.error = (...args) => {
686 const text = args
687 .map(a => (typeof a === 'string' ? a : (a instanceof Error ? `Error: ${a.message}` : '')))
688 .filter(Boolean)
689 .join(' ');
690
691 /** @type {string|null} */
692 let errorMessage = null;
693 for (const a of args) {
694 if (a instanceof Error) {
695 errorMessage ??= a.message;
696 continue;
697 }
698 if (a && typeof a === 'object' && 'error' in a && a.error instanceof Error) {
699 errorMessage ??= a.error.message;
700 }
701 }
702
703 errors.push({ text, errorMessage });
704 };
705
706 try {
707 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
708 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
709
710 // Registering an invalid alias does not throw. It returns false and logs an error.
711 const success = MacroRegistry.registerMacroAlias(targetMacroName, aliasName, options);
712 return { success, errors };
713 } finally {
714 console.error = originalError;
715 }
716 }, { targetMacroName, aliasName, options });
717
718 return result;
719}