Macros 2.0 (v0.4) - Add scoped macros (last arg can be scoped), {{if}} macro and macro flags (baseline implementation) (#4913)

* Macros: make category optional, default to UNCATEGORIZED

* Tests: Update macro tests to match new error handling behavior

- Change MacroRegistry tests from expecting thrown errors to capturing console.error logs
- Update MacroLexer tests to expect plaintext fallback instead of lexer errors for invalid tokens
- Fix MacroEngine test to use `char` macro instead of `newline` for arity validation
- Update MacroParser test with corrected expected error message for invalid identifiers
- Remove "[Error]" prefixes from test descriptions where lexer no longer errors

* Macros: Implement macro execution flags system

- Add MacroFlags module with flag parsing and validation (!, ?, ~, /, >, ., $, #)
- Update lexer to tokenize flags as separate tokens before macro identifier
- Modify parser to capture flags in CST under 'flags' label
- Update CST walker to parse flag tokens into MacroFlags object
- Pass parsed flags to macro handlers via MacroCall and MacroContext
- Update autocomplete parser to handle flags before identifier
- Add comprehensive tests for flag parsing,

* Macros: Fix autocomplete positioning for macros with flags and whitespace

- Add identifierStart to parseMacroContext to track where identifier begins in macro text
- Update autocomplete to use identifierStart for correct range calculation
- Simplify indexMacros regex to use global flag instead of manual loop
- Use parseMacroContext in indexMacros to extract identifier (handles flags/whitespace)
- Fix autocomplete range starting at wrong position when flags or whitespace present

* Macros: Add autocomplete support for macro execution flags

- Add MacroFlagAutoCompleteOption class for rendering flag options in autocomplete
- Extend parseMacroContext to track currentFlag (flag cursor is on) and isInFlagsArea
- Track flagEndPositions to determine which flag cursor is currently typing
- Update #buildEnhancedMacroOptions to show flag options when cursor is in flags area
- Show current flag first if cursor just typed it, then show remaining available flags
- Add renderItem and renderDetails methods to MacroFlag

* Macros: Implement scoped macro syntax with opening and closing tags

- Add scoped macro processing to MacroCstWalker to find and merge opening/closing pairs
- Parse closing block flag (/) to identify closing macros and match with opening tags
- Extract content between opening and closing tags as the last unnamed argument
- Add `isScoped` property to MacroCall and MacroContext to track scoped invocations
- Implement `#processScopedMacros` to find outermost matching pairs and handle nesting

* Macros: Add autocomplete warnings, scoped content info, and closing tag suggestions

- Add arity warning banners in autocomplete details for invalid argument counts
- Show warning when using space-separated args on multi-arg or no-arg macros
- Add scoped content info banner when cursor is inside unclosed scoped macro
- Implement MacroClosingTagAutoCompleteOption to suggest closing tags for scoped macros
- Add sortPriority property to AutoCompleteOption for controlling sort order

* SlashCommands: Disable unimplemented flags and closing flag when no unclosed scopes in autocomplete

- Set closing block flag as non-selectable when no unclosed scopes exist
- Set unimplemented flags as non-selectable with empty valueProvider
- Lower sort priority (12) for non-selectable flags vs selectable flags (10)

* Autocomplete: Show scoped content info and auto-close no-arg macros

- Show scoped content info when cursor is at closing }} of unclosed scoped macro
- Auto-complete no-arg macros with closing }} using valueProvider
- Trigger autocomplete on select (isSelect) to refresh after choosing an option
- Simplify MacroFlagAutoCompleteOption to use base makeItem for consistent styling
- Change closing tag icon from '{/}' to '{/' for better visual consistency

* Macros: Add scoped trim macro to trim content inside opening/closing tags

- Add scoped usage for {{trim}}content{{/trim}} to trim whitespace from content
- Keep non-scoped {{trim}} behavior (post-processing marker) for backward compatibility
- Add optional unnamed 'content' argument for scoped usage
- Update description to explain both scoped and non-scoped behavior
- Handler checks isScoped flag to determine which behavior to use

* Autocomplete: Fix closing tag parsing to prevent `/` being treated as flag

- Add special case in parseMacroContext to detect closing tags (`/` + identifier char)
- Stop flag parsing when `/` is followed by identifier character (closing tag syntax)
- Simplify MacroClosingTagAutoCompleteOption valueProvider to return full closing tag
- Remove input-based logic since autocomplete replaces entire identifier

* Macros: Add {{if}} conditional macro with auto-resolution of macro names

- Add {{if condition}}content{{/if}} macro to conditionally show content
- Auto-resolve condition if it matches a registered macro name (0 required args)
- Support both scoped content ({{if x}}...{{/if}}) and explicit args ({{if::x::content}})
- Treat empty string, "false", "off", "0" as falsy conditions
- Inherit environment context when resolving macro names
- Update autocomplete warning to allow space-separated syntax

* Macros: Add centralized identifier validation with pattern enforcement

- Export MACRO_IDENTIFIER_PATTERN from MacroLexer for reuse across modules
- Add isIdentifierValid() helper function to validate macro names and aliases
- Enforce identifier pattern: must start with letter, followed by word chars or hyphens
- Update macro registration to validate both primary names and alias identifiers
- Improve error messages to explain identifier requirements
- Add comprehensive e2e tests for valid/invalid identifier patterns

* Macros: Add tests for scoped {{trim}} macro functionality

* SlashCommands: Fix macro indexing to properly handle nested macros with brace depth tracking

- Replace regex-based macro detection with manual brace depth tracking
- Track opening/closing brace pairs to correctly identify macro boundaries
- Ensure nested macros like {{reverse::Hey {{user}}}} are properly indexed
- Index both outer and inner macros by scanning content recursively
- Handle unclosed macros by defaulting to end of text

* Macros: Add {{else}} branch support to {{if}} conditional macro

- Add {{else}} macro as marker to split then/else branches in {{if}} blocks
- Use control character sequence (\u0000\u001FELSE\u001F\u0000) as internal marker
- Split scoped content on else marker and trim both branches independently
- Return then-branch if condition is truthy, else-branch if falsy
- Auto-suggest {{else}} in autocomplete when inside scoped {{if}} block
- Make {{else}} non-selectable in autocomplete when outside {{if}} scope

* Macros: Add negation support to {{if}} conditional macro with ! prefix

- Add ! prefix support to invert condition evaluation in {{if}} macro
- Parse original macro text to detect ! prefix before macro resolution
- Strip ! from condition after detecting inversion to avoid double-negation
- Invert isFalsy result when ! prefix is detected in original condition
- Prevent ! in resolved values from triggering inversion (only original syntax)

* Autocomplete: Add {{if}} condition autocomplete with zero-arg macro suggestions

- Add EnhancedMacroAutoCompleteOptions typedef for noBraces/paddingAfter/closeWithBraces options
- Support options object in EnhancedMacroAutoCompleteOption constructor alongside context
- Add noBraces mode to display macro names without {{ }} braces (for use as values)
- Add paddingAfter option to match opening whitespace style before closing }}

* Autocomplete: Match opening whitespace padding when auto-closing macros

* Fix `{{if}}` example usages

* Macros: Hide `comment` alias from autocomplete suggestions for `//` macro

* Macros: Simplify {{trim}} handler with destructured parameters and clearer content check

* Macros: Use MacroEngine.evaluate for zero-arg macro resolution in {{if}} condition handler

* Macros: Add auto-trim for scoped content with # flag to preserve whitespace

- Auto-trim scoped content by default in MacroCstWalker before passing to handlers
- Add preserveWhitespace flag (# symbol) to prevent auto-trimming when needed
- Rename legacyHash flag to preserveWhitespace across engine and definitions
- Update {{trim}} handler to rely on engine auto-trim for scoped content
- Update {{if}} handler to respect # flag when trimming branches around {{else}} marker

* Macros: Clarify macro name validation error message to use "alphanumeric characters" instead of "word chars"

* Add 'setspriteoverride' optional 'name' argument

* Refactor ElevenLabs TTS API key handling (#4906)

* Refactor ElevenLabs TTS API key handling #4483

* Remove unused connection button and related event handler from ElevenLabs TTS provider

* Add ElevenLabs STT endpoint

* Add caching system prompt feature for OpenRouter Gemini (#4903)

* feat: add caching system prompt for OpenRouter Gemini

* fix: resolve reviews

* Update GitHub links to llama.cpp

* Add model selection support for llama.cpp router mode (#4910)

* Add model selection support for llama.cpp router mode

- Add llamacpp_model setting to textgen-settings.js
- Implement loadLlamaCppModels() function to fetch and populate models
- Add onLlamaCppModelSelect() handler for model selection
- Update status check to load llama.cpp models when connecting
- Update getTextGenModel() to return selected llama.cpp model
- Add model dropdown to HTML UI in llama.cpp section
- Initialize event handlers and Select2 for better UX
- Add llamacpp_model to preset manager for save/load support
- Add llamacpp_model to slash commands support

This implements model selection for llama.cpp router mode, allowing
users to select from multiple models without restarting the server.
Follows the same pattern as Ollama, Tabby, and vLLM implementations.

* Correct spelling

* Fix clear selection position

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

* Add glm-4.7 model option and context mapping for Z.AI

* Add select2 style for NanoGPT list
Closes #4911

* Disable macro engine init traces

* Update type annotations for instruct presets and context presets

* Fix test for new error text

* Add MacroStoryString tests

* Add trimContent utility for consistent indentation dedenting in scoped macros

- Add trimScopedContent method to MacroEngine that trims and dedents scoped content based on first non-empty line indentation
- Pass trimContent utility through evaluation context to all macro handlers
- Update {{if}} macro to use trimContent instead of direct trim() call
- Update auto-trim logic in MacroCstWalker to use trimContent for consistent dedenting
- Add trimContent to MacroExecutionContext type definitions

* Add ELSE_MARKER export and cleanup leftover markers in macro processing

* Update trimContent parameter to use options object pattern in JSDoc

* Add processor registration system to MacroEngine with priority-based execution

- Add MacroProcessor callback and RegisteredProcessor typedef for pre/post processors
- Add addPreProcessor/removePreProcessor and addPostProcessor/removePostProcessor methods with priority-based sorting
- Refactor core legacy syntax handling into registered processors with reserved priorities (0-50)
- Move legacy time syntax, marker replacements, brace unescaping, trim macro, and ELSE_MARKER cleanup to registered processors

* Split core processor registration into separate pre and post processor methods

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Wolfsblvt
2025-12-28 20:36:13 +01:00
committed by GitHub
parent 829db7f2d0
commit e9bedadc0b
20 changed files with 3670 additions and 217 deletions
File diff suppressed because it is too large Load Diff
+48 -34
View File
@@ -653,6 +653,35 @@ test.describe('MacroLexer', () => {
expect(tokens).toEqual(expectedTokens);
});
// {{>filtered}}
test('should support > filter flag as separate token', async ({ page }) => {
const input = '{{>filtered}}';
const tokens = await runLexerGetTokens(page, input);
const expectedTokens = [
{ type: 'Macro.Start', text: '{{' },
{ type: 'Macro.FilterFlag', text: '>' },
{ type: 'Macro.Identifier', text: 'filtered' },
{ type: 'Macro.End', text: '}}' },
];
expect(tokens).toEqual(expectedTokens);
});
// {{ ! > user }}
test('should support filter flag combined with other flags', async ({ page }) => {
const input = '{{ ! > user }}';
const tokens = await runLexerGetTokens(page, input);
const expectedTokens = [
{ type: 'Macro.Start', text: '{{' },
{ type: 'Macro.Flag', text: '!' },
{ type: 'Macro.FilterFlag', text: '>' },
{ type: 'Macro.Identifier', text: 'user' },
{ type: 'Macro.End', text: '}}' },
];
expect(tokens).toEqual(expectedTokens);
});
// {{ a shaaark }}
test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {
const input = '{{ a shaaark }}';
@@ -668,42 +697,35 @@ test.describe('MacroLexer', () => {
expect(tokens).toEqual(expectedTokens);
});
test.describe('Error Cases (Macro Execution Modifiers)', () => {
test.describe('"Error" Cases (Macro Execution Modifiers)', () => {
// {{ @unknown }}
test('[Error] should not capture unknown special characters as flag', async ({ page }) => {
test('should not capture unknown special characters as flag', async ({ page }) => {
const input = '{{ @unknown }}';
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
const expectedErrors = [
{ message: 'unexpected character: ->@<- at offset: 3, skipped 1 characters.' },
];
expect(errors).toMatchObject(expectedErrors);
// No errors expected, as lexer should not error out even on invalid macros
expect(errors).toMatchObject([]);
const expectedTokens = [
{ type: 'Macro.Start', text: '{{' },
// Do not capture '@' as anything, as it's a lexer error
{ type: 'Macro.Identifier', text: 'unknown' },
{ type: 'Macro.End', text: '}}' },
// Because '@' is invalid in lexer, it'll "pop out" and be captured as plaintext
{ type: 'Plaintext', text: '@unknown }}' },
];
expect(tokens).toEqual(expectedTokens);
});
// {{ 2 cents }}
test('[Error] should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
test('should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
const input = '{{ 2 cents }}';
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
const expectedErrors = [
{ message: 'unexpected character: ->2<- at offset: 3, skipped 1 characters.' },
];
expect(errors).toMatchObject(expectedErrors);
// No errors expected, as lexer should not error out even on invalid macros
expect(errors).toMatchObject([]);
const expectedTokens = [
{ type: 'Macro.Start', text: '{{' },
// Do not capture '2' as anything, as it's a lexer error
{ type: 'Macro.Identifier', text: 'cents' },
{ type: 'Macro.End', text: '}}' },
// Because '2' is invalid in lexer, it'll "pop out" and be captured as plaintext
{ type: 'Plaintext', text: '2 cents }}' },
];
expect(tokens).toEqual(expectedTokens);
@@ -868,20 +890,16 @@ test.describe('MacroLexer', () => {
test.describe('Error Cases (Macro Output Modifiers)', () => {
// {{|macro}}
test('[Error] should not capture when starting the macro with a pipe', async ({ page }) => {
test('should not capture when starting the macro with a pipe', async ({ page }) => {
const input = '{{|macro}}';
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
const expectedErrors = [
{ message: 'unexpected character: ->|<- at offset: 2, skipped 1 characters.' },
];
expect(errors).toMatchObject(expectedErrors);
// No errors expected, as lexer should not error out even on invalid macros
expect(errors).toMatchObject([]);
const expectedTokens = [
{ type: 'Macro.Start', text: '{{' },
{ type: 'Macro.Identifier', text: 'macro' },
{ type: 'Macro.End', text: '}}' },
{ type: 'Plaintext', text: '|macro}}' },
];
expect(tokens).toEqual(expectedTokens);
@@ -1052,18 +1070,14 @@ test.describe('MacroLexer', () => {
const input = 'invalid {{ 000 }} followed by correct {{ macro }}';
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
const expectedErrors = [
{ message: 'unexpected character: ->0<- at offset: 11, skipped 3 characters.' },
];
expect(errors).toMatchObject(expectedErrors);
// No errors expected, as lexer should not error out even on invalid macros
expect(errors).toMatchObject([]);
const expectedTokens = [
{ type: 'Plaintext', text: 'invalid ' },
{ type: 'Macro.Start', text: '{{' },
// Do not capture '000' as anything, as it's a lexer error
{ type: 'Macro.End', text: '}}' },
{ type: 'Plaintext', text: ' followed by correct ' },
// '000' is invalid vor the lexer, so it is captured as plaintext
{ type: 'Plaintext', text: '000 }} followed by correct ' },
{ type: 'Macro.Start', text: '{{' },
{ type: 'Macro.Identifier', text: 'macro' },
{ type: 'Macro.End', text: '}}' },
+141 -3
View File
@@ -61,15 +61,15 @@ test.describe('MacroParser', () => {
expect(errors).toMatchObject(expectedErrors);
expect(errors[0].message).toMatch(expectedMessage);
});
// {{§!#&blah}}
// {{§%€blah}}
test('[Error] should throw an error for invalid identifier', async ({ page }) => {
const input = '{{§!#&blah}}';
const input = '{{§%€blah}}';
const { macroCst, errors } = await runParserAndGetErrors(page, input);
const expectedErrors = [
{ name: 'NoViableAltException' },
];
const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '!'/gs;
const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '§%blah}}'/gs;
expect(macroCst).toBeUndefined();
expect(errors).toMatchObject(expectedErrors);
@@ -556,6 +556,144 @@ This is the second line
});
});
test.describe('Macro Flags', () => {
// {{!user}}
test('should parse macro with single flag', async ({ page }) => {
const input = '{{!user}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '!',
'Macro.identifier': 'user',
'Macro.End': '}}',
});
});
// {{?delayed}}
test('should parse macro with delayed flag', async ({ page }) => {
const input = '{{?delayed}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '?',
'Macro.identifier': 'delayed',
'Macro.End': '}}',
});
});
// {{/closing}}
test('should parse macro with closing block flag', async ({ page }) => {
const input = '{{/closing}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '/',
'Macro.identifier': 'closing',
'Macro.End': '}}',
});
});
// {{>filtered}}
test('should parse macro with filter flag', async ({ page }) => {
const input = '{{>filtered}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '>',
'Macro.identifier': 'filtered',
'Macro.End': '}}',
});
});
// {{!?user}}
test('should parse macro with multiple flags', async ({ page }) => {
const input = '{{!?user}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': ['!', '?'],
'Macro.identifier': 'user',
'Macro.End': '}}',
});
});
// {{ ! > macro }}
test('should parse macro with flags and whitespace', async ({ page }) => {
const input = '{{ ! > macro }}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': ['!', '>'],
'Macro.identifier': 'macro',
'Macro.End': '}}',
});
});
// {{#legacy}}
test('should parse macro with legacy hash flag', async ({ page }) => {
const input = '{{#legacy}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '#',
'Macro.identifier': 'legacy',
'Macro.End': '}}',
});
});
// {{!setvar::value::test}}
test('should parse macro with flag and arguments', async ({ page }) => {
const input = '{{!setvar::value::test}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '!',
'Macro.identifier': 'setvar',
'arguments': {
'separator': '::',
'argument': ['value', 'test'],
},
'Macro.End': '}}',
});
});
// {{.myvar}} - variable shorthand
test('should parse macro with variable dot shorthand flag', async ({ page }) => {
const input = '{{.myvar}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '.',
'Macro.identifier': 'myvar',
'Macro.End': '}}',
});
});
// {{$myvar}} - variable shorthand
test('should parse macro with variable dollar shorthand flag', async ({ page }) => {
const input = '{{$myvar}}';
const macroCst = await runParser(page, input);
expect(macroCst).toEqual({
'Macro.Start': '{{',
'flags': '$',
'Macro.identifier': 'myvar',
'Macro.End': '}}',
});
});
});
});
/**
+277 -74
View File
@@ -44,110 +44,313 @@ test.describe('MacroRegistry', () => {
test.describe('reject', () => {
test('should reject invalid macro name', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// Empty name
MacroRegistry.registerMacro(' ', {
handler: () => '',
});
})).rejects.toThrow(/Macro name must be a non-empty string/);
const result = await registerMacroAndCaptureErrors(page, {
macroName: ' ',
options: {},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro ""');
expect(registrationError?.errorMessage).toContain('Must start with a letter, followed by alphanumeric characters or hyphens.');
});
test('should reject invalid options object', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// Options must be object
// @ts-expect-error intentionally wrong
MacroRegistry.registerMacro('invalid-options', null);
})).rejects.toThrow(/options must be a non-null object/);
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'invalid-options',
options: null,
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "invalid-options"');
expect(registrationError?.errorMessage).toContain('options must be a non-null object');
});
test('should reject invalid handler', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// Handler must be function
// @ts-expect-error intentionally wrong
MacroRegistry.registerMacro('no-handler', { handler: null });
})).rejects.toThrow(/options\.handler must be a function/);
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'no-handler',
options: { handler: null },
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "no-handler"');
expect(registrationError?.errorMessage).toContain('options.handler must be a function');
});
test('should reject invalid unnamedArgs', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// unnamedArgs must be non-negative integer
MacroRegistry.registerMacro('bad-required', {
// @ts-expect-error intentionally wrong
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-required',
options: {
unnamedArgs: -1,
handler: () => '',
});
})).rejects.toThrow(/options\.unnamedArgs must be a non-negative integer/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-required"');
expect(registrationError?.errorMessage).toContain('options.unnamedArgs must be a non-negative integer');
});
test('should reject invalid strictArgs', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// strictArgs must be boolean
MacroRegistry.registerMacro('bad-strict', {
// @ts-expect-error intentionally wrong
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-strict',
options: {
strictArgs: 'yes',
handler: () => '',
});
})).rejects.toThrow(/options\.strictArgs must be a boolean/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-strict"');
expect(registrationError?.errorMessage).toContain('options.strictArgs must be a boolean');
});
test('should reject invalid list configuration', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// list must be boolean or object
MacroRegistry.registerMacro('bad-list-type', {
// @ts-expect-error intentionally wrong
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-list-type',
options: {
list: 'invalid',
handler: () => '',
});
})).rejects.toThrow(/options\.list must be a boolean or an object/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-list-type"');
expect(registrationError?.errorMessage).toContain('options.list must be a boolean');
});
test('should reject invalid list.min', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// list.min must be non-negative
MacroRegistry.registerMacro('bad-list-min', {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-list-min',
options: {
list: { min: -1 },
handler: () => '',
});
})).rejects.toThrow(/options\.list\.min must be a non-negative integer/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-list-min"');
expect(registrationError?.errorMessage).toContain('options.list.min must be a non-negative integer');
});
test('should reject invalid list.max', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// list.max must be >= min
MacroRegistry.registerMacro('bad-list-max', {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-list-max',
options: {
list: { min: 2, max: 1 },
handler: () => '',
});
})).rejects.toThrow(/options\.list\.max must be greater than or equal to options\.list\.min/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-list-max"');
expect(registrationError?.errorMessage).toContain('options.list.max must be greater than or equal to options.list.min');
});
test('should reject invalid description', async ({ page }) => {
await expect(page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
// description must be string
MacroRegistry.registerMacro('bad-desc', {
// @ts-expect-error intentionally wrong
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'bad-desc',
options: {
description: 123,
handler: () => '',
});
})).rejects.toThrow(/options\.description must be a string/);
},
});
expect(result.registered).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError).toBeTruthy();
expect(registrationError?.text).toContain('Failed to register macro "bad-desc"');
expect(registrationError?.errorMessage).toContain('options.description must be a string');
});
});
test.describe('identifier validation', () => {
test('should accept valid identifier with letters only', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'validMacro',
options: {},
});
expect(result.registered).not.toBeNull();
expect(result.errors.length).toBe(0);
});
test('should accept valid identifier with hyphens', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'my-macro-name',
options: {},
});
expect(result.registered).not.toBeNull();
expect(result.errors.length).toBe(0);
});
test('should accept valid identifier with underscores', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'my_macro_name',
options: {},
});
expect(result.registered).not.toBeNull();
expect(result.errors.length).toBe(0);
});
test('should accept valid identifier with digits after first char', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'macro123',
options: {},
});
expect(result.registered).not.toBeNull();
expect(result.errors.length).toBe(0);
});
test('should reject identifier starting with digit', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: '123macro',
options: {},
});
expect(result.registered).toBeNull();
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError?.errorMessage).toContain('is invalid');
});
test('should reject identifier starting with hyphen', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: '-macro',
options: {},
});
expect(result.registered).toBeNull();
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError?.errorMessage).toContain('is invalid');
});
test('should reject identifier with special characters', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'macro@name',
options: {},
});
expect(result.registered).toBeNull();
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError?.errorMessage).toContain('is invalid');
});
test('should reject identifier with spaces', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'macro name',
options: {},
});
expect(result.registered).toBeNull();
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError?.errorMessage).toContain('is invalid');
});
test('should accept valid alias identifier', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'primaryMacro',
options: {
aliases: [{ alias: 'valid-alias_123' }],
},
});
expect(result.registered).not.toBeNull();
expect(result.errors.length).toBe(0);
});
test('should reject invalid alias identifier', async ({ page }) => {
const result = await registerMacroAndCaptureErrors(page, {
macroName: 'primaryMacro2',
options: {
aliases: [{ alias: '123-invalid' }],
},
});
expect(result.registered).toBeNull();
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
expect(registrationError?.errorMessage).toContain('is invalid');
});
});
});
/**
* @typedef {Object} CapturedConsoleError
* @property {string} text
* @property {string|null} errorMessage
*/
/**
* @param {import('@playwright/test').Page} page
* @param {{ macroName: string, options: import('../../public/scripts/macros/engine/MacroRegistry.js').MacroDefinitionOptions|null }} params
* @returns {Promise<{ registered: unknown, errors: CapturedConsoleError[] }>}
*/
async function registerMacroAndCaptureErrors(page, { macroName, options }) {
const result = await page.evaluate(async ({ macroName, options }) => {
/** @type {CapturedConsoleError[]} */
const errors = [];
const originalError = console.error;
console.error = (...args) => {
const text = args
.map(a => (typeof a === 'string' ? a : (a instanceof Error ? `Error: ${a.message}` : '')))
.filter(Boolean)
.join(' ');
/** @type {string|null} */
let errorMessage = null;
for (const a of args) {
if (a instanceof Error) {
errorMessage ??= a.message;
continue;
}
if (a && typeof a === 'object' && 'error' in a && a.error instanceof Error) {
errorMessage ??= a.error.message;
}
}
errors.push({ text, errorMessage });
};
try {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
/** @type {any} */
let resolvedOptions = options;
if (resolvedOptions && typeof resolvedOptions === 'object' && !('handler' in resolvedOptions)) {
resolvedOptions = {
...resolvedOptions,
handler: () => '',
};
}
// Registering an invalid macro does not throw. It returns null and logs an error.
const registered = MacroRegistry.registerMacro(macroName, resolvedOptions);
return { registered, errors };
} finally {
console.error = originalError;
}
}, { macroName, options });
return result;
}
+82
View File
@@ -0,0 +1,82 @@
import fs from 'node:fs';
import path from 'node:path';
import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
import { serverDirectory } from '../../src/server-directory.js';
test.describe('MacroStoryString', () => {
test.beforeEach(testSetup.awaitST);
/** @type {any[]} */
const defaultContextPresets = [];
test.beforeAll(() => {
const contextPresetsPath = path.join(serverDirectory, 'default', 'content', 'presets', 'context');
const files = fs.readdirSync(contextPresetsPath).filter(f => path.extname(f).toLowerCase() === '.json');
for (const file of files) {
const fullPath = path.join(contextPresetsPath, file);
const fileContent = fs.readFileSync(fullPath, 'utf-8');
const preset = JSON.parse(fileContent);
defaultContextPresets.push(preset);
}
});
test('should produce equivalent story strings with new macro engine', async ({ page }) => {
const output = await page.evaluate(async ([defaultContextPresets]) => {
const { substituteParams, extension_prompt_types } = await import('./script.js');
const { power_user, renderStoryString } = await import('./scripts/power-user.js');
power_user.experimental_macro_engine = true;
const context = {
description: 'character description',
personality: 'character personality',
persona: 'persona details',
scenario: 'scenario setup',
system: 'system instructions',
char: 'character name',
user: 'user name',
wiBefore: 'world info before',
wiAfter: 'world info after',
loreBefore: 'lore before',
loreAfter: 'lore after',
anchorBefore: 'before anchor text',
anchorAfter: 'after anchor text',
mesExamples: 'example messages',
mesExamplesRaw: 'raw example messages',
};
const customInstructSettings = {
enabled: false,
};
const customContextSettings = {
story_string_position: extension_prompt_types.IN_PROMPT,
};
const result = [];
function getMacroStoryString(templateString) {
let output = substituteParams(templateString, { name1Override: context.user, name2Override: context.char, replaceCharacterCard: true, dynamicMacros: context });
output = output.replace(/^\n+/, '');
if (output.length > 0 && !output.endsWith('\n')) {
output += '\n';
}
return output;
}
for (const template of defaultContextPresets) {
const classicStoryString = renderStoryString(context, { customStoryString: template.story_string, customContextSettings, customInstructSettings });
const macroStoryString = getMacroStoryString(template.story_string);
result.push({ name: template.name, classicStoryString, macroStoryString });
}
return result;
}, [defaultContextPresets]);
for (const { classicStoryString, macroStoryString, name } of output) {
expect(macroStoryString, `Mismatch in template: ${name}`).toBe(classicStoryString);
}
});
});