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 {
200200 name = typeof name === 'string' ? name.trim() : String(name);
201201
202202 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-
208203 // Detect extension/third-party status from call stack
209204 const { isExtension, isThirdParty, source } = detectMacroSource();
210205
@@ -213,22 +208,12 @@ class MacroRegistry {
213208 source: { name: source, isExtension, isThirdParty },
214209 });
215210
216- this.#macros.set(nameKey, definition);
211+ // Register the primary macro
212+ this.#registerMacroEntry(name, definition);
217213
218214 // Register alias entries pointing to the same definition
219215 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);
232217 }
233218
234219 return definition;
@@ -243,6 +228,97 @@ class MacroRegistry {
243228 }
244229
245230 /**
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+ /**
246322 * Unregisters a macro.
247323 *
248324 * @param {string} name - Macro name (identifier).
public/scripts/macros/macro-system.js+1 -0
@@ -55,6 +55,7 @@ export const macros = {
5555
5656 // shorthand functions
5757 register: MacroRegistry.registerMacro.bind(MacroRegistry),
58+ registerAlias: MacroRegistry.registerMacroAlias.bind(MacroRegistry),
5859};
5960
6061/**
tests/frontend/MacroRegistry.e2e.js+365 -2
@@ -5,7 +5,7 @@ test.describe('MacroRegistry', () => {
55 // Currently this test suits runs without ST context. Enable, if ever needed
66 test.beforeEach(testSetup.awaitST);
77
88 test.describe('register valid', () => {
99 test('should register a macro with valid options', async ({ page }) => {
1010 const result = await page.evaluate(async () => {
1111 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
@@ -42,7 +42,7 @@ test.describe('MacroRegistry', () => {
4242 });
4343 });
4444
4545 test.describe('register reject', () => {
4646 test('should reject invalid macro name', async ({ page }) => {
4747 const result = await registerMacroAndCaptureErrors(page, {
4848 macroName: ' ',
@@ -291,6 +291,322 @@ test.describe('MacroRegistry', () => {
291291 expect(registrationError?.errorMessage).toContain('is invalid');
292292 });
293293 });
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+ });
294610});
295611
296612/**
@@ -354,3 +670,50 @@ async function registerMacroAndCaptureErrors(page, { macroName, options }) {
354670
355671 return result;
356672}
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+ */
679+async 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+}